{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "3b7cbeae",
   "metadata": {},
   "source": [
    "# Module 3 — Array Operations & Broadcasting\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b71cfb86",
   "metadata": {},
   "source": [
    "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/inigoochoa/comprehensive-numpy/blob/main/module_3_operations_broadcasting.ipynb)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "efcf5c8a",
   "metadata": {},
   "source": [
    "## Welcome\n",
    "\n",
    "This module is the heart of \"thinking in NumPy.\" You'll learn what a ufunc\n",
    "actually is, the broadcasting rules that let arrays of different shapes\n",
    "combine without you writing a single loop, and how to spot — and fix — code\n",
    "that's secretly looping in disguise.\n",
    "\n",
    "**Learning goals**\n",
    "- Explain what a universal function (ufunc) is and why operators are ufuncs in disguise\n",
    "- Apply the three broadcasting rules to predict the output shape of an operation\n",
    "- Rewrite loop-based numeric code as vectorized NumPy\n",
    "- Use comparison/logical ufuncs and `np.where` for vectorized conditionals\n",
    "- Build pairwise/outer computations with broadcasting instead of nested loops\n",
    "- Recognize that `np.vectorize` is a convenience, not a performance tool\n",
    "\n",
    "**Setup** — run this first.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2bb77c76",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import time\n",
    "print(np.__version__)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "44362f9f",
   "metadata": {},
   "source": [
    "## Lesson 1 — Universal Functions (ufuncs)\n",
    "\n",
    "A **ufunc** is a function that operates elementwise on arrays, implemented\n",
    "in compiled C, supporting broadcasting, type casting, and more. Most of the\n",
    "math you already use is a ufunc: `arr + 1` is shorthand for\n",
    "`np.add(arr, 1)`, and `arr1 * arr2` is shorthand for `np.multiply(arr1, arr2)`.\n",
    "\n",
    "| Category | Examples |\n",
    "|---|---|\n",
    "| Unary (one array in) | `np.sqrt`, `np.exp`, `np.log`, `np.abs`, `np.negative` |\n",
    "| Binary (two arrays in) | `np.add`, `np.subtract`, `np.multiply`, `np.divide`, `np.power` |\n",
    "\n",
    "Because ufuncs are just functions, you can call them directly when you want\n",
    "to pass extra keyword arguments (like `out=` to write into an existing\n",
    "array) that an operator like `+` doesn't give you access to.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "75dabb02",
   "metadata": {},
   "outputs": [],
   "source": [
    "a = np.array([1.0, 4.0, 9.0, 16.0])\n",
    "\n",
    "print(a + 1)              # operator form\n",
    "print(np.add(a, 1))       # explicit ufunc form -- identical result\n",
    "\n",
    "print(np.sqrt(a))         # unary ufunc\n",
    "print(np.multiply(a, 2))  # binary ufunc\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "48c08a9f",
   "metadata": {},
   "source": [
    "### 💪 Exercise 1.1: Operators in disguise\n",
    "\n",
    "Write `same_result(a, b)` that returns `True` if `a + b` (operator form) and\n",
    "`np.add(a, b)` (explicit ufunc form) produce exactly equal arrays for the\n",
    "given inputs, `False` otherwise. Use this to convince yourself they're\n",
    "always the same operation.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9418d929",
   "metadata": {},
   "outputs": [],
   "source": [
    "def same_result(a, b):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "49decb64",
   "metadata": {},
   "outputs": [],
   "source": [
    "result = same_result(np.array([1, 2, 3]), np.array([4, 5, 6]))\n",
    "print(\"same_result(np.array([1, 2, 3]), np.array([4, 5, 6])) ->\")\n",
    "print(result)\n",
    "\n",
    "_passed = (\n",
    "    result is True\n",
    "    and same_result(np.array([1.5, 2.5]), np.array([0.5, 0.5])) is True\n",
    ")\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected: True\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9f2bed95",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "Use `np.array_equal` to compare `a + b` with `np.add(a, b)`, and wrap the result in `bool(...)` so you return a plain Python bool.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2db7801c",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def same_result(a, b):\n",
    "    return bool(np.array_equal(a + b, np.add(a, b)))\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1388aa9a",
   "metadata": {},
   "source": [
    "### 💪 Exercise 1.2: Normalize with ufuncs only\n",
    "\n",
    "Write `rescale(arr)` that maps every value of a float array into the\n",
    "`[0, 1]` range using the formula `(x - min) / (max - min)`, using only\n",
    "ufuncs/array ops (`.min()`, `.max()`, `-`, `/`) — no Python loop.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6d4a0ba0",
   "metadata": {},
   "outputs": [],
   "source": [
    "def rescale(arr):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "66ee9e6e",
   "metadata": {},
   "outputs": [],
   "source": [
    "result = rescale(np.array([0.0, 5.0, 10.0]))\n",
    "print(\"rescale(np.array([0.0, 5.0, 10.0])) ->\")\n",
    "print(result)\n",
    "\n",
    "expected = np.array([0.0, 0.5, 1.0])\n",
    "_passed = np.allclose(result, expected)\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected:\")\n",
    "    print(expected)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e78f4daa",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "`arr.min()` and `arr.max()` give you scalars; subtract and divide the whole array by them at once.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ce1ea8f2",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def rescale(arr):\n",
    "    lo, hi = arr.min(), arr.max()\n",
    "    return (arr - lo) / (hi - lo)\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9e4772f9",
   "metadata": {},
   "source": [
    "## Lesson 2 — Broadcasting Rules\n",
    "\n",
    "Broadcasting lets NumPy combine arrays of *different* shapes without you\n",
    "copying data to make them match. The rule set, applied by comparing shapes\n",
    "from the **right**:\n",
    "\n",
    "1. If the two shapes have a different number of dimensions, pad the smaller\n",
    "   shape with `1`s on the **left** until they match.\n",
    "2. Two dimensions are compatible if they're equal, **or** one of them is `1`\n",
    "   (the size-1 dimension is stretched to match the other).\n",
    "3. If any pair of dimensions is neither equal nor `1`, broadcasting fails\n",
    "   with a `ValueError`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "867419cc",
   "metadata": {},
   "outputs": [],
   "source": [
    "# (3,) + scalar -> scalar broadcasts to every element\n",
    "print(np.array([1, 2, 3]) + 10)\n",
    "\n",
    "# (3,1) + (1,4) -> (3,4): both arrays stretch along their size-1 axis\n",
    "col = np.array([[1], [2], [3]])        # shape (3, 1)\n",
    "row = np.array([[10, 20, 30, 40]])     # shape (1, 4)\n",
    "print(col + row)\n",
    "print((col + row).shape)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "410bbc6e",
   "metadata": {},
   "outputs": [],
   "source": [
    "# A genuine mismatch: (3,) and (4,) share no size-1 dimension to stretch.\n",
    "try:\n",
    "    np.array([1, 2, 3]) + np.array([1, 2, 3, 4])\n",
    "except ValueError as e:\n",
    "    print(\"ValueError:\", e)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f5c45b23",
   "metadata": {},
   "source": [
    "### 💪 Exercise 2.1: Predict, then verify\n",
    "\n",
    "Write `broadcast_shape(shape_a, shape_b)` that takes two shape **tuples**\n",
    "and returns the resulting broadcast shape as a tuple, by actually creating\n",
    "`np.zeros` arrays of those shapes, adding them, and reading off `.shape`.\n",
    "If broadcasting is impossible, return `None` instead of raising.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b2da2097",
   "metadata": {},
   "outputs": [],
   "source": [
    "def broadcast_shape(shape_a, shape_b):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8bb5f80d",
   "metadata": {},
   "outputs": [],
   "source": [
    "result = broadcast_shape((3, 1), (1, 4))\n",
    "print(\"broadcast_shape((3, 1), (1, 4)) ->\")\n",
    "print(result)\n",
    "\n",
    "_passed = (\n",
    "    result == (3, 4)\n",
    "    and broadcast_shape((5,), (1,)) == (5,)\n",
    "    and broadcast_shape((3,), (4,)) is None\n",
    ")\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected: (3, 4)\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3acaa840",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "Wrap the addition in a `try/except ValueError` and return `None` in the `except` branch; otherwise return `(a + b).shape`.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b4642b7f",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def broadcast_shape(shape_a, shape_b):\n",
    "    try:\n",
    "        result = np.zeros(shape_a) + np.zeros(shape_b)\n",
    "        return result.shape\n",
    "    except ValueError:\n",
    "        return None\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b9e851e0",
   "metadata": {},
   "source": [
    "A quick preview before this next exercise — Module 4 covers aggregations\n",
    "and the `axis` parameter in full, but you need one piece of it now:\n",
    "`matrix.mean(axis=1)` averages across axis 1 (the columns), collapsing each\n",
    "row down to a single number, so a `(3, 4)` matrix becomes a `(3,)` array of\n",
    "row means. Passing `keepdims=True` keeps that collapsed axis as a length-1\n",
    "dimension instead of dropping it — `(3, 1)` instead of `(3,)` — which is\n",
    "exactly what you need here, because a `(3, 1)` array broadcasts cleanly\n",
    "against the original `(3, 4)` matrix while a plain `(3,)` array doesn't.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0b3363ac",
   "metadata": {},
   "outputs": [],
   "source": [
    "m = np.array([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]])\n",
    "print(m.mean(axis=1))                   # shape (2,)  -- can't broadcast against m\n",
    "print(m.mean(axis=1, keepdims=True))    # shape (2, 1) -- broadcasts against m\n",
    "print(m.mean(axis=1, keepdims=True).shape)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2ad15b18",
   "metadata": {},
   "source": [
    "### 💪 Exercise 2.2: Per-row normalization\n",
    "\n",
    "Write `subtract_row_means(matrix)` that takes a 2-D array and returns a new\n",
    "array where the mean of each **row** has been subtracted from that row —\n",
    "using broadcasting, not a loop over rows.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "951a858a",
   "metadata": {},
   "outputs": [],
   "source": [
    "def subtract_row_means(matrix):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0aedade9",
   "metadata": {},
   "outputs": [],
   "source": [
    "m = np.array([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]])\n",
    "result = subtract_row_means(m)\n",
    "print(\"subtract_row_means(m) ->\")\n",
    "print(result)\n",
    "\n",
    "_passed = np.allclose(result.mean(axis=1), [0.0, 0.0]) and np.allclose(result[0], [-1.0, 0.0, 1.0])\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected: each row's mean is 0, with row 0 == [-1. 0. 1.]\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "73292d00",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "`matrix.mean(axis=1)` gives one mean per row as a 1-D array of shape `(n_rows,)` — you need it as `(n_rows, 1)` to broadcast correctly against `matrix`. Use `[:, np.newaxis]` (or `keepdims=True` in `.mean`).\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "59df98b4",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def subtract_row_means(matrix):\n",
    "    row_means = matrix.mean(axis=1, keepdims=True)\n",
    "    return matrix - row_means\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2f77e87b",
   "metadata": {},
   "source": [
    "## Lesson 3 — Vectorization vs. Explicit Loops\n",
    "\n",
    "\"Vectorizing\" means replacing an element-by-element Python loop with a\n",
    "single array-level operation that NumPy executes in compiled code. The\n",
    "result is not just shorter code — it's dramatically faster for large\n",
    "arrays, because Python's per-element overhead disappears entirely.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "12d6ebce",
   "metadata": {},
   "outputs": [],
   "source": [
    "def clip_and_square_loop(values, lo, hi):\n",
    "    out = []\n",
    "    for v in values:\n",
    "        v = max(lo, min(hi, v))   # clip\n",
    "        out.append(v * v)         # square\n",
    "    return out\n",
    "\n",
    "\n",
    "def clip_and_square_vectorized(values, lo, hi):\n",
    "    clipped = np.clip(values, lo, hi)\n",
    "    return clipped ** 2\n",
    "\n",
    "\n",
    "data = list(range(-5, 6))\n",
    "print(clip_and_square_loop(data, -2, 2))\n",
    "print(clip_and_square_vectorized(np.array(data), -2, 2))\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0917acc6",
   "metadata": {},
   "outputs": [],
   "source": [
    "big = list(range(1_000_000))\n",
    "big_arr = np.array(big)\n",
    "\n",
    "start = time.perf_counter()\n",
    "clip_and_square_loop(big, -500_000, 500_000)\n",
    "print(f\"loop:       {time.perf_counter() - start:.4f}s\")\n",
    "\n",
    "start = time.perf_counter()\n",
    "clip_and_square_vectorized(big_arr, -500_000, 500_000)\n",
    "print(f\"vectorized: {time.perf_counter() - start:.4f}s\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c2c6b1ea",
   "metadata": {},
   "source": [
    "### 💪 Exercise 3.1: De-loop a threshold function\n",
    "\n",
    "Here is a loop-based function:\n",
    "\n",
    "```python\n",
    "def flag_outliers_loop(values, threshold):\n",
    "    flags = []\n",
    "    for v in values:\n",
    "        flags.append(abs(v) > threshold)\n",
    "    return flags\n",
    "```\n",
    "\n",
    "Write `flag_outliers_vectorized(values, threshold)` that takes a NumPy array\n",
    "and a scalar threshold, and returns a **boolean NumPy array** with the same\n",
    "result — no loop, no comprehension.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "461ee2b6",
   "metadata": {},
   "outputs": [],
   "source": [
    "def flag_outliers_vectorized(values, threshold):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a61d2e35",
   "metadata": {},
   "outputs": [],
   "source": [
    "result = flag_outliers_vectorized(np.array([1, -10, 3, 8]), 5)\n",
    "print(\"flag_outliers_vectorized(np.array([1, -10, 3, 8]), 5) ->\")\n",
    "print(result)\n",
    "\n",
    "expected = np.array([False, True, False, True])\n",
    "_passed = result.dtype == np.bool_ and np.array_equal(result, expected)\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected:\")\n",
    "    print(expected)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "af6bbbb8",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "`np.abs(values) > threshold` is already a full boolean array — that's the whole function.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "68f9c3c1",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def flag_outliers_vectorized(values, threshold):\n",
    "    return np.abs(values) > threshold\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "976e923b",
   "metadata": {},
   "source": [
    "One more vectorization-friendly function worth knowing: `np.cumsum(arr)`\n",
    "returns the running (cumulative) sum — replacing exactly the kind of loop\n",
    "that keeps adding to a running total as it walks through a sequence.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "920b67c5",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(np.cumsum(np.array([1, 2, 3, 4])))   # [1, 3, 6, 10]\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "86627fb6",
   "metadata": {},
   "source": [
    "### 💪 Exercise 3.2: Vectorized running total check\n",
    "\n",
    "Write `matches_cumulative_sum(values, running_totals)` that checks, without\n",
    "any explicit Python loop, whether `running_totals` equals the cumulative\n",
    "sum of `values` (i.e. `running_totals[i] == sum(values[:i+1])` for every\n",
    "`i`). Return a single `bool`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "792f8244",
   "metadata": {},
   "outputs": [],
   "source": [
    "def matches_cumulative_sum(values, running_totals):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8da47a45",
   "metadata": {},
   "outputs": [],
   "source": [
    "v = np.array([1, 2, 3, 4])\n",
    "good = np.array([1, 3, 6, 10])\n",
    "bad = np.array([1, 3, 7, 10])\n",
    "\n",
    "result_good = matches_cumulative_sum(v, good)\n",
    "print(\"matches_cumulative_sum(v, good) ->\")\n",
    "print(result_good)\n",
    "\n",
    "result_bad = matches_cumulative_sum(v, bad)\n",
    "print(\"matches_cumulative_sum(v, bad) ->\")\n",
    "print(result_bad)\n",
    "\n",
    "_passed = result_good is True and result_bad is False\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected: True for `good`, False for `bad`\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7d9a0b79",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "`np.cumsum(values)` computes the running total in one call; compare it to `running_totals` with `np.array_equal`.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f948b891",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def matches_cumulative_sum(values, running_totals):\n",
    "    return bool(np.array_equal(np.cumsum(values), running_totals))\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "592469a2",
   "metadata": {},
   "source": [
    "## Lesson 4 — Comparison & Logical Ufuncs\n",
    "\n",
    "Comparison operators (`==`, `!=`, `<`, `<=`, `>`, `>=`) are ufuncs too — they\n",
    "return a boolean array instead of raising/short-circuiting like Python's\n",
    "`==` on lists does. Combine boolean arrays with `&`, `|`, `~` (never the\n",
    "Python keywords `and`/`or`/`not`, which don't work elementwise).\n",
    "\n",
    "For floating-point data, never compare with `==` directly — use\n",
    "`np.isclose(a, b)` (or `np.allclose` for whole arrays), which accounts for\n",
    "rounding error. Both also accept an `atol=` (absolute tolerance) keyword,\n",
    "useful when the default relative tolerance alone isn't strict enough —\n",
    "e.g. when comparing values near zero. `np.where(condition, if_true,\n",
    "if_false)` is the vectorized equivalent of a Python ternary/if-else,\n",
    "applied elementwise.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f0498aec",
   "metadata": {},
   "outputs": [],
   "source": [
    "a = np.array([1, 2, 3, 4, 5])\n",
    "\n",
    "mask = (a > 1) & (a < 5)           # combine with & , not `and`\n",
    "print(mask)\n",
    "print(a[mask])\n",
    "\n",
    "print(np.where(a % 2 == 0, \"even\", \"odd\"))\n",
    "\n",
    "x = np.array([0.1 + 0.2, 0.3])\n",
    "print(x[0] == x[1])        # False due to floating point!\n",
    "print(np.isclose(x[0], x[1]))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8d28c424",
   "metadata": {},
   "source": [
    "### 💪 Exercise 4.1: Classify values\n",
    "\n",
    "Write `classify(arr)` that returns an array of strings the same length as\n",
    "`arr`, where each element is `\"negative\"`, `\"zero\"`, or `\"positive\"`\n",
    "depending on the sign of the corresponding input value. Use `np.where`\n",
    "(you'll need it twice, nested) — no loop.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f6c50afa",
   "metadata": {},
   "outputs": [],
   "source": [
    "def classify(arr):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "62bdb380",
   "metadata": {},
   "outputs": [],
   "source": [
    "result = classify(np.array([-5, 0, 7]))\n",
    "print(\"classify(np.array([-5, 0, 7])) ->\")\n",
    "print(list(result))\n",
    "\n",
    "expected = [\"negative\", \"zero\", \"positive\"]\n",
    "_passed = list(result) == expected\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected:\")\n",
    "    print(expected)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "079f14ea",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "First handle the positive/non-positive split with one `np.where`, then wrap that call in a second `np.where` that handles the zero case.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "938fb770",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def classify(arr):\n",
    "    return np.where(arr > 0, \"positive\", np.where(arr == 0, \"zero\", \"negative\"))\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6729d340",
   "metadata": {},
   "source": [
    "### 💪 Exercise 4.2: Close enough\n",
    "\n",
    "Write `count_close_pairs(a, b, tol)` that returns how many positions `i`\n",
    "satisfy `np.isclose(a[i], b[i], atol=tol)`, as a plain Python `int`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d2d9c57e",
   "metadata": {},
   "outputs": [],
   "source": [
    "def count_close_pairs(a, b, tol):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "196ac8c7",
   "metadata": {},
   "outputs": [],
   "source": [
    "a = np.array([1.0, 2.0, 3.0001])\n",
    "b = np.array([1.0001, 2.5, 3.0])\n",
    "result = count_close_pairs(a, b, tol=0.01)\n",
    "print(\"count_close_pairs(a, b, tol=0.01) ->\")\n",
    "print(result)\n",
    "\n",
    "_passed = result == 2\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected: 2\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e7a51abb",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "`np.isclose(a, b, atol=tol)` gives a boolean array; `.sum()` on a boolean array counts the `True`s.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1b448692",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def count_close_pairs(a, b, tol):\n",
    "    return int(np.isclose(a, b, atol=tol).sum())\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9d1600af",
   "metadata": {},
   "source": [
    "## Lesson 5 — Outer Operations & Pairwise Computation via Broadcasting\n",
    "\n",
    "`np.outer(a, b)` computes every product `a[i] * b[j]` and arranges them into\n",
    "a matrix of shape `(len(a), len(b))`. More generally, you can build *any*\n",
    "pairwise table — differences, distances, comparisons — by reshaping one\n",
    "array to a column `(n, 1)` and the other to a row `(1, m)`, then letting\n",
    "broadcasting fill in the full `(n, m)` grid. This avoids a nested Python\n",
    "loop entirely.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b2effe74",
   "metadata": {},
   "outputs": [],
   "source": [
    "a = np.array([1, 2, 3])\n",
    "b = np.array([10, 20])\n",
    "\n",
    "print(np.outer(a, b))               # every a[i] * b[j]\n",
    "\n",
    "# Pairwise absolute differences, without np.outer:\n",
    "diffs = np.abs(a[:, np.newaxis] - b[np.newaxis, :])\n",
    "print(diffs)\n",
    "print(diffs.shape)                   # (3, 2): one row per element of a\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ef9c394d",
   "metadata": {},
   "source": [
    "### 💪 Exercise 5.1: Pairwise squared distances\n",
    "\n",
    "Write `pairwise_sq_dist(a, b)` that takes two 1-D arrays and returns a 2-D\n",
    "array of shape `(len(a), len(b))` where entry `[i, j]` is\n",
    "`(a[i] - b[j]) ** 2`. No nested loop — use broadcasting with `np.newaxis`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fc3f9303",
   "metadata": {},
   "outputs": [],
   "source": [
    "def pairwise_sq_dist(a, b):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "25260777",
   "metadata": {},
   "outputs": [],
   "source": [
    "result = pairwise_sq_dist(np.array([0, 1]), np.array([0, 2, 3]))\n",
    "print(\"pairwise_sq_dist(np.array([0, 1]), np.array([0, 2, 3])) ->\")\n",
    "print(result)\n",
    "\n",
    "expected = np.array([[0, 4, 9], [1, 1, 4]])\n",
    "_passed = np.array_equal(result, expected)\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected:\")\n",
    "    print(expected)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e3bbca9d",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "`a[:, np.newaxis]` has shape `(len(a), 1)`; `b[np.newaxis, :]` has shape `(1, len(b))`. Subtract them, then square.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "067daabb",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def pairwise_sq_dist(a, b):\n",
    "    return (a[:, np.newaxis] - b[np.newaxis, :]) ** 2\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4d22e308",
   "metadata": {},
   "source": [
    "### 💪 Exercise 5.2: Multiplication table\n",
    "\n",
    "Write `multiplication_table(n)` that returns the `n x n` multiplication\n",
    "table (entry `[i, j]` equals `(i+1) * (j+1)`) using `np.outer` on\n",
    "`np.arange(1, n+1)` — not a loop.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9728a4a3",
   "metadata": {},
   "outputs": [],
   "source": [
    "def multiplication_table(n):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "56dd3edf",
   "metadata": {},
   "outputs": [],
   "source": [
    "result = multiplication_table(3)\n",
    "print(\"multiplication_table(3) ->\")\n",
    "print(result)\n",
    "\n",
    "expected = np.array([[1, 2, 3], [2, 4, 6], [3, 6, 9]])\n",
    "_passed = np.array_equal(result, expected)\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected:\")\n",
    "    print(expected)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6a9e8c3b",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "`np.arange(1, n + 1)` gives `[1, 2, ..., n]`; pass it to `np.outer` twice (as both arguments).\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bc4a84a1",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def multiplication_table(n):\n",
    "    r = np.arange(1, n + 1)\n",
    "    return np.outer(r, r)\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cb157074",
   "metadata": {},
   "source": [
    "## Lesson 6 — Case Study: Vectorizing a Real Loop\n",
    "\n",
    "Let's take a realistic loop-based function and vectorize it end to end.\n",
    "\n",
    "```python\n",
    "def score_loop(values):\n",
    "    \"\"\"Clip each value into [0, 100], then apply a bonus:\n",
    "    +10 if the clipped value is above 80, otherwise no bonus.\"\"\"\n",
    "    out = []\n",
    "    for v in values:\n",
    "        v = max(0, min(100, v))\n",
    "        if v > 80:\n",
    "            v += 10\n",
    "        out.append(v)\n",
    "    return out\n",
    "```\n",
    "\n",
    "A tempting shortcut is `np.vectorize(score_loop_single_element)`: it *looks*\n",
    "vectorized because it accepts arrays, but internally it still calls your\n",
    "Python function once per element in a Python loop — you get NumPy's\n",
    "*syntax* without NumPy's *speed*. Prefer composing real ufuncs\n",
    "(`np.clip`, `np.where`, comparisons) instead.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fa721628",
   "metadata": {},
   "outputs": [],
   "source": [
    "def score_loop(values):\n",
    "    out = []\n",
    "    for v in values:\n",
    "        v = max(0, min(100, v))\n",
    "        if v > 80:\n",
    "            v += 10\n",
    "        out.append(v)\n",
    "    return out\n",
    "\n",
    "\n",
    "def score_vectorized(values):\n",
    "    clipped = np.clip(values, 0, 100)\n",
    "    return np.where(clipped > 80, clipped + 10, clipped)\n",
    "\n",
    "\n",
    "sample = [-5, 50, 85, 120]\n",
    "print(score_loop(sample))\n",
    "print(score_vectorized(np.array(sample)))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "80dd73a4",
   "metadata": {},
   "source": [
    "### 💪 Exercise 6.1: Vectorize the scoring function\n",
    "\n",
    "Write `score_vectorized_checked(values)` that reproduces `score_loop`'s\n",
    "behavior exactly (clip to `[0, 100]`, then `+10` bonus if the clipped value\n",
    "is `> 80`) using only `np.clip` and `np.where` — no loop, no\n",
    "`np.vectorize`. Return a NumPy array.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cb2f7578",
   "metadata": {},
   "outputs": [],
   "source": [
    "def score_vectorized_checked(values):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5c5338a4",
   "metadata": {},
   "outputs": [],
   "source": [
    "values = np.array([-5, 50, 85, 120])\n",
    "result = score_vectorized_checked(values)\n",
    "print(\"score_vectorized_checked(np.array([-5, 50, 85, 120])) ->\")\n",
    "print(result)\n",
    "\n",
    "expected = np.array(score_loop(list(values)))\n",
    "_passed = np.array_equal(result, expected)\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected:\")\n",
    "    print(expected)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "09445328",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "Clip first with `np.clip(values, 0, 100)`, then use `np.where` on the clipped result to add the bonus conditionally.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "be43771e",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def score_vectorized_checked(values):\n",
    "    clipped = np.clip(values, 0, 100)\n",
    "    return np.where(clipped > 80, clipped + 10, clipped)\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2a3475cd",
   "metadata": {},
   "source": [
    "### 💪 Exercise 6.2: Spot the fake vectorization\n",
    "\n",
    "Write `is_real_ufunc_pipeline(func)` that takes a function and returns\n",
    "`False` if `func` is an instance of `np.vectorize` (i.e. built by wrapping a\n",
    "scalar Python function), and `True` otherwise. This is a quick sanity check\n",
    "you can literally run on your own code to catch accidental fake\n",
    "vectorization.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9cd75837",
   "metadata": {},
   "outputs": [],
   "source": [
    "def is_real_ufunc_pipeline(func):\n",
    "    # YOUR CODE HERE\n",
    "    raise NotImplementedError\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "eec3d6df",
   "metadata": {},
   "outputs": [],
   "source": [
    "fake = np.vectorize(lambda v: v * 2)\n",
    "result_fake = is_real_ufunc_pipeline(fake)\n",
    "print(\"is_real_ufunc_pipeline(np.vectorize(...)) ->\")\n",
    "print(result_fake)\n",
    "\n",
    "result_real = is_real_ufunc_pipeline(score_vectorized)\n",
    "print(\"is_real_ufunc_pipeline(score_vectorized) ->\")\n",
    "print(result_real)\n",
    "\n",
    "_passed = result_fake is False and result_real is True\n",
    "\n",
    "if not _passed:\n",
    "    print()\n",
    "    print(\"Expected: False for the np.vectorize-wrapped function, True for score_vectorized\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14a63356",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>💡 Hint (click to expand)</summary>\n",
    "\n",
    "Use `isinstance(func, np.vectorize)`.\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c9312812",
   "metadata": {},
   "source": [
    "<details>\n",
    "<summary>✅ Solution (click to expand)</summary>\n",
    "\n",
    "```python\n",
    "def is_real_ufunc_pipeline(func):\n",
    "    return not isinstance(func, np.vectorize)\n",
    "```\n",
    "\n",
    "</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "093af325",
   "metadata": {},
   "source": [
    "## 🎯 Key Takeaways\n",
    "\n",
    "- Operators (`+`, `*`, `>`, ...) are shorthand for **ufuncs**\n",
    "  (`np.add`, `np.multiply`, `np.greater`, ...) running in compiled code.\n",
    "- Broadcasting compares shapes right-to-left: equal dims match, size-1 dims\n",
    "  stretch, anything else raises `ValueError`.\n",
    "- A vectorized rewrite of a loop isn't just shorter — it's dramatically\n",
    "  faster, because per-element Python overhead disappears.\n",
    "- Build boolean masks with `&`/`|`/`~` and `np.where`; never trust `==` on\n",
    "  floats — use `np.isclose`/`np.allclose`.\n",
    "- `np.outer` and `[:, np.newaxis]` / `[np.newaxis, :]` broadcasting build\n",
    "  full pairwise tables without nested loops.\n",
    "- `np.vectorize` is a convenience for mapping a scalar function over an\n",
    "  array — it does **not** give you a real performance improvement; prefer\n",
    "  composing real ufuncs.\n",
    "\n",
    "Next up: **Module 4 — Aggregations, Statistics & Sorting**, where you'll\n",
    "learn to summarize and reorder array data along any axis.\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.x"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
