From 2e19192ad9579d689bcd09e3626157c604055238 Mon Sep 17 00:00:00 2001
From: radoskov <radoslav.skoviera@cvut.cz>
Date: Wed, 12 Mar 2025 00:53:06 +0100
Subject: [PATCH] Uploaded first half of lecture 04

---
 src/pge_lectures/lecture_04/_quarto.yml       |   13 +
 .../lecture_04/l4_numpy_arrays.ipynb          | 1365 +++++++++++++++++
 .../lecture_04/l4_numpy_arrays.qmd            |  782 ++++++++++
 3 files changed, 2160 insertions(+)
 create mode 100644 src/pge_lectures/lecture_04/_quarto.yml
 create mode 100644 src/pge_lectures/lecture_04/l4_numpy_arrays.ipynb
 create mode 100644 src/pge_lectures/lecture_04/l4_numpy_arrays.qmd

diff --git a/src/pge_lectures/lecture_04/_quarto.yml b/src/pge_lectures/lecture_04/_quarto.yml
new file mode 100644
index 0000000..7f6c2b2
--- /dev/null
+++ b/src/pge_lectures/lecture_04/_quarto.yml
@@ -0,0 +1,13 @@
+project:
+  type: default
+
+format:
+  html:
+    theme: cosmo
+    toc: true
+    code-fold: false
+  pdf:
+    code-fold: false
+    toc: true
+
+jupyter: python3
\ No newline at end of file
diff --git a/src/pge_lectures/lecture_04/l4_numpy_arrays.ipynb b/src/pge_lectures/lecture_04/l4_numpy_arrays.ipynb
new file mode 100644
index 0000000..3245512
--- /dev/null
+++ b/src/pge_lectures/lecture_04/l4_numpy_arrays.ipynb
@@ -0,0 +1,1365 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "---\n",
+        "title: Programming for Engineers\n",
+        "subtitle: \"Lecture 4 - NumPy\"\n",
+        "author: Radoslav Ĺ koviera\n",
+        "\n",
+        "format:\n",
+        "  pdf:\n",
+        "    code-fold: false\n",
+        "  html:\n",
+        "    code-fold: false\n",
+        "jupyter: python3\n",
+        "toc: true\n",
+        "---\n",
+        "\n",
+        "# Lecture 4 - NumPy\n",
+        "\n",
+        "## Introduction to NumPy\n",
+        "\n",
+        "The `NumPy` library enhances the Python with support for numerical operations. It is especially useful for large (multi-dimensional) arrays and matrices.\n",
+        "Much of it is actually programmed in C and uses vectorization (parallelization) for efficient computations.\n",
+        "The arrays are typed, which puts a restriction on what can be stored in them (unlike Python lists). On the other hand,\n",
+        "it they are memory efficient and faster to work with. They also allow special manipulation, like **reshaping** and **broadcasting**.\n",
+        "\n",
+        "This lecture covers only the basic operations and concepts.\n",
+        "Numpy offers many other functions, operations, tricks and convenience methods.\n",
+        "You can find more information in the [NumPy documentation](https://numpy.org/doc/stable/reference/).\n",
+        "\n",
+        "Installation: `pip install numpy`\n",
+        "\n",
+        "Import:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "import numpy as np"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Array Creation\n",
+        "\n",
+        "### 1D Arrays\n",
+        "\n",
+        "#### From Python Lists:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.array([1, 2, 3, 4, 5])\n",
+        "print(\"1D numpy array:\", a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Direct Creation with `np.r_`:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.r_[1, 2, 3, 4, 5]\n",
+        "print(\"1D numpy array:\", a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The `np.r_` function (notice, it's actually not a function) creates a 1D array from a list of values. It can also be used to create more complex arrays (reader is encouraged to check the `NumPy documentation <https://numpy.org/doc/stable/reference/generated/numpy.r_.html>`_ for more details).\n",
+        "\n",
+        "#### Using Ranges and Linspace:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a_range = np.arange(0, 10, 2)  # same as np.array(range(0, 10, 2))\n",
+        "a_range_float = np.arange(0.5, 11.5, 2.5)  # also support floats\n",
+        "a_lin = np.linspace(0, 1, 5)  # 5 values, evenly spaced 0 to 1\n",
+        "print(\"arange:\", a_range)\n",
+        "print(\"arange with floats:\", a_range_float)\n",
+        "print(\"linspace:\", a_lin)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The function `np.arange` has the same signature as the Python `range` function: `np.arange(start, stop, step)`.\n",
+        "The `np.linspace` function creates a sequence of evenly-spaced values, between start and stop. The signature is `np.linspace(start, stop, num=100, endpoint=True)`, where `num` is the number of samples to generate and `endpoint` is a boolean that indicates whether to include the stop value in the sequence (`False` for similar behavior to `range`).\n",
+        "\n",
+        "`np.r_` can also be used to generate sequences, if instead of a sequence of numbers, a slice is passed as an argument.\n",
+        "This slice has the same format as in the Python `range` function: `start:stop:step`. However, unlike for `range` the values do not have integers and the can even be **imaginary numbers** - this makes this operation behave like `linspace`."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.r_[1:10:2]\n",
+        "b = np.r_[0:1:0.1]\n",
+        "c = np.r_[0:1:5j]\n",
+        "print(\"values from 1 to 10, with step 2:\", a)\n",
+        "print(\"values from 0 to 1, with step 0.1:\", b)\n",
+        "print(\"5 values, evenly spaced between 0 and 1:\", c)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "`np.r_` can also be used to quickly combine multiple lists and arrays into a 1D array - by default, it \"flattens\" all the input arrays and lists."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = [1, 2, 3]  # a Python list\n",
+        "b = np.array([4, 5, 6])  # a 1D numpy array\n",
+        "c = np.r_[7, 8, 9]  # a 1D numpy array\n",
+        "\n",
+        "d = np.r_[a, b, c]\n",
+        "print(\"combined array:\", d)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "You can even combine both the range creation and concatenation of lists and arrays:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "e = np.r_[a, b, c, 10, 11, 12:20:2, 20:30:6j]\n",
+        "print(\"combined array:\", e)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Pre-filled Arrays:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "zeros = np.zeros(5)\n",
+        "ones = np.ones(5)\n",
+        "sevens = np.full(5, 7)  # the same as np.ones(5) * 7\n",
+        "print(\"Zeros:\", zeros)\n",
+        "print(\"Ones:\", ones)\n",
+        "print(\"Sevens:\", sevens)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Random Arrays:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "rand = np.random.rand(5)\n",
+        "randn = np.random.randn(5)\n",
+        "randint = np.random.randint(0, 10, 5)\n",
+        "print(\"5 random values in range [0, 1):\", rand)\n",
+        "print(\"5 random normally distributed values:\", randn)\n",
+        "print(\"5 random integers in range [0, 10):\", randint)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### 2D Arrays (Matrices)\n",
+        "\n",
+        "#### From Nested Lists:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "array_2d = np.array(\n",
+        "    [\n",
+        "        [1, 2, 3],\n",
+        "        [4, 5, 6],\n",
+        "        [7, 8, 9]\n",
+        "    ]\n",
+        ")\n",
+        "print(\"2D Array:\\n\", array_2d)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### The `numpy.matrix` Class\n",
+        "There is also a specific class for matrices in numpy - `numpy.matrix`. It contains some matrix-specific syntax sugar but it is also more \"cumbersome\" and thus normally not recommended (unless you need nicer syntax and outputs)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "matrix = np.matrix(array_2d)\n",
+        "print(\"Matrix:\\n\", matrix)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "One of the 'advantages' is that standard operations on matrix are automatically treated as matrix operations. For example,\n",
+        "multiplication and division for arrays are elementwise, while in matrices they are matrix multiplication and matrix division."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(\"Array multiplication:\\n\", array_2d * array_2d)\n",
+        "print(\"Matrix multiplication:\\n\", matrix * matrix)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Pre-filled 2D Arrays\n",
+        "Similarly to 1D arrays, you can create pre-filled and random 2D arrays:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# all matrices have 3 rows and 4 columns\n",
+        "zeros = np.zeros((3, 4))\n",
+        "ones = np.ones((3, 4))\n",
+        "sevens = np.full((3, 4), 7)\n",
+        "rand = np.random.rand(3,4)\n",
+        "randn = np.random.randn(3,4)\n",
+        "randint = np.random.randint(0, 10, (3, 4))\n",
+        "print(\"Zeros:\\n\", zeros)\n",
+        "print(\"Ones:\\n\", ones)\n",
+        "print(\"Sevens:\\n\", sevens)\n",
+        "print(\"Random:\\n\", rand)\n",
+        "print(\"Random normally distributed:\\n\", randn)\n",
+        "print(\"Random integers:\\n\", randint)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Reshaping 1D Arrays\n",
+        "\n",
+        "You can also take a 1D array and **reshape** it into a 2D array\n",
+        "with the `reshape` array method. The reshape takes the height and width (number of rows and columns) as arguments. And example of use case is getting a 1D list of byte values and reshaping it into a 2D array of pixel intensities for an image. This is how some image formats store images.\n",
+        "Reshape organizes the values row-by-row. You have to make sure that there is enough values to fill the array, i.e., `height * width == len(array_1d)`."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(12).reshape(3, 4)\n",
+        "print(\"Reshaped array:\\n\", a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The following would throw an error, because there is not enough values to create a 2D array of the shape (4, 5):\n",
+        "```python\n",
+        "a = np.arange(12).reshape(4, 5)\n",
+        "```\n",
+        "\n",
+        "You can also **omit** one of the dimensions when reshaping, in which case the other dimension will be calculated automatically. In this case, the length of the input (1D) array must be divisible by the specified value."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(12)\n",
+        "print(\"Reshaped array (3, -1):\\n\", a.reshape(3, -1))\n",
+        "print(\"Reshaped array (-1, 4):\\n\", a.reshape(-1, 4))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The following would throw an error, because the length of the input array length is not divisible by 5:\n",
+        "```python\n",
+        "a = np.arange(12).reshape(-1, 5)\n",
+        "a = np.arange(12).reshape(5, -1)\n",
+        "```\n",
+        "\n",
+        "#### Indexing trick `np.r_`\n",
+        "\n",
+        "The `np.r_` operator can be used to create a 2D array as well. If all inputs are 2D, the can be directly concatenated. The concatenation is row-wise by default."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(6).reshape(2, -1)\n",
+        "b = np.arange(6, 12).reshape(2, -1)\n",
+        "print(f\"a =\\n{a}\")\n",
+        "print(f\"b =\\n{b}\")\n",
+        "print(\"Concatenated:\\n\", np.r_[a, b])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The inputs can be also 1D arrays or a mix of 1D and 2D arrays. In this case, a string **directive** is specified as the first \"argument\". It determines how the arrays are concatenated.\n",
+        "The format of the directive is as follows:\n",
+        "```python\n",
+        "'axis,min_dim,transpose_spec'\n",
+        "```\n",
+        "where:\n",
+        "* `axis` - the **axis** along which the arrays (remaining arguments) are **concatenated**\n",
+        "* `min_dim` is the **minimum dimension**. Inputs with lower dimension are first \"upgraded\" to this dimension\n",
+        "* `transpose_spec` specifies how lower-dimensional inputs are transposed before concatenation. It can be omitted, in which case it is set to `-1`.\n",
+        "\n",
+        "For example, a row-wise stacking into a 2D array would be specified as:\n",
+        "```python\n",
+        "np.r_['0,2', a, b, ...]\n",
+        "```\n",
+        "where `a` and `b` are 1D arrays. Note that the concatenated arrays must have the same length.\n",
+        "\n",
+        "For the `axis`, 0 means row-wise and 1 means column-wise (can be higher for multi-dimensional arrays).\n",
+        "For 2D arrays, the `min_dim` must be set to 2. This means that all 1D inputs are first \"upgraded\" to 2D. Already 2D inputs are not changed.\n",
+        "The last argument specifies \"where to put\" the upgraded 1D inputs.\n",
+        "The default value is `-1`, which means that for 2D case. the 1D inputs are upgraded to have the dimension of [1, N], where N is the length of the 1D input. If we change it to `0`, the 1D inputs are upgraded to have the dimension of [N, 1]. This happens before concatenation and this it will affect how the result will be shaped.\n",
+        "\n",
+        "For example, for K inputs, each being 1D with length N, the spec \"0,2,-1\" will result in a 2D array of shape (K, N):\n",
+        "- row-wise stacking\n",
+        "- upgrade 1D inputs to 2D with dimension [1, N] (the last \"-1\" means \"put the 1D inputs into the last dimension\")\n",
+        "\n",
+        "The spec \"1,2,0\" will result in a 2D array of shape (N, K):\n",
+        "- column-wise stacking\n",
+        "- upgrade 1D inputs to 2D with dimension [N, 1] (the last \"0\" means \"put the 1D inputs into the first dimension\")\n",
+        "\n",
+        "To make it perhaps more clear, for 3D case, the spec \"0,3,-1\" (the same as writing \"0,3\") will upgrade 1D input of length N to a 3D array of shape [1, 1, N]. It will also upgrade 2D inputs of shape [M, N] to a 3D array of shape [1, M, N]. The spec \"0,3,0\" will upgrade 1D input of length N to a 3D array of shape [N, 1, 1]. It will also upgrade 2D inputs of shape [M, N] to a 3D array of shape [M, N, 1]. Lastly, the spec \"0,3,1\" will upgrade 1D input of length N to a 3D array of shape [1, N, 1]. For 2D inputs, it is the same as \"0,3,-1\".\n",
+        "\n",
+        "Additionally, you can also \"append\" 2D arrays or lists. Although, these must also have each row (or column) of the same length.\n",
+        "\n",
+        "Here, we combine multiple items into one 2D array."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.array([1, 2, 3])\n",
+        "c = [7, 8, 9]\n",
+        "d = np.r_[\n",
+        "    '0,2',  # row-wise 2D array\n",
+        "    a,      # 1D array\n",
+        "    4:7,    # range(4, 7)\n",
+        "    c,      # list\n",
+        "    [10, 11, 12],  # list, directly specified\n",
+        "    [[13, 14, 15], [16, 17, 18]],  # 2D list\n",
+        "    np.array([[19, 20, 21], [22, 23, 24]]),  # 2D array\n",
+        "    25:30:3j  # range with 3 evenly spaced values\n",
+        "]\n",
+        "print(\"Row-wise stacking:\\n\", d)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "You can also stack the items column-wise:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "e = np.r_[\n",
+        "    '1,2,0',  # column-wise 2D array\n",
+        "    a,      # 1D array\n",
+        "    4:7,    # range(4, 7)\n",
+        "    c,      # list\n",
+        "    [10, 11, 12],  # list, directly specified\n",
+        "    [[13, 14], [15, 16], [17, 18]],  # 2D list\n",
+        "    np.array([[19, 20], [21, 22], [23, 24]]),  # 2D array\n",
+        "    25:30:3j  # range with 3 evenly spaced values\n",
+        "]\n",
+        "print(\"Column-wise stacking:\\n\", e)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Note that the 2D arrays must have the same number of columns, in the column.wise stacking case.\n",
+        "\n",
+        "More explanation of `np.r_` can be found at [https://rmoralesdelgado.com/all/numpy-concatenate-r_-c_/](https://rmoralesdelgado.com/all/numpy-concatenate-r_-c_/).\n",
+        "\n",
+        "### 3D Arrays\n",
+        "\n",
+        "All the previously shown methods for 1 an 2D arrays also work for 3D arrays. Actually, you can create an array of any dimension with similar techniques.\n",
+        "\n",
+        "#### From Nested Lists:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "array_3d = np.array(\n",
+        "    [\n",
+        "        [  # first 'slice' of the 3D matrix\n",
+        "            [1, 2, 3], # first row of the first slice\n",
+        "            [4, 5, 6],\n",
+        "            [7, 8, 9]\n",
+        "        ],\n",
+        "        [  # second 'slice' of the 3D matrix\n",
+        "            [10, 11, 12], # first row of the second slice\n",
+        "            [13, 14, 15],\n",
+        "            [16, 17, 18]\n",
+        "        ]\n",
+        "    ]\n",
+        ")\n",
+        "print(\"3D Array:\\n\", array_3d)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### From 1D arrays using reshape\n",
+        "\n",
+        "Similarly to 2D, make sure that there is enough values to create a 3D array of the shape (height, width, depth). Also, you can omit one of the dimensions when reshaping, in which case the other dimension will be calculated automatically."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(24).reshape(2, 3, 4)\n",
+        "print(\"3D Array shape:\", a.shape)\n",
+        "print(a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Pre-filled 3D Arrays\n",
+        "\n",
+        "Each of these arrays has a shape of (2, 3, 4)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.zeros((2, 3, 4))\n",
+        "b = np.ones((2, 3, 4))\n",
+        "c = np.full((2, 3, 4), 5)\n",
+        "d = np.random.rand(2, 3, 4)\n",
+        "e = np.random.randn(2, 3, 4)\n",
+        "f = np.random.randint(0, 10, (2, 3, 4))\n",
+        "print(\"Zeros:\\n\", a)\n",
+        "print(\"Ones:\\n\", b)\n",
+        "print(\"Fives:\\n\", c)\n",
+        "print(\"Random:\\n\", d)\n",
+        "print(\"Random normal:\\n\", e)\n",
+        "print(\"Random integers:\\n\", f)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Using `np.r_`\n",
+        "\n",
+        "Similarly to previous cases, you can use `np.r_` to create 3D arrays."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "array_3d = np.r_[\n",
+        "    \"0,3\",\n",
+        "    np.arange(12).reshape(3, 4),\n",
+        "    np.arange(12, 24).reshape(3, 4),\n",
+        "]\n",
+        "print(\"3D Array:\\n\", array_3d)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Array size\n",
+        "\n",
+        "The default function `len` will only reveal the number of rows of the array, not the number of elements (this is because of how iterating over the arrays work). To get the total number of elements, use the `size` attribute:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.random.rand(2, 3, 4)\n",
+        "print(\"Array size:\", a.size)  # = 2 * 3 * 4"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "To see the sizes of the array along each dimension, you can use the `shape` attribute:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.random.rand(2, 3, 4)\n",
+        "print(\"Array shape:\", a.shape)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Finally, to get the number of dimensions of the array, use the `ndim` attribute:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.random.rand(2, 3, 4)\n",
+        "print(\"Array dimensions:\", a.ndim)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Data type\n",
+        "\n",
+        "By default, Numpy automatically determines the \"best\" **data type** for a given array, based on the provided values. E.g., if all the values are integers, it will be an integer array. If any of the values is a float, it will be a float array.\n",
+        "Strings and chars are also supported. You can also create mixed arrays, in which case the data type will be `object` (though, this is not recommended).\n",
+        "\n",
+        "Often, however, you will need to specify the data type explicitly. There are three main reasons for this:\n",
+        "\n",
+        "- You want to avoid automatic type conversion.\n",
+        "- You want to control the memory usage.\n",
+        "- Specific data type is required for your specific application (e.g., if you need to store binary or image data).\n",
+        "\n",
+        "Automatic data type conversion might happen, for example, when you append a numeric value to a string array."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "string_array = np.array(\"a b c d\".split() + [5.0])\n",
+        "string_array = np.concat((string_array, np.r_[7]))\n",
+        "print(\"String array:\", string_array)\n",
+        "print(\"Data type:\", string_array.dtype)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Notice that both `5.0` and `7` where converted to strings. `U32` means fixed-length unicode string of 32 characters - that is 32x4 bytes for each item. Numpy arrays store string data in strings of equal length -\n",
+        "the length is determined and expanded automatically based on the maximum length of the string in the array.\n",
+        "\n",
+        "The memory issue comes into place when working with very large arrays, for example, images or large data sets.\n",
+        "\n",
+        "First, create a helper function to print the data type and memory usage:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "import sys\n",
+        "\n",
+        "def print_memory_usage(array):\n",
+        "    print(\n",
+        "        f\"Memory usage for data type '{array.dtype}':\",\n",
+        "        f\"{sys.getsizeof(array) / 1024**2:0.3f} MB\"\n",
+        "    )"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The `sys.getsizeof` function returns the size in bytes that the object takes up in the memory.\n",
+        "\n",
+        "Let's create a matrix with *three million* elements (1000x1000x3) of byte values (0-255, e.g., typical image)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "import sys\n",
+        "\n",
+        "integer_array = np.random.randint(0, 256, (1000, 1000, 3))\n",
+        "print_memory_usage(integer_array)\n",
+        "# convert the integer array to bytes\n",
+        "byte_array = integer_array.astype(np.uint8)\n",
+        "print_memory_usage(byte_array)\n",
+        "print(np.allclose(integer_array, byte_array))  # make sure there is now data loss"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The default string data type - unicode - is also less memory efficient - 4 bytes are needed for each character. It might be useful to change if you need to store large text data with only ASCII characters."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "import string  # import the string module and get the list of ASCII letters\n",
+        "letters = np.array(list(string.ascii_letters))\n",
+        "\n",
+        "random_text = np.random.choice(letters, int(1e6))  # 1 million characters\n",
+        "print_memory_usage(random_text)\n",
+        "random_bytes = random_text.astype(np.bytes_)  # 1-byte characters\n",
+        "print_memory_usage(random_bytes)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Setting the data type\n",
+        "\n",
+        "You can set data type manually using the `dtype` parameter, when creating the array."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "short_int_array = np.array([1, 2, 3], dtype=np.int16)\n",
+        "print_memory_usage(short_int_array)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Some other Numpy functions also support the `dtype` parameter. For example, `np.random.randint`:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "random_byte_array = np.random.randint(0, 256, (1000, 1000, 3), dtype=np.uint8)\n",
+        "print_memory_usage(random_byte_array)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Converting data types\n",
+        "\n",
+        "Existing arrays can also be converted to different data types with the `astype` array method. For example:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "int_array = np.array([1, 2, 3])\n",
+        "float_array = int_array.astype(np.float32)\n",
+        "print(\"Integer array:\", int_array)\n",
+        "print(\"Float array:\", float_array)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Defining the data type\n",
+        "\n",
+        "You have already seen a few data types. Basic data types are simply provided by their Python *type* name (e.g., `int`, `float`, `str`, `bool`). For more specific types, such as integer with specific number of bits, you can use the types defined by Numpy (e.g., `np.int32`, `np.float64`).\n",
+        "You can also specify a data type using `np.dtype` function. It takes the data type code as an argument (e.g., `np.dtype('int32')`). You have seen some of these codes when we were printing the size of arrays of different types.\n",
+        "\n",
+        "For reference, here is a list of **base codes**:\n",
+        "\n",
+        "**Character Code**:\n",
+        "  - `i`: Signed integer.\n",
+        "  - `u`: Unsigned integer.\n",
+        "  - `f`: Float.\n",
+        "  - `c`: Complex.\n",
+        "  - `b`: Boolean.\n",
+        "  - `S`: Bytes (ASCII string).\n",
+        "  - `U`: Unicode string.\n",
+        "  - `M`: Datetime.\n",
+        "  - `m`: Timedelta.\n",
+        "  - `O`: Python object.\n",
+        "  - `V`: Void (raw data).\n",
+        "\n",
+        "Of these, you will most likely only use 'i', 'u', 'f' and maybe 'U',\n",
+        "which can be defined directly: `np.int<bits>`, `np.uint<bits>`, `np.float<bits>`, `np.str_` (substitute `<bits>` with the number of bits used by the specific data type).\n",
+        "\n",
+        "Nonetheless, it is useful to know the other types in case your array \"get converted to\" one of those and you will see that code when you inspect the `dtype` attribute of the array.\n",
+        "\n",
+        "For completeness, the code might also include `<`, `>`, `|`. This indicates the bit ordering (little or big endian).\n",
+        "\n",
+        "\n",
+        "## Indexing\n",
+        "\n",
+        "Indexing works in similar way to the Python lists, i.e., the \"usual\" indexing and slicing. The difference is that for multi-dimensional lists the indexing is:\n",
+        "```python\n",
+        "list_2d[row][column]\n",
+        "```\n",
+        "while for multi-dimensional Numpy arrays the indexing is:\n",
+        "```python\n",
+        "array_2d[row, column]\n",
+        "```\n",
+        "That is, packed into single square brackets.\n",
+        "\n",
+        "### Simple indexing"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.array([1, 2, 3, 4, 5])\n",
+        "print(\"Element at index 0:\", a[0])\n",
+        "print(\"Element at index -1:\", a[-1])\n",
+        "\n",
+        "b = np.array([[1, 2, 3], [4, 5, 6]])\n",
+        "print(\"Element at row 1, col 2:\", b[1, 2])\n",
+        "print(\"Element at row -1, col 0:\", b[-1, 0])\n",
+        "print(\"Element at row -1 (last row), col -2 (second to last):\", b[-1, -2])\n",
+        "\n",
+        "c = np.random.rand(3, 4, 5)\n",
+        "print(\"Element at row 1, col 2, depth 3:\", c[1, 2, 3])\n",
+        "print(\"Element at row 0, col 0, depth 0:\", c[0, 0, 0])\n",
+        "print(\"Element at row -1, col -1, depth -1:\", c[-1, -1, -1])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Slicing\n",
+        "\n",
+        "One bid advantage of Numpy arrays is that you can create multi-dimensional slices of multi-dimensional arrays. For example:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "m = np.random.rand(3, 4, 5)\n",
+        "print(\"First row of m:\\n\", m[0, ...])  # same as m[0, :, :] or m[0]\n",
+        "print(\"First column of m:\\n\", m[:, 0])  # same as m[:, 0, :]\n",
+        "print(\"First depth slice of m:\\n\", m[..., 0])  # same as m[:, :, 0]"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "All of these are 2D matrices - slices from a 3D array."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(m[:, :2, -2:])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "If this seems a little confusing - each element in the brackets (separated by comma), specifies slice for the specific dimension. Otherwise, the slicing principle is the same as for Python lists.\n",
+        "\n",
+        "\n",
+        "### Assignment\n",
+        "\n",
+        "The assignment works in the same way as in Python lists. However, you can directly assign to multi-dimensional slices of multi-dimensional arrays.\n",
+        "What's more, you can either assign scalars (each value of the slice will be set to this value) or multi-dimensional arrays (of the same dimension as the slice).\n",
+        "\n",
+        "Scalar assignment to a slice along a single dimension:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(24).reshape(2, 3, 4)\n",
+        "a[0, ...] = 1  # same as a[0, :, :] or a[0]\n",
+        "print(a)\n",
+        "a[:, 0] = 2  # same as a[:, 0, :]\n",
+        "print(a)\n",
+        "a[..., 0] = 3  # same as a[:, :, 0]\n",
+        "print(a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Scalar assignment to a 2D slice:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(24).reshape(2, 3, 4)\n",
+        "a[:, :2, -2:] = 4  # same as a[:, :2, -2:]\n",
+        "print(a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Assigning a 2D array to a 2D slice:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(24).reshape(2, 3, 4).astype(np.float16)\n",
+        "b = np.random.rand(2, 2)\n",
+        "print(\"b:\\n\", b)\n",
+        "a[0, :2, -2:] = b\n",
+        "print(a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Assigning a 3D array to a 3D slice:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(24).reshape(2, 3, 4).astype(np.float16)\n",
+        "b = np.random.rand(2, 2, 2)\n",
+        "print(\"b:\\n\", b)\n",
+        "a[:, :2, -2:] = b\n",
+        "print(a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Assigning a 1D array to a 3D slice:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(24).reshape(2, 3, 4).astype(np.float16)\n",
+        "b = np.random.rand(2)\n",
+        "print(\"b:\\n\", b)\n",
+        "a[:, :2, -2:] = b\n",
+        "print(a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "This last case shows the **broadcasting** ability of Numpy arrays. See below for more details.\n",
+        "\n",
+        "\n",
+        "## Iteration of arrays\n",
+        "\n",
+        "Even though vectorized operations are preferred (see next section), sometimes you may want to iterate (e.g., for debugging or custom operations).\n",
+        "Like the Python lists, you can use `for` loop to iterate over the indices or in the **for each** *version*. In the later case, the iteration will happen along the first dimension of the array (row).\n",
+        "\n",
+        "### Iterating over 1D arrays\n",
+        "For 1D arrays, the iteration is fairly simple:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.array([10, 20, 30])\n",
+        "for element in a:  # for each\n",
+        "    print(\"Element:\", element)\n",
+        "# OR\n",
+        "for i in range(len(a)):\n",
+        "    print(f\"Element {i}:\", a[i])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Iterating over 2D arrays\n",
+        "\n",
+        "For higher dimensional arrays, you can iterate over the rows. For example:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(12).reshape(3, 4)\n",
+        "for row in a:  # for each row\n",
+        "    print(row)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Subsequent iteration will iterate over the first dimension of the \"slice\":"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "for row in a:\n",
+        "    for element in row:\n",
+        "        print(element, end=\" \")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Iterating over 3D arrays"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(24).reshape(2, 3, 4)\n",
+        "for depth in a:\n",
+        "    for row in depth:\n",
+        "        for element in row:\n",
+        "            print(element, end=\" \")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Linear iteration over multi-dimensional arrays\n",
+        "\n",
+        "You can directly iterate over individual elements of a multi-dimensional array (called linear iteration). You can either **ravel** the array or use the `nditer` iterator.\n",
+        "\n",
+        "The `ravel` method will simply \"stretch\" or flatten the array into a 1D array, row-by-row. It has (almost) the same effect as `reshape(-1)`, although, it is the preferred method. The reason is that these two methods will return a flattened **view** of the array (not a copy). The difference is that `ravel` returns a **contiguous** view, if possible. This means, certain operations will be faster.\n",
+        "There is also a `flatten` method, which also returns a 1D array, but it returns a **copy** of the original array instead of a view."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "M = np.arange(24).reshape(2, 3, 4)\n",
+        "print(\"Original array:\\n\", M)\n",
+        "print(\"Flattened array:\\n\", M.ravel())"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Try uncommenting any one of the \"flattening\" lines to see the difference:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "M = np.arange(24).reshape(2, 3, 4)\n",
+        "\n",
+        "flat_array = M.ravel()\n",
+        "# flat_array = M.reshape(-1)\n",
+        "# flat_array = M.flatten()\n",
+        "\n",
+        "for i, x in enumerate(flat_array):\n",
+        "    flat_array[i] = x * 2\n",
+        "print(\"Original array:\\n\", M)\n",
+        "print(\"Modified flattened array:\\n\", flat_array)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Linear iteration with `np.nditer`. This function returns a special **iterator** object that can be used by the for loop.\n",
+        "It is actually a very powerful method that allows all sorts of special iterations but that is beyond the scope of this course. You can read more about it [here](https://numpy.org/doc/stable/reference/generated/numpy.nditer.html#numpy.nditer)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "M = np.arange(24).reshape(2, 3, 4)\n",
+        "for x in np.nditer(M):\n",
+        "    print(x, end=\" \")\n",
+        "print()"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Arithmetic operations and vectorization\n",
+        "\n",
+        "A big advantage of Numpy arrays is that you can perform arithmetic operations on them directly. Unlike Python lists, for which we had to implement functions, iterating over individual elements, you can compute certain operations directly on the array.\n",
+        "\n",
+        "### Basic arithmetic operations"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "A = np.arange(12).reshape(3, 4)\n",
+        "B = A * 2 + 3\n",
+        "print(\"A multiplied by 2, then added 3:\\n\", B)\n",
+        "print(\"A^2\", A**2)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Mathematical functions\n",
+        "\n",
+        "The Numpy library also specifies a handful of mathematical operations:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(f\"square root of A:\\n{np.sqrt(A)}\")\n",
+        "print(f\"exponential of A:\\n{np.exp(A)}\")\n",
+        "print(f\"logarithm of A:\\n{np.log(A + 1e-9)}\")\n",
+        "print(f\"sine of A:\\n{np.sin(A)}\")\n",
+        "print(f\"cosine of A:\\n{np.cos(A)}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Aggregation functions"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "A = np.arange(12).reshape(3, 4)\n",
+        "print(\"A:\\n\", A)\n",
+        "print()\n",
+        "print(f\"sum of A:\\n{np.sum(A)}\")\n",
+        "print(f\"mean of A:\\n{np.mean(A)}\")\n",
+        "print(f\"minimum of A:\\n{np.min(A)}\")\n",
+        "print(f\"maximum of A:\\n{np.max(A)}\")\n",
+        "print(f\"standard deviation of A:\\n{np.std(A)}\")\n",
+        "print(f\"variance of A:\\n{np.var(A)}\")\n",
+        "print(f\"median of A:\\n{np.median(A)}\")\n",
+        "print(f\"cumulative sum of A:\\n{np.cumsum(A)}\")\n",
+        "print(f\"cumulative product of A:\\n{np.cumprod(A)}\")\n",
+        "print(f\"norm of A:\\n{np.linalg.norm(A)}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "For the aggregation functions, the dimension along which the aggregation should be done, can be specified. Some of the aggregation functions are also array **methods**. For example:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(f\"sum of A along axis 0 (row-wise):\\n{A.sum(axis=0)}\")\n",
+        "print(f\"sum of A along axis 1 (column-wise):\\n{A.sum(axis=1)}\")\n",
+        "print(f\"mean of A along axis 0:\\n{A.mean(axis=0)}\")\n",
+        "print(f\"mean of A along axis 1:\\n{A.mean(axis=1)}\")\n",
+        "print(f\"norm of A along axis 0:\\n{np.linalg.norm(A, axis=0)}\")\n",
+        "print(f\"norm of A along axis 1:\\n{np.linalg.norm(A, axis=1)}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Operations between arrays\n",
+        "\n",
+        "#### Mathematical operations between arrays\n",
+        "\n",
+        "Much like arithmetics with scalars, many operations can be performed between arrays.\n",
+        "\n",
+        "All of the following operations are element-wise:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(12).reshape(3, 4)\n",
+        "b = np.arange(12, 24).reshape(3, 4)\n",
+        "print(\"a + b = \\n\", a + b)\n",
+        "print(\"a - b = \\n\", a - b)\n",
+        "print(\"a * b = \\n\", a * b)\n",
+        "print(\"a / b = \\n\", a / b)\n",
+        "print(\"a ** b = \\n\", a ** b)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "There are also matrix versions of sum of the operations, for example, the matrix dot product:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = np.arange(12).reshape(3, 4)\n",
+        "b = np.arange(12, 24).reshape(4, 3)\n",
+        "print(\"a . b = \\n\", a.dot(b))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Broadcasting\n",
+        "\n",
+        "#### 1.2.3 Broadcasting and Arithmetic on Matrices\n",
+        "- **Adding a Row Vector to All Rows:**\n",
+        "  ```python\n",
+        "  v = np.array([10, 20, 30])\n",
+        "  M_broadcast = M + v\n",
+        "  print(\"Broadcasted addition (row vector):\\n\", M_broadcast)\n",
+        "  ```\n",
+        "- **Multiplying Each Column by a Scalar:**\n",
+        "  ```python\n",
+        "  col_factors = np.array([1, 2, 3])\n",
+        "  M_scaled = M * col_factors\n",
+        "  print(\"Each column scaled:\\n\", M_scaled)\n",
+        "  ```\n",
+        "\n",
+        "## Array filtering / masking (boolean indexing)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "A = np.arange(12).reshape(3, 4)\n",
+        "print(\"A:\\n\", A)\n",
+        "print()\n",
+        "print(\"A > 5:\\n\", A > 5)\n",
+        "print(\"A[A > 5]:\\n\", A[A > 5])\n",
+        "print(\"A[A % 2 == 0]:\\n\", A[A % 2 == 0])\n",
+        "print(\"A[A % 2 == 1]:\\n\", A[A % 2 == 1])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### `where` function"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "A = np.arange(12).reshape(3, 4)\n",
+        "print(\"A:\\n\", A)\n",
+        "print()\n",
+        "print(\"A where A > 5:\\n\", np.where(A > 5, A, 0))\n",
+        "print(\"A where A % 2 == 0:\\n\", np.where(A % 2 == 0, A, 0))\n",
+        "print(\"A where A % 2 == 1:\\n\", np.where(A % 2 == 1, A, 0))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    }
+  ],
+  "metadata": {
+    "kernelspec": {
+      "display_name": "Python 3",
+      "language": "python",
+      "name": "python3"
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 4
+}
\ No newline at end of file
diff --git a/src/pge_lectures/lecture_04/l4_numpy_arrays.qmd b/src/pge_lectures/lecture_04/l4_numpy_arrays.qmd
new file mode 100644
index 0000000..0330e8d
--- /dev/null
+++ b/src/pge_lectures/lecture_04/l4_numpy_arrays.qmd
@@ -0,0 +1,782 @@
+---
+title: Programming for Engineers
+subtitle: "Lecture 4 - NumPy"
+author: Radoslav Ĺ koviera
+
+format:
+  pdf:
+    code-fold: false
+  html:
+    code-fold: false
+jupyter: python3
+toc: true
+---
+
+# Lecture 4 - NumPy
+
+## Introduction to NumPy
+
+The `NumPy` library enhances the Python with support for numerical operations. It is especially useful for large (multi-dimensional) arrays and matrices.
+Much of it is actually programmed in C and uses vectorization (parallelization) for efficient computations.
+The arrays are typed, which puts a restriction on what can be stored in them (unlike Python lists). On the other hand,
+it they are memory efficient and faster to work with. They also allow special manipulation, like **reshaping** and **broadcasting**.
+
+This lecture covers only the basic operations and concepts.
+Numpy offers many other functions, operations, tricks and convenience methods.
+You can find more information in the [NumPy documentation](https://numpy.org/doc/stable/reference/).
+
+Installation: `pip install numpy`
+
+Import:
+```{python}
+import numpy as np
+```
+
+## Array Creation
+
+### 1D Arrays
+
+#### From Python Lists:
+```{python}
+a = np.array([1, 2, 3, 4, 5])
+print("1D numpy array:", a)
+```
+
+#### Direct Creation with `np.r_`:
+```{python}
+a = np.r_[1, 2, 3, 4, 5]
+print("1D numpy array:", a)
+```
+
+The `np.r_` function (notice, it's actually not a function) creates a 1D array from a list of values. It can also be used to create more complex arrays (reader is encouraged to check the `NumPy documentation <https://numpy.org/doc/stable/reference/generated/numpy.r_.html>`_ for more details).
+
+#### Using Ranges and Linspace:
+```{python}
+a_range = np.arange(0, 10, 2)  # same as np.array(range(0, 10, 2))
+a_range_float = np.arange(0.5, 11.5, 2.5)  # also support floats
+a_lin = np.linspace(0, 1, 5)  # 5 values, evenly spaced 0 to 1
+print("arange:", a_range)
+print("arange with floats:", a_range_float)
+print("linspace:", a_lin)
+```
+
+The function `np.arange` has the same signature as the Python `range` function: `np.arange(start, stop, step)`.
+The `np.linspace` function creates a sequence of evenly-spaced values, between start and stop. The signature is `np.linspace(start, stop, num=100, endpoint=True)`, where `num` is the number of samples to generate and `endpoint` is a boolean that indicates whether to include the stop value in the sequence (`False` for similar behavior to `range`).
+
+`np.r_` can also be used to generate sequences, if instead of a sequence of numbers, a slice is passed as an argument.
+This slice has the same format as in the Python `range` function: `start:stop:step`. However, unlike for `range` the values do not have integers and the can even be **imaginary numbers** - this makes this operation behave like `linspace`.
+
+```{python}
+a = np.r_[1:10:2]
+b = np.r_[0:1:0.1]
+c = np.r_[0:1:5j]
+print("values from 1 to 10, with step 2:", a)
+print("values from 0 to 1, with step 0.1:", b)
+print("5 values, evenly spaced between 0 and 1:", c)
+```
+
+`np.r_` can also be used to quickly combine multiple lists and arrays into a 1D array - by default, it "flattens" all the input arrays and lists.
+```{python}
+a = [1, 2, 3]  # a Python list
+b = np.array([4, 5, 6])  # a 1D numpy array
+c = np.r_[7, 8, 9]  # a 1D numpy array
+
+d = np.r_[a, b, c]
+print("combined array:", d)
+```
+
+You can even combine both the range creation and concatenation of lists and arrays:
+```{python}
+e = np.r_[a, b, c, 10, 11, 12:20:2, 20:30:6j]
+print("combined array:", e)
+```
+
+#### Pre-filled Arrays:
+
+```{python}
+zeros = np.zeros(5)
+ones = np.ones(5)
+sevens = np.full(5, 7)  # the same as np.ones(5) * 7
+print("Zeros:", zeros)
+print("Ones:", ones)
+print("Sevens:", sevens)
+```
+
+#### Random Arrays:
+
+```{python}
+rand = np.random.rand(5)
+randn = np.random.randn(5)
+randint = np.random.randint(0, 10, 5)
+print("5 random values in range [0, 1):", rand)
+print("5 random normally distributed values:", randn)
+print("5 random integers in range [0, 10):", randint)
+```
+
+### 2D Arrays (Matrices)
+
+#### From Nested Lists:
+```{python}
+array_2d = np.array(
+    [
+        [1, 2, 3],
+        [4, 5, 6],
+        [7, 8, 9]
+    ]
+)
+print("2D Array:\n", array_2d)
+```
+
+#### The `numpy.matrix` Class
+There is also a specific class for matrices in numpy - `numpy.matrix`. It contains some matrix-specific syntax sugar but it is also more "cumbersome" and thus normally not recommended (unless you need nicer syntax and outputs).
+
+```{python}
+matrix = np.matrix(array_2d)
+print("Matrix:\n", matrix)
+```
+
+One of the 'advantages' is that standard operations on matrix are automatically treated as matrix operations. For example,
+multiplication and division for arrays are elementwise, while in matrices they are matrix multiplication and matrix division.
+
+```{python}
+print("Array multiplication:\n", array_2d * array_2d)
+print("Matrix multiplication:\n", matrix * matrix)
+```
+
+#### Pre-filled 2D Arrays
+Similarly to 1D arrays, you can create pre-filled and random 2D arrays:
+
+```{python}
+# all matrices have 3 rows and 4 columns
+zeros = np.zeros((3, 4))
+ones = np.ones((3, 4))
+sevens = np.full((3, 4), 7)
+rand = np.random.rand(3,4)
+randn = np.random.randn(3,4)
+randint = np.random.randint(0, 10, (3, 4))
+print("Zeros:\n", zeros)
+print("Ones:\n", ones)
+print("Sevens:\n", sevens)
+print("Random:\n", rand)
+print("Random normally distributed:\n", randn)
+print("Random integers:\n", randint)
+```
+
+#### Reshaping 1D Arrays
+
+You can also take a 1D array and **reshape** it into a 2D array
+with the `reshape` array method. The reshape takes the height and width (number of rows and columns) as arguments. And example of use case is getting a 1D list of byte values and reshaping it into a 2D array of pixel intensities for an image. This is how some image formats store images.
+Reshape organizes the values row-by-row. You have to make sure that there is enough values to fill the array, i.e., `height * width == len(array_1d)`.
+
+```{python}
+a = np.arange(12).reshape(3, 4)
+print("Reshaped array:\n", a)
+```
+
+The following would throw an error, because there is not enough values to create a 2D array of the shape (4, 5):
+```python
+a = np.arange(12).reshape(4, 5)
+```
+
+You can also **omit** one of the dimensions when reshaping, in which case the other dimension will be calculated automatically. In this case, the length of the input (1D) array must be divisible by the specified value.
+
+```{python}
+a = np.arange(12)
+print("Reshaped array (3, -1):\n", a.reshape(3, -1))
+print("Reshaped array (-1, 4):\n", a.reshape(-1, 4))
+```
+
+The following would throw an error, because the length of the input array length is not divisible by 5:
+```python
+a = np.arange(12).reshape(-1, 5)
+a = np.arange(12).reshape(5, -1)
+```
+
+#### Indexing trick `np.r_`
+
+The `np.r_` operator can be used to create a 2D array as well. If all inputs are 2D, the can be directly concatenated. The concatenation is row-wise by default.
+
+```{python}
+a = np.arange(6).reshape(2, -1)
+b = np.arange(6, 12).reshape(2, -1)
+print(f"a =\n{a}")
+print(f"b =\n{b}")
+print("Concatenated:\n", np.r_[a, b])
+```
+
+The inputs can be also 1D arrays or a mix of 1D and 2D arrays. In this case, a string **directive** is specified as the first "argument". It determines how the arrays are concatenated.
+The format of the directive is as follows:
+```python
+'axis,min_dim,transpose_spec'
+```
+where:
+* `axis` - the **axis** along which the arrays (remaining arguments) are **concatenated**
+* `min_dim` is the **minimum dimension**. Inputs with lower dimension are first "upgraded" to this dimension
+* `transpose_spec` specifies how lower-dimensional inputs are transposed before concatenation. It can be omitted, in which case it is set to `-1`.
+
+For example, a row-wise stacking into a 2D array would be specified as:
+```python
+np.r_['0,2', a, b, ...]
+```
+where `a` and `b` are 1D arrays. Note that the concatenated arrays must have the same length.
+
+For the `axis`, 0 means row-wise and 1 means column-wise (can be higher for multi-dimensional arrays).
+For 2D arrays, the `min_dim` must be set to 2. This means that all 1D inputs are first "upgraded" to 2D. Already 2D inputs are not changed.
+The last argument specifies "where to put" the upgraded 1D inputs.
+The default value is `-1`, which means that for 2D case. the 1D inputs are upgraded to have the dimension of [1, N], where N is the length of the 1D input. If we change it to `0`, the 1D inputs are upgraded to have the dimension of [N, 1]. This happens before concatenation and this it will affect how the result will be shaped.
+
+For example, for K inputs, each being 1D with length N, the spec "0,2,-1" will result in a 2D array of shape (K, N):
+- row-wise stacking
+- upgrade 1D inputs to 2D with dimension [1, N] (the last "-1" means "put the 1D inputs into the last dimension")
+
+The spec "1,2,0" will result in a 2D array of shape (N, K):
+- column-wise stacking
+- upgrade 1D inputs to 2D with dimension [N, 1] (the last "0" means "put the 1D inputs into the first dimension")
+
+To make it perhaps more clear, for 3D case, the spec "0,3,-1" (the same as writing "0,3") will upgrade 1D input of length N to a 3D array of shape [1, 1, N]. It will also upgrade 2D inputs of shape [M, N] to a 3D array of shape [1, M, N]. The spec "0,3,0" will upgrade 1D input of length N to a 3D array of shape [N, 1, 1]. It will also upgrade 2D inputs of shape [M, N] to a 3D array of shape [M, N, 1]. Lastly, the spec "0,3,1" will upgrade 1D input of length N to a 3D array of shape [1, N, 1]. For 2D inputs, it is the same as "0,3,-1".
+
+Additionally, you can also "append" 2D arrays or lists. Although, these must also have each row (or column) of the same length.
+
+Here, we combine multiple items into one 2D array.
+```{python}
+a = np.array([1, 2, 3])
+c = [7, 8, 9]
+d = np.r_[
+    '0,2',  # row-wise 2D array
+    a,      # 1D array
+    4:7,    # range(4, 7)
+    c,      # list
+    [10, 11, 12],  # list, directly specified
+    [[13, 14, 15], [16, 17, 18]],  # 2D list
+    np.array([[19, 20, 21], [22, 23, 24]]),  # 2D array
+    25:30:3j  # range with 3 evenly spaced values
+]
+print("Row-wise stacking:\n", d)
+```
+
+You can also stack the items column-wise:
+```{python}
+e = np.r_[
+    '1,2,0',  # column-wise 2D array
+    a,      # 1D array
+    4:7,    # range(4, 7)
+    c,      # list
+    [10, 11, 12],  # list, directly specified
+    [[13, 14], [15, 16], [17, 18]],  # 2D list
+    np.array([[19, 20], [21, 22], [23, 24]]),  # 2D array
+    25:30:3j  # range with 3 evenly spaced values
+]
+print("Column-wise stacking:\n", e)
+```
+
+Note that the 2D arrays must have the same number of columns, in the column.wise stacking case.
+
+More explanation of `np.r_` can be found at [https://rmoralesdelgado.com/all/numpy-concatenate-r_-c_/](https://rmoralesdelgado.com/all/numpy-concatenate-r_-c_/).
+
+### 3D Arrays
+
+All the previously shown methods for 1 an 2D arrays also work for 3D arrays. Actually, you can create an array of any dimension with similar techniques.
+
+#### From Nested Lists:
+
+```{python}
+array_3d = np.array(
+    [
+        [  # first 'slice' of the 3D matrix
+            [1, 2, 3], # first row of the first slice
+            [4, 5, 6],
+            [7, 8, 9]
+        ],
+        [  # second 'slice' of the 3D matrix
+            [10, 11, 12], # first row of the second slice
+            [13, 14, 15],
+            [16, 17, 18]
+        ]
+    ]
+)
+print("3D Array:\n", array_3d)
+```
+
+#### From 1D arrays using reshape
+
+Similarly to 2D, make sure that there is enough values to create a 3D array of the shape (height, width, depth). Also, you can omit one of the dimensions when reshaping, in which case the other dimension will be calculated automatically.
+
+```{python}
+a = np.arange(24).reshape(2, 3, 4)
+print("3D Array shape:", a.shape)
+print(a)
+```
+
+#### Pre-filled 3D Arrays
+
+Each of these arrays has a shape of (2, 3, 4)
+```{python}
+a = np.zeros((2, 3, 4))
+b = np.ones((2, 3, 4))
+c = np.full((2, 3, 4), 5)
+d = np.random.rand(2, 3, 4)
+e = np.random.randn(2, 3, 4)
+f = np.random.randint(0, 10, (2, 3, 4))
+print("Zeros:\n", a)
+print("Ones:\n", b)
+print("Fives:\n", c)
+print("Random:\n", d)
+print("Random normal:\n", e)
+print("Random integers:\n", f)
+```
+
+#### Using `np.r_`
+
+Similarly to previous cases, you can use `np.r_` to create 3D arrays.
+
+```{python}
+array_3d = np.r_[
+    "0,3",
+    np.arange(12).reshape(3, 4),
+    np.arange(12, 24).reshape(3, 4),
+]
+print("3D Array:\n", array_3d)
+```
+
+## Array size
+
+The default function `len` will only reveal the number of rows of the array, not the number of elements (this is because of how iterating over the arrays work). To get the total number of elements, use the `size` attribute:
+
+```{python}
+a = np.random.rand(2, 3, 4)
+print("Array size:", a.size)  # = 2 * 3 * 4
+```
+
+To see the sizes of the array along each dimension, you can use the `shape` attribute:
+
+```{python}
+a = np.random.rand(2, 3, 4)
+print("Array shape:", a.shape)
+```
+
+Finally, to get the number of dimensions of the array, use the `ndim` attribute:
+
+```{python}
+a = np.random.rand(2, 3, 4)
+print("Array dimensions:", a.ndim)
+```
+
+## Data type
+
+By default, Numpy automatically determines the "best" **data type** for a given array, based on the provided values. E.g., if all the values are integers, it will be an integer array. If any of the values is a float, it will be a float array.
+Strings and chars are also supported. You can also create mixed arrays, in which case the data type will be `object` (though, this is not recommended).
+
+Often, however, you will need to specify the data type explicitly. There are three main reasons for this:
+
+- You want to avoid automatic type conversion.
+- You want to control the memory usage.
+- Specific data type is required for your specific application (e.g., if you need to store binary or image data).
+
+Automatic data type conversion might happen, for example, when you append a numeric value to a string array.
+
+```{python}
+string_array = np.array("a b c d".split() + [5.0])
+string_array = np.concat((string_array, np.r_[7]))
+print("String array:", string_array)
+print("Data type:", string_array.dtype)
+```
+
+Notice that both `5.0` and `7` where converted to strings. `U32` means fixed-length unicode string of 32 characters - that is 32x4 bytes for each item. Numpy arrays store string data in strings of equal length -
+the length is determined and expanded automatically based on the maximum length of the string in the array.
+
+The memory issue comes into place when working with very large arrays, for example, images or large data sets.
+
+First, create a helper function to print the data type and memory usage:
+
+```{python}
+import sys
+
+def print_memory_usage(array):
+    print(
+        f"Memory usage for data type '{array.dtype}':",
+        f"{sys.getsizeof(array) / 1024**2:0.3f} MB"
+    )
+```
+
+The `sys.getsizeof` function returns the size in bytes that the object takes up in the memory.
+
+Let's create a matrix with *three million* elements (1000x1000x3) of byte values (0-255, e.g., typical image).
+
+```{python}
+import sys
+
+integer_array = np.random.randint(0, 256, (1000, 1000, 3))
+print_memory_usage(integer_array)
+# convert the integer array to bytes
+byte_array = integer_array.astype(np.uint8)
+print_memory_usage(byte_array)
+print(np.allclose(integer_array, byte_array))  # make sure there is now data loss
+```
+
+The default string data type - unicode - is also less memory efficient - 4 bytes are needed for each character. It might be useful to change if you need to store large text data with only ASCII characters.
+
+```{python}
+import string  # import the string module and get the list of ASCII letters
+letters = np.array(list(string.ascii_letters))
+
+random_text = np.random.choice(letters, int(1e6))  # 1 million characters
+print_memory_usage(random_text)
+random_bytes = random_text.astype(np.bytes_)  # 1-byte characters
+print_memory_usage(random_bytes)
+```
+
+#### Setting the data type
+
+You can set data type manually using the `dtype` parameter, when creating the array.
+
+```{python}
+short_int_array = np.array([1, 2, 3], dtype=np.int16)
+print_memory_usage(short_int_array)
+```
+
+Some other Numpy functions also support the `dtype` parameter. For example, `np.random.randint`:
+
+```{python}
+random_byte_array = np.random.randint(0, 256, (1000, 1000, 3), dtype=np.uint8)
+print_memory_usage(random_byte_array)
+```
+
+#### Converting data types
+
+Existing arrays can also be converted to different data types with the `astype` array method. For example:
+
+```{python}
+int_array = np.array([1, 2, 3])
+float_array = int_array.astype(np.float32)
+print("Integer array:", int_array)
+print("Float array:", float_array)
+```
+
+#### Defining the data type
+
+You have already seen a few data types. Basic data types are simply provided by their Python *type* name (e.g., `int`, `float`, `str`, `bool`). For more specific types, such as integer with specific number of bits, you can use the types defined by Numpy (e.g., `np.int32`, `np.float64`).
+You can also specify a data type using `np.dtype` function. It takes the data type code as an argument (e.g., `np.dtype('int32')`). You have seen some of these codes when we were printing the size of arrays of different types.
+
+For reference, here is a list of **base codes**:
+
+**Character Code**:
+  - `i`: Signed integer.
+  - `u`: Unsigned integer.
+  - `f`: Float.
+  - `c`: Complex.
+  - `b`: Boolean.
+  - `S`: Bytes (ASCII string).
+  - `U`: Unicode string.
+  - `M`: Datetime.
+  - `m`: Timedelta.
+  - `O`: Python object.
+  - `V`: Void (raw data).
+
+Of these, you will most likely only use 'i', 'u', 'f' and maybe 'U',
+which can be defined directly: `np.int<bits>`, `np.uint<bits>`, `np.float<bits>`, `np.str_` (substitute `<bits>` with the number of bits used by the specific data type).
+
+Nonetheless, it is useful to know the other types in case your array "get converted to" one of those and you will see that code when you inspect the `dtype` attribute of the array.
+
+For completeness, the code might also include `<`, `>`, `|`. This indicates the bit ordering (little or big endian).
+
+
+## Indexing
+
+Indexing works in similar way to the Python lists, i.e., the "usual" indexing and slicing. The difference is that for multi-dimensional lists the indexing is:
+```python
+list_2d[row][column]
+```
+while for multi-dimensional Numpy arrays the indexing is:
+```python
+array_2d[row, column]
+```
+That is, packed into single square brackets.
+
+### Simple indexing
+
+```{python}
+a = np.array([1, 2, 3, 4, 5])
+print("Element at index 0:", a[0])
+print("Element at index -1:", a[-1])
+
+b = np.array([[1, 2, 3], [4, 5, 6]])
+print("Element at row 1, col 2:", b[1, 2])
+print("Element at row -1, col 0:", b[-1, 0])
+print("Element at row -1 (last row), col -2 (second to last):", b[-1, -2])
+
+c = np.random.rand(3, 4, 5)
+print("Element at row 1, col 2, depth 3:", c[1, 2, 3])
+print("Element at row 0, col 0, depth 0:", c[0, 0, 0])
+print("Element at row -1, col -1, depth -1:", c[-1, -1, -1])
+```
+
+### Slicing
+
+One bid advantage of Numpy arrays is that you can create multi-dimensional slices of multi-dimensional arrays. For example:
+```{python}
+m = np.random.rand(3, 4, 5)
+print("First row of m:\n", m[0, ...])  # same as m[0, :, :] or m[0]
+print("First column of m:\n", m[:, 0])  # same as m[:, 0, :]
+print("First depth slice of m:\n", m[..., 0])  # same as m[:, :, 0]
+```
+All of these are 2D matrices - slices from a 3D array.
+
+```{python}
+print(m[:, :2, -2:])
+```
+
+If this seems a little confusing - each element in the brackets (separated by comma), specifies slice for the specific dimension. Otherwise, the slicing principle is the same as for Python lists.
+
+
+### Assignment
+
+The assignment works in the same way as in Python lists. However, you can directly assign to multi-dimensional slices of multi-dimensional arrays.
+What's more, you can either assign scalars (each value of the slice will be set to this value) or multi-dimensional arrays (of the same dimension as the slice).
+
+Scalar assignment to a slice along a single dimension:
+```{python}
+a = np.arange(24).reshape(2, 3, 4)
+a[0, ...] = 1  # same as a[0, :, :] or a[0]
+print(a)
+a[:, 0] = 2  # same as a[:, 0, :]
+print(a)
+a[..., 0] = 3  # same as a[:, :, 0]
+print(a)
+```
+
+Scalar assignment to a 2D slice:
+```{python}
+a = np.arange(24).reshape(2, 3, 4)
+a[:, :2, -2:] = 4  # same as a[:, :2, -2:]
+print(a)
+```
+
+Assigning a 2D array to a 2D slice:
+```{python}
+a = np.arange(24).reshape(2, 3, 4).astype(np.float16)
+b = np.random.rand(2, 2)
+print("b:\n", b)
+a[0, :2, -2:] = b
+print(a)
+```
+
+Assigning a 3D array to a 3D slice:
+```{python}
+a = np.arange(24).reshape(2, 3, 4).astype(np.float16)
+b = np.random.rand(2, 2, 2)
+print("b:\n", b)
+a[:, :2, -2:] = b
+print(a)
+```
+
+Assigning a 1D array to a 3D slice:
+```{python}
+a = np.arange(24).reshape(2, 3, 4).astype(np.float16)
+b = np.random.rand(2)
+print("b:\n", b)
+a[:, :2, -2:] = b
+print(a)
+```
+
+This last case shows the **broadcasting** ability of Numpy arrays. See below for more details.
+
+
+## Iteration of arrays
+
+Even though vectorized operations are preferred (see next section), sometimes you may want to iterate (e.g., for debugging or custom operations).
+Like the Python lists, you can use `for` loop to iterate over the indices or in the **for each** *version*. In the later case, the iteration will happen along the first dimension of the array (row).
+
+### Iterating over 1D arrays
+For 1D arrays, the iteration is fairly simple:
+
+```{python}
+a = np.array([10, 20, 30])
+for element in a:  # for each
+    print("Element:", element)
+# OR
+for i in range(len(a)):
+    print(f"Element {i}:", a[i])
+```
+
+### Iterating over 2D arrays
+
+For higher dimensional arrays, you can iterate over the rows. For example:
+```{python}
+a = np.arange(12).reshape(3, 4)
+for row in a:  # for each row
+    print(row)
+```
+
+Subsequent iteration will iterate over the first dimension of the "slice":
+
+```{python}
+for row in a:
+    for element in row:
+        print(element, end=" ")
+```
+
+### Iterating over 3D arrays
+
+```{python}
+a = np.arange(24).reshape(2, 3, 4)
+for depth in a:
+    for row in depth:
+        for element in row:
+            print(element, end=" ")
+```
+
+### Linear iteration over multi-dimensional arrays
+
+You can directly iterate over individual elements of a multi-dimensional array (called linear iteration). You can either **ravel** the array or use the `nditer` iterator.
+
+The `ravel` method will simply "stretch" or flatten the array into a 1D array, row-by-row. It has (almost) the same effect as `reshape(-1)`, although, it is the preferred method. The reason is that these two methods will return a flattened **view** of the array (not a copy). The difference is that `ravel` returns a **contiguous** view, if possible. This means, certain operations will be faster.
+There is also a `flatten` method, which also returns a 1D array, but it returns a **copy** of the original array instead of a view.
+
+```{python}
+M = np.arange(24).reshape(2, 3, 4)
+print("Original array:\n", M)
+print("Flattened array:\n", M.ravel())
+```
+
+Try uncommenting any one of the "flattening" lines to see the difference:
+```{python}
+M = np.arange(24).reshape(2, 3, 4)
+
+flat_array = M.ravel()
+# flat_array = M.reshape(-1)
+# flat_array = M.flatten()
+
+for i, x in enumerate(flat_array):
+    flat_array[i] = x * 2
+print("Original array:\n", M)
+print("Modified flattened array:\n", flat_array)
+```
+
+Linear iteration with `np.nditer`. This function returns a special **iterator** object that can be used by the for loop.
+It is actually a very powerful method that allows all sorts of special iterations but that is beyond the scope of this course. You can read more about it [here](https://numpy.org/doc/stable/reference/generated/numpy.nditer.html#numpy.nditer).
+
+```{python}
+M = np.arange(24).reshape(2, 3, 4)
+for x in np.nditer(M):
+    print(x, end=" ")
+print()
+```
+
+
+## Arithmetic operations and vectorization
+
+A big advantage of Numpy arrays is that you can perform arithmetic operations on them directly. Unlike Python lists, for which we had to implement functions, iterating over individual elements, you can compute certain operations directly on the array.
+
+### Basic arithmetic operations
+
+```{python}
+A = np.arange(12).reshape(3, 4)
+B = A * 2 + 3
+print("A multiplied by 2, then added 3:\n", B)
+print("A^2", A**2)
+```
+
+### Mathematical functions
+
+The Numpy library also specifies a handful of mathematical operations:
+```{python}
+print(f"square root of A:\n{np.sqrt(A)}")
+print(f"exponential of A:\n{np.exp(A)}")
+print(f"logarithm of A:\n{np.log(A + 1e-9)}")
+print(f"sine of A:\n{np.sin(A)}")
+print(f"cosine of A:\n{np.cos(A)}")
+```
+
+### Aggregation functions
+
+```{python}
+A = np.arange(12).reshape(3, 4)
+print("A:\n", A)
+print()
+print(f"sum of A:\n{np.sum(A)}")
+print(f"mean of A:\n{np.mean(A)}")
+print(f"minimum of A:\n{np.min(A)}")
+print(f"maximum of A:\n{np.max(A)}")
+print(f"standard deviation of A:\n{np.std(A)}")
+print(f"variance of A:\n{np.var(A)}")
+print(f"median of A:\n{np.median(A)}")
+print(f"cumulative sum of A:\n{np.cumsum(A)}")
+print(f"cumulative product of A:\n{np.cumprod(A)}")
+print(f"norm of A:\n{np.linalg.norm(A)}")
+```
+
+For the aggregation functions, the dimension along which the aggregation should be done, can be specified. Some of the aggregation functions are also array **methods**. For example:
+
+```{python}
+print(f"sum of A along axis 0 (row-wise):\n{A.sum(axis=0)}")
+print(f"sum of A along axis 1 (column-wise):\n{A.sum(axis=1)}")
+print(f"mean of A along axis 0:\n{A.mean(axis=0)}")
+print(f"mean of A along axis 1:\n{A.mean(axis=1)}")
+print(f"norm of A along axis 0:\n{np.linalg.norm(A, axis=0)}")
+print(f"norm of A along axis 1:\n{np.linalg.norm(A, axis=1)}")
+```
+
+### Operations between arrays
+
+#### Mathematical operations between arrays
+
+Much like arithmetics with scalars, many operations can be performed between arrays.
+
+All of the following operations are element-wise:
+```{python}
+a = np.arange(12).reshape(3, 4)
+b = np.arange(12, 24).reshape(3, 4)
+print("a + b = \n", a + b)
+print("a - b = \n", a - b)
+print("a * b = \n", a * b)
+print("a / b = \n", a / b)
+print("a ** b = \n", a ** b)
+```
+
+There are also matrix versions of sum of the operations, for example, the matrix dot product:
+
+```{python}
+a = np.arange(12).reshape(3, 4)
+b = np.arange(12, 24).reshape(4, 3)
+print("a . b = \n", a.dot(b))
+```
+
+#### Broadcasting
+
+#### 1.2.3 Broadcasting and Arithmetic on Matrices
+- **Adding a Row Vector to All Rows:**
+  ```python
+  v = np.array([10, 20, 30])
+  M_broadcast = M + v
+  print("Broadcasted addition (row vector):\n", M_broadcast)
+  ```
+- **Multiplying Each Column by a Scalar:**
+  ```python
+  col_factors = np.array([1, 2, 3])
+  M_scaled = M * col_factors
+  print("Each column scaled:\n", M_scaled)
+  ```
+
+## Array filtering / masking (boolean indexing)
+
+```{python}
+A = np.arange(12).reshape(3, 4)
+print("A:\n", A)
+print()
+print("A > 5:\n", A > 5)
+print("A[A > 5]:\n", A[A > 5])
+print("A[A % 2 == 0]:\n", A[A % 2 == 0])
+print("A[A % 2 == 1]:\n", A[A % 2 == 1])
+```
+
+### `where` function
+
+```{python}
+A = np.arange(12).reshape(3, 4)
+print("A:\n", A)
+print()
+print("A where A > 5:\n", np.where(A > 5, A, 0))
+print("A where A % 2 == 0:\n", np.where(A % 2 == 0, A, 0))
+print("A where A % 2 == 1:\n", np.where(A % 2 == 1, A, 0))
+```
+
-- 
GitLab