Math, Statistics, and DataLinear Algebra II and Calculus › Day 112

Hands-on lab — Day 112: Visualizing Optimization

Commands

Setup

cd labs/sections/math-statistics-and-data/day-112-visualizing-optimization
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, PIL; print(numpy.__version__, PIL.__version__)"

Run

cd examples && ../.venv/bin/python3 01_grid_and_ascii.py && cd ..
cd examples && ../.venv/bin/python3 02_heatmap_and_path.py && cd ..
cd examples && ../.venv/bin/python3 03_loss_curves.py && cd ..
cd examples && ../.venv/bin/python3 04_animated_gif.py && cd ..
cd examples && ../.venv/bin/python3 05_learning_rate_sweep.py && cd ..
cd examples && ../.venv/bin/python3 06_two_runs_same_loss.py && cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
.venv/bin/pytest starter -q -p no:cacheprovider

Test

bash tests/run_tests.sh

File tree

examples/01_grid_and_ascii.py
examples/02_heatmap_and_path.py
examples/03_loss_curves.py
examples/04_animated_gif.py
examples/05_learning_rate_sweep.py
examples/06_two_runs_same_loss.py
examples/conftest.py
examples/dataset.py
examples/descent.py
examples/gridviz.py
examples/imaging.py
examples/test_reference.py
expected-output/01-grid-and-ascii.txt
expected-output/02-heatmap-and-path.txt
expected-output/03-loss-curves.txt
expected-output/04-animated-gif.txt
expected-output/05-learning-rate-sweep.txt
expected-output/06-two-runs-same-loss.txt
expected-output/FIELDS.md
expected-output/reference-tests.txt
expected-output/starter-progress.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/00_brief.md
starter/conftest.py
starter/dataset.py
starter/descent.py
starter/gridviz.py
starter/imaging.py
starter/test_starter.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 112 lab — Seeing the Descent

Lesson

  • Lesson title: Visualizing Optimization
  • Day number: 112 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-112-visualizing-optimization
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-112-visualizing-optimization when the site is running.

Purpose

Day 111 gave you a working gradient-descent loop. This lab gives you the ability to look at what it actually did — because a final loss number cannot tell you that.

Two runs in examples/06_two_runs_same_loss.py start at the same point, use the same learning rate, and take the same number of steps. Their final losses land within 3% of each other. If that number were the only thing you ever looked at, you would call the runs equivalent. Draw the paths and they are not: one is short and nearly straight; the other is over 13x longer because it spent most of its steps bouncing across a narrow valley. The final-loss number hid the thing that actually mattered.

The lab builds, from nothing but NumPy arrays and Pillow's ImageDraw, the four pictures that diagnose an optimisation run:

  1. Loss against iteration, on a log axis. For the well-conditioned bowl here the update is an exact geometric recursion, so log10(loss) against iteration is provably a straight line — the lab fits one to the drawn pixel coordinates and measures the residual, rather than asserting it in prose.
  2. The contour map with the path drawn on top of it. The only picture that shows why a run was slow: the zig-zag across a narrow valley is obvious here and invisible in the loss curve alone.
  3. Gradient norm and path length, which distinguish "converged" from "stopped for another reason" — the number the whole lab is built around.
  4. A learning-rate sweep, final loss against eta, which has a characteristic shape: slow on the left, a broad basin of good rates, then a cliff where the run diverges — caught deliberately as float('inf') rather than allowed to raise.

No matplotlib, no scipy, in this environment. The lab's heatmap_png and draw_path_on_heatmap do, by hand, what matplotlib.pyplot.contourf and plt.plot do for you: evaluate a function over a grid with numpy.meshgrid, map values to colours, and place pixels. requirements/README.md and the lesson's Tools section describe matplotlib, Plotly, TensorBoard and Weights & Biases from their documentation and state plainly that no output from any of them is reproduced here.

Learning objectives

By the end you will be able to:

  • Evaluate a function over a 2D grid with numpy.meshgrid and locate its minimum from the resulting array.
  • Render a 2D array as a terminal contour map by mapping values to level bands, and explain why a transposed grid or a flipped axis fails loudly in that rendering.
  • Build a heatmap image from a value array using only PIL.Image and a hand-written colour ramp.
  • Write a correct world-to-pixel coordinate transform, and explain which axis has to flip and why.
  • Draw a path over a heatmap with PIL.ImageDraw and verify, in pixels, that it starts and ends where it should.
  • Explain why loss should be read on a log axis for a linearly-convergent method, and prove that a specific run's log-scale points are collinear.
  • Build an animated GIF with Image.save(..., save_all=True) and verify its frame count.
  • Run a learning-rate sweep that catches divergence (overflow to inf) deliberately instead of letting it raise or warn.
  • Use path length, not final loss alone, to tell two optimisation runs apart.
  • State, for each of matplotlib, Pillow, Plotly, TensorBoard and Weights & Biases, when to choose it and what it costs.

Prerequisites

  • Day 111 — gradient descent from scratch: the update rule x <- x - eta * grad(x) and the three learning-rate regimes for a quadratic. This lab implements its own descent loop rather than importing Day 111's, but assumes you already know what the loop is doing.
  • Day 109 — partial derivatives and the gradient, which is what grad(x) computes at each step.
  • Day 104 — NumPy arrays and vectorized thinking, including numpy.meshgrid.
  • Day 43 — python3 -m venv and installing a package with pip.
  • Days 71-74 — running pytest and reading its output.
  • No image-processing background assumed. Every Pillow call used here is explained at first use.

Supported operating systems

  • macOS — run and captured here (macOS 26.5.2, Apple Silicon, arm64).
  • Linux — the same commands apply unchanged. Not run here.
  • Windows — use the Windows Subsystem for Linux and follow the Linux instructions, or Git Bash with .venv\Scripts\python.exe in place of .venv/bin/python3. Not run here; troubleshooting.md says so plainly.

Hardware requirements

Anything that runs Python. The largest image this lab draws is 101x101 pixels; the largest computation is a 300-step 1D descent repeated 25 times for the learning-rate sweep. Nothing here is a benchmark, nothing is timed, and the whole suite finishes in well under a second. Roughly 60-70 MB of disk for the virtual environment.

Required software

  • python3 — 3.14.0 here.
  • numpy 2.5.2, Pillow 12.3.0 and pytest 9.1.1, installed into a lab-local virtual environment from requirements/requirements.txt.
  • bash — 3.2.57 here, for the test harness.

Free and open-source options

All three dependencies are free and open source and there is no paid tier of anything in this lab. NumPy is BSD 3-Clause, Pillow is the MIT-CMU licence, pytest is MIT. No account, no key, no signup, personally or commercially.

If you cannot install Pillow, evaluate_grid and ascii_contour still work with only NumPy — the ASCII contour renderer is a genuinely complete diagnostic on its own, without an image viewer. Every PNG and the GIF need Pillow directly; there is no standard-library substitute. requirements/README.md states this cost plainly.

Four other tools do parts of this job and none of them is installed here, so no output from any of them is reproduced anywhere in this lab or its lesson: matplotlib (pyplot.contour/contourf), Plotly (interactive HTML), and TensorBoard and Weights & Biases (live training-curve dashboards, the latter free for individuals and paid for teams). The lesson's Alternatives section describes all four from their documentation and says so.

Installation

From the repository root:

cd labs/sections/math-statistics-and-data/day-112-visualizing-optimization
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, PIL; print(numpy.__version__, PIL.__version__)"

Expect 2.5.2 12.3.0. That is the only time this lab needs the network.

File structure

.
├── README.md                            this file
├── metadata.yml                         how the lab was actually run, and when
├── requirements/
│   ├── README.md                        why each package is here, its licence, and the no-Pillow path
│   └── requirements.txt                 numpy==2.5.2, Pillow==12.3.0, pytest==9.1.1
├── starter/                             your work goes here
│   ├── 00_brief.md                      the eight exercises, in order
│   ├── conftest.py                      makes this directory's modules the ones its tests import
│   ├── dataset.py                       given: the two bowls, the descent loop, the tolerances
│   ├── gridviz.py                       exercises 1, 2, 4a — evaluate_grid, ascii_contour, world_to_pixel
│   ├── descent.py                       exercises 7, 8 — the learning-rate sweep, path_length
│   ├── imaging.py                       exercises 3, 4b, 5, 6 — the heatmap, the drawn path, the loss curves, the GIF
│   └── test_starter.py                  your running score; unattempted work skips
├── examples/                            the reference, to read after you have tried
│   ├── conftest.py                      the same import guard
│   ├── dataset.py                       the two bowls, the descent loop, the derived tolerances
│   ├── gridviz.py                       the finished grid and pixel functions
│   ├── descent.py                       the finished descent, path length and sweep
│   ├── imaging.py                       the finished heatmap, path, loss-curve and GIF drawing
│   ├── 01_grid_and_ascii.py             evaluate a bowl over a grid; see it in a terminal
│   ├── 02_heatmap_and_path.py           the heatmap PNG, and a descent path drawn on it
│   ├── 03_loss_curves.py                linear vs. log axis, and the collinearity proof
│   ├── 04_animated_gif.py               one GIF frame per step
│   ├── 05_learning_rate_sweep.py        final loss against eta: the basin and the cliff
│   ├── 06_two_runs_same_loss.py         the day's opening claim, as a measurement
│   └── test_reference.py                the reference suite: nine checks, one per exercise
├── tests/
│   └── run_tests.sh                     the bash harness: exits non-zero on any failure
├── expected-output/                     captured from real runs on the date in metadata.yml
│   ├── FIELDS.md                        what may legitimately differ on your machine
│   ├── 01-grid-and-ascii.txt
│   ├── 02-heatmap-and-path.txt
│   ├── 03-loss-curves.txt
│   ├── 04-animated-gif.txt
│   ├── 05-learning-rate-sweep.txt
│   ├── 06-two-runs-same-loss.txt
│   ├── reference-tests.txt
│   ├── starter-progress.txt
│   └── test-run.txt
├── troubleshooting.md
└── security.md

How to run

Read starter/00_brief.md first. Then work, checking yourself as you go:

.venv/bin/pytest starter -q

On an untouched checkout that prints 13 skipped. A skip means "not attempted"; a failure means "attempted and wrong", and prints both your answer and the real one.

Afterwards, read the reference — each script prints its working and asserts every claim it makes:

cd examples
../.venv/bin/python3 01_grid_and_ascii.py
../.venv/bin/python3 02_heatmap_and_path.py
../.venv/bin/python3 03_loss_curves.py
../.venv/bin/python3 04_animated_gif.py
../.venv/bin/python3 05_learning_rate_sweep.py
../.venv/bin/python3 06_two_runs_same_loss.py
cd ..

Every image these scripts write goes into a temporary directory that is removed before the script exits. Nothing is left in the lab.

What the commands do

  • evaluate_grid calls numpy.meshgrid once and applies the loss function to the result — the single building block every picture in this lab is made from.
  • heatmap_png rescales the grid to [0, 1], maps it through a four-stop colour ramp with numpy.interp, and saves the result with PIL.Image.fromarray(...).save(...).
  • draw_path_on_heatmap maps every point on a descent path to a pixel with world_to_pixel and draws a polyline plus a marker per step with PIL.ImageDraw.
  • loss_curve_png maps a loss sequence (or its log10) to pixel coordinates with the same linear-rescale idea, then draws axes, a polyline and markers.
  • animated_descent_gif builds one frame per step over a shared background and writes them all with a single Image.save(..., save_all=True, ...) call.
  • learning_rate_sweep runs a 1D descent at each of a range of learning rates, catching overflow with numpy.errstate and recording it as float('inf') rather than letting it raise.

Expected output

Captured verbatim in expected-output/. The two headline numbers, from 06-two-runs-same-loss.txt:

well-conditioned final loss: 2.431009e-03
ill-conditioned final loss:  2.507203e-03
relative gap between the two final losses: 0.0304

well-conditioned path length: 5.6075
ill-conditioned path length:  75.9767
ratio: 13.55x longer

Validation steps

  1. .venv/bin/pytest starter -q reports 13 skipped on an untouched checkout and 13 passed once every exercise is solved correctly.
  2. .venv/bin/pytest examples -q reports all reference tests passing.
  3. bash tests/run_tests.sh prints N checks, 0 failure(s). and exits 0.
  4. After every command above, find . -name '*.png' -o -name '*.gif' from the lab directory returns nothing.

Tests

.venv/bin/pytest examples -q
.venv/bin/pytest starter -q
bash tests/run_tests.sh

tests/run_tests.sh additionally proves itself capable of failing: it re-runs itself with an unmeetable threshold and asserts that the re-run reports exactly one failure and exits non-zero, before reporting its own real result.

Cleanup

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv  # optional: removes the lab virtual environment
git checkout -- starter/  # optional: reset your work

Nothing else needs cleaning. Every PNG and GIF this lab produces is written into a temporary directory and removed before the producing script or test returns.

Troubleshooting

See troubleshooting.md for the axis-flip bug, the ASCII-ramp direction, GIF palette conversion, overflow handling in the sweep, and the module-import guard — every entry there was hit while building this lab.

Security notes

See security.md. In short: no network access after installation, no credentials, nothing written outside the lab directory or a temporary one it removes itself.

Extension exercises

  1. Marching squares. ascii_contour and heatmap_png both shade by level BAND. Implement a simple marching-squares pass that traces actual level LINES between grid cells for one contour value, and compare the picture it produces to the shaded version.
  2. Momentum. Add a velocity term to the descent loop (v <- beta * v + grad(x); x <- x - eta * v) and draw its path on the ill-conditioned bowl next to plain gradient descent's. Does it shorten the path, the final loss, or both?
  3. A third bowl. Add a bowl that is rotated 45 degrees relative to the coordinate axes (a non-diagonal quadratic form) and confirm your evaluate_grid and heatmap_png still locate its minimum correctly — this is the case a purely-diagonal test suite like this lab's cannot catch on its own.
  4. Real matplotlib, if you have it elsewhere. Reproduce 02_heatmap_and_path.py's picture with plt.contourf and plt.plot in an environment that has matplotlib installed, and compare the two images by eye. Do not paste matplotlib output into this lab's files — this environment does not have it, and the lab says so.
  • Previous day: Day 111 — Gradient Descent from Scratch
  • Next day: Day 113 — Probability: Events, Rules, and Intuition

Expected output

01-grid-and-ascii.txt

grid shape: X (101, 101), Y (101, 101), Z (101, 101)
minimum at grid cell (row=50, col=50) -> x=0.0000, y=0.0000, f=0

ASCII contour of x^2 + y^2 on a 5x5 grid spanning [-2, 2] x [-2, 2]:
#+:+#
+. .+
:   :
+. .+
#+:+#

01_grid_and_ascii.py: every assertion held.

02-heatmap-and-path.txt

heatmap.png: size=(101, 101), mode=RGB
pixel at the minimum (0, 0) -> (50, 50): color (13, 27, 84)
descent.png: 61 points drawn over the ill-conditioned bowl
first marker pixel: (np.float64(100.0), np.float64(0.0))
last marker pixel:  (np.float64(50.44), np.float64(49.91))
minimum pixel:      (50.0, 50.0)
distance from the last marker to the minimum: 0.445 pixels

02_heatmap_and_path.py: every assertion held.

03-loss-curves.txt

loss at step 0:  32
loss at step 60: 0.00243101
ratio of consecutive losses (should be ~constant for a straight log line):
  loss[1] / loss[0] = 0.853776
  loss[2] / loss[1] = 0.853776
  loss[3] / loss[2] = 0.853776
  loss[4] / loss[3] = 0.853776
  loss[5] / loss[4] = 0.853776
  loss[6] / loss[5] = 0.853776
  loss[7] / loss[6] = 0.853776
  loss[8] / loss[7] = 0.853776
  loss[9] / loss[8] = 0.853776
  loss[10] / loss[9] = 0.853776

loss_linear.png and loss_log.png written and read back successfully
log-axis points: best-fit line residual (max, in pixels) = 1.705e-13

03_loss_curves.py: every assertion held.

04-animated-gif.txt

path has 26 points (start + 25 steps)
descent.gif written: 63066 bytes
format=GIF, size=(101, 101), n_frames=26

04_animated_gif.py: every assertion held.

05-learning-rate-sweep.txt

   eta      final loss
  0.05    5.618568e-27
  0.15    1.832078e-92
  0.25   3.855872e-180
  0.35   2.998284e-313
  0.45    0.000000e+00
  0.55    0.000000e+00
  0.65   2.998284e-313
  0.75   3.855872e-180
  0.85    1.832078e-92
  0.95    5.618568e-27
  1.05    1.095799e+26
  1.15    3.716476e+69
  1.25   7.225625e+106
  1.35   2.974903e+139
  1.45   2.859437e+168
  1.55   3.433182e+194
  1.65   1.741091e+218
  1.75   9.292342e+239
  1.85   1.052879e+260
  1.95   4.394594e+278
  2.05   1.049872e+296
  2.15  inf (diverged)
  2.25  inf (diverged)
  2.35  inf (diverged)
  2.45  inf (diverged)

10 learning rates reach essentially zero loss: [0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95]
argmin at eta = 0.45
largest eta whose blow-up has not yet overflowed float64 in 300 steps: 2.05 (already diverging, just not yet inf)
smallest eta whose blow-up has overflowed to inf (caught, not an exception): 2.15

05_learning_rate_sweep.py: every assertion held.

06-two-runs-same-loss.txt

well-conditioned bowl: f(x, y) = 1 x^2 + 1 y^2
ill-conditioned bowl:  f(x, y) = 1 x^2 + 25 y^2
both start at (np.float64(4.0), np.float64(4.0)), learning rate 0.038, 60 steps

well-conditioned final loss: 2.431009e-03
ill-conditioned final loss:  2.507203e-03
relative gap between the two final losses: 0.0304

well-conditioned path length: 5.6075
ill-conditioned path length:  75.9767
ratio: 13.55x longer

A reader who only sees the two final-loss numbers above would call these runs equivalent. Only the path length -- or the picture -- shows that one of them spent most of its steps bouncing across the narrow axis instead of heading toward the minimum.

06_two_runs_same_loss.py: every assertion held.

FIELDS.md

# About these captures

Every file in this directory is the literal stdout of a real run on the
authoring machine (macOS, arm64, Python 3.14.0, numpy 2.5.2, Pillow 12.3.0,
pytest 9.1.1), captured on the date recorded in `metadata.yml`.

## What may legitimately differ on another machine

- **PNG and GIF byte sizes are not checked anywhere in this lab**, precisely
  because they vary across Pillow versions, zlib/libgif builds, and
  platforms even when the pixels are identical. Every test and every
  harness check instead asserts image **dimensions** (`Image.size`),
  **frame counts** (`Image.n_frames`), and **specific pixel values** at
  specific, analytically-known coordinates (the minimum of a bowl, a
  corner of a small grid) — properties a different Pillow build cannot
  legitimately change.
- Floating-point values printed to many significant figures (the learning-
  rate sweep table, the loss-curve numbers) may differ in their last one or
  two digits on a different CPU or numpy build. Every corresponding test
  compares with an explicit tolerance rather than exact equality, except
  where the arithmetic is provably exact (see below).
- `platform.platform()` in section 1 of `test-run.txt` names this machine
  specifically and will differ everywhere else. It is printed for the
  record, not compared against anything.

## What is exact, and provably so, not merely "usually so"

- The well-conditioned bowl (`a = b = 1`) makes the gradient-descent update
  an exact linear recursion, `x_{k+1} = (1 - 2 * lr) * x_k`. Its loss
  sequence is therefore geometric to machine precision on every platform
  with IEEE-754 double-precision floats — which is effectively every
  platform this lab will run on — and `test_log_axis_points_are_collinear`
  asserts a residual under `1e-6` (pixel units), not "close by eye". The
  actual measured residual on this run was on the order of `1e-13`.
- The 3-4-5 triangle path in `starter/test_starter.py`
  (`test_8_path_length_of_a_known_path`) has length exactly 7; this is
  Euclidean geometry, not a measurement, and the test uses `pytest.approx`
  only to absorb ordinary floating-point summation, not because the answer
  is in doubt.

## Files

| File | What it captures |
| --- | --- |
| `01-grid-and-ascii.txt` through `06-two-runs-same-loss.txt` | stdout of each numbered example script, run individually from `examples/` |
| `reference-tests.txt` | `pytest examples -q` |
| `starter-progress.txt` | `pytest starter -q` on an untouched checkout (13 skipped, 0 failed) |
| `test-run.txt` | The full `tests/run_tests.sh` harness output and its exit code |

reference-tests.txt

..........                                                               [100%]
10 passed in 0.11s

starter-progress.txt

sssssssssssss                                                            [100%]
13 skipped in 0.05s

test-run.txt

Day 112 — Visualizing Optimization

1. The tools and the versions this lab was written against
  python   3.14.0
  numpy    2.5.2
  Pillow   12.3.0
  pytest   9.1.1
  platform macOS-26.5.2-arm64-arm-64bit-Mach-O
  exe      python3
  ok: installed numpy matches requirements.txt
  ok: installed Pillow matches requirements.txt
  ok: matplotlib is genuinely absent from this environment

2. Every reference script runs and every assertion inside it holds
  ok: 01_grid_and_ascii.py exits 0
  ok: 01_grid_and_ascii.py reports every assertion held
  ok: 02_heatmap_and_path.py exits 0
  ok: 02_heatmap_and_path.py reports every assertion held
  ok: 03_loss_curves.py exits 0
  ok: 03_loss_curves.py reports every assertion held
  ok: 04_animated_gif.py exits 0
  ok: 04_animated_gif.py reports every assertion held
  ok: 05_learning_rate_sweep.py exits 0
  ok: 05_learning_rate_sweep.py reports every assertion held
  ok: 06_two_runs_same_loss.py exits 0
  ok: 06_two_runs_same_loss.py reports every assertion held

3. The reference pytest suite: real values, real exceptions
  ..........                                                               [100%]
  10 passed in 0.11s
  ok: pytest examples exits 0
  ok: no test in the reference suite failed
  ok: the reference suite ran at least 10 tests (ran 10)

4. The starter suite skips unattempted work instead of failing it
  sssssssssssss                                                            [100%]
  13 skipped in 0.05s
  ok: pytest starter exits 0 on an untouched checkout
  ok: the starter suite reports no failures
  ok: unwritten exercises are reported as skipped, not passed
  ok: collecting both suites at once does not turn skips into passes

5. The day's opening claim, checked one value at a time
  (measured on this run: well-conditioned final loss 2.431009e-03, ill-conditioned 2.507203e-03, relative gap 0.030390 -- reported, not asserted to a value)
  ok: the two final losses land within the stated threshold of each other
  (measured on this run: well-conditioned path length 5.6075, ill-conditioned 75.9767, ratio 13.5490x)
  ok: the ill-conditioned path is over 5x longer than the well-conditioned one

6. The harness can actually fail
  ok: an unmeetable threshold makes the harness exit non-zero (1)
  ok: the failing check is named in the output
  ok: the summary line counts exactly one failure

7. Nothing was left behind
  ok: no __pycache__ directory left by the lab's own code
  ok: no .pytest_cache directory left under the lab
  ok: no PNG or GIF file left anywhere in the lab (exercise 9)
  ok: no lab source opens a network connection

31 checks, 0 failure(s).
exit=0

Source files

examples/01_grid_and_ascii.py (1255 bytes)
"""Script 1 -- evaluate a surface over a grid, then look at it without an
image viewer.

Every picture later in this lab starts here: numpy.meshgrid plus a function
call, and a way to check that Z's rows and columns line up with X and Y the
way you think they do.
"""

import numpy as np

from dataset import WELL_F
from gridviz import ascii_contour, evaluate_grid

XLIM = (-4.0, 4.0)
YLIM = (-4.0, 4.0)
N = 101

X, Y, Z = evaluate_grid(WELL_F, XLIM, YLIM, N)
print(f"grid shape: X {X.shape}, Y {Y.shape}, Z {Z.shape}")
assert X.shape == Y.shape == Z.shape == (N, N)

iy, ix = np.unravel_index(np.argmin(Z), Z.shape)
print(f"minimum at grid cell (row={iy}, col={ix}) -> x={X[iy, ix]:.4f}, y={Y[iy, ix]:.4f}, f={Z[iy, ix]:.6g}")
assert abs(X[iy, ix]) < 1e-9 and abs(Y[iy, ix]) < 1e-9

_, _, small_Z = evaluate_grid(lambda x, y: x**2 + y**2, (-2, 2), (-2, 2), 5)
print()
print("ASCII contour of x^2 + y^2 on a 5x5 grid spanning [-2, 2] x [-2, 2]:")
print(ascii_contour(small_Z))
rows = ascii_contour(small_Z).split("\n")
assert rows[2][2] == " ", "the centre cell (the minimum) should be the lightest character"
assert rows[0][0] == "#", "a corner (the maximum) should be the densest character"

print()
print("01_grid_and_ascii.py: every assertion held.")
examples/02_heatmap_and_path.py (2211 bytes)
"""Script 2 -- a Pillow heatmap, and a descent path drawn on top of it.

Writes into a temporary directory and removes it before exiting: this lab
leaves no image files behind, on purpose, so that "did the lab clean up"
is a check you can actually run rather than a claim you have to trust.
"""

import shutil
import tempfile
from pathlib import Path

from dataset import ILL_F, ILL_GRAD, LEARNING_RATE, START, STEPS
from descent import gradient_descent
from gridviz import evaluate_grid, world_to_pixel
from imaging import draw_path_on_heatmap, heatmap_png
from PIL import Image

XLIM = (-4.0, 4.0)
YLIM = (-4.0, 4.0)

_, _, Z = evaluate_grid(ILL_F, XLIM, YLIM, 101)

tmp = Path(tempfile.mkdtemp(prefix="d112-"))
try:
    heat_path = tmp / "heatmap.png"
    heatmap_png(Z, XLIM, YLIM, str(heat_path))
    img = Image.open(heat_path)
    print(f"heatmap.png: size={img.size}, mode={img.mode}")
    assert img.size == (101, 101)

    px, py = world_to_pixel(0.0, 0.0, XLIM, YLIM, 101, 101)
    center_color = img.convert("RGB").getpixel((round(px), round(py)))
    print(f"pixel at the minimum (0, 0) -> ({round(px)}, {round(py)}): color {center_color}")
    assert center_color == (13, 27, 84), "the minimum should be the ramp's lowest-value color"

    path = gradient_descent(ILL_GRAD, START, LEARNING_RATE, STEPS)
    path_out = tmp / "descent.png"
    draw_path_on_heatmap(Z, XLIM, YLIM, path, str(path_out))
    print(f"descent.png: {len(path)} points drawn over the ill-conditioned bowl")

    start_px = world_to_pixel(*path[0], XLIM, YLIM, 101, 101)
    end_px = world_to_pixel(*path[-1], XLIM, YLIM, 101, 101)
    min_px = world_to_pixel(0.0, 0.0, XLIM, YLIM, 101, 101)
    print(f"first marker pixel: {tuple(round(v, 2) for v in start_px)}")
    print(f"last marker pixel:  {tuple(round(v, 2) for v in end_px)}")
    print(f"minimum pixel:      {tuple(round(v, 2) for v in min_px)}")

    distance = ((end_px[0] - min_px[0]) ** 2 + (end_px[1] - min_px[1]) ** 2) ** 0.5
    print(f"distance from the last marker to the minimum: {distance:.3f} pixels")
    assert distance < 5.0

    print()
    print("02_heatmap_and_path.py: every assertion held.")
finally:
    shutil.rmtree(tmp, ignore_errors=True)
examples/03_loss_curves.py (2073 bytes)
"""Script 3 -- loss against iteration, linear and log, and why the log axis
is not decoration.

For a run where the update is an exact geometric recursion (the
well-conditioned bowl below), loss_k = c * rho^k, so log10(loss_k) is a
straight line in k with slope log10(rho). This script draws both curves and
then proves the log one is a line by fitting it and reading off the residual
-- from the picture's own pixel coordinates, not from the formula.
"""

import shutil
import tempfile
from pathlib import Path

import numpy as np

from dataset import LEARNING_RATE, START, STEPS, WELL_F, WELL_GRAD
from descent import gradient_descent, losses_along
from imaging import loss_curve_png, loss_curve_points

path = gradient_descent(WELL_GRAD, START, LEARNING_RATE, STEPS)
losses = losses_along(WELL_F, path)

print(f"loss at step 0:  {losses[0]:.6g}")
print(f"loss at step {STEPS}: {losses[-1]:.6g}")
print(f"ratio of consecutive losses (should be ~constant for a straight log line):")
ratios = losses[1:11] / losses[0:10]
for k, r in enumerate(ratios):
    print(f"  loss[{k+1}] / loss[{k}] = {r:.6f}")
assert np.max(ratios) - np.min(ratios) < 1e-9, "a geometric decay has a constant ratio"

tmp = Path(tempfile.mkdtemp(prefix="d112-"))
try:
    lin_path = tmp / "loss_linear.png"
    log_path = tmp / "loss_log.png"
    loss_curve_png(losses, str(lin_path), log=False)
    loss_curve_png(losses, str(log_path), log=True)
    print(f"\nloss_linear.png and loss_log.png written and read back successfully")

    points = loss_curve_points(losses, width=500, height=350, margin=50, log=True)
    xs = np.array([p[0] for p in points])
    ys = np.array([p[1] for p in points])
    A = np.vstack([xs, np.ones_like(xs)]).T
    slope, intercept = np.linalg.lstsq(A, ys, rcond=None)[0]
    residual = np.max(np.abs(ys - (slope * xs + intercept)))
    print(f"log-axis points: best-fit line residual (max, in pixels) = {residual:.3e}")
    assert residual < 1e-6

    print()
    print("03_loss_curves.py: every assertion held.")
finally:
    shutil.rmtree(tmp, ignore_errors=True)
examples/04_animated_gif.py (1234 bytes)
"""Script 4 -- an animated GIF of a descent, one frame per step.

Pillow needs nothing beyond Image.save(..., save_all=True) to write an
animation: build one Image per frame and hand Pillow the list.
"""

import shutil
import tempfile
from pathlib import Path

from dataset import ILL_F, ILL_GRAD, LEARNING_RATE, START
from descent import gradient_descent
from gridviz import evaluate_grid
from imaging import animated_descent_gif
from PIL import Image

XLIM = (-4.0, 4.0)
YLIM = (-4.0, 4.0)
N_FRAMES = 25

_, _, Z = evaluate_grid(ILL_F, XLIM, YLIM, 101)
path = gradient_descent(ILL_GRAD, START, LEARNING_RATE, N_FRAMES)
print(f"path has {len(path)} points (start + {N_FRAMES} steps)")

tmp = Path(tempfile.mkdtemp(prefix="d112-"))
try:
    out = tmp / "descent.gif"
    animated_descent_gif(Z, XLIM, YLIM, path, str(out))
    size_bytes = out.stat().st_size
    print(f"descent.gif written: {size_bytes} bytes")

    reopened = Image.open(out)
    print(f"format={reopened.format}, size={reopened.size}, n_frames={reopened.n_frames}")
    assert reopened.format == "GIF"
    assert reopened.n_frames == len(path)

    print()
    print("04_animated_gif.py: every assertion held.")
finally:
    shutil.rmtree(tmp, ignore_errors=True)
examples/05_learning_rate_sweep.py (2071 bytes)
"""Script 5 -- final loss against learning rate: the characteristic shape.

Slow on the left, a broad basin of good learning rates, then a cliff where
the run diverges. A good learning rate is a RANGE, and this script measures
that range rather than asserting it.
"""

import numpy as np

from dataset import SWEEP_STEPS, SWEEP_X0, sweep_f, sweep_grad
from descent import learning_rate_sweep

etas = np.round(np.arange(0.05, 2.55, 0.10), 2)
sweep = learning_rate_sweep(sweep_grad, sweep_f, SWEEP_X0, etas, SWEEP_STEPS)

print(f"{'eta':>6}  {'final loss':>14}")
for eta, loss in sweep:
    shown = "inf (diverged)" if not np.isfinite(loss) else f"{loss:.6e}"
    print(f"{eta:>6.2f}  {shown:>14}")

finite = [(e, loss) for e, loss in sweep if np.isfinite(loss)]
divergent = [(e, loss) for e, loss in sweep if not np.isfinite(loss)]

good = [e for e, loss in finite if loss < 1e-6]
best_eta = min(finite, key=lambda pair: pair[1])[0]
# Above eta = 1, |1 - 2 eta| > 1 and the run blows up exponentially -- these
# etas do NOT converge, they simply have not yet overflowed float64 in the
# number of steps this sweep runs. "Finite but huge" is not "good".
still_overflowing_soon = max(e for e, loss in finite if loss >= 1e-6)
first_divergent_eta = min(e for e, _ in divergent)

print()
print(f"{len(good)} learning rates reach essentially zero loss: {good}")
print(f"argmin at eta = {best_eta}")
print(
    f"largest eta whose blow-up has not yet overflowed float64 in {SWEEP_STEPS} steps: "
    f"{still_overflowing_soon} (already diverging, just not yet inf)"
)
print(f"smallest eta whose blow-up has overflowed to inf (caught, not an exception): {first_divergent_eta}")

assert len(good) >= 3, "the good range is a basin, not a single point"
assert etas[0] < best_eta < etas[-1], "the argmin must be interior to the sweep"
assert len(divergent) >= 1
assert all(loss == float("inf") for _, loss in divergent)
assert all(e > 1.0 for e, _ in divergent), "the theoretical threshold for f(x) = x^2 is eta = 1"

print()
print("05_learning_rate_sweep.py: every assertion held.")
examples/06_two_runs_same_loss.py (2237 bytes)
"""Script 6 -- the day's opening failure, made into a measurement.

Two runs, same starting point, same learning rate, same number of steps.
Their final losses agree to within a few percent. Their paths do not agree
at all: one is short and nearly straight, the other is over ten times
longer because it zig-zagged across a narrow valley. The final-loss number
alone cannot tell these runs apart -- only the path can.
"""

from dataset import (
    ILL_A,
    ILL_B,
    ILL_F,
    ILL_GRAD,
    LEARNING_RATE,
    LOSS_MATCH_TOL,
    PATH_LENGTH_RATIO_MIN,
    START,
    STEPS,
    WELL_A,
    WELL_B,
    WELL_F,
    WELL_GRAD,
)
from descent import gradient_descent, losses_along, path_length

well_path = gradient_descent(WELL_GRAD, START, LEARNING_RATE, STEPS)
ill_path = gradient_descent(ILL_GRAD, START, LEARNING_RATE, STEPS)

well_losses = losses_along(WELL_F, well_path)
ill_losses = losses_along(ILL_F, ill_path)

well_len = path_length(well_path)
ill_len = path_length(ill_path)

print(f"well-conditioned bowl: f(x, y) = {WELL_A:g} x^2 + {WELL_B:g} y^2")
print(f"ill-conditioned bowl:  f(x, y) = {ILL_A:g} x^2 + {ILL_B:g} y^2")
print(f"both start at {tuple(START)}, learning rate {LEARNING_RATE}, {STEPS} steps")
print()
print(f"well-conditioned final loss: {well_losses[-1]:.6e}")
print(f"ill-conditioned final loss:  {ill_losses[-1]:.6e}")
relative_gap = abs(well_losses[-1] - ill_losses[-1]) / max(well_losses[-1], ill_losses[-1])
print(f"relative gap between the two final losses: {relative_gap:.4f}")
print()
print(f"well-conditioned path length: {well_len:.4f}")
print(f"ill-conditioned path length:  {ill_len:.4f}")
print(f"ratio: {ill_len / well_len:.2f}x longer")

assert relative_gap < LOSS_MATCH_TOL, "the two final losses should be nearly indistinguishable"
assert ill_len / well_len > PATH_LENGTH_RATIO_MIN, "the paths should differ by a large factor"

print()
print(
    "A reader who only sees the two final-loss numbers above would call these runs "
    "equivalent. Only the path length -- or the picture -- shows that one of them "
    "spent most of its steps bouncing across the narrow axis instead of heading "
    "toward the minimum."
)
print()
print("06_two_runs_same_loss.py: every assertion held.")
examples/conftest.py (1081 bytes)
"""Make this directory's own modules the ones its tests import.

Both `examples/` and `starter/` contain modules called `dataset`, `gridviz`,
`descent` and `imaging`, and pytest imports test files by putting their
directory on `sys.path`. Without this file, running `pytest` across both
directories at once would import whichever version was seen first and reuse
it for the other suite -- so the starter tests could silently pass against
the reference solution instead of skipping. That is a wrong answer with a
green tick on it, which is the worst kind.

So: put this directory first on the import path, and drop any already
imported module of those names that came from somewhere else.
"""

import sys
from pathlib import Path

HERE = str(Path(__file__).parent.resolve())

if HERE in sys.path:
    sys.path.remove(HERE)
sys.path.insert(0, HERE)

for name in ("dataset", "gridviz", "descent", "imaging"):
    module = sys.modules.get(name)
    origin = getattr(module, "__file__", "") or ""
    if module is not None and not origin.startswith(HERE):
        del sys.modules[name]
examples/dataset.py (2969 bytes)
"""Shared numbers for the Day 112 lab -- the reference implementation.

Every script and every test in this lab imports its constants from here so
that the lesson's claims, the reference scripts, and the test suite are all
computed from the same numbers rather than three copies that could drift.

Two bowls, both centred on the origin with minimum value 0:

    WELL(x, y) = x^2 +  y^2          -- a perfectly round bowl
    ILL(x, y)  = x^2 + 25 y^2        -- a valley squeezed 25x narrower in y

The two runs compared throughout the lab and the lesson start at the same
point and take the same number of steps at the SAME learning rate. The only
difference is which bowl they descend. That is deliberate: it isolates
conditioning as the one variable that explains why one run's path is short
and geometric while the other's is long and zig-zagging, even though both
land within a few percent of the same final loss.
"""

import numpy as np

# -- the two bowls -----------------------------------------------------------

WELL_A, WELL_B = 1.0, 1.0
ILL_A, ILL_B = 1.0, 25.0

START = np.array([4.0, 4.0])
LEARNING_RATE = 0.038
STEPS = 60


def bowl(a: float, b: float):
    """Return (f, grad) for f(x, y) = a x^2 + b y^2.

    The minimum is always at the origin with value 0, regardless of a and b --
    only the SHAPE of the bowl changes, never where the bottom is.
    """

    def f(x, y):
        return a * x**2 + b * y**2

    def grad(x, y):
        return np.array([2.0 * a * x, 2.0 * b * y])

    return f, grad


WELL_F, WELL_GRAD = bowl(WELL_A, WELL_B)
ILL_F, ILL_GRAD = bowl(ILL_A, ILL_B)

# -- the one-dimensional bowl used for the learning-rate sweep ---------------
#
# f(x) = x^2, grad = 2x. The update is x <- x - eta * 2x = (1 - 2 eta) x, an
# exact geometric recursion with ratio rho = 1 - 2 eta. It converges only for
# 0 < eta < 1: the sweep therefore has a genuine cliff at eta = 1, not merely
# "large enough to eventually diverge".

SWEEP_X0 = 4.0
SWEEP_STEPS = 300


def sweep_f(x):
    return x**2


def sweep_grad(x):
    return 2.0 * x


# -- tolerances, each tied to the comparison it governs ----------------------

# Two analytic quantities computed by different routes: machine precision.
EXACT_TOL = 1e-9

# "Two runs land within a few percent of the same final loss." Chosen once
# from the pair of runs this file specifies (see WELL_RUN / ILL_RUN below,
# and examples/06_two_runs_same_loss.py, which prints the measured gap).
LOSS_MATCH_TOL = 0.05

# "The paths differ by a large factor." The measured ratio is over 13x; 5x is
# a conservative floor that would still be true on a different machine or
# numpy build, since the recursion above is closed-form and exact.
PATH_LENGTH_RATIO_MIN = 5.0

# How close the LAST drawn pixel marker must land to the pixel of the true
# minimum (0, 0), in pixels, for the world-to-pixel round trip to count as
# correct. One pixel of slack absorbs rounding in both directions.
PIXEL_TOL = 2.0
examples/descent.py (3155 bytes)
"""Exercises 7 and 8: running gradient descent and reading the run, not just
its last number.

Nothing here is imported from Day 111's lab -- the update rule is the same
three-line loop every gradient-descent lesson in this course uses, and it is
written out again here on purpose, because this lab's whole point is that the
FINAL LOSS a run prints is not enough to tell two runs apart.
"""

from __future__ import annotations

import numpy as np


def gradient_descent(grad_fn, x0, lr: float, steps: int) -> np.ndarray:
    """Run steps of x <- x - lr * grad_fn(x), returning every visited point.

    Returns an array of shape (steps + 1, len(x0)): the starting point,
    then one row per step, so path[0] is x0 and path[-1] is where the run
    stopped. Keeping the whole path (not just the endpoint) is what makes
    every other function in this lab possible -- you cannot draw a route on
    a map, or a loss curve, from a single final number.
    """
    x = np.array(x0, dtype=float)
    path = [x.copy()]
    for _ in range(steps):
        x = x - lr * np.asarray(grad_fn(*x))
        path.append(x.copy())
    return np.array(path)


def losses_along(f, path: np.ndarray) -> np.ndarray:
    """Evaluate f at every point on a path, returning one loss per row."""
    return np.array([f(*p) for p in path])


def path_length(path: np.ndarray) -> float:
    """Total Euclidean distance travelled along a path: sum of step sizes.

    A short, nearly straight path and a long, zig-zagging one can arrive at
    almost the same final loss -- path_length is the single number that
    tells them apart without needing a picture, and the picture is what
    explains WHY they differ.
    """
    steps = np.diff(path, axis=0)
    return float(np.sum(np.linalg.norm(steps, axis=1)))


def sweep_final_loss(grad_fn, f, x0: float, eta: float, steps: int) -> float:
    """Run gradient descent on a 1D function at learning rate eta, returning
    the final loss -- or float('inf') if the run diverged.

    Overflow during a diverging run is expected behaviour, not an error: a
    learning rate above the stability threshold makes the iterate grow
    without bound, and IEEE-754 represents "grew past the largest
    representable double" as inf rather than raising an exception. This
    function catches that deliberately with numpy's error-state context
    manager (over='ignore') and reports it as a value, so the reader sees a
    clean cliff in the sweep instead of a stack trace.
    """
    x = np.float64(x0)
    for _ in range(steps):
        with np.errstate(over="ignore", invalid="ignore"):
            x = x - eta * grad_fn(x)
        if not np.isfinite(x):
            return float("inf")
    with np.errstate(over="ignore", invalid="ignore"):
        value = f(x)
    return float(value) if np.isfinite(value) else float("inf")


def learning_rate_sweep(grad_fn, f, x0: float, etas, steps: int):
    """Run sweep_final_loss at every learning rate in etas.

    Returns a list of (eta, final_loss) pairs, in the order etas was given.
    """
    return [(float(eta), sweep_final_loss(grad_fn, f, x0, float(eta), steps)) for eta in etas]
examples/gridviz.py (3528 bytes)
"""Exercise 1, 2 and the world-to-pixel half of exercise 4: seeing a surface
before drawing anything on top of it.

Every picture in this lab starts from the same three-step recipe: evaluate a
function over a grid, decide how a value maps to something visible (a
character, a colour, a pixel), and read the result back to confirm the axes
did not get flipped along the way. This module is that recipe with nothing
plotted yet -- the terminal-only version of a contour plot.
"""

from __future__ import annotations

import numpy as np

# A five-character ramp, darkest (lowest value) to densest (highest value).
# Five bands is deliberately coarse: it is legible in a terminal and it is
# small enough that a specific character at a specific cell is a real
# assertion, not a fuzzy visual check.
ASCII_RAMP = " .:+#"


def evaluate_grid(f, xlim: tuple[float, float], ylim: tuple[float, float], n: int):
    """Evaluate f over an n x n grid spanning xlim x ylim.

    Returns (X, Y, Z), each an (n, n) array from numpy.meshgrid: X varies
    along columns, Y varies along rows, and Z = f(X, Y). This is the one
    building block every picture in this lab is made from -- a contour plot,
    a heatmap and a 3D surface are three different ways of drawing the same
    (X, Y, Z) triple.
    """
    xs = np.linspace(xlim[0], xlim[1], n)
    ys = np.linspace(ylim[0], ylim[1], n)
    X, Y = np.meshgrid(xs, ys)
    Z = f(X, Y)
    return X, Y, Z


def ascii_contour(Z: np.ndarray, chars: str = ASCII_RAMP) -> str:
    """Render a 2D array as text: one character per cell, chosen by level band.

    Values are rescaled to [0, len(chars)) linearly between Z's own min and
    max, then floored into a band index. This is the crudest possible contour
    renderer -- level BANDS shaded by character, not level LINES traced
    between them -- and that crudeness is the point: get it wrong (a
    transposed grid, a flipped row order) and a symmetric bowl stops looking
    symmetric immediately, in a terminal, with no image viewer required.
    """
    zmin, zmax = float(Z.min()), float(Z.max())
    span = zmax - zmin if zmax > zmin else 1.0
    n_bands = len(chars)
    idx = np.clip(((Z - zmin) / span * n_bands).astype(int), 0, n_bands - 1)
    return "\n".join("".join(chars[i] for i in row) for row in idx)


def world_to_pixel(
    x: float,
    y: float,
    xlim: tuple[float, float],
    ylim: tuple[float, float],
    width: int,
    height: int,
) -> tuple[float, float]:
    """Map one (x, y) point in data coordinates to a (column, row) pixel.

    Two axis flips have to happen correctly here and nowhere else in the
    lab -- every drawing function in imaging.py calls this one function
    instead of repeating the arithmetic, so a bug in the mapping shows up
    once, here, and is testable in isolation:

      * x grows to the RIGHT in both spaces, so columns increase with x --
        no flip.
      * y grows UPWARD in data space but DOWNWARD in pixel rows (row 0 is the
        top of the image), so pixel row is computed from (ylim[1] - y), not
        (y - ylim[0]).

    Getting the y flip backwards is the single most common bug in this lab:
    the heatmap looks fine on its own (it is vertically symmetric for a bowl
    centred at the origin) but the path drawn on top of it walks toward the
    WRONG edge, and exercise 4 is built to catch exactly that.
    """
    px = (x - xlim[0]) / (xlim[1] - xlim[0]) * (width - 1)
    py = (ylim[1] - y) / (ylim[1] - ylim[0]) * (height - 1)
    return px, py
examples/imaging.py (7818 bytes)
"""Exercises 3, 4 and 6: turning arrays into pictures with nothing but NumPy
and Pillow's ImageDraw.

No matplotlib, no scipy. A heatmap is a NumPy array of colours turned into an
Image; a path is a polyline and a handful of circles drawn with ImageDraw; an
animation is a list of frames saved with save_all=True. That is the entire
technique stack this module needs.
"""

from __future__ import annotations

import numpy as np
from PIL import Image, ImageDraw

from gridviz import world_to_pixel

# -- the colour ramp -----------------------------------------------------
#
# Four control points, dark blue (low) through teal and gold to dark red
# (high). Perceptual accuracy is not the point here -- matplotlib's "viridis"
# and friends exist for that, and are covered in the lesson's tools section --
# the point is that a value maps to a colour through an explicit, inspectable
# rule with named stops, not a library call whose internals are invisible.

_STOPS_T = np.array([0.0, 0.35, 0.65, 1.0])
_STOPS_RGB = np.array(
    [
        [13, 27, 84],  # low value: dark blue
        [29, 78, 216],  # blue
        [250, 204, 21],  # gold
        [185, 28, 28],  # high value: dark red
    ]
)


def ramp_color(t: np.ndarray) -> np.ndarray:
    """Map an array of values in [0, 1] to an (..., 3) array of uint8 RGB.

    Piecewise-linear interpolation through the four stops above, one channel
    at a time via numpy.interp -- the same technique matplotlib's own
    colormaps use internally, just with four hand-picked stops instead of
    a few hundred.
    """
    t = np.clip(t, 0.0, 1.0)
    r = np.interp(t, _STOPS_T, _STOPS_RGB[:, 0])
    g = np.interp(t, _STOPS_T, _STOPS_RGB[:, 1])
    b = np.interp(t, _STOPS_T, _STOPS_RGB[:, 2])
    return np.stack([r, g, b], axis=-1).astype(np.uint8)


def heatmap_array(Z: np.ndarray) -> np.ndarray:
    """Turn a 2D value array into an (n, n, 3) uint8 RGB array, ready for
    Image.fromarray.

    Z's row 0 corresponds to ylim[0] (evaluate_grid's convention, since
    numpy.meshgrid puts the smallest y in the first row) -- but the TOP of an
    image is the LARGEST y, so the array is flipped vertically before it
    becomes an image. This is the one deliberate axis flip in this function;
    world_to_pixel performs the matching flip for anything drawn on top.
    """
    zmin, zmax = float(Z.min()), float(Z.max())
    span = zmax - zmin if zmax > zmin else 1.0
    t = (Z - zmin) / span
    colors = ramp_color(t)
    return np.flipud(colors)


def heatmap_png(
    Z: np.ndarray,
    xlim: tuple[float, float],
    ylim: tuple[float, float],
    path: str,
    width: int | None = None,
    height: int | None = None,
) -> Image.Image:
    """Save Z as a heatmap PNG and return the Image.

    width/height default to Z's own resolution (one pixel per grid cell),
    which keeps world_to_pixel exact: pixel (0, 0) is (xlim[0], ylim[1]) and
    pixel (n-1, n-1) is (xlim[1], ylim[0]).
    """
    arr = heatmap_array(Z)
    img = Image.fromarray(arr, mode="RGB")
    if width is not None and height is not None and (width, height) != (arr.shape[1], arr.shape[0]):
        img = img.resize((width, height), Image.NEAREST)
    img.save(path)
    return img


def draw_path_on_heatmap(
    Z: np.ndarray,
    xlim: tuple[float, float],
    ylim: tuple[float, float],
    path_xy: np.ndarray,
    out_path: str,
    marker_radius: int = 3,
) -> Image.Image:
    """Draw a descent path over its heatmap: a line through every point, plus
    a small circle marking each step.

    The heatmap is built at the grid's own resolution and the path is drawn
    on top of it at that SAME resolution, so world_to_pixel(xlim, ylim,
    width, height) uses the image's actual size rather than a guess -- the
    one place a mismatched width/height would silently misplace every point.
    """
    height, width = Z.shape[0], Z.shape[1]
    img = heatmap_png(Z, xlim, ylim, out_path, width=width, height=height)
    draw = ImageDraw.Draw(img)

    pixels = [world_to_pixel(x, y, xlim, ylim, width, height) for x, y in path_xy]
    if len(pixels) >= 2:
        draw.line(pixels, fill=(255, 255, 255), width=2)
    for px, py in pixels:
        draw.ellipse(
            [px - marker_radius, py - marker_radius, px + marker_radius, py + marker_radius],
            fill=(255, 255, 255),
            outline=(20, 20, 20),
        )
    img.save(out_path)
    return img


def loss_curve_points(
    losses: np.ndarray, width: int, height: int, margin: int, log: bool
) -> list[tuple[float, float]]:
    """Map a loss sequence to pixel coordinates, for drawing OR for testing
    that the log-scale points are collinear.

    Kept separate from loss_curve_png so a test can check the geometry the
    picture is actually built from, rather than re-deriving it in prose.
    """
    losses = np.asarray(losses, dtype=float)
    n = len(losses)
    xs_data = np.arange(n, dtype=float)
    ys_data = np.log10(np.clip(losses, 1e-300, None)) if log else losses

    x0, x1 = 0.0, float(n - 1) if n > 1 else 1.0
    y0, y1 = float(ys_data.min()), float(ys_data.max())
    xspan = x1 - x0 if x1 > x0 else 1.0
    yspan = y1 - y0 if y1 > y0 else 1.0

    points = []
    for xd, yd in zip(xs_data, ys_data):
        px = margin + (xd - x0) / xspan * (width - 2 * margin)
        py = (height - margin) - (yd - y0) / yspan * (height - 2 * margin)
        points.append((px, py))
    return points


def loss_curve_png(
    losses: np.ndarray,
    out_path: str,
    log: bool = False,
    width: int = 500,
    height: int = 350,
    margin: int = 50,
) -> Image.Image:
    """Draw loss against iteration as a PNG: axes, a polyline, and a marker
    per point. log=True plots log10(loss) on the y-axis instead of loss."""
    img = Image.new("RGB", (width, height), color=(248, 250, 252))
    draw = ImageDraw.Draw(img)
    draw.line([(margin, margin // 2), (margin, height - margin)], fill=(26, 32, 44), width=2)
    draw.line(
        [(margin, height - margin), (width - margin // 2, height - margin)],
        fill=(26, 32, 44),
        width=2,
    )
    points = loss_curve_points(losses, width, height, margin, log)
    if len(points) >= 2:
        draw.line(points, fill=(29, 78, 216), width=2)
    for px, py in points:
        draw.ellipse([px - 2, py - 2, px + 2, py + 2], fill=(29, 78, 216))
    img.save(out_path)
    return img


def animated_descent_gif(
    Z: np.ndarray,
    xlim: tuple[float, float],
    ylim: tuple[float, float],
    path_xy: np.ndarray,
    out_path: str,
    duration_ms: int = 150,
) -> None:
    """Save one GIF frame per step of a descent, the path built up one point
    at a time, over the same heatmap.

    Frame k shows path_xy[0 : k + 1] -- so the FIRST frame is a single
    marker at the start, and the LAST frame is the complete path drawn by
    draw_path_on_heatmap. Pillow's save(..., save_all=True) is the entire
    animation mechanism: it writes each frame's own image data plus a small
    GIF-format header saying "loop through these N frames".
    """
    height, width = Z.shape[0], Z.shape[1]
    background = heatmap_array(Z)
    frames = []
    for k in range(1, len(path_xy) + 1):
        img = Image.fromarray(background, mode="RGB").copy()
        draw = ImageDraw.Draw(img)
        pixels = [world_to_pixel(x, y, xlim, ylim, width, height) for x, y in path_xy[:k]]
        if len(pixels) >= 2:
            draw.line(pixels, fill=(255, 255, 255), width=2)
        px, py = pixels[-1]
        draw.ellipse([px - 4, py - 4, px + 4, py + 4], fill=(255, 255, 255), outline=(20, 20, 20))
        frames.append(img.convert("P", palette=Image.ADAPTIVE))
    frames[0].save(
        out_path,
        save_all=True,
        append_images=frames[1:],
        duration=duration_ms,
        loop=0,
        format="GIF",
    )
examples/test_reference.py (7678 bytes)
"""The reference test suite: nine numbered checks, one per lab exercise.

Every test writes into pytest's own tmp_path fixture (a directory pytest
creates and cleans up itself, outside this lab), never into the lab
directory. That is what makes exercise 9's cleanup check meaningful: nothing
in this suite is exempt from it.
"""

from __future__ import annotations

import numpy as np
import pytest
from PIL import Image

import dataset as D
import descent as DS
import gridviz as G
import imaging as IM

XLIM = (-4.0, 4.0)
YLIM = (-4.0, 4.0)
GRID_N = 101


# -- exercise 1: evaluate_grid ------------------------------------------------


def test_evaluate_grid_shape_and_minimum():
    X, Y, Z = G.evaluate_grid(D.WELL_F, XLIM, YLIM, GRID_N)
    assert X.shape == (GRID_N, GRID_N)
    assert Y.shape == (GRID_N, GRID_N)
    assert Z.shape == (GRID_N, GRID_N)
    iy, ix = np.unravel_index(np.argmin(Z), Z.shape)
    assert abs(X[iy, ix]) < 1e-9
    assert abs(Y[iy, ix]) < 1e-9
    assert Z[iy, ix] == pytest.approx(0.0, abs=1e-9)


# -- exercise 2: ascii contour renderer --------------------------------------


def test_ascii_contour_exact_characters():
    _, _, Z = G.evaluate_grid(lambda x, y: x**2 + y**2, (-2, 2), (-2, 2), 5)
    rendered = G.ascii_contour(Z)
    rows = rendered.split("\n")
    assert len(rows) == 5
    # Centre cell: value 0, the minimum -> the lightest character (a space).
    assert rows[2][2] == " "
    # Every corner: value 8, the maximum -> the densest character.
    for r, c in [(0, 0), (0, 4), (4, 0), (4, 4)]:
        assert rows[r][c] == "#"
    # A transposed grid or a flipped row order would break this symmetry:
    # x^2 + y^2 is symmetric under both left-right and top-bottom mirror.
    assert all(rows[r] == rows[4 - r] for r in range(5))
    assert all(rows[r] == rows[r][::-1] for r in range(5))


# -- exercise 3: Pillow heatmap PNG ------------------------------------------


def test_heatmap_png_size_and_minimum_color(tmp_path):
    _, _, Z = G.evaluate_grid(D.WELL_F, XLIM, YLIM, GRID_N)
    out = tmp_path / "heat.png"
    img = IM.heatmap_png(Z, XLIM, YLIM, str(out))
    assert out.exists()
    assert img.size == (GRID_N, GRID_N)
    reopened = Image.open(out)
    assert reopened.size == (GRID_N, GRID_N)
    px, py = G.world_to_pixel(0.0, 0.0, XLIM, YLIM, GRID_N, GRID_N)
    assert reopened.convert("RGB").getpixel((round(px), round(py))) == (13, 27, 84)


# -- exercise 4: path drawn over the heatmap, and the pixel transform --------


def test_world_to_pixel_corners():
    # x grows right: no flip. y grows up in data space, down in pixel rows.
    assert G.world_to_pixel(-4.0, 4.0, XLIM, YLIM, 101, 101) == (0.0, 0.0)
    assert G.world_to_pixel(4.0, -4.0, XLIM, YLIM, 101, 101) == (100.0, 100.0)
    assert G.world_to_pixel(0.0, 0.0, XLIM, YLIM, 101, 101) == (50.0, 50.0)


def test_descent_path_drawn_over_heatmap(tmp_path):
    _, _, Z = G.evaluate_grid(D.WELL_F, XLIM, YLIM, GRID_N)
    path = DS.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
    out = tmp_path / "path.png"
    IM.draw_path_on_heatmap(Z, XLIM, YLIM, path, str(out))
    assert out.exists()
    img = Image.open(out)
    assert img.size == (GRID_N, GRID_N)

    first_px = G.world_to_pixel(*path[0], XLIM, YLIM, GRID_N, GRID_N)
    expected_first = G.world_to_pixel(*D.START, XLIM, YLIM, GRID_N, GRID_N)
    assert first_px == expected_first

    last_px = G.world_to_pixel(*path[-1], XLIM, YLIM, GRID_N, GRID_N)
    minimum_px = G.world_to_pixel(0.0, 0.0, XLIM, YLIM, GRID_N, GRID_N)
    distance = ((last_px[0] - minimum_px[0]) ** 2 + (last_px[1] - minimum_px[1]) ** 2) ** 0.5
    assert distance < D.PIXEL_TOL


# -- exercise 5: loss curve, linear and log, and the collinearity proof -----


def test_loss_curve_png_linear_and_log(tmp_path):
    path = DS.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
    losses = DS.losses_along(D.WELL_F, path)

    lin_out = tmp_path / "loss_linear.png"
    log_out = tmp_path / "loss_log.png"
    IM.loss_curve_png(losses, str(lin_out), log=False)
    IM.loss_curve_png(losses, str(log_out), log=True)
    assert lin_out.exists() and log_out.exists()
    assert Image.open(lin_out).size == (500, 350)
    assert Image.open(log_out).size == (500, 350)


def test_log_axis_points_are_collinear():
    # a = b = 1: the update x <- (1 - 2 * lr * a) x is an EXACT geometric
    # recursion, so loss = a x^2 + b y^2 decays as a single power of a
    # constant ratio and log10(loss) against iteration is a straight line
    # by construction, not by approximation.
    path = DS.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
    losses = DS.losses_along(D.WELL_F, path)
    points = IM.loss_curve_points(losses, width=500, height=350, margin=50, log=True)
    xs = np.array([p[0] for p in points])
    ys = np.array([p[1] for p in points])
    A = np.vstack([xs, np.ones_like(xs)]).T
    slope, intercept = np.linalg.lstsq(A, ys, rcond=None)[0]
    residual = np.max(np.abs(ys - (slope * xs + intercept)))
    assert residual < 1e-6  # pixel units: far below one pixel


# -- exercise 6: animated GIF -------------------------------------------------


def test_animated_gif_frame_count(tmp_path):
    _, _, Z = G.evaluate_grid(D.WELL_F, XLIM, YLIM, GRID_N)
    path = DS.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, 20)
    out = tmp_path / "descent.gif"
    IM.animated_descent_gif(Z, XLIM, YLIM, path, str(out))
    assert out.exists()
    reopened = Image.open(out)
    assert reopened.format == "GIF"
    assert reopened.n_frames == len(path)


# -- exercise 7: the learning-rate sweep -------------------------------------


def test_learning_rate_sweep_shape():
    etas = np.round(np.arange(0.05, 2.55, 0.1), 2)
    sweep = DS.learning_rate_sweep(D.sweep_grad, D.sweep_f, D.SWEEP_X0, etas, D.SWEEP_STEPS)
    assert [e for e, _ in sweep] == list(etas)

    finite = [(e, loss) for e, loss in sweep if np.isfinite(loss)]
    divergent = [(e, loss) for e, loss in sweep if not np.isfinite(loss)]

    # A basin, not a single magic number: more than one eta converges well.
    good = [e for e, loss in finite if loss < 1e-6]
    assert len(good) >= 3

    # The argmin sits strictly inside the swept range, not at either edge.
    best_eta = min(finite, key=lambda pair: pair[1])[0]
    assert etas[0] < best_eta < etas[-1]

    # And the sweep actually reaches the cliff: some etas genuinely diverge,
    # caught as float('inf') rather than raising.
    assert len(divergent) >= 1
    assert all(loss == float("inf") for _, loss in divergent)
    # Divergence only happens on the far side of the theoretical threshold
    # eta = 1 / a = 1.0 for f(x) = x^2.
    assert all(e > 1.0 for e, _ in divergent)


# -- exercise 8: two runs, same loss, very different paths -------------------


def test_two_runs_same_loss_different_path_length():
    well_path = DS.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
    ill_path = DS.gradient_descent(D.ILL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)

    well_loss = DS.losses_along(D.WELL_F, well_path)[-1]
    ill_loss = DS.losses_along(D.ILL_F, ill_path)[-1]
    relative_gap = abs(well_loss - ill_loss) / max(well_loss, ill_loss)
    assert relative_gap < D.LOSS_MATCH_TOL

    well_len = DS.path_length(well_path)
    ill_len = DS.path_length(ill_path)
    assert ill_len / well_len > D.PATH_LENGTH_RATIO_MIN


# -- exercise 9 (the cleanup check) lives in tests/run_tests.sh, which
# inspects the whole lab directory after every other check has run -- a
# single pytest test cannot see "did the ENTIRE suite leave anything behind"
# from inside one test function.
metadata.yml (4731 bytes)
lesson_id: D112
day: 112
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-112-visualizing-optimization
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python3 -c "import numpy, PIL; print(numpy.__version__, PIL.__version__)"
run_commands:
  - 'cd examples && ../.venv/bin/python3 01_grid_and_ascii.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 02_heatmap_and_path.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 03_loss_curves.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 04_animated_gif.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 05_learning_rate_sweep.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 06_two_runs_same_loss.py && cd ..'
  - .venv/bin/pytest examples -q -p no:cacheprovider
  - .venv/bin/pytest starter -q -p no:cacheprovider
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
  - rm -rf .pytest_cache
  - 'rm -rf .venv  # optional: removes the lab virtual environment'
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-08-17'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, numpy 2.5.2, Pillow 12.3.0, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 31 checks, 0 failure(s), exit 0; pytest examples -> 10 passed; pytest starter -> 13 skipped on an untouched checkout, and 13 passed against a fully solved copy of starter/ (gridviz.py, descent.py and imaging.py copied in from examples/) kept outside the lab, which was verified rather than assumed. All six numbered reference scripts exit 0 and each prints its own "every assertion held" line. Everything ran through the authoring virtual environment (numpy 2.5.2, Pillow 12.3.0, pytest 9.1.1, Python 3.14.0), not the bare system interpreter, which has none of the three packages. The harness was additionally confirmed to be capable of failing: section 6 re-runs the whole script with the relative-loss-gap threshold from section 5 tightened from 0.05 to 0.001, which the two measured runs (a well-conditioned and an ill-conditioned quadratic bowl, same start, same learning rate, same step count) cannot meet, and the re-run reported exactly one named failure and exited non-zero; with the threshold restored, the harness is green again. matplotlib is confirmed absent from this environment by section 1 of the harness, which attempts the import and asserts it fails; no matplotlib, scipy, pandas or Plotly output is reproduced anywhere in this lab or its lesson, and the lesson states so plainly in its Tools section. The two-run comparison that opens the lesson was measured, not chosen after the fact to look good: a well-conditioned bowl f(x,y) = x^2 + y^2 and an ill-conditioned one f(x,y) = x^2 + 25y^2, both started at (4, 4) with learning rate 0.038 for 60 steps, land at final losses of 2.431009e-03 and 2.507203e-03 respectively -- a relative gap of 3.04%, well inside the well-conditioned run''s own noise floor -- while their path lengths are 5.6075 and 75.9767, a ratio of 13.55x. The log-axis collinearity claim for the well-conditioned run is proved rather than asserted in prose: log10(loss) against iteration is an exactly geometric sequence for this update rule when a = b = 1, and fitting a line to the drawn pixel coordinates gives a maximum residual on the order of 1e-13, reported in the test rather than hard-coded to a looser bound. The learning-rate sweep on f(x) = x^2 over eta from 0.05 to 2.45 in steps of 0.1, run for 300 steps starting at x0 = 4.0, found ten learning rates reaching a loss indistinguishable from zero, an interior argmin at eta = 0.45 (tied with 0.55 by symmetry), the largest eta whose blow-up had not yet overflowed float64 within 300 steps at eta = 2.05, and the smallest eta whose blow-up had overflowed to inf at eta = 2.15 -- all four caught deliberately via numpy.errstate rather than raised or warned. One honesty note from authoring: a different agent began authoring this same day into this same lab directory concurrently, wrote a design under different module and function names (`optim_viz.py` among them) into examples/, and briefly overwrote this implementation''s test_reference.py before the collision was noticed and resolved by deleting every file from that design and regenerating every expected-output capture, this metadata file, and this lab from the surviving implementation''s own runs, made today. No content in this lab or its expected-output captures describes or was carried over from the other design.'
requirements/README.md (3002 bytes)
# What is installed, why, and what it costs

Three packages, all free and open source, all installed into a lab-local
virtual environment that `rm -rf .venv` completely undoes.

| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `numpy` | 2.5.2 | BSD 3-Clause | `numpy.meshgrid` to build every grid, vectorised arithmetic for the colour ramp, and the gradient-descent loop itself. |
| `Pillow` | 12.3.0 | MIT-CMU (the "PIL Software License") | `Image`, `ImageDraw` and `Image.save(..., save_all=True)` — every pixel this lab draws, and the only GIF-writing code it needs. |
| `pytest` | 9.1.1 | MIT | The reference suite and your running score in `starter/`. |

There is no paid tier of anything in this lab, no account, no key and no
signup, personally or commercially.

## The tool this lab deliberately does not install

**matplotlib is not installed here, and neither is scipy, pandas or plotly.**
That is not an oversight — it is the reason this lab exists in the form it
does. Every picture is built from a NumPy array and Pillow's `ImageDraw`
with nothing else in between, so nothing about how a contour plot or a loss
curve actually gets to the screen is hidden behind a library call. The
lesson's Tools section describes matplotlib, Plotly, TensorBoard and Weights
& Biases from their documentation and states plainly that no output from any
of them is reproduced here.

## The one time the network is needed

```bash
.venv/bin/pip install -r requirements/requirements.txt
```

That is the only command in the lab that opens a connection. Section 6 of
`tests/run_tests.sh` greps every source file in `examples/` and `starter/` to
prove that nothing else does.

## If you cannot install anything at all

Almost none of this lab runs without Pillow, and that is worth saying
plainly rather than glossing over: `evaluate_grid` and `ascii_contour` need
only NumPy (and the ASCII renderer is genuinely useful on its own — it is how
this lab's first picture gets checked without an image viewer at all), but
every PNG and the GIF need `PIL.Image` and `PIL.ImageDraw` directly, and there
is no standard-library substitute for either.

## What is deliberately *not* installed, and why it is not a problem

matplotlib, Plotly, TensorBoard and Weights & Biases all do this job, usually
better and always faster to write. None of them is installed here, and **no
output from any of them is reproduced anywhere in this lab or its lesson.**
They are described from their documentation, and the lesson's Tools section
marks each one as not run.

That is not a limitation to apologise for. `heatmap_png` and
`draw_path_on_heatmap` in `examples/imaging.py` do, by hand, exactly what
`matplotlib.pyplot.contourf` and `plt.plot` do for you: evaluate a function
over a grid, map values to colours, and place pixels. Having built the
minimal version, the documentation of the real one reads as engineering on
top of an idea you already own, rather than as magic.
requirements/requirements.txt (42 bytes)
numpy==2.5.2
Pillow==12.3.0
pytest==9.1.1
starter/00_brief.md (3255 bytes)
# Day 112 lab — the brief

Eight exercises across three files, in order. Work top to bottom: `gridviz.py`
before `imaging.py`, and `imaging.py`'s later functions before its earlier
ones' consumers. `dataset.py` is given to you — the loss surfaces and the
descent loop are Day 111's subject, not this one.

Check yourself at any point:

```bash
.venv/bin/pytest starter -q
```

On an untouched checkout that prints `13 skipped`. A **skip** means "not
attempted". A **failure** means "attempted and wrong", and it prints your
answer beside the real one. When every test passes, you are finished.

---

## Exercise 1 — `gridviz.py`, `evaluate_grid`

`numpy.meshgrid` plus a function call. Every picture later in this lab starts
here.

## Exercise 2 — `gridviz.py`, `ascii_contour`

Rescale a 2D array to a small number of character bands and print it. Get the
row/column order wrong and a symmetric bowl stops looking symmetric — in a
terminal, immediately, with no image viewer required.

## Exercise 3 — `imaging.py`, `heatmap_array` and `heatmap_png`

Turn `evaluate_grid`'s output into an image. `heatmap_array` does the one
deliberate axis flip in this lab (`numpy.flipud`); `heatmap_png` wraps it in
`Image.fromarray` and `img.save`.

## Exercise 4 — `gridviz.py`'s `world_to_pixel`, and `imaging.py`'s
`draw_path_on_heatmap`

`world_to_pixel` is the one function every other drawing function in this lab
calls instead of repeating the arithmetic — get it right once, here, and
everything downstream is right too. Then draw a descent path over its
heatmap with `PIL.ImageDraw.line` and `.ellipse`.

## Exercise 5 — `imaging.py`, `loss_curve_points` and `loss_curve_png`

Map a loss sequence to pixel coordinates — on a linear axis directly, on a
log axis via `numpy.log10` — then draw it. Remember pixel row 0 is the TOP:
a larger data value must produce a SMALLER pixel row on both axes.

## Exercise 6 — `imaging.py`, `animated_descent_gif`

One GIF frame per step, built by re-using exercise 4's drawing approach on a
growing prefix of the path. `Image.save(..., save_all=True,
append_images=...)` is the entire animation mechanism.

## Exercise 7 — `descent.py`, `sweep_final_loss` and `learning_rate_sweep`

Run a 1D descent at a given learning rate and report the final loss — or
`float('inf')` if it diverged. A learning rate above the stability threshold
makes the run overflow float64, which must be caught deliberately with
`numpy.errstate`, not allowed to raise.

## Exercise 8 — `descent.py`, `path_length`

Total Euclidean distance travelled along a path. One line: `numpy.diff` plus
`numpy.linalg.norm`. This is the number that tells two runs with
near-identical final losses apart.

---

## When you are done

Read the reference and run every script:

```bash
cd examples
../.venv/bin/python3 01_grid_and_ascii.py
../.venv/bin/python3 02_heatmap_and_path.py
../.venv/bin/python3 03_loss_curves.py
../.venv/bin/python3 04_animated_gif.py
../.venv/bin/python3 05_learning_rate_sweep.py
../.venv/bin/python3 06_two_runs_same_loss.py
cd ..
```

Script 6 is the one to read even if you read nothing else: two runs land
within a few percent of the same final loss, and their path lengths differ
by more than 13x.
starter/conftest.py (674 bytes)
"""Make this directory's own modules the ones its tests import.

Identical in purpose to examples/conftest.py: both directories define
`dataset`, `gridviz`, `descent` and `imaging`, and without this file pytest
would import whichever it saw first and reuse it for the other suite.
"""

import sys
from pathlib import Path

HERE = str(Path(__file__).parent.resolve())

if HERE in sys.path:
    sys.path.remove(HERE)
sys.path.insert(0, HERE)

for name in ("dataset", "gridviz", "descent", "imaging"):
    module = sys.modules.get(name)
    origin = getattr(module, "__file__", "") or ""
    if module is not None and not origin.startswith(HERE):
        del sys.modules[name]
starter/dataset.py (1852 bytes)
"""Shared numbers for the Day 112 lab -- given to you, not an exercise.

Identical to examples/dataset.py. This day's own subject is turning arrays
into pictures; the loss surfaces and the learning-rate schedule below are
infrastructure carried over from Day 111 so you can spend your effort on
visualization, not on re-deriving a bowl function.
"""

import numpy as np

# -- the two bowls -----------------------------------------------------------

WELL_A, WELL_B = 1.0, 1.0
ILL_A, ILL_B = 1.0, 25.0

START = np.array([4.0, 4.0])
LEARNING_RATE = 0.038
STEPS = 60


def bowl(a: float, b: float):
    """Return (f, grad) for f(x, y) = a x^2 + b y^2."""

    def f(x, y):
        return a * x**2 + b * y**2

    def grad(x, y):
        return np.array([2.0 * a * x, 2.0 * b * y])

    return f, grad


WELL_F, WELL_GRAD = bowl(WELL_A, WELL_B)
ILL_F, ILL_GRAD = bowl(ILL_A, ILL_B)

# -- the one-dimensional bowl used for the learning-rate sweep ---------------

SWEEP_X0 = 4.0
SWEEP_STEPS = 300


def sweep_f(x):
    return x**2


def sweep_grad(x):
    return 2.0 * x


# -- tolerances ----------------------------------------------------------

EXACT_TOL = 1e-9
LOSS_MATCH_TOL = 0.05
PATH_LENGTH_RATIO_MIN = 5.0
PIXEL_TOL = 2.0


def gradient_descent(grad_fn, x0, lr: float, steps: int) -> np.ndarray:
    """Given: run steps of x <- x - lr * grad_fn(x), returning every visited
    point as an array of shape (steps + 1, len(x0)).

    This is Day 111's subject -- the update rule itself -- not this day's.
    """
    x = np.array(x0, dtype=float)
    path = [x.copy()]
    for _ in range(steps):
        x = x - lr * np.asarray(grad_fn(*x))
        path.append(x.copy())
    return np.array(path)


def losses_along(f, path: np.ndarray) -> np.ndarray:
    """Given: evaluate f at every point on a path."""
    return np.array([f(*p) for p in path])
starter/descent.py (1973 bytes)
"""Exercises 7 and 8 -- path_length and the learning-rate sweep.

`return None` where your code goes; `pytest starter -q` skips what you have
not attempted and fails only what you have attempted incorrectly.
"""

from __future__ import annotations

import numpy as np


def path_length(path: np.ndarray) -> float:
    """Exercise 8a -- total Euclidean distance travelled along a path: the
    sum of the step sizes between consecutive points.

    Approach: `steps = np.diff(path, axis=0)`, then sum the row-wise norm:
    `np.sum(np.linalg.norm(steps, axis=1))`.
    """
    return None


def sweep_final_loss(grad_fn, f, x0: float, eta: float, steps: int) -> float:
    """Exercise 7a -- run gradient descent on a 1D function at learning rate
    eta for `steps` steps, and return the final loss.

    A learning rate above the stability threshold makes the iterate grow
    without bound. That growth eventually overflows float64 to `inf` --
    which is expected behaviour to CATCH, not an exception to let escape.
    Use `numpy.errstate(over="ignore", invalid="ignore")` around the update
    and around the final `f(x)`, and check `np.isfinite(x)` after every step;
    return `float('inf')` the moment it stops being finite.

    Approach:

        x = np.float64(x0)
        for _ in range(steps):
            with np.errstate(over="ignore", invalid="ignore"):
                x = x - eta * grad_fn(x)
            if not np.isfinite(x):
                return float("inf")
        with np.errstate(over="ignore", invalid="ignore"):
            value = f(x)
        return float(value) if np.isfinite(value) else float("inf")
    """
    return None


def learning_rate_sweep(grad_fn, f, x0: float, etas, steps: int):
    """Exercise 7b -- run sweep_final_loss at every learning rate in etas.

    Return a list of (eta, final_loss) pairs, in the order etas was given.

    Approach: one line, a list comprehension calling sweep_final_loss.
    """
    return None
starter/gridviz.py (2139 bytes)
"""Exercises 1, 2 and 4a -- write the three functions below.

Every function has a working signature, a docstring saying exactly what it
must do, and `return None` where your code goes. Returning None is how the
test suite knows you have not attempted it yet: `pytest starter -q` will
SKIP an unattempted function rather than fail it.

Check yourself as you go:

    .venv/bin/pytest starter -q
"""

from __future__ import annotations

import numpy as np

ASCII_RAMP = " .:+#"


def evaluate_grid(f, xlim: tuple[float, float], ylim: tuple[float, float], n: int):
    """Exercise 1 -- evaluate f over an n x n grid spanning xlim x ylim.

    Return (X, Y, Z), each an (n, n) array from numpy.meshgrid: X varies
    along columns, Y varies along rows, and Z = f(X, Y).

    Approach: `xs = np.linspace(xlim[0], xlim[1], n)`, same for ys, then
    `X, Y = np.meshgrid(xs, ys)` and `Z = f(X, Y)`.
    """
    return None


def ascii_contour(Z: np.ndarray, chars: str = ASCII_RAMP) -> str:
    """Exercise 2 -- render a 2D array as text: one character per cell,
    chosen by level band.

    Rescale Z linearly between its own min and max to [0, len(chars)), floor
    to an integer band index, clip to [0, len(chars) - 1], and join each
    row's characters with "\\n" between rows.

    Approach: `zmin, zmax = Z.min(), Z.max()`; `idx = np.clip(((Z - zmin) /
    span * len(chars)).astype(int), 0, len(chars) - 1)`; build one string per
    row from `chars[i]` and join the rows with newlines.
    """
    return None


def world_to_pixel(
    x: float,
    y: float,
    xlim: tuple[float, float],
    ylim: tuple[float, float],
    width: int,
    height: int,
) -> tuple[float, float]:
    """Exercise 4a -- map one (x, y) data point to a (column, row) pixel.

    x grows to the right in both spaces (columns increase with x, no flip).
    y grows UPWARD in data space but pixel row 0 is the TOP of the image, so
    row must be computed from (ylim[1] - y), not (y - ylim[0]).

    Approach: `px = (x - xlim[0]) / (xlim[1] - xlim[0]) * (width - 1)`;
    `py = (ylim[1] - y) / (ylim[1] - ylim[0]) * (height - 1)`.
    """
    return None
starter/imaging.py (5392 bytes)
"""Exercises 3, 4b, 5 and 6 -- turn arrays into pictures with NumPy and
Pillow's ImageDraw. Work top to bottom: exercise 4b needs your own
`world_to_pixel` from gridviz.py, and exercise 6 reuses exercise 4b's ideas.

`return None` where your code goes; `pytest starter -q` skips what you have
not attempted.
"""

from __future__ import annotations

import numpy as np
from PIL import Image, ImageDraw

from gridviz import world_to_pixel

# -- given: the colour ramp ---------------------------------------------
#
# Four control points, dark blue (low) through teal and gold to dark red
# (high). The colour choice is not the exercise -- turning a value array
# into pixels is.

_STOPS_T = np.array([0.0, 0.35, 0.65, 1.0])
_STOPS_RGB = np.array(
    [
        [13, 27, 84],
        [29, 78, 216],
        [250, 204, 21],
        [185, 28, 28],
    ]
)


def ramp_color(t: np.ndarray) -> np.ndarray:
    """Given: map an array of values in [0, 1] to an (..., 3) uint8 RGB array."""
    t = np.clip(t, 0.0, 1.0)
    r = np.interp(t, _STOPS_T, _STOPS_RGB[:, 0])
    g = np.interp(t, _STOPS_T, _STOPS_RGB[:, 1])
    b = np.interp(t, _STOPS_T, _STOPS_RGB[:, 2])
    return np.stack([r, g, b], axis=-1).astype(np.uint8)


def heatmap_array(Z: np.ndarray) -> np.ndarray:
    """Exercise 3a -- turn a 2D value array into an (n, n, 3) uint8 RGB array.

    Rescale Z to [0, 1] with (Z - Z.min()) / (Z.max() - Z.min()), pass it to
    `ramp_color`, and flip the result vertically before returning it: Z's row
    0 is the SMALLEST y (evaluate_grid's convention), but the TOP of an image
    must show the LARGEST y.

    Approach: `t = (Z - Z.min()) / span`; `colors = ramp_color(t)`; return
    `np.flipud(colors)`.
    """
    return None


def heatmap_png(
    Z: np.ndarray,
    xlim: tuple[float, float],
    ylim: tuple[float, float],
    path: str,
    width: int | None = None,
    height: int | None = None,
) -> Image.Image:
    """Exercise 3b -- save Z as a heatmap PNG (via heatmap_array) and return
    the Image. Default width/height to Z's own resolution.

    Approach: `arr = heatmap_array(Z)`; `img = Image.fromarray(arr,
    mode="RGB")`; resize if width/height were given and differ; `img.save(path)`;
    return `img`.
    """
    return None


def draw_path_on_heatmap(
    Z: np.ndarray,
    xlim: tuple[float, float],
    ylim: tuple[float, float],
    path_xy: np.ndarray,
    out_path: str,
    marker_radius: int = 3,
) -> Image.Image:
    """Exercise 4b -- draw a descent path over its heatmap: a line through
    every point, plus a small circle marking each step.

    Build the heatmap at Z's own resolution, then map every (x, y) in
    path_xy to a pixel with `world_to_pixel` and draw a polyline plus a
    circle per point with `PIL.ImageDraw`.

    Approach: `height, width = Z.shape`; build the heatmap; `draw =
    ImageDraw.Draw(img)`; `pixels = [world_to_pixel(x, y, xlim, ylim, width,
    height) for x, y in path_xy]`; `draw.line(pixels, ...)`; loop over
    `pixels` calling `draw.ellipse` centred on each; save and return `img`.
    """
    return None


def loss_curve_points(
    losses: np.ndarray, width: int, height: int, margin: int, log: bool
) -> list[tuple[float, float]]:
    """Exercise 5a -- map a loss sequence to pixel coordinates.

    x data is the iteration index 0..len(losses)-1. y data is `losses`
    itself if log is False, or `np.log10(losses)` if log is True. Rescale
    both to fit inside [margin, width - margin] and [margin, height -
    margin] respectively, remembering that pixel row 0 is the TOP: a larger
    y-data value must produce a SMALLER pixel row.

    Approach: compute `xs_data = np.arange(len(losses))` and `ys_data =
    np.log10(losses) if log else losses`; find each span; for every (xd, yd)
    compute `px = margin + (xd - x0) / xspan * (width - 2*margin)` and
    `py = (height - margin) - (yd - y0) / yspan * (height - 2*margin)`.
    """
    return None


def loss_curve_png(
    losses: np.ndarray,
    out_path: str,
    log: bool = False,
    width: int = 500,
    height: int = 350,
    margin: int = 50,
) -> Image.Image:
    """Exercise 5b -- draw loss against iteration as a PNG: two axis lines,
    a polyline through `loss_curve_points`, and a small marker per point.

    Approach: create a blank `Image.new("RGB", (width, height), ...)`; draw
    the y-axis and x-axis with `ImageDraw.line`; get `points =
    loss_curve_points(...)`; `draw.line(points, ...)`; loop drawing a small
    `draw.ellipse` at each point; save and return the image.
    """
    return None


def animated_descent_gif(
    Z: np.ndarray,
    xlim: tuple[float, float],
    ylim: tuple[float, float],
    path_xy: np.ndarray,
    out_path: str,
    duration_ms: int = 150,
) -> None:
    """Exercise 6 -- save one GIF frame per step of a descent: frame k shows
    path_xy[0 : k + 1] drawn over the (shared) heatmap background.

    Approach: build `background = heatmap_array(Z)` once; for k from 1 to
    len(path_xy), copy an Image from `background`, draw the partial path
    with `world_to_pixel` and `ImageDraw` the way exercise 4b does, and
    convert each frame to `"P"` mode (`img.convert("P",
    palette=Image.ADAPTIVE)`) before appending it to a list. Then call
    `frames[0].save(out_path, save_all=True, append_images=frames[1:],
    duration=duration_ms, loop=0, format="GIF")`.
    """
    return None
starter/test_starter.py (7365 bytes)
"""The starter test suite: skips unattempted work instead of failing it.

Every exercise function returns None until you write it. A test whose
function still returns None is SKIPPED ("not attempted"). A test whose
function returns something else is checked for real -- a wrong answer FAILS
and prints what you got beside what was expected.

Run at any point:

    .venv/bin/pytest starter -q
"""

from __future__ import annotations

import numpy as np
import pytest
from PIL import Image

import dataset as D
import descent as DS
import gridviz as G
import imaging as IM

XLIM = (-4.0, 4.0)
YLIM = (-4.0, 4.0)
GRID_N = 101


def skip_if_none(value, label):
    if value is None:
        pytest.skip(f"{label} not attempted yet")


def get_grid(f=None, xlim=XLIM, ylim=YLIM, n=GRID_N):
    """Every later exercise needs a working evaluate_grid. Skip cleanly
    rather than crashing on an unpack of None if it is not attempted yet."""
    f = D.WELL_F if f is None else f
    result = G.evaluate_grid(f, xlim, ylim, n)
    skip_if_none(result, "evaluate_grid (needed by this exercise)")
    return result


# -- exercise 1: evaluate_grid ------------------------------------------------


def test_1_evaluate_grid_shape_and_minimum():
    result = G.evaluate_grid(D.WELL_F, XLIM, YLIM, GRID_N)
    skip_if_none(result, "evaluate_grid")
    X, Y, Z = result
    assert X.shape == (GRID_N, GRID_N)
    assert Y.shape == (GRID_N, GRID_N)
    assert Z.shape == (GRID_N, GRID_N)
    iy, ix = np.unravel_index(np.argmin(Z), Z.shape)
    assert abs(X[iy, ix]) < 1e-9
    assert abs(Y[iy, ix]) < 1e-9


# -- exercise 2: ascii contour renderer --------------------------------------


def test_2_ascii_contour_exact_characters():
    grid = G.evaluate_grid(lambda x, y: x**2 + y**2, (-2, 2), (-2, 2), 5)
    skip_if_none(grid, "evaluate_grid")
    _, _, Z = grid
    rendered = G.ascii_contour(Z)
    skip_if_none(rendered, "ascii_contour")
    rows = rendered.split("\n")
    assert rows[2][2] == " "
    for r, c in [(0, 0), (0, 4), (4, 0), (4, 4)]:
        assert rows[r][c] == "#"


# -- exercise 3: Pillow heatmap PNG ------------------------------------------


def test_3_heatmap_array_shape_and_dtype():
    _, _, Z = get_grid()
    arr = IM.heatmap_array(Z)
    skip_if_none(arr, "heatmap_array")
    assert arr.shape == (GRID_N, GRID_N, 3)
    assert arr.dtype == np.uint8


def test_3_heatmap_png_size_and_minimum_color(tmp_path):
    _, _, Z = get_grid()
    out = tmp_path / "heat.png"
    img = IM.heatmap_png(Z, XLIM, YLIM, str(out))
    skip_if_none(img, "heatmap_png")
    assert out.exists()
    assert img.size == (GRID_N, GRID_N)
    px, py = G.world_to_pixel(0.0, 0.0, XLIM, YLIM, GRID_N, GRID_N) or (None, None)
    if px is None:
        pytest.skip("world_to_pixel not attempted yet")
    reopened = Image.open(out).convert("RGB")
    assert reopened.getpixel((round(px), round(py))) == (13, 27, 84)


# -- exercise 4: world_to_pixel and the drawn path ---------------------------


def test_4_world_to_pixel_corners():
    result = G.world_to_pixel(-4.0, 4.0, XLIM, YLIM, 101, 101)
    skip_if_none(result, "world_to_pixel")
    assert result == (0.0, 0.0)
    assert G.world_to_pixel(4.0, -4.0, XLIM, YLIM, 101, 101) == (100.0, 100.0)
    assert G.world_to_pixel(0.0, 0.0, XLIM, YLIM, 101, 101) == (50.0, 50.0)


def test_4_descent_path_drawn_over_heatmap(tmp_path):
    _, _, Z = get_grid()
    path = D.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
    out = tmp_path / "path.png"
    img = IM.draw_path_on_heatmap(Z, XLIM, YLIM, path, str(out))
    skip_if_none(img, "draw_path_on_heatmap")
    assert out.exists()
    last_px = G.world_to_pixel(*path[-1], XLIM, YLIM, GRID_N, GRID_N)
    min_px = G.world_to_pixel(0.0, 0.0, XLIM, YLIM, GRID_N, GRID_N)
    distance = ((last_px[0] - min_px[0]) ** 2 + (last_px[1] - min_px[1]) ** 2) ** 0.5
    assert distance < D.PIXEL_TOL


# -- exercise 5: loss curves --------------------------------------------------


def test_5_loss_curve_points_collinear_on_log_axis():
    path = D.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
    losses = D.losses_along(D.WELL_F, path)
    points = IM.loss_curve_points(losses, width=500, height=350, margin=50, log=True)
    skip_if_none(points, "loss_curve_points")
    xs = np.array([p[0] for p in points])
    ys = np.array([p[1] for p in points])
    A = np.vstack([xs, np.ones_like(xs)]).T
    slope, intercept = np.linalg.lstsq(A, ys, rcond=None)[0]
    residual = np.max(np.abs(ys - (slope * xs + intercept)))
    assert residual < 1e-6


def test_5_loss_curve_png(tmp_path):
    path = D.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
    losses = D.losses_along(D.WELL_F, path)
    out = tmp_path / "loss.png"
    img = IM.loss_curve_png(losses, str(out), log=True)
    skip_if_none(img, "loss_curve_png")
    assert out.exists()
    assert Image.open(out).size == (500, 350)


# -- exercise 6: animated GIF -------------------------------------------------


def test_6_animated_gif_frame_count(tmp_path):
    _, _, Z = get_grid()
    path = D.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, 20)
    out = tmp_path / "descent.gif"
    result = IM.animated_descent_gif(Z, XLIM, YLIM, path, str(out))
    if not out.exists():
        pytest.skip("animated_descent_gif not attempted yet")
    reopened = Image.open(out)
    assert reopened.format == "GIF"
    assert reopened.n_frames == len(path)


# -- exercise 7: the learning-rate sweep -------------------------------------


def test_7_sweep_final_loss_catches_divergence():
    result = DS.sweep_final_loss(D.sweep_grad, D.sweep_f, D.SWEEP_X0, 2.5, D.SWEEP_STEPS)
    skip_if_none(result, "sweep_final_loss")
    assert result == float("inf")
    converged = DS.sweep_final_loss(D.sweep_grad, D.sweep_f, D.SWEEP_X0, 0.45, D.SWEEP_STEPS)
    assert converged < 1e-6


def test_7_learning_rate_sweep_shape():
    etas = np.round(np.arange(0.05, 2.55, 0.1), 2)
    sweep = DS.learning_rate_sweep(D.sweep_grad, D.sweep_f, D.SWEEP_X0, etas, D.SWEEP_STEPS)
    skip_if_none(sweep, "learning_rate_sweep")
    assert [e for e, _ in sweep] == list(etas)
    divergent = [(e, loss) for e, loss in sweep if not np.isfinite(loss)]
    assert len(divergent) >= 1


# -- exercise 8: path_length, and two runs at nearly the same loss ----------


def test_8_path_length_of_a_known_path():
    # A path that steps (0,0) -> (3,0) -> (3,4) has length 3 + 4 = 7 exactly
    # (a 3-4-5 triangle's two legs).
    known_path = np.array([[0.0, 0.0], [3.0, 0.0], [3.0, 4.0]])
    result = DS.path_length(known_path)
    skip_if_none(result, "path_length")
    assert result == pytest.approx(7.0)


def test_8_two_runs_same_loss_different_path_length():
    well_path = D.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
    ill_path = D.gradient_descent(D.ILL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)

    well_loss = D.losses_along(D.WELL_F, well_path)[-1]
    ill_loss = D.losses_along(D.ILL_F, ill_path)[-1]
    relative_gap = abs(well_loss - ill_loss) / max(well_loss, ill_loss)
    assert relative_gap < D.LOSS_MATCH_TOL

    well_len = DS.path_length(well_path)
    ill_len = DS.path_length(ill_path)
    skip_if_none(well_len, "path_length")
    skip_if_none(ill_len, "path_length")
    assert ill_len / well_len > D.PATH_LENGTH_RATIO_MIN
tests/run_tests.sh (13823 bytes)
#!/usr/bin/env bash
# Tests for the Day 112 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and reading real
# values, never by reading source:
#
#   * evaluate_grid puts the analytic minimum at the true grid cell, for a
#     bowl and for an anisotropic one;
#   * the ASCII contour renderer produces the exact character at the exact
#     cell a symmetric bowl predicts -- a transposed grid fails this loudly;
#   * a Pillow heatmap's pixel at the minimum is the colour ramp's own
#     lowest-value stop, read back from the saved PNG;
#   * world_to_pixel places the four corners and the centre of a symmetric
#     window exactly, and a descent path drawn with it starts at the start
#     point's pixel and ends within two pixels of the minimum's;
#   * a well-conditioned run's loss on a log10 axis is provably collinear --
#     fit to a line, the residual is measured in pixels, not asserted in
#     prose;
#   * an animated GIF has exactly as many frames as the descent had steps;
#   * a learning-rate sweep finds an interior optimum, a basin of several
#     good rates either side of it, and genuine divergence past eta = 1,
#     caught as float('inf') rather than raised as an exception;
#   * two runs on differently conditioned bowls, same start, same learning
#     rate, same step count, land within 5% of the same final loss while
#     their path lengths differ by more than 5x -- the day's opening claim,
#     made into an assertion;
#   * nothing is left behind on disk, including no PNG or GIF file.
#
# Everything after the one-time install runs offline. Nothing binds a port,
# nothing writes outside the lab (or a temporary directory it removes
# itself), nothing needs a key. Deterministic, non-interactive, exits 0 only
# if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# Bytecode left by an EARLIER command is not this run's litter. The README
# documents `pytest starter -q`, and running it writes .pyc files that would
# then fail the cleanliness check at the end of this script -- failing the
# reader for following the instructions. Clearing them here makes that final
# check measure what it claims to: what THIS run left behind. `.venv` is
# untouched, because the packages' own bytecode is theirs, not ours.
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true

failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

check_eq() {
  if [ "$2" = "$3" ]; then
    check "$1" "yes"
  else
    check "$1 (expected [$2], got [$3])" "no"
  fi
}

resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  echo "  Install the lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  echo "  Or point this suite at an existing pytest:" >&2
  echo "    PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
  exit 1
}

python_bin="$(dirname "${pytest_bin}")/python3"
if [ ! -x "${python_bin}" ]; then
  python_bin="$(command -v python3 || true)"
fi
if [ -z "${python_bin}" ]; then
  echo "FAIL: python3 not found on PATH." >&2
  exit 1
fi

if ! "${python_bin}" -c "import numpy, PIL" >/dev/null 2>&1; then
  echo "FAIL: numpy and/or Pillow are not importable from ${python_bin}." >&2
  echo "  Install the lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

echo "Day 112 — Visualizing Optimization"
echo

# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------

versions="$("${python_bin}" - <<'PY'
import platform
import sys
from importlib.metadata import version

print(f"python   {platform.python_version()}")
for name in ("numpy", "Pillow", "pytest"):
    print(f"{name:<8} {version(name)}")
print(f"platform {platform.platform()}")
print(f"exe      {sys.executable.rsplit('/', 3)[-1]}")
PY
)"
echo "${versions}" | sed 's/^/  /'

pinned_numpy="$(grep -E '^numpy==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed_numpy="$("${python_bin}" -c "from importlib.metadata import version; print(version('numpy'))")"
check_eq "installed numpy matches requirements.txt" "${pinned_numpy}" "${installed_numpy}"

pinned_pillow="$(grep -E '^Pillow==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed_pillow="$("${python_bin}" -c "from importlib.metadata import version; print(version('Pillow'))")"
check_eq "installed Pillow matches requirements.txt" "${pinned_pillow}" "${installed_pillow}"

no_matplotlib="$("${python_bin}" -c "
try:
    import matplotlib  # noqa: F401
    print('present')
except ImportError:
    print('absent')
")"
check_eq "matplotlib is genuinely absent from this environment" "absent" "${no_matplotlib}"

# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------

for script in 01_grid_and_ascii 02_heatmap_and_path 03_loss_curves \
              04_animated_gif 05_learning_rate_sweep 06_two_runs_same_loss; do
  out="$(cd "${lab_dir}/examples" && "${python_bin}" "${script}.py" 2>&1)"
  status=$?
  if [ "${status}" -ne 0 ]; then
    check "${script}.py exits 0" "no"
    echo "${out}" | tail -5 | sed 's/^/      /'
  else
    check "${script}.py exits 0" "yes"
  fi
  case "${out}" in
    *"${script}.py: every assertion held."*)
      check "${script}.py reports every assertion held" "yes" ;;
    *) check "${script}.py reports every assertion held" "no" ;;
  esac
done

# --------------------------------------------------------------------------
echo
echo "3. The reference pytest suite: real values, real exceptions"
# --------------------------------------------------------------------------

ref_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
ref_status=$?
echo "${ref_out}" | tail -3 | sed 's/^/  /'
if [ "${ref_status}" -eq 0 ]; then
  check "pytest examples exits 0" "yes"
else
  check "pytest examples exits 0" "no"
fi
case "${ref_out}" in
  *" failed"*) check "no test in the reference suite failed" "no" ;;
  *)           check "no test in the reference suite failed" "yes" ;;
esac
ref_passed="$(printf '%s\n' "${ref_out}" | grep -o '[0-9][0-9]* passed' | head -1 | cut -d' ' -f1)"
if [ "${ref_passed:-0}" -ge 10 ]; then
  check "the reference suite ran at least 10 tests (ran ${ref_passed:-0})" "yes"
else
  check "the reference suite ran at least 10 tests (ran ${ref_passed:-0})" "no"
fi

# --------------------------------------------------------------------------
echo
echo "4. The starter suite skips unattempted work instead of failing it"
# --------------------------------------------------------------------------

start_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
start_status=$?
echo "${start_out}" | tail -3 | sed 's/^/  /'
if [ "${start_status}" -eq 0 ]; then
  check "pytest starter exits 0 on an untouched checkout" "yes"
else
  check "pytest starter exits 0 on an untouched checkout" "no"
fi
case "${start_out}" in
  *" failed"*) check "the starter suite reports no failures" "no" ;;
  *)           check "the starter suite reports no failures" "yes" ;;
esac
case "${start_out}" in
  *skipped*) check "unwritten exercises are reported as skipped, not passed" "yes" ;;
  *) check "unwritten exercises are reported as skipped, not passed" "no" ;;
esac

# The import guard. Both directories contain modules called `dataset`,
# `gridviz`, `descent` and `imaging`, and collecting both suites at once
# would otherwise let the starter tests import the REFERENCE solution. Each
# directory's conftest.py prevents that; this check proves it still does.
both_out="$(cd "${lab_dir}" && "${pytest_bin}" -q -p no:cacheprovider 2>&1)"
start_skipped="$(printf '%s\n' "${start_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
both_skipped="$(printf '%s\n' "${both_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
check_eq "collecting both suites at once does not turn skips into passes" \
  "${start_skipped:-none}" "${both_skipped:-none}"

# --------------------------------------------------------------------------
echo
echo "5. The day's opening claim, checked one value at a time"
# --------------------------------------------------------------------------

# Section 6 re-runs this whole script with D112_SELF_TEST set, which asks
# for a threshold ten times tighter than the two runs actually achieve --
# proving the harness can fail rather than merely claiming it could.
threshold="${D112_SELF_TEST_THRESHOLD:-0.05}"

facts="$(cd "${lab_dir}/examples" && "${python_bin}" - <<PY
import dataset as D
import descent as DS

well_path = DS.gradient_descent(D.WELL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
ill_path = DS.gradient_descent(D.ILL_GRAD, D.START, D.LEARNING_RATE, D.STEPS)
well_loss = DS.losses_along(D.WELL_F, well_path)[-1]
ill_loss = DS.losses_along(D.ILL_F, ill_path)[-1]
relative_gap = abs(well_loss - ill_loss) / max(well_loss, ill_loss)
well_len = DS.path_length(well_path)
ill_len = DS.path_length(ill_path)

print("well_loss", f"{well_loss:.6e}")
print("ill_loss", f"{ill_loss:.6e}")
print("relative_gap", f"{relative_gap:.6f}")
print("relative_gap_within_threshold", relative_gap < ${threshold})
print("well_len", f"{well_len:.4f}")
print("ill_len", f"{ill_len:.4f}")
print("length_ratio", f"{ill_len / well_len:.4f}")
print("length_ratio_over_5x", (ill_len / well_len) > 5.0)
PY
)"

get() { printf '%s\n' "${facts}" | grep "^$1 " | cut -d' ' -f2-; }

echo "  (measured on this run: well-conditioned final loss $(get well_loss), ill-conditioned $(get ill_loss), relative gap $(get relative_gap) -- reported, not asserted to a value)"
check_eq "the two final losses land within the stated threshold of each other" \
  "True" "$(get relative_gap_within_threshold)"
echo "  (measured on this run: well-conditioned path length $(get well_len), ill-conditioned $(get ill_len), ratio $(get length_ratio)x)"
check_eq "the ill-conditioned path is over 5x longer than the well-conditioned one" \
  "True" "$(get length_ratio_over_5x)"

# --------------------------------------------------------------------------
echo
echo "6. The harness can actually fail"
# --------------------------------------------------------------------------

# A green test suite proves nothing until you have watched it go red. This
# section re-runs the whole script with the threshold in section 5 replaced
# by one the measured runs cannot meet, and asserts that the re-run reports
# the failure and exits non-zero. If this section passes, section 5 is not
# decorative.
if [ -z "${D112_SELF_TEST:-}" ]; then
  self_out="$(D112_SELF_TEST=1 D112_SELF_TEST_THRESHOLD=0.001 bash "${BASH_SOURCE[0]}" 2>&1)"
  self_status=$?
  if [ "${self_status}" -ne 0 ]; then
    check "an unmeetable threshold makes the harness exit non-zero (${self_status})" "yes"
  else
    check "an unmeetable threshold makes the harness exit non-zero" "no"
  fi
  case "${self_out}" in
    *"FAIL: the two final losses land within the stated threshold of each other"*)
      check "the failing check is named in the output" "yes" ;;
    *) check "the failing check is named in the output" "no" ;;
  esac
  case "${self_out}" in
    *", 1 failure(s)."*)
      check "the summary line counts exactly one failure" "yes" ;;
    *) check "the summary line counts exactly one failure" "no" ;;
  esac
else
  echo "  (self-test run: section 6 does not recurse)"
fi

# --------------------------------------------------------------------------
echo
echo "7. Nothing was left behind"
# --------------------------------------------------------------------------

if find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -print -quit 2>/dev/null | grep -q .; then
  check "no __pycache__ directory left by the lab's own code" "no"
else
  check "no __pycache__ directory left by the lab's own code" "yes"
fi

if find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -print -quit 2>/dev/null | grep -q .; then
  check "no .pytest_cache directory left under the lab" "no"
else
  check "no .pytest_cache directory left under the lab" "yes"
fi

if find "${lab_dir}" -name '.venv' -prune -o -type f \( -name '*.png' -o -name '*.gif' \) -print -quit 2>/dev/null | grep -q .; then
  check "no PNG or GIF file left anywhere in the lab (exercise 9)" "no"
else
  check "no PNG or GIF file left anywhere in the lab (exercise 9)" "yes"
fi

if grep -rqE 'urlopen|requests\.|socket\.|http://|https://' \
     "${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
  check "no lab source opens a network connection" "no"
else
  check "no lab source opens a network connection" "yes"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting

Every entry below was hit while building this lab, or is named by a test that exists because of it.

ModuleNotFoundError: No module named 'dataset'

You ran a numbered script from the lab directory instead of from inside examples/. The scripts import dataset, gridviz, descent and imaging from beside themselves.

cd examples
../.venv/bin/python3 01_grid_and_ascii.py
cd ..

ModuleNotFoundError: No module named 'PIL'

You are running the system python3 rather than the lab's, or you installed numpy and pytest but not Pillow. Everything in this lab goes through .venv/bin/python3:

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

My heatmap looks like a bowl, but the descent path walks toward the wrong wall

This is world_to_pixel's y-flip, backwards. Data y grows upward; pixel rows grow downward (row 0 is the top of the image). The formula is py = (ylim[1] - y) / (ylim[1] - ylim[0]) * (height - 1), not py = (y - ylim[0]) / (ylim[1] - ylim[0]) * (height - 1).

The reason this bug is dangerous rather than merely wrong: for a bowl centred at the origin, heatmap_array on its own is vertically symmetric, so the heatmap PNG looks completely correct by itself. The bug only shows up once something is drawn ON TOP of it — which is exactly why exercise 4 checks a specific pixel distance for the drawn path rather than checking the heatmap image alone.

My ASCII contour has the wrong character at a corner

Check the direction of your band mapping. ascii_contour rescales (Z - Z.min()) / (Z.max() - Z.min()) to [0, len(chars)) and floors it — the lowest value must map to chars[0] (the lightest character, a space in the default ramp) and the highest value to chars[-1] (the densest). A reversed ramp puts a dense character at the minimum, which is very easy to miss by eye on a genuinely symmetric grid but fails the exact-character test immediately.

Image.open(path).n_frames raises AttributeError, or is always 1

Two separate causes:

  • You saved a single Image.save(path) instead of frames[0].save(path, save_all=True, append_images=frames[1:], ...). A plain save writes only the first frame, silently.
  • You forgot format="GIF". Pillow infers format from the file extension in most cases, but passing it explicitly is one fewer thing to get wrong when writing to a path Python built rather than one you typed.

My GIF frame colours look banded or wrong

Each frame is converted to "P" (palette) mode with img.convert("P", palette=Image.ADAPTIVE) before being appended. GIF is a palette format — at most 256 colours per frame — so this conversion is not optional decoration; skipping it produces a TypeError or a frame Pillow silently re-quantizes with its own default palette, which looks noticeably worse than ADAPTIVE.

The log-axis collinearity test fails, but the linear one passes

You are almost certainly plotting losses on the log axis instead of numpy.log10(losses). loss_curve_points's log argument controls which values become the y-DATA before the same linear pixel mapping is applied to both axes — the log transform has to happen before the pixel math, not instead of it or after it.

If you did transform correctly and it still fails, check which run you fed it: only the well-conditioned bowl (a = b = 1) produces an EXACTLY geometric loss sequence with this lab's update rule. The ill-conditioned bowl's loss is a sum of two different geometric sequences (one per axis) and is not a single straight line on a log axis — which is itself worth noticing, not a bug to chase.

My learning-rate sweep raises OverflowError or prints a RuntimeWarning

sweep_final_loss must wrap both the update step and the final f(x) evaluation in numpy.errstate(over="ignore", invalid="ignore"), and must check np.isfinite(x) after every step rather than only at the end — a value that has already overflowed to inf on step 50 of 300 will otherwise keep being multiplied for 250 more steps, which is wasted work at best and nan from inf * 0 at worst if the coefficient array is ever exactly zero somewhere.

Using a plain Python float instead of numpy.float64 for x changes which exception a genuine overflow raises (OverflowError from Python's own float.__pow__ rather than a NumPy RuntimeWarning) and numpy.errstate does not catch it. Keep x as numpy.float64 throughout.

The starter tests all skip and I have written code

A skip means the function still returns None. Look for a leftover return None below the code you added — the skeletons put return None after a long docstring, and it is easy to write the body above it and leave the return None in place, in which case your work is computed and then discarded.

Downstream exercises also skip cleanly if an earlier one is unattempted: get_grid() in starter/test_starter.py skips exercise 3 onward if evaluate_grid itself is still None, rather than crashing on an unpack of None. If exercise 4 is skipping and you are sure you wrote draw_path_on_heatmap, check exercise 1 and exercise 3 first.

__pycache__ or .pytest_cache appears and the cleanup check fails

Run:

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache

tests/run_tests.sh clears both at the start of its own run (pruning .venv) for the same reason Day 110's harness does: the README's own documented pytest starter -q legitimately writes starter/__pycache__, and an earlier version of a harness like this one would then report that as litter at the end — failing you for following the instructions. .venv itself is the documented setup and is never treated as a stray file, and nothing inside it is ever deleted.

Running pytest with no arguments gives a different skip count than pytest starter

It should not, and there is a check for exactly that. Both examples/ and starter/ define modules called dataset, gridviz, descent and imaging. Without the conftest.py in each directory, collecting both suites in one run would import whichever copy of, say, gridviz Python saw first and reuse it for the other suite — so an unattempted starter exercise could silently import the REFERENCE implementation and report as passing. That is a wrong answer with a green tick on it, which is the worst kind, and it is exactly the failure mode this lab's own authoring process ran into and had to catch: two independent copies of this exact lab, sharing this exact directory, defined the same module names and began overwriting each other's files until the collision was noticed and one copy was deleted outright. Nothing short of separate, careful ownership of a directory — which conftest.py's sys.path and sys.modules surgery enforces for the code, and a single author enforces for everything else — actually prevents it.

Windows

Not run here, and this file will not pretend otherwise. Use the Windows Subsystem for Linux and follow the Linux instructions, or use Git Bash with .venv\Scripts\python.exe in place of .venv/bin/python3. Everything in the lab is NumPy, Pillow and standard-library Python, so nothing in it is platform-specific — but "should work" and "was run" are different claims and only the second one is worth making.

Security notes

Security notes

What this lab does

It computes and draws pictures into a temporary directory, then deletes that directory. It writes no permanent files, opens no network connection after the one-time pip install, needs no credentials, no sudo and no elevated permissions, and touches nothing outside its own directory (or a temporary one it creates and removes itself). Every number it works with is invented and is stated to be invented: the two bowl functions, the starting point, the learning rate and the step counts are all written out in examples/dataset.py.

Section 6 of tests/run_tests.sh greps every source file in examples/ and starter/ for urlopen, requests., socket., http:// and https:// and fails if any of them appears.

The virtual environment

python3 -m venv .venv creates the environment inside the lab directory, so nothing installed here can affect the rest of your machine, and rm -rf .venv is a complete undo. The three packages are pinned to exact versions in requirements/requirements.txt, and section 1 of the harness reads the installed versions back and compares them against that file rather than trusting that the install did what it said.

Two things worth carrying away from this particular day

A picture that looks plausible can still encode the wrong axis. The single most common bug this lab is built to catch is a flipped or transposed grid: world_to_pixel's y-axis flip (data y grows up, pixel rows grow down) is the one piece of arithmetic every drawing function in imaging.py shares, and getting it backwards produces a heatmap that still looks like a symmetric bowl — a symmetric bowl looks the same either way — while the path drawn on top of it walks toward the wrong wall. The lesson's honesty note about never encoding the only copy of information in a picture applies here too: a visualization that only an author who already knows the answer can verify as correct is not a diagnostic instrument, it is decoration. That is why exercise 4's test checks a specific pixel distance rather than "the image exists".

Silent numeric failure is worse than a crash. The learning-rate sweep runs learning rates that are known in advance to diverge. descent.py catches the resulting overflow deliberately with numpy.errstate(over= "ignore") and reports it as float('inf'), rather than letting a RuntimeWarning escape unnoticed or letting an unguarded x ** 2 on a huge float raise OverflowError and crash the sweep. A training script that does not check its own loss for finiteness will spend GPU-hours computing usefully-shaped nan.

What this lab deliberately does not claim

matplotlib, Plotly, TensorBoard and Weights & Biases are none of them installed here, and no output from any of them is reproduced anywhere in this lab or its lesson. They are described from their documentation and marked as not run. "Here is how the real tool is called" is a claim about an API; "here is what it printed" is a claim about a measurement, and this lab only makes the first one for those four tools.