diff --git a/exercises/week01/Background material - introduction to python.ipynb b/exercises/week01/Background material - introduction to python.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..944e2928ab02acd2f9074d99c415c404ce4dfa6a
--- /dev/null
+++ b/exercises/week01/Background material - introduction to python.ipynb	
@@ -0,0 +1,1648 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9c8c9f98",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "outputs": [],
+   "source": [
+    "# Initialize Otter\n",
+    "import otter\n",
+    "grader = otter.Notebook(\"Background material - introduction to python.ipynb\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6ea9e1f3-15b6-494e-ad15-de8b909eb99a",
+   "metadata": {},
+   "source": [
+    "# Matrix Analysis 2025 - EE312\n",
+    "## Week 1  - Background material - Python/Numpy\n",
+    "[N. Aspert](https://people.epfl.ch/nicolas.aspert) - [LTS2](https://lts2.epfl.ch)\n",
+    "\n",
+    "The assignments and exercises for this will use Python as a programming language. Python is a general-purpose programming language which is used in various areas. It benefits from an ecosystem of libraries, such as [numpy](https://numpy.org), [scipy](https://scipy.org), [matplotlib](https://matplotlib.org) and many others, which turns it into a powerful tool for scientific computing.\n",
+    "\n",
+    "## Objectives\n",
+    "This notebook is meant as an introduction (or a reminder) to Python, Numpy and Matplotlib.\n",
+    "\n",
+    "### Notebooks\n",
+    "During this course, we will be using Python through **notebooks**. A notebook allow you to write and execute Python code within your web browser. You can either run the notebook locally on your computer, or using an online service such as [noto](https://noto.epfl.ch) , or [binder](https://mybinder.org). See the [README](https://gitlab.epfl.ch/lts2/matrix-analysis-2025) file in the course Github repository for more details and installation instructions.\n",
+    "\n",
+    "You can always call the `help` function within the notebook to access documentation about a particular function/type..., e.g."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8a73970c-f4a0-4827-badc-987031ea32b9",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "help(complex)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c6d34fae-b5e4-492a-8acb-d51523b4e3a0",
+   "metadata": {},
+   "source": [
+    "## Python\n",
+    "Python is a very popular dynamically-typed interpreted language. Its design emphasizes readability, by using indentation as code block delimiter, and plain English words (`and`, `or`, ...) instead of the logical operator symbols you may encounter in C or Java : \n",
+    "```\n",
+    "// in C or Java, indentation is optional (but highly recommended to ensure code remains readable)\n",
+    "int f(int x, int y) {\n",
+    "   if (x == 1 || y > 2) {\n",
+    "     x += 2;\n",
+    "     y = x*3;\n",
+    "  }\n",
+    "  return x*y;\n",
+    "}\n",
+    "```\n",
+    "is written as \n",
+    "```\n",
+    "# in Python indentation is NOT optional\n",
+    "def f(x, y):\n",
+    "    if x == 1 or y > 2:\n",
+    "        x += 2\n",
+    "        y = x*3\n",
+    "    return x*y\n",
+    "```\n",
+    "\n",
+    "### Basic types\n",
+    "Unsurprisingly, Python has a number of base types you can also find in other programming languages, such as integers, floats, strings and booleans, and behave in a similar way. \n",
+    "\n",
+    "#### Numbers"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8717ffea-a560-4c93-ba08-a7e1fae84ce0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x = 5\n",
+    "print(type(x)) # Prints \"<class 'int'>\"\n",
+    "print(x)       # Prints \"5\"\n",
+    "print(x + 1)   # Addition; prints \"6\"\n",
+    "print(x - 1)   # Subtraction; prints \"4\"\n",
+    "print(x * 2)   # Multiplication; prints \"10\"\n",
+    "print(x ** 2)  # Exponentiation; prints \"25\"\n",
+    "x += 1\n",
+    "print(x)  # Prints \"6\"\n",
+    "x *= 2\n",
+    "print(x)  # Prints \"12\"\n",
+    "y = 1.5\n",
+    "print(type(y)) # Prints \"<class 'float'>\"\n",
+    "print(y, y + 1, y * 2, y ** 2) # Prints \"1.5 2.5 3.0 2.25\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "1ee1f385-dbfe-44bf-85ee-34bb49bb85f4",
+   "metadata": {},
+   "source": [
+    "In Python, the `float` type is *double-precision* (i.e. 64-bit), unlike C or Java. Python also benefits from a built-in `complex` type:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2fc7c8dd-8237-4cac-8213-068736b3ff74",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "z = 1. + 1.j\n",
+    "print(2*z)     # Prints \"2+2j\"\n",
+    "print(z**2)    # Prints \"2j\"\n",
+    "print(type(z)) # Prints \"<class 'complex'>\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "16707a13-1a84-46db-8ef6-60ce83a893f7",
+   "metadata": {},
+   "source": [
+    "#### Boolean\n",
+    "Boolean type can have (surprise) two values: `True` or `False` (capital first character matters). Instead of using symbolic operators, Python uses plain English words `and`, `or`, `not`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a3d629ea-4dda-4a4d-8da7-08e1f2cf153b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "b = True\n",
+    "c = False\n",
+    "print(type(b))     # Prints \"<class 'bool'>\"\n",
+    "print(b and c)     # Prints \"False\"\n",
+    "print(b and not c) # Prints \"True\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "5da8a22b-b99a-476c-94b8-312f4e587df3",
+   "metadata": {},
+   "source": [
+    "A little care needs to be taken about the `None` value. The `None` keyword is used to define a null value, or no value at all. `None` is not the same as 0, False, or an empty string. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "d27e37db-af3e-4330-ba5a-a5cdde69738f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "n = None\n",
+    "print(type(n))    # Prints \"< class 'NoneType'>\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "22da9ea1-828b-49c7-bbfc-a690ca9febce",
+   "metadata": {},
+   "source": [
+    "However, logical operators treat `None` in a specific way"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "191609f5-93ab-421a-abe6-d5e39509f6ae",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(n and b)  # Prints \"None\"\n",
+    "print(not n)    # Prints \"True\"\n",
+    "print(n or b)   # Prints \"True\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9c115ccf-6f2a-43aa-9d75-5d77244917f4",
+   "metadata": {},
+   "source": [
+    "#### Strings\n",
+    "Python supports strings"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "78e0f787-45d8-4843-b7e1-d51d115cc7c5",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ma = 'matrix'         # String literals can use single quotes\n",
+    "an = \"analysis\"       # or double quotes; it does not matter.\n",
+    "print(ma)             # Prints \"matrix\"\n",
+    "print(len(ma))        # String length; prints \"6\"\n",
+    "maan = ma + ' ' + an  # String concatenation\n",
+    "print(maan)             # prints \"matrix analysis\"\n",
+    "maan23 = '{} {} {}'.format(ma, an, 2023) # string formatting\n",
+    "print(maan23)         # prints \"matrix analysis 2023\"\n",
+    "y0 = 2000\n",
+    "y1 = 23\n",
+    "maan23b = f'{ma} {an} {y0 + y1}' # in python >= 3.6, you can do \"string interpolation\"\n",
+    "print(maan23b)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e8159eda-9dfe-4ee1-b1f7-cd0a4ea0f28e",
+   "metadata": {},
+   "source": [
+    "`string` has several built-in methods (check the official [documentation](https://docs.python.org/3.10/library/stdtypes.html#string-methods) for more) :"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "23c524d0-6456-4ed0-8c01-cbeeb8da280e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "s = \"matrix\"\n",
+    "print(s.capitalize())  # Capitalize a string; prints \"Matrix\"\n",
+    "print(s.upper())       # Convert a string to uppercase; prints \"MATRIX\"\n",
+    "print(s.rjust(7))      # Right-justify a string, padding with spaces; prints \" matrix\"\n",
+    "print(s.center(8))     # Center a string, padding with spaces; prints \" matrix \"\n",
+    "print(an.replace('a', '(a)'))  # Replace all instances of one substring with another;\n",
+    "                                # prints \"(a)n(a)lysis\"\n",
+    "print('  analysis '.strip())  # Strip leading and trailing whitespace; prints \"analysis\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "97c55eaf-76ae-4ae0-a8ff-590f79fbb440",
+   "metadata": {},
+   "source": [
+    "### Functions\n",
+    "Python functions are defined using the `def` keyword, e.g.  :"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2c3c69a7-329e-4618-a1cc-58975e4ed9bc",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def f(x, y):\n",
+    "    if x == 1 or y > 2:\n",
+    "        x += 2\n",
+    "        y = x*3\n",
+    "    return x*y"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8de94c7f-42d3-4168-a072-7ede461ee21d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f(1, 2)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "54b35024-477f-4cd2-a126-b9288752f73e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f(2, 2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "cde2dc6c-a93b-4589-bb11-3f78cb153790",
+   "metadata": {},
+   "source": [
+    "default arguments can be handy"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b69bb42a-b8d3-435e-9c45-8cb3f463b1dc",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def f_default(x, y=2):\n",
+    "    return x**y + x"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "26e9ee43-51d5-45dc-a822-551fac13ff8b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f_default(3) # equivalent to writing: f_default(3, 2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "db9c6909-e292-4118-bd70-d59770bee052",
+   "metadata": {},
+   "source": [
+    "It is often the case that functions have a lot of default arguments and you only need to specify a small number of them. You can then use the named argument shortcut:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "acdbc77f-bb18-4499-9f78-c908d6daf04b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def f_long(x, y=1, z=2, h=3, t=None):\n",
+    "    res = x + y - z + h\n",
+    "    if t: # test if t has a value\n",
+    "        res = res*t\n",
+    "    return res"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "25b5a8ca-1e7d-4935-8842-3ac2e685e12b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f_long(12, h=4)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "134846f6-2f40-49bc-aa11-b1ffa3352b71",
+   "metadata": {},
+   "source": [
+    "Check the [documentation](https://docs.python.org/3.10/tutorial/controlflow.html#defining-functions) for more details.\n",
+    "\n",
+    "#### Control flow\n",
+    "Python supports `if`...`elif`...`else`, `match`..., `for` loops, `while`..., check the [documentation](https://docs.python.org/3.10/reference/compound_stmts.html) or the [tutorial](https://docs.python.org/3.10/tutorial/controlflow.html#) for more details.\n",
+    "\n",
+    "### Data structures\n",
+    "Python has several built-in containers, we will only present the lists and tuples. Check the [documentation](https://docs.python.org/3.10/tutorial/datastructures.html) for more details and other interesting data structures (such as dictionaries or sets).\n",
+    "\n",
+    "#### Lists\n",
+    "A Python list is the equivalent of an array, but it can be resized and can mix different types of elements."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "249faa6f-1ebd-4f41-8326-9354f6f685ba",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "xs = [2, 0, 3]    # Create a list\n",
+    "print(xs, xs[2])  # Prints \"[2, 0, 3] 3\"\n",
+    "print(xs[-1])     # Negative indices count from the end of the list; prints \"3\"\n",
+    "xs[2] = 'hello'     # Lists can contain elements of different types\n",
+    "print(xs)         # Prints \"[2, 0, 'hello']\"\n",
+    "xs.append('world')  # Add a new element to the end of the list\n",
+    "print(xs)         # Prints \"[2, 0, 'hello', 'world']\"\n",
+    "x = xs.pop()      # Remove and return the last element of the list\n",
+    "print(x, xs)      # Prints \"world [2, 0, 'hello']\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "cbd37263-ab32-4b5e-b62f-177598215443",
+   "metadata": {},
+   "source": [
+    "You can not only access individual elements, but also range of elements. This is known as *slicing*"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "993c8e49-4394-41e7-b8ac-7208e018e152",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nums = list(range(8))     # range is a built-in function that creates a list of integers\n",
+    "print(nums)               # Prints \"[0, 1, 2, 3, 4, 6, 7]\"\n",
+    "print(nums[3:5])          # Get a slice from index 2 to 4 (exclusive); prints \"[3, 4]\"\n",
+    "print(nums[4:])           # Get a slice from index 2 to the end; prints \"[4, 5, 6, 7]\"\n",
+    "print(nums[:2])           # Get a slice from the start to index 2 (exclusive); prints \"[0, 1]\"\n",
+    "print(nums[:])            # Get a slice of the whole list; prints \"[0, 1, 2, 3, 4, 5, 6, 7]\"\n",
+    "print(nums[:-1])          # Slice indices can be negative; prints \"[0, 1, 2, 3, 4, 5, 6]\"\n",
+    "nums[2:4] = [8, 9]        # Assign a new sublist to a slice\n",
+    "print(nums)               # Prints \"[0, 1, 8, 9, 4, 5, 6, 7]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "75654e1e-0b1a-4bca-82a0-4f6568768f66",
+   "metadata": {},
+   "source": [
+    "You can iterate over a list:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "23e132bb-a53a-448d-b557-f4ca688f6f81",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fruits = ['apple', 'banana', 'pear']\n",
+    "for fruit in fruits:\n",
+    "    print(fruit)\n",
+    "# Prints \"apple\", \"banana\", \"pear\", each on its own line."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "2cc8dedf-42d3-4e4d-ab8c-da6225ae1b7c",
+   "metadata": {},
+   "source": [
+    "*List comprehensions* are a convenient shortcut for list transformation operations :"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "14e8ea49-de95-43ce-aab5-074895e8cbba",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nums = list(range(4))\n",
+    "dbl = []\n",
+    "for n in nums:\n",
+    "    dbl.append(n*2)\n",
+    "print(dbl)\n",
+    "# Will print \"[0, 2, 4, 6]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9b98c63a-a15c-4f27-9c32-f603ac54c80b",
+   "metadata": {},
+   "source": [
+    "Rewrite the above loop with a list comprehension:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8008e4fe-df16-4bb5-a0a1-c9a17576f77a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dbl_c = [n*2 for n in nums]\n",
+    "print(dbl_c) # Prints \"[0, 2, 4, 6]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "df73bcde-1c47-4b07-b6da-5ae74f2516cb",
+   "metadata": {},
+   "source": [
+    "You can include conditions in list comprehensions:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "21d6a2a8-910c-4b2d-8043-2b2b9ff9e2e2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dbl_cc = [n*2 for n in nums if n < 2]\n",
+    "print(dbl_cc) # Prints \"[0, 2]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "801114b6-3e68-4924-95ad-becc288ebd54",
+   "metadata": {
+    "tags": []
+   },
+   "source": [
+    "#### Tuples\n",
+    "A tuple is an immutable list of values. A tuple is in many ways similar to a list; one of the most important differences is that tuples can be used as keys in dictionaries and as elements of sets, while lists cannot. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c183a69f-bce4-4d51-93ab-823b736b9526",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "t = (5, 6)        # Create a tuple\n",
+    "print(type(t))    # Prints \"<class 'tuple'>\"\n",
+    "print(t[0])       # Prints \"5\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6a8c72d4-8edc-4918-a81c-13fc68494493",
+   "metadata": {},
+   "source": [
+    "## Numpy\n",
+    "\n",
+    "[Numpy](https://nupy.org) is a core library for scientific computing. It provides optimized routines for multi-dimensional arrays. You can find many online tutorials about Numpy. If you are familiar with Matlab, you may want to check the [Numpy for Matlab users tutorial](https://numpy.org/doc/stable/user/numpy-for-matlab-users.html). As always, check [Numpy documentation](https://numpy.org/doc/stable/user/basics.html) for more information.\n",
+    "\n",
+    "### Arrays\n",
+    "Arrays are the base type used by Numpy. Unlike Python lists, they consists in elements having all the same type. They are indexed by non-negative integers, and their elements can also be accessed using square brackets.\n",
+    "\n",
+    "You can easily create Numpy arrays from nested lists."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "3ded689b-7cf3-4523-9248-bb096e3809e8",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "\n",
+    "a = np.array([1, 2, 3])   # Create a 1D array\n",
+    "print(type(a))            # Prints \"<class 'numpy.ndarray'>\"\n",
+    "print(a.shape)            # Prints \"(3,)\", this is Python's way of displaying a tuple with only one element\n",
+    "print(a[0], a[1], a[2])   # Prints \"1 2 3\"\n",
+    "a[0] = 42                  # Change an element of the array\n",
+    "print(a)                  # Prints \"[42, 2, 3]\"\n",
+    "\n",
+    "b = np.array([[1,2,3],[4,5,6]])    # Create a 2D array\n",
+    "print(b.shape)                     # Prints \"(2, 3)\"\n",
+    "print(b[0, 0], b[0, 2], b[1, 0])   # Prints \"1 3 4\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "645ed555-832f-457d-aeea-e517b0529a12",
+   "metadata": {},
+   "source": [
+    "Numpy has helper functions to create arrays:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "eff9d7e8-96d7-4850-8af7-d03be195390d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "a = np.zeros((3,3))   # Create an array of all zeros. You have to pass a tuple as argument \n",
+    "                      # (beware of the double parentheses)\n",
+    "print(a)              # Prints \"[[ 0.  0.  0.]\n",
+    "                      #          [ 0.  0.  0.]\n",
+    "                      #          [ 0.  0.  0.]]\"\n",
+    "\n",
+    "b = np.ones((1, 3))    # Create an array of all ones\n",
+    "print(b)              # Prints \"[[ 1.  1.  1.]]\"\n",
+    "\n",
+    "c = np.full((2,2), 4)  # Create a constant array\n",
+    "print(c)               # Prints \"[[ 4.  4.]\n",
+    "                       #          [ 4.  4.]]\"\n",
+    "\n",
+    "d = np.eye(2)         # Create a 2x2 identity matrix\n",
+    "print(d)              # Prints \"[[ 1.  0.]\n",
+    "                      #          [ 0.  1.]]\"\n",
+    "\n",
+    "e = np.random.random((2,2))  # Create an array filled with random values\n",
+    "print(e)                     # Might print \"[[0.45199657 0.95344055]\n",
+    "                             #               [0.65255911 0.79999078]]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "805a0618-b803-40e9-8480-50520f9ff1be",
+   "metadata": {},
+   "source": [
+    "### Array indexing\n",
+    "We will just review the most important ways of accessing Numpy array elements. Check the [documentation](https://numpy.org/doc/stable/reference/arrays.indexing.html) for details.\n",
+    "\n",
+    "You can slice Numpy arrays similarly to lists. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "03a2dad6-8df9-4ddf-8c6c-3ee93a8e0c72",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Create the following rank 2 array with shape (3, 4)\n",
+    "# [[ 1  2  3  4]\n",
+    "#  [ 5  6  7  8]\n",
+    "#  [ 9 10 11 12]]\n",
+    "a = np.array([[1,2,3,4], [5,6,7,8], [9,10,11,12]])\n",
+    "\n",
+    "# Use slicing to pull out the subarray consisting of the first 2 rows\n",
+    "# and columns 1 and 2; b is the following array of shape (2, 2):\n",
+    "# [[2 3]\n",
+    "#  [6 7]]\n",
+    "b = a[:2, 1:3]\n",
+    "\n",
+    "# A slice of an array is a view into the same data, so modifying it\n",
+    "# will modify the original array !!\n",
+    "print(a[0, 1])   # Prints \"2\"\n",
+    "b[0, 0] = 77     # b[0, 0] is the same piece of data as a[0, 1]\n",
+    "print(a[0, 1])   # Prints \"77\"\n",
+    "\n",
+    "# You can use ':' to specify the whole range of a dimension. \n",
+    "# This prints\n",
+    "# [[ 1 77]\n",
+    "#  [ 5  6]\n",
+    "#  [ 9 10]]\n",
+    "print(a[:, :2])\n",
+    "\n",
+    "# When dealing with n-dimensional arrays, the special shortcut '...' stands for 'all remaining dimensions'\n",
+    "c = np.ones((3,2,2)) # create a 3D array\n",
+    "c[1, ...] = c[1, :, :]*2\n",
+    "c[2, ...] = c[2, :, :]*3\n",
+    "# This prints\n",
+    "# [[[1. 1.]\n",
+    "#   [1. 1.]]\n",
+    "#\n",
+    "# [[2. 2.]\n",
+    "#  [2. 2.]]\n",
+    "#\n",
+    "# [[3. 3.]\n",
+    "#  [3. 3.]]]\n",
+    "print(c) \n",
+    "\n",
+    "# This prints\n",
+    "# [[[1., 1.],\n",
+    "#   [1., 1.]]\n",
+    "#\n",
+    "#  [[2., 2.],\n",
+    "#   [2., 2.]]]\n",
+    "print(c[:2, ...])\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c66cff24-0346-4eca-a259-ce095e5db4a1",
+   "metadata": {},
+   "source": [
+    "If you want to avoid modifying the original array when accessing a slice, you need to make a copy of it before writing values."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c1deae7f-fb12-4ee4-8487-2286f9844993",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "a = np.array([[1,2,3,4], [5,6,7,8], [9,10,11,12]])\n",
+    "b = np.array(a) # create a copy of a\n",
+    "c = a[:2, :2]   # create a slice\n",
+    "\n",
+    "b[0, 0] = 42 # modify the copy\n",
+    "c[0, 0] = 17 # slice modification\n",
+    "# The following will print :\n",
+    "# [[17  2  3  4]\n",
+    "#  [ 5  6  7  8]\n",
+    "#  [ 9 10 11 12]]\n",
+    "# [[42  2  3  4]\n",
+    "#  [ 5  6  7  8]\n",
+    "#  [ 9 10 11 12]]\n",
+    "# [[17  2]\n",
+    "#  [ 5  6]]\n",
+    "print(a)\n",
+    "print(b)\n",
+    "print(c)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "00fcee14-8d4a-4693-83d2-26e274e5ec8c",
+   "metadata": {},
+   "source": [
+    "While slicing produces a sub-array of the original array, **integer array indexing** provides a way to construct an arbitrary array from the original one."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "92810750-d561-4c76-ab6f-b8cbc961f141",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "a = np.array([[1,2], [3, 4], [5, 6], [7, 8]])\n",
+    "\n",
+    "# An example of integer array indexing.\n",
+    "# The returned array will have shape (3,) and\n",
+    "# the elements a[0, 0], a[2, 1] and a[3, 0]\n",
+    "print(a[[0, 2, 3], \n",
+    "        [0, 1, 0]])  # Prints \"[1 6 7]\"\n",
+    "\n",
+    "# When using integer array indexing, you can use the same\n",
+    "# element from the source array multiple times:\n",
+    "print(a[[0, 0, 0], [1, 1, 1]])  # Prints \"[2 2 2]\"\n",
+    "\n",
+    "# Equivalent to the previous integer array indexing example\n",
+    "print(np.array([a[0, 1], a[0, 1], a[0, 1]]))  # Prints \"[2 2 2]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "13801e62-70fb-4947-a27c-63e7b53c0e61",
+   "metadata": {},
+   "source": [
+    "**Boolean indexing** lets you pick arbitrary elements from an array. This is useful when selecting elements that satisfy some condition."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "65eab8fb-98ff-46ae-a0f7-abe744b012d3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "a = np.array([[1,2], [3, 4], [5, 6], [7, 8]])\n",
+    "\n",
+    "bool_idx = (a > 3) # Returns an numpy array of booleans, having a shape identical to a.\n",
+    "                   # Each element will be True if it satisfies the condition, i.e the corresponding element of a is > 3.\n",
+    "\n",
+    "# This prints\n",
+    "# [[False False]\n",
+    "#  [False  True]\n",
+    "#  [ True  True]\n",
+    "#  [ True  True]]\n",
+    "print(bool_idx)\n",
+    "\n",
+    "# This will construct a 1D array made of the values of a corresponding to the True values in bool_idx\n",
+    "print(a[bool_idx])  # Prints \"[4 5 6 7 8]\"\n",
+    "\n",
+    "# This can also be done in a single statement:\n",
+    "print(a[a > 3])     # Prints \"[4 5 6 7 8]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "7d677384-af91-40e1-b35d-8c0f62919a63",
+   "metadata": {},
+   "source": [
+    "### Data types\n",
+    "Numpy arrays' elements, unlike python lists, are all of the same type. Numpy provides [many types](https://numpy.org/doc/stable/reference/arrays.dtypes.html) that can be used to build an array. You can either let Numpy guess the best datatype for your elements, or explicitly specify it."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8ff11366-992d-4b0f-8198-662759c58bd3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x = np.array([1, 2])   # Let numpy choose the datatype\n",
+    "print(x.dtype)         # Prints \"int64\"\n",
+    "\n",
+    "x = np.array([1.0, 2.0])   # Let numpy choose the datatype\n",
+    "print(x.dtype)             # Prints \"float64\"\n",
+    "\n",
+    "x = np.array([1, 2], dtype=np.float64)   # Force a particular datatype\n",
+    "print(x.dtype)                         # Prints \"float64\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "8530c0db-4133-4117-8c30-07e5eddcc983",
+   "metadata": {},
+   "source": [
+    "### Array operations\n",
+    "\n",
+    "You can perform element-wise mathematical operations on Numpy arrays, either using operator overloads or Numpy functions. If you are familiar with Matlab be careful: `*` is the element-wise multiplication in Numpy, and NOT the matrix multiplication (as it is in Matlab) !\n",
+    "\n",
+    "Check the [full list of mathematical operations](https://numpy.org/doc/stable/reference/routines.math.html)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "0b9a9d7a-b927-4f41-b477-7da5f376c100",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x = np.array([[1,2],[3,4]], dtype=np.float64)\n",
+    "y = np.array([[5,6],[7,8]], dtype=np.float64)\n",
+    "\n",
+    "# Elementwise sum; both produce the array\n",
+    "# [[ 6.0  8.0]\n",
+    "#  [10.0 12.0]]\n",
+    "print(x + y)\n",
+    "print(np.add(x, y))\n",
+    "\n",
+    "# Elementwise difference; both produce the array\n",
+    "# [[-4.0 -4.0]\n",
+    "#  [-4.0 -4.0]]\n",
+    "print(x - y)\n",
+    "print(np.subtract(x, y))\n",
+    "\n",
+    "# Elementwise product; both produce the array\n",
+    "# [[ 5.0 12.0]\n",
+    "#  [21.0 32.0]]\n",
+    "print(x * y)\n",
+    "print(np.multiply(x, y))\n",
+    "\n",
+    "# Elementwise division; both produce the array\n",
+    "# [[ 0.2         0.33333333]\n",
+    "#  [ 0.42857143  0.5       ]]\n",
+    "print(x / y)\n",
+    "print(np.divide(x, y))\n",
+    "\n",
+    "# Elementwise square root; produces the array\n",
+    "# [[ 1.          1.41421356]\n",
+    "#  [ 1.73205081  2.        ]]\n",
+    "print(np.sqrt(x))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e7fcfee6-e5f7-499d-8bb2-27a235459bf0",
+   "metadata": {},
+   "source": [
+    "Reshaping an array can be useful, especially through transposition. Transposition can be achieved using the `T` attribute. Numpy also has a `reshape` function."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "530bbab9-c477-429f-908a-7402391161e1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x = np.array([[1, 0], [2, -1]])\n",
+    "print(x)     # Prints \"[[ 1  0]\n",
+    "             #          [ 2 -1]]\"\n",
+    "print(x.T)   # Prints \"[[ 1  2]\n",
+    "             #          [ 0 -1]]\"\n",
+    "\n",
+    "# Be careful with complex matrices, if you want to get the Hermitian transpose, \n",
+    "# you need to do it explicitely via the H attribute, and not just call .T\n",
+    "z = np.array([[1+1.j, 1.j], [0, -1.j]])\n",
+    "print(z.T)   # Prints \"[[ 1.+1.j  0.+0.j]\n",
+    "             #          [ 0.+1.j -0.-1.j]]\", i.e. this is just a transposition\n",
+    "print(np.conjugate(z.T))  # Prints \"[[ 1.-1.j  0.-0.j]\n",
+    "                          #          [ 0.-2.j -0.+1.j]]\"\n",
+    "    \n",
+    "# reshape examples\n",
+    "a = np.arange(0, 6)\n",
+    "print(a)                       # Prints \"[0 1 2 3 4 5]\"\n",
+    "# reshape the 1D array into a 2D one\n",
+    "print(a.reshape((2,3)))        # Prints \"[[0 1 2]\n",
+    "                               #          [3 4 5]]\"\n",
+    "# reshape a 2D array into a 1D one\n",
+    "print(x.reshape(4))            # Prints \"[ 1  0  2 -1]\"\n",
+    "# You can choose if you want to reshape in C (default for Numpy) or Fortran (default for Matlab) layout, \n",
+    "# i.e. by rows or columns.\n",
+    "print(x.reshape(4, order='F')) # Prints \"[ 1  2  0 -1]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d6dc2e0b-105c-4c1d-a6cf-ae73eed52b71",
+   "metadata": {},
+   "source": [
+    "As stated previously, `*` is the element-wise multiplication. If you want to multiply two matrices, you need to call\n",
+    " `np.matmul` or its shortcut `@` (make sure you checked the course repository's README if you have troubles typing it in Jupyter). `np.dot` computes the inner product between two vectors. It will return the same results as `np.matmul` for matrices (i.e. 2D arrays) but using `np.matmul`or `@` should be preferred."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "bcc63eed-b6ca-4ca5-86c9-482d684cdba9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "a = np.array([[1, 2], [-2, 1]])\n",
+    "b = np.array([[1, 0], [0, -1]])\n",
+    "\n",
+    "# This will print 4 times \n",
+    "# [[ 1 -2]\n",
+    "#  [-2 -1]]\n",
+    "# It will *NOT* work for arrays of dimension greater than 2.\n",
+    "print(np.dot(a, b))\n",
+    "print(np.matmul(a, b))\n",
+    "print(a@b)\n",
+    "print(np.inner(a, b))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "04c0f3a7-a464-4bd4-b7a7-9da71cf69f2c",
+   "metadata": {},
+   "source": [
+    "There are several ways of computing the inner product $\\langle u,v \\rangle = u^Tv = \\sum_i u_i v_i$, but those equivalences hold only for matrices and (often) not for $N$-dimensional arrays (with $N>2$).\n",
+    "\n",
+    "Similarly, you can compute the outer product $uv^T$ using different methods, with the same remarks regarding dimension."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "fa3e7434-1e1f-4610-a1fe-6147665826ed",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "u = np.array([1, 1])       # shape = (2,)\n",
+    "uu = np.array([[1], [1]])  # shape = (2,1), we made sure uu would be a column vector, as in the definitions used in the slides.\n",
+    "v = np.array([-1, 0])      # shape = (2,)\n",
+    "vv = np.array([[-1], [0]]) # shape = (2,1)\n",
+    "# Compute the inner product between 2 vectors\n",
+    "# If you want to compute u^T v, you need to resahpe vectors from (2,) to (2,1),\n",
+    "# since here u.T = u and v.T = v\n",
+    "# This prints twice \"-1\" and once \"[[-1]]\". Using the matrix multiplication returns an array, instead of a scalar.\n",
+    "print(np.dot(u, v))\n",
+    "print(np.inner(u, v))\n",
+    "print(uu.T@vv)\n",
+    "\n",
+    "\n",
+    "# If you want to compute u^T.v you need to reshape vectors from (2,) to (2,1) (or use np.outer)\n",
+    "# This prints 3 times\n",
+    "# [[-1  0]\n",
+    "#  [-1  0]]\n",
+    "print(np.dot(uu, vv.T))\n",
+    "print(np.outer(u, v))\n",
+    "print(uu@vv.T)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "dd8254a1-f9e9-4d9b-8ccc-746ea4a43e98",
+   "metadata": {},
+   "source": [
+    "### Broadcasting\n",
+    "\n",
+    "Broadcasting allows to perform arithmetical operations between arrays of different sizes. Of course you need to follow a rule in order to do so: \n",
+    "\n",
+    "\"In order to broadcast, the size of the trailing axes for both arrays in an operation must either be the same size or one of them must be one.\"\n",
+    "\n",
+    "In the following example, `x.shape` is `(4, 3)` and `v.shape` is `(3,)`, which fulfills the \"equal trailing axis size\" condition."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "437cb02e-ab70-4360-95ef-04d7a78dfb09",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# We will add the vector v to each row of the matrix x,\n",
+    "# storing the result in the matrix y\n",
+    "x = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])\n",
+    "v = np.array([1, 0, 1])\n",
+    "y = x + v  # Add v to each row of x using broadcasting\n",
+    "print(y)  # Prints \"[[ 2  2  4]\n",
+    "          #          [ 5  5  7]\n",
+    "          #          [ 8  8 10]\n",
+    "          #          [11 11 13]]\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "0b6e33b9-df5b-4745-a8a6-04ac8851df20",
+   "metadata": {},
+   "source": [
+    "Implementing this without broadcasting could be done as:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7eb57e40-daf5-4267-8e8f-586de76df4e3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "y = np.empty_like(x)   # Create an empty matrix with the same shape as x\n",
+    "# Add the vector v to each row of the matrix x with an explicit loop\n",
+    "for i in range(4):\n",
+    "    y[i, :] = x[i, :] + v\n",
+    "\n",
+    "# Now y is the following\n",
+    "# [[ 2  2  4]\n",
+    "#  [ 5  5  7]\n",
+    "#  [ 8  8 10]\n",
+    "#  [11 11 13]]\n",
+    "print(y)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "33807f29-227f-463a-994b-658f6271acb7",
+   "metadata": {},
+   "source": [
+    "In addition to being more compact notation-wise, broadcasting allows for faster execution time and reduced memory requirements (useful when you work with bigger matrices).\n",
+    "\n",
+    "In general, for performance reasons, you should try to avoid using `for` loops and use Numpy functions and broadcasting.\n",
+    "\n",
+    "### Exercise\n",
+    "\n",
+    "In order to experience that for yourself, fill the function below that will implement \"naive\" matrix multiplication, using `for` loops (do not use Numpy functions or the `@` operator). Do not forget to check that those matrices can be multiplied together before proceeding with the loops. If you find they are not compatible, you can use the `raise` statement to throw an exception, e.g.\n",
+    "```\n",
+    "def matmul(a, b):\n",
+    "    if <insert test here>:\n",
+    "        raise ValueError('Incompatible sizes')\n",
+    "```\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "10e5409b-35a5-4c39-8fe8-d84f6096a89a",
+   "metadata": {
+    "tags": [
+     "otter_answer_cell"
+    ]
+   },
+   "outputs": [],
+   "source": [
+    "def my_matmul(a, b):\n",
+    "    ..."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "541328cb-5986-4667-9288-673b8026f093",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "source": [
+    "Make sure those examples are giving the expected results"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "879bf25c-02c5-4961-9046-3a367fbd407f",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "outputs": [],
+   "source": [
+    "a = np.ones((2,2))\n",
+    "b = np.eye(2)\n",
+    "c = np.array([[1, -1, 0], [-1, 0, 1]])\n",
+    "print(my_matmul(a,b))\n",
+    "print(a@b)\n",
+    "print(my_matmul(a, c))\n",
+    "print(a@c)\n",
+    "# print(matmul(a, c.T)  # This should generate an exception because of incompatible sizes\n",
+    "# print(a@c.T) # This will generate an exception because of incompatible sizes"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4a47ced4",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "outputs": [],
+   "source": [
+    "grader.check(\"q1\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "bce512cf-2040-4589-94db-f09563ce075c",
+   "metadata": {},
+   "source": [
+    "Once done, we will check its performance when compared to the Numpy version. Let us generate matrices of suitable size filled with random coefficients (feel free to play with the size to see the impact on performance)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "dac07fba-b91d-44ee-877c-9ef8363e048a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "A1 = np.random.rand(100, 200)\n",
+    "A2 = np.random.rand(200, 100)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "675d7469-2cdf-4db8-94cc-e9eb346aec3b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%%timeit\n",
+    "matmul(A1, A2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "284efd22-85cb-4219-bf5d-1c053bf14cbc",
+   "metadata": {},
+   "source": [
+    "The `%%timeit` cell magic will display timing information about the execution of a cell. You can try different matrix sizes to see the impact. In general it is always better to use Numpy native functions (if it is missing, look again in the documentation because it is very likely that you missed it ;) than implementing them yourself. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e951d875-6450-4bc6-b03b-6b4c98c03b8a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%%timeit\n",
+    "A1@A2"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "4ca9606e-f97d-4b54-a28c-ef40f9a6525c",
+   "metadata": {},
+   "source": [
+    "## Matplotlib\n",
+    "[Matplotlib](https://matplotlib.org/) is a library to create plots in Python. It has lots of features, we will therefore only focus on a small subset of them, especially `pyplot` which is a collection of functions that make Matplotlib work like Matlab. Make sure to check the [cheatsheets](https://matplotlib.org/cheatsheets), the [tutorials](https://matplotlib.org/stable/tutorials/index) and of course the [documentation](https://matplotlib.org/stable/api/index.html).\n",
+    "\n",
+    "### Basic plots"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1e2c66bd-fd49-4ca9-82c3-04556f08319f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import matplotlib.pyplot as plt\n",
+    "\n",
+    "# Build some data to be plotted\n",
+    "# x between -2pi and +2pi, sampled with a 0.2 interval\n",
+    "x = np.arange(-2*np.pi, 2*np.pi, 0.2)\n",
+    "y = np.cos(x)\n",
+    "\n",
+    "# you can plot directly the 'y' values (python will fill the 'x' values with indices)\n",
+    "plt.plot(y)\n",
+    "plt.show() # can be omitted but avoids extra info to be displayed"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "538ac798-77f3-4e4c-8f90-38c493a9789a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt.plot(x,y) # if you specify 'x', the x-axis uses those values for display.\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "62ee9b8b-cce4-44ee-90de-9612e11ab8da",
+   "metadata": {},
+   "source": [
+    "It is fairly easy to add legends, axis labels, etc."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7cd1d0b9-55ba-4b45-915a-0de4e438e400",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Compute the x and y coordinates for points on sine and cosine curves\n",
+    "x = np.arange(-2*np.pi, 2*np.pi, 0.2)\n",
+    "y_sin = np.sin(x)\n",
+    "y_cos = np.cos(x)\n",
+    "\n",
+    "# Plot the points using matplotlib\n",
+    "plt.plot(x, y_sin)\n",
+    "plt.plot(x, y_cos)\n",
+    "plt.xlabel('x axis label')\n",
+    "plt.ylabel('y axis label')\n",
+    "plt.title('Sine and Cosine')\n",
+    "plt.legend(['Sine', 'Cosine'])\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "381eca03-226d-4c86-9c79-ca6d7ebf9868",
+   "metadata": {},
+   "source": [
+    "You can style each plot differently (axes, colors, markers, ...). Check the [plot documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for more."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b56af9d3-fb1f-4a33-85fa-33a4798fbeb4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt.plot(x, y_sin, 'r+')\n",
+    "plt.plot(x, y_cos, 'g-')\n",
+    "plt.axis([-10, 10, -2, 2])\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "016e33cd-b947-4737-950e-62b9f2fe3466",
+   "metadata": {},
+   "source": [
+    "### Subplots\n",
+    "A single figure can contain multiple plots thanks to `subplot`\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5f6e754e-c815-4465-a299-282ccea4c162",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Set up a subplot grid that has height 2 and width 1,\n",
+    "# and set the first such subplot as active.\n",
+    "plt.subplot(2, 1, 1)\n",
+    "\n",
+    "# Make the first plot\n",
+    "plt.plot(x, y_sin)\n",
+    "plt.title('Sine')\n",
+    "\n",
+    "# Set the second subplot as active, and make the second plot.\n",
+    "plt.subplot(212) # shortcut notation for plt.subplot(2, 1, 2)\n",
+    "plt.plot(x, y_cos)\n",
+    "plt.title('Cosine')\n",
+    "plt.subplots_adjust(top=1.5) # avoid plot legend collision with axis\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "be159e75-4e55-419c-a36b-3e5d2332dfef",
+   "metadata": {},
+   "source": [
+    "### Visualizing linear transforms\n",
+    "Let us use Matplotlib to visualize the effect of (simple) linear transforms.\n",
+    "We use `mgrid` to generate the x/y coordinates of a regular lattice."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f1f42c59-0d19-4e25-8eac-7528c72da926",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "c = np.mgrid[-5:6, -5:6]\n",
+    "plt.scatter(c[0], c[1]) # 'scatter' plots point clouds\n",
+    "plt.axis([-10, 10, -10, 10])\n",
+    "plt.grid(visible=True) # show the grid lines\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9093b27d-07bd-4015-8308-f6ff76521384",
+   "metadata": {},
+   "source": [
+    "For ease of visualization, our linear transform will be defined by a matrix $A \\in \\mathbb{R}^{2 \\times 2}$ as transformation $\\mathbb{R}^2 \\rightarrow \\mathbb{R}^2$. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b349ad3c-ceba-4911-9610-8071fec5a3c9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "A = np.array([[2., 0.5], [0, 0.5]])\n",
+    "print(A)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "d4c285b9-aedb-42d2-a917-126acb647621",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# coordinates need to be reshaped for matrix multiplication \n",
+    "coords = np.vstack([c[0].ravel(), c[1].ravel()]) # ravel will flatten a 2D array into a 1D one\n",
+    "print(coords.shape)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "14cec790-0d80-42c9-b1fa-4f0ccf851a15",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "coord_transformed = A@coords"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e0a3cd72-8bdb-4f0c-9c0c-a0c7e719ad5a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt.scatter(c[0], c[1]) # 'scatter' plots point clouds\n",
+    "plt.axis('equal') # display x and y axes with equal steps\n",
+    "plt.scatter(coord_transformed[0, :], coord_transformed[1, :], marker='+', color='r')\n",
+    "plt.grid(visible=True) # show the grid lines\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "56299471-4125-48fc-ab97-f7c6037c95a3",
+   "metadata": {},
+   "source": [
+    "For future use, let us wrap this in a function (you may tweak it according to your needs/preferences)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f35fb90b-d021-4cc5-87c6-3232a1bc8ac6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# expects an input grid produced via mgrid\n",
+    "def visualize_transform_grid(input_matrix, input_grid):\n",
+    "    coords = np.vstack([input_grid[0].ravel(), input_grid[1].ravel()])\n",
+    "    coords_tr = input_matrix@coords\n",
+    "    plt.scatter(input_grid[0], input_grid[1])\n",
+    "    plt.axis('equal')\n",
+    "    plt.scatter(coords_tr[0, :], coords_tr[1, :], marker='+', color='r')\n",
+    "    plt.grid(visible=True) # show the grid lines\n",
+    "    plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c548ea41-003c-4184-bc6c-0121d09d8506",
+   "metadata": {},
+   "source": [
+    "Let us visualize the effect of a transformation using an \"ellipse plot\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "616fe759-be09-4db8-b74c-114ca9a0d0b0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# v1 and v2 are the indices of two unit vectors\n",
+    "def visualize_transform_ellipse(input_matrix, v1=65, v2=100):\n",
+    "    # Creating the vectors for a circle and storing them in x\n",
+    "    xi1 = np.linspace(-1.0, 1.0, 100)\n",
+    "    xi2 = np.linspace(1.0, -1.0, 100)\n",
+    "    yi1 = np.sqrt(1 - xi1**2)\n",
+    "    yi2 = -np.sqrt(1 - xi2**2)\n",
+    "\n",
+    "    xi = np.concatenate((xi1, xi2), axis=0)\n",
+    "    yi = np.concatenate((yi1, yi2), axis=0)\n",
+    "    x = np.vstack((xi, yi))\n",
+    "\n",
+    "    # getting two samples vector from x\n",
+    "    x_sample1 = x[:, v1]\n",
+    "    x_sample2 = x[:, v2]\n",
+    "    \n",
+    "    # compute the action of A on x\n",
+    "    t = input_matrix @ x\n",
+    "    \n",
+    "    # find transformed sample vectors\n",
+    "    t_sample1 = t[:, v1]\n",
+    "    t_sample2 = t[:, v2]\n",
+    "    \n",
+    "    # plot the result\n",
+    "    f, (ax1, ax2) = plt.subplots(1, 2, sharex=True)\n",
+    "    ax1.plot(x[0,:],x[1,:], color='black')\n",
+    "    ax1.axis('equal')\n",
+    "    ax1.quiver(x_sample1[0], x_sample1[1], angles='xy', scale_units='xy', scale=1, color='b')\n",
+    "    ax1.quiver(x_sample2[0], x_sample2[1], angles='xy', scale_units='xy', scale=1, color='r')\n",
+    "    ax1.grid()\n",
+    "\n",
+    "    ax2.plot(t[0,:],t[1,:],color='black')\n",
+    "    ax2.axis('equal')\n",
+    "    ax2.quiver(t_sample1[0], t_sample1[1], angles='xy', scale_units='xy', scale=1, color='b')\n",
+    "    ax2.quiver(t_sample2[0], t_sample2[1], angles='xy', scale_units='xy', scale=1, color='r')\n",
+    "    ax2.grid()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a827f052-58fa-41d2-a94d-42dbae2d2140",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# change the values of v1 and v2 to see the effect depending on the orientation\n",
+    "visualize_transform_ellipse(A, 75, 100) "
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "a2ecaec6-1d68-49a3-ae2f-31ff96091ff9",
+   "metadata": {},
+   "source": [
+    "The blue vector gets rotated and stretched but the red horizontal vector is only stretched:\n",
+    "$\n",
+    "A x_2 = \\lambda x_2\n",
+    "$\n",
+    "In this example that first eigenvector was easy to spot since reading the columns of the matrix we see that the vector $(1,0)$ is mapped to $(2,0)$, i.e. it is an eigenvector with eigenvalue 2."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6731f49f-f631-44cb-9b2f-f2c636311023",
+   "metadata": {},
+   "source": [
+    "### Exercise\n",
+    "Let us study one particular transform, defined by the following matrix:\n",
+    "\n",
+    "$$B = \\begin{pmatrix} \\frac{1}{2} & -\\frac{\\sqrt{3}}{2} \\\\ \\frac{\\sqrt{3}}{2} & \\frac{1}{2} \\end{pmatrix}$$\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "37da1312-a192-40dc-892c-a759a9e571a3",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "source": [
+    "<!-- BEGIN QUESTION -->\n",
+    "\n",
+    "*Question:* What are the properties of this matrix ? What is the effect of this transformation (you can use the visualization functions defined previously) ?"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "0bc1090d",
+   "metadata": {
+    "tags": [
+     "otter_answer_cell"
+    ]
+   },
+   "source": [
+    "_Type your answer here, replacing this text._"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "abd49697",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "source": [
+    "<!-- END QUESTION -->\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8b4a8658-7242-459d-96ab-75692a91e0e0",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "outputs": [],
+   "source": [
+    "B = np.array([[0.5, -0.5*np.sqrt(3)],[0.5*np.sqrt(3), 0.5]])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "440262bf-caab-4fd3-9e11-8ab411387ea3",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "source": [
+    "Compute the matrix of the inverse transformation (without using Numpy's builtin `numpy.linalg.inv`). Hint: remember the properties of $B$."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "d3bdce77-a2a3-43cb-bd41-9e5b9d0d3af5",
+   "metadata": {
+    "tags": [
+     "otter_answer_cell"
+    ]
+   },
+   "outputs": [],
+   "source": [
+    "B_inv = ..."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "81be4c98-b933-47c1-ae60-18f05f396855",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "outputs": [],
+   "source": [
+    "# verify that you are correct\n",
+    "B@B_inv"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b75d9858",
+   "metadata": {
+    "deletable": false,
+    "editable": false
+   },
+   "outputs": [],
+   "source": [
+    "grader.check(\"q3\")"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.10"
+  },
+  "otter": {
+   "OK_FORMAT": true,
+   "tests": {
+    "q1": {
+     "name": "q1",
+     "points": null,
+     "suites": [
+      {
+       "cases": [
+        {
+         "code": ">>> a = np.ones((2, 2))\n>>> b = np.eye(2)\n>>> d = np.arange(1, 5).reshape((2, 2))\n>>> np.testing.assert_array_almost_equal(my_matmul(a, b), a @ b, err_msg='Something seems wrong, check your implementation !')\n>>> np.testing.assert_array_almost_equal(my_matmul(a, d), a @ d, err_msg='Something seems wrong, check your implementation !')\n",
+         "hidden": false,
+         "locked": false
+        },
+        {
+         "code": ">>> c = np.array([[1, -1, 0], [-1, 0, 1]])\n>>> np.testing.assert_array_almost_equal(my_matmul(a, c), a @ c, err_msg='Something seems wrong, check your implementation !')\n",
+         "hidden": false,
+         "locked": false
+        },
+        {
+         "code": ">>> from unittest.mock import patch\n>>> def check_matmul(a, b):\n...     with patch('numpy.matmul') as mock_matmul:\n...         my_matmul(a, b)\n...         mock_matmul.assert_not_called()\n>>> check_matmul(a, b)\n",
+         "failure_message": "Do not use numpy.matmul in your function",
+         "hidden": false,
+         "locked": false,
+         "success_message": "Good, you implemented the matrix multiplication without using numpy.matmul"
+        },
+        {
+         "code": ">>> from unittest.mock import patch\n>>> def check_matmul2(a, b):\n...     with patch('numpy.ndarray') as mock_arr:\n...         ap = mock_arr(a)\n...         ap.shape = a.shape\n...         with patch.object(ap, '__matmul__') as mock_mm:\n...             c = my_matmul(ap, b)\n...             mock_mm.assert_not_called()\n>>> check_matmul2(a, b)\n",
+         "failure_message": "Do not use the '@' operator in your function",
+         "hidden": false,
+         "locked": false,
+         "success_message": "Good, you implemented the matrix multiplication without using '@'"
+        },
+        {
+         "code": ">>> with np.testing.assert_raises(ValueError):\n...     a = np.ones((2, 2))\n...     b = np.eye(3)\n...     my_matmul(a, b)\n",
+         "failure_message": "Did you forget to validate the input size before performing the computation ?",
+         "hidden": false,
+         "locked": false,
+         "success_message": "Good, you properly validated sizes before computing the result"
+        },
+        {
+         "code": ">>> from unittest.mock import patch\n>>> def check_matmul3(a, b):\n...     with patch('numpy.dot') as mock_matmul:\n...         my_matmul(a, b)\n...         mock_matmul.assert_not_called()\n>>> check_matmul3(a, b)\n",
+         "failure_message": "Do not use numpy.dot in your function",
+         "hidden": false,
+         "locked": false,
+         "success_message": "Good, you implemented the matrix multiplication without using numpy.dot"
+        }
+       ],
+       "scored": true,
+       "setup": "",
+       "teardown": "",
+       "type": "doctest"
+      }
+     ]
+    },
+    "q3": {
+     "name": "q3",
+     "points": null,
+     "suites": [
+      {
+       "cases": [
+        {
+         "code": ">>> np.testing.assert_array_almost_equal(B @ B_inv, np.eye(2), err_msg='Check your answer...')\n",
+         "hidden": false,
+         "locked": false
+        }
+       ],
+       "scored": true,
+       "setup": "",
+       "teardown": "",
+       "type": "doctest"
+      }
+     ]
+    }
+   }
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}