Math, Statistics, and DataData Visualization › Day 130

Hands-on lab — Day 130: Distributions and Relationships

Commands

Setup

cd labs/sections/math-statistics-and-data/day-130-distributions-and-relationships
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import seaborn; print(seaborn.__version__)"

Run

.venv/bin/pytest examples
.venv/bin/pytest starter

Test

bash tests/run_tests.sh

File tree

examples/conftest.py
examples/data.py
examples/test_distributions.py
expected-output/examples-run.txt
expected-output/FIELDS.md
expected-output/starter-run.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/data.py
starter/test_distributions.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 130 lab — Pictures of a Distribution

Lesson

Purpose

Nine numbered exercises, each proving one real fact about how NumPy, seaborn and matplotlib picture a distribution — headless, via the Agg backend, asserting on computed values and artist state rather than image bytes. The through-line is that a histogram, a KDE and a boxplot are each one picture of the data, not the data itself, and each hides something a different picture shows. Exercise 5 makes that concrete as directly as it can be made: two samples are engineered so their five-number summaries — the entire content of a boxplot — agree to within 0.3 units, and a histogram of either one at the same bin count still shows 2 modes for one sample and 1 for the other. Every other exercise adds one more parameter nobody thinks to ask about: the bin width (1, 2), the KDE bandwidth and its boundary problem on strictly positive data (3, 4), overplotting and its fixes (7), the difference between a linear relationship and a monotonic one and a shaped one (8), and the honest disclosure jitter requires (9).

Learning objectives

By the end of this lab you will be able to:

  • Demonstrate that the same 500-point sample looks unimodal at a coarse bin count and like noise at a fine one, and that numpy.histogram_bin_edges(..., bins='fd') recovers its real structure in between.
  • Show that Sturges, Scott and Freedman-Diaconis choose three different bin counts on the same skewed sample, and name the statistic each rule is built from.
  • Demonstrate that a KDE's bandwidth (bw_adjust in seaborn) is exactly as consequential as a histogram's bin width, by finding two modes at one bandwidth and one at another on the identical sample.
  • Demonstrate, by direct integration of a KDE curve, that a KDE of strictly positive data places real, non-trivial probability mass below zero — and state why.
  • Construct — or, having read data.py, explain — two samples that share a five-number summary within a tight tolerance while their histograms show a different number of modes, and say what a boxplot of either one would never have shown you.
  • Confirm an ECDF passes through every observation and that its median reading matches numpy.median exactly.
  • Quantify overplotting directly: convert data coordinates to pixel coordinates, count how many of 20,000 points collide onto the same pixel, and confirm a hexbin recovers a real density peak from the same cloud.
  • Demonstrate that a strong quadratic relationship can have near-zero Pearson AND Spearman correlation, and that fitting the actual shape (or plotting it) is what reveals it.
  • Apply jitter to discrete data, quantify exactly how far it moves each point, and confirm the source data is untouched.

Prerequisites

  • Day 116 — descriptive statistics that don't lie, specifically Anscombe's quartet and the discipline of asking what a summary statistic discarded, which this lab's exercise 5 is a second, more extreme instance of.
  • Day 127 — why to visualize and how to choose a chart type.
  • Day 128 — matplotlib's object model and testing by asserting on artists, which every exercise in this lab that reads ax.lines, ax.transData, or a hexbin's array depends on directly.
  • Day 129 — seaborn's axes-level/figure-level split and its statistical plots, which exercises 3, 4 and 6 (kdeplot, ecdfplot) build on without re-explaining.
  • A working python3 on your PATH to create the lab's virtual environment.

Supported operating systems

System Status
macOS (Apple Silicon or Intel) Captured here — macOS 26.5.2, arm64
Linux (any current distribution) Expected identical, given the pinned versions below and the headless Agg backend
Windows Use WSL and follow the Linux path. mktemp -d is used inside tests/run_tests.sh; native Windows was not tested and no output is claimed for it

Hardware requirements

Anything. The largest sample in this lab is 20,000 points, generated in memory; no GPU, no display, and no meaningful disk use beyond the one-time package install.

Required software

Tool Minimum Used here Why
python3 3.11 3.14.0 Runs everything; standard library venv builds the lab's environment
seaborn 0.13.2 exactly 0.13.2 kdeplot (exercises 3, 4) and ecdfplot (exercise 6)
matplotlib 3.11.1 exactly 3.11.1 hist, scatter, hexbin, and seaborn's own drawing engine underneath
pandas 3.0.5 exactly 3.0.5 .corr() (exercise 8)
numpy 2.5.2 2.5.2 Every sample in data.py; histogram_bin_edges, percentile, polyfit, trapezoid
pytest 9.1.1 9.1.1 The test harness every exercise is written against
bash 3.2 3.2.57 The outer test harness

Check your Python in one line: python3 --version.

Free and open-source options

Everything here is free.

  • seaborn (BSD 3-Clause), matplotlib (PSF-derived, BSD-style), pandas (BSD 3-Clause), NumPy (BSD 3-Clause) and pytest (MIT) are fully open source with no paid tier.
  • scipy.stats.gaussian_kde (BSD 3-Clause, described from documentation only, not installed in this environment) is the general-purpose free KDE implementation the wider Python ecosystem reaches for outside a plotting call specifically.
  • plotnine and Vega-Lite / Altair (both free and open source, described from documentation only, not run here) are grammar-of- graphics alternatives mentioned briefly in the lesson.

No account, no key, no paid tier, and no part of this lab is degraded without one.

Installation

cd labs/sections/math-statistics-and-data/day-130-distributions-and-relationships
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import seaborn; print(seaborn.__version__)"

If your tools live somewhere unusual, tests/run_tests.sh takes an override rather than guessing:

PYTEST=/path/to/pytest bash tests/run_tests.sh

File structure

day-130-distributions-and-relationships/
├── README.md                     this file
├── metadata.yml                  lab metadata and the recorded run
├── security.md                   what this lab does to your machine
├── troubleshooting.md            grouped by the message you actually see
├── requirements/
│   ├── README.md                  versions, and what each package is for
│   └── requirements.txt           seaborn==0.13.2, matplotlib==3.11.1, pandas==3.0.5, numpy==2.5.2, pytest==9.1.1
├── starter/                      YOUR work happens here
│   ├── 00_brief.md                exercise-by-exercise instructions
│   ├── data.py                    every sample this lab is built from
│   ├── conftest.py                fixtures wrapping data.py, headless Agg setup
│   └── test_distributions.py      nine exercises, each a pytest.skip to replace
├── examples/                     the reference. Read AFTER you have tried
│   ├── data.py
│   ├── conftest.py
│   └── test_distributions.py      the fully worked, 9-test answer key
├── tests/
│   └── run_tests.sh               16 checks of real behaviour
└── expected-output/               captured from a real run on 2026-08-20
    ├── FIELDS.md                   what must match, what is version-specific, and what is sampled
    ├── examples-run.txt            pytest examples -v -s, captured
    ├── starter-run.txt             pytest starter -v, captured (all skip)
    └── test-run.txt                the full harness run

How to run

## 1. The reference suite. Read this AFTER you have tried the exercises,
##    never before -- it is the answer key.
.venv/bin/pytest examples
.venv/bin/pytest examples -v -s

## 2. Where you stand on the exercises. An untouched checkout reports
##    9 skipped, 0 failed.
.venv/bin/pytest starter -v

## 3. Your work: open starter/test_distributions.py and starter/00_brief.md,
##    and replace each pytest.skip(...) with real assertions.
.venv/bin/pytest starter -v -k test_01
.venv/bin/pytest starter -v -k test_02
## ... and so on through test_09, or just:
.venv/bin/pytest starter -v

## 4. Check everything, including the harness's own proof that it can fail.
bash tests/run_tests.sh

Never run pytest examples starter in one command. Both directories define a module named test_distributions.py; pytest imports test modules by their dotted name, and running both together was tested directly in this lab and aborts collection outright with an import file mismatch error before running a single test. Run them as two separate commands, always, as shown above.

What the commands do

.venv/bin/pytest examples runs the fully worked reference suite: 9 tests across the nine exercises, each asserting a real value read off a real NumPy/seaborn/matplotlib/pandas computation built from one of the samples in data.py.

.venv/bin/pytest starter runs your own suite. On an untouched checkout, every one of the 9 tests calls pytest.skip(...) and is reported as s, so the run exits 0 with nothing yet proven. Replace a skip with real assertions and delete the skip line; when all 9 are written and passing, the exercise is done.

bash tests/run_tests.sh confirms the installed packages match requirements.txt exactly, runs pytest examples and requires 9 passed, runs pytest starter and requires 9 skipped on the checked-in state, confirms pytest examples starter in one invocation aborts collection with import file mismatch rather than silently letting one shadow the other, then solves every exercise in a scratch copy made with mktemp -d (never touching the real starter/test_distributions.py), confirms that copy passes in full, deliberately breaks one assertion inside it, confirms the run now exits non-zero with a failure reported, restores the line, and confirms it passes again. It finishes by checking no file in examples/ or starter/ contains a URL, that no image file is left anywhere inside the lab, and that nothing is left on disk.

Expected output

The harness ends with a real captured line:

16 checks, 0 failure(s)

and exits 0. pytest examples ends with:

9 passed in 0.27s

pytest starter, on the checked-in state, ends with:

9 skipped in 0.17s

Exercise 2's rule disagreement, exactly as captured:

sturges=10 scott=14 fd=21

Exercise 5's centrepiece, exactly as captured — two samples whose five-number summaries agree to within 0.3 units of the same five target numbers, while their histograms at 15 bins show a different number of modes:

bimodal 5-num: [10.21 28.02 40.   51.98 69.79] (2 modes at 15 bins)
unimodal 5-num: [10.17 28.06 40.   51.94 69.83] (1 mode at 15 bins)

The full capture of both suites is in expected-output/, and expected-output/FIELDS.md says which values are exact everywhere, which are specific to this seaborn/matplotlib/NumPy pin, and the one place (exercise 8's Spearman correlation) where a direct measurement turned out sharper than the day's own brief expected.

Validation steps

  1. bash tests/run_tests.sh ends with 16 checks, 0 failure(s) and exits 0.
  2. The same bimodal sample shows 1 mode at 5 bins and more than 10 at 100 bins; numpy.histogram_bin_edges(..., bins='fd') on it recovers exactly 2 modes.
  3. numpy.histogram_bin_edges with 'sturges', 'scott' and 'fd' on a skewed sample produce three different bin counts.
  4. sns.kdeplot(..., bw_adjust=1.0) finds 2 modes on the bimodal sample; bw_adjust=3.0 (over-smoothed) finds 1.
  5. A KDE of a strictly positive sample places more than 3% of its total density below zero.
  6. Two engineered samples' five-number summaries agree within 0.3 units of five shared target values, yet their histograms at 15 bins show 2 modes and 1 mode respectively.
  7. An ECDF's x-values include every observation, and the x-value where the ECDF first reaches 0.5 equals numpy.median to within 1e-9.
  8. A (3, 3)-inch, 72-dpi scatter of 20,000 points paints fewer than half as many distinct screen pixels as there are points; the same cloud's hexbin densest bin holds more than 20 points.
  9. A quadratic relationship's Pearson AND Spearman correlations are both under 0.1 in magnitude, while a fitted quadratic's R² exceeds 0.95.
  10. A jittered copy of discrete data never moves any point by more than the stated jitter width, and the source array is unchanged.

Tests

bash tests/run_tests.sh
echo "exit code: $?"

16 checks, exit 0 when they all pass and non-zero otherwise. They are value checks, not file-existence checks: the reference suite's 9 tests are exercised through pytest, the exercise suite is confirmed all-skip on the checked-in state, running both directories together is confirmed to abort rather than silently collide, and a scratch copy proves the suite can genuinely fail and then recover.

Override, if your tools are somewhere unusual:

PYTEST=/path/to/pytest bash tests/run_tests.sh

Cleanup

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

tests/run_tests.sh clears __pycache__ and .pytest_cache both before and after it runs, and its scratch copy of the solved suite lives in a mktemp -d directory removed immediately after use — so if you only ran the harness, there is nothing left to clean up.

To remove the lab's virtual environment entirely: rm -rf .venv.

To reset your own work and start the exercises again:

git checkout -- starter/

Troubleshooting

troubleshooting.md has the full list, grouped by the message you actually see. The ones you are most likely to meet:

  • pytest examples starter aborts with import file mismatch — do not run both directories in one invocation; they share a module name.
  • ModuleNotFoundError: No module named 'scipy' — expected. scipy is not installed here; exercise 8 works around pandas needing it for Spearman by computing rank correlation directly.
  • A plot window tries to open — something imported pyplot before matplotlib.use("Agg") ran; both conftest.py files set the backend first, and the harness also exports MPLBACKEND=Agg.
  • Exercise 5's two five-number summaries do not agree within 0.3 — both samples are built deterministically with no randomness at all; confirm you are calling numpy.percentile with [0, 25, 50, 75, 100] exactly, not a boxplot's own whisker convention.

Security notes

security.md has the full account. In short: this lab opens the network exactly once, to install its five pinned packages, renders entirely headless via matplotlib's Agg backend, writes only inside its own .venv and one deliberately temporary directory it cleans up itself, and touches no real data — every sample is generated from a fixed random seed or a hand-written deterministic function in data.py.

Extension exercises

  1. A three-way bin-width sweep on your own data. Pick any numeric column from a public dataset you already have on disk, and plot it at 5, 20 and Freedman-Diaconis's own bin count; write one sentence on which one you would actually publish and why.
  2. A second matched-quartile pair. Using matched_quartile_pair's approach in data.py as a template, construct a third sample sharing the same five-number summary but with three modes instead of two, and confirm the same tolerance holds.
  3. sns.rugplot as a fourth honest option. Add a rug plot underneath this lab's ECDF (exercise 6) and describe, in one paragraph, what it shows that the ECDF's smooth step function does not make as visible.
  4. KDE bandwidth selection rules. Read scipy.stats.gaussian_kde's documentation (not installed here) on Scott's and Silverman's rules for bandwidth, and compare the bandwidth seaborn.kdeplot chooses by default (readable via its bw_method parameter) against what those named rules would choose on this lab's bimodal_for_binning sample.
  5. A log-scaled hexbin. Redraw exercise 7's hexbin with bins='log' and describe, in one paragraph, what changes about which part of the density the human eye notices first, and why that might matter for a chart meant to draw attention to a rare but important cluster of points.
  • Previous day: Day 129 — Statistical Plots with seaborn (labs/sections/math-statistics-and-data/day-129-statistical-plots-with-seaborn/).
  • Next day: Day 131 — Time Series Visualization (labs/sections/math-statistics-and-data/), continuing Week 19.
  • Week 19 project: the week's project directory (labs/sections/math-statistics-and-data/projects/week-19/), building directly on this week's charting fundamentals.

Expected output

FIELDS.md

# What in these captures is exact, and what may differ

Captured from a real run on 2026-08-20, in this lab's own `.venv`, on
seaborn 0.13.2, matplotlib 3.11.1, pandas 3.0.5, NumPy 2.5.2, pytest
9.1.1, Python 3.14.0, macOS (arm64). scipy is not installed.

## Exact everywhere on this exact pin set

- `examples-run.txt` ends with `9 passed`, `starter-run.txt` ends with
  `9 skipped` — both counts are structural (9 test functions in each
  file) and do not depend on the machine.
- `test-run.txt` ends with `16 checks, 0 failure(s)` and exit 0.
- Exercise 5's five target control points -- `10.0`, `28.0`, `40.0`,
  `52.0`, `70.0` -- are hand-picked literals in `data.py`
  (`target_five_number_summary`), not sampled, and are exact on any
  correctly installed NumPy.
- Exercise 5's two samples (`matched_quartile_pair`) are built from a
  deterministic piecewise-linear function evaluated at 240 evenly spaced
  ranks with **no randomness at all** — their five-number summaries
  (`[10.21, 28.02, 40.0, 51.98, 69.79]` for the bimodal sample, `[10.17,
  28.06, 40.0, 51.94, 69.83]` for the unimodal one) and their mode counts
  at 15 bins (2 and 1 respectively) are exact on any machine.
- Exercise 6's ECDF median matches `numpy.median` to `1e-9` by
  construction: `ecdf_sample` has an odd length (301), so the median is a
  single real observation and not an average of two, and the assertion
  checks this exactly rather than approximately.
- Exercise 9's max jitter shift (`0.1499...`) is always `<=` the stated
  jitter width (`0.15`) by construction of `numpy.random.uniform`'s
  bounds, on any machine.
- Exercise 2's bin-count disagreement (`sturges=10 scott=14 fd=21`) is
  deterministic given the fixed seed in `skewed_for_bin_rules` and is
  exact on this NumPy version; NumPy's `histogram_bin_edges` formulas
  for these three rules are stable across NumPy versions, so this should
  reproduce identically on other NumPy 2.x releases too, though only the
  seed-42 numbers above were directly verified here.

## Version-specific or sampled, checked directly rather than assumed

- Exercise 1's bimodal sample (`bimodal_for_binning`, means 40 and 54,
  sd 8, seed 42) is a random draw. The specific mode counts reported —
  1 mode at 5 bins, 2 modes under Freedman-Diaconis (13 bins on this
  draw), 23 spurious modes at 100 bins — are exact on NumPy 2.5.2 with
  this seed and are expected to reproduce identically on any correctly
  installed NumPy 2.x, since `default_rng`'s bit generator is part of
  NumPy's stable public API; only the seed-42 draw itself was directly
  verified here.
- Exercise 3's KDE mode counts (2 at `bw_adjust=1.0`, 1 at
  `bw_adjust=3.0`) depend on seaborn's internal bandwidth-selection code
  and are specific to seaborn 0.13.2. seaborn's own KDE implementation
  changed between major versions historically; this exact pair of
  `bw_adjust` values was chosen and verified against seaborn 0.13.2
  specifically.
- Exercise 4's fraction of KDE mass below zero (`0.0954`, about 9.5%)
  depends on both the exponential draw (`positive_for_kde_boundary`,
  seed 9) and seaborn's default bandwidth rule; it is reported here as
  "a real, non-trivial fraction" (the lab's actual assertion threshold is
  a much looser `> 0.03`) rather than as an exact figure to reproduce.
- Exercise 7's overplotting numbers (`6988` distinct pixel positions out
  of `20000` points, `34.94%`; hexbin max bin count `210`) depend on
  exact pixel-transform behaviour of matplotlib's `Agg` backend at a
  specific figure size and DPI (`figsize=(3, 3), dpi=72`). Sub-pixel
  rounding could plausibly shift this by a handful of pixels on a
  different matplotlib build; the lab's actual assertion (`fraction <
  0.5`) is comfortably clear of that margin.
- Exercise 8's numbers (`pearson r=-0.0044`, `spearman r=-0.0226`,
  `R^2=0.9907`) depend on the fixed seed in `quadratic_relationship`
  (seed 5) and are exact on this NumPy version; see the honesty note
  below about what this pair of numbers actually shows.

## An honesty note on exercise 8's Spearman correlation

The day brief that this lab was written from suggested demonstrating "a
strong non-linear relationship with a near-zero correlation" where
"Spearman or a fitted quadratic reveals it." Measured directly: for
`quadratic_relationship`'s sample (`x` uniform on `[-10, 10]`, `y = x**2
+ noise`), Spearman's correlation is **also** near zero (`-0.0226`,
essentially the same magnitude as Pearson's `-0.0044`), because the
parabola is symmetric around `x = 0` and therefore has no monotonic
component for a rank correlation to detect either — a symmetric U-shape
defeats both Pearson and Spearman equally. Only the fitted quadratic
(`R^2 = 0.9907`) or looking directly at the scatter reveals the
relationship. This is reported as measured, because it is a sharper
version of the day's actual argument (plot before computing a
coefficient) rather than a weaker one, and changing the sample to make
Spearman succeed would have hidden a genuinely interesting fact about
rank correlation's own blind spot.

examples-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-130-distributions-and-relationships/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-130-distributions-and-relationships
collecting ... collected 9 items

examples/test_distributions.py::test_01_bin_width_changes_the_story   bins=5 -> 1 mode(s); bins=100 -> 23 mode(s); Freedman-Diaconis -> 13 bins, 2 mode(s)
PASSED
examples/test_distributions.py::test_02_the_three_rules_disagree   sturges=10 scott=14 fd=21
PASSED
examples/test_distributions.py::test_03_kde_bandwidth   bw_adjust=1.0 -> 2 mode(s); bw_adjust=3.0 -> 1 mode(s)
PASSED
examples/test_distributions.py::test_04_kde_boundary_problem   fraction of KDE mass below zero: 0.0954
PASSED
examples/test_distributions.py::test_05_boxplot_blind_spot   bimodal 5-num: [10.21 28.02 40.   51.98 69.79] (2 modes at 15 bins)
  unimodal 5-num: [10.17 28.06 40.   51.94 69.83] (1 mode at 15 bins)
PASSED
examples/test_distributions.py::test_06_ecdf_is_parameter_free   ECDF median 0.026125 == numpy.median 0.026125
PASSED
examples/test_distributions.py::test_07_overplotting   6988 distinct pixel positions from 20000 points (34.94%); hexbin max bin count 210
PASSED
examples/test_distributions.py::test_08_correlation_without_shape   pearson r=-0.0044, spearman r=-0.0226, quadratic fit R^2=0.9907 (coeffs [ 1.0033  0.0045 -0.2147])
PASSED
examples/test_distributions.py::test_09_jitter_is_distortion   max jitter shift 0.1499 (width 0.15); values unchanged
PASSED

============================== 9 passed in 0.25s ===============================

starter-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-130-distributions-and-relationships/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-130-distributions-and-relationships
collecting ... collected 9 items

starter/test_distributions.py::test_01_bin_width_changes_the_story SKIPPED [ 11%]
starter/test_distributions.py::test_02_the_three_rules_disagree SKIPPED  [ 22%]
starter/test_distributions.py::test_03_kde_bandwidth SKIPPED (Draw s...) [ 33%]
starter/test_distributions.py::test_04_kde_boundary_problem SKIPPED      [ 44%]
starter/test_distributions.py::test_05_boxplot_blind_spot SKIPPED (C...) [ 55%]
starter/test_distributions.py::test_06_ecdf_is_parameter_free SKIPPED    [ 66%]
starter/test_distributions.py::test_07_overplotting SKIPPED (Render ...) [ 77%]
starter/test_distributions.py::test_08_correlation_without_shape SKIPPED [ 88%]
starter/test_distributions.py::test_09_jitter_is_distortion SKIPPED      [100%]

============================== 9 skipped in 0.17s ==============================

test-run.txt

Day 130 — Pictures of a Distribution

1. The tools and the versions this lab was written against
python   3.14.0
seaborn    0.13.2
matplotlib 3.11.1
pandas     3.0.5
numpy      2.5.2
pytest     9.1.1

  ok: installed packages match requirements.txt exactly

2. Reference suite -- examples/ must pass in full
.........                                                                [100%]
9 passed in 0.27s
  ok: examples/ exits 0
  ok: examples/ reports 9 passed, 0 failed

3. Exercise suite -- starter/ is all-skip on an untouched checkout
sssssssss                                                                [100%]
9 skipped in 0.17s
  ok: starter/ (untouched) exits 0
  ok: starter/ (untouched) reports 9 skipped, 0 failed

4. Never run 'pytest examples starter' in one invocation -- same
   module name (test_distributions.py) in both directories means
   pytest collects them by dotted module name and the second can
   collide with the first. Documented, and run only as two commands.
  ok: 'pytest examples starter' aborts collection rather than silently passing
  ok: the collision is reported as an import file mismatch

5. Prove the suite can genuinely FAIL: solve every exercise in a
   scratch copy, confirm green, break one assertion on purpose,
   confirm a non-zero exit and a printed FAIL, then restore.
  ok: scratch copy of the solved suite exits 0
  ok: scratch copy reports 9 passed
  ok: broken scratch copy exits non-zero
  ok: broken scratch copy prints a FAIL/failed line
  ok: restored scratch copy exits 0 again
  ok: restored scratch copy reports 9 passed again

6. Nothing in examples/ or starter/ opens a network connection, and
   no image file is left anywhere inside this lab
  ok: no URLs inside examples/ or starter/
  ok: no image files left anywhere inside the lab

7. Cleanliness -- nothing left behind by THIS run
  ok: no __pycache__ or .pytest_cache left behind

-------------------------------------------------------------
16 checks, 0 failure(s)

Source files

examples/conftest.py (1452 bytes)
"""Shared fixtures and headless matplotlib setup.

pytest finds this file by itself -- nothing imports it. `matplotlib.use`
must run before `pyplot` is imported anywhere, which is why it happens
here, first. Every test that opens a Figure is responsible for its own
`plt.close()`; `_close_all_figures` below is a backstop, not a substitute.
"""

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import pytest

from data import (
    bimodal_for_binning,
    discrete_for_jitter,
    matched_quartile_pair,
    normal_for_ecdf,
    overplotted_cloud,
    positive_for_kde_boundary,
    quadratic_relationship,
    skewed_for_bin_rules,
    target_five_number_summary,
)


@pytest.fixture
def bimodal_sample():
    return bimodal_for_binning()


@pytest.fixture
def skewed_sample():
    return skewed_for_bin_rules()


@pytest.fixture
def positive_sample():
    return positive_for_kde_boundary()


@pytest.fixture
def quartile_pair():
    return matched_quartile_pair()


@pytest.fixture
def quartile_targets():
    return target_five_number_summary()


@pytest.fixture
def ecdf_sample():
    return normal_for_ecdf()


@pytest.fixture
def overplot_cloud():
    return overplotted_cloud()


@pytest.fixture
def quadratic_data():
    return quadratic_relationship()


@pytest.fixture
def discrete_sample():
    return discrete_for_jitter()


@pytest.fixture(autouse=True)
def _close_all_figures():
    yield
    plt.close("all")
examples/data.py (7891 bytes)
"""The samples every exercise in this lab is built from.

Every table here is generated from a fixed `numpy.random.default_rng` seed,
never loaded from a file and never re-seeded per test -- so the same call
produces the same numbers on any machine with the same NumPy version, and a
reader can reproduce every asserted value by running the function directly.

Two constructed pairs carry the lab's two centrepiece demonstrations:

`bimodal_for_binning()` -- exercise 1 and exercise 3. Two normal clusters
close enough together that a coarse 5-bin histogram merges them into one
hump, a well-chosen bin count (Freedman-Diaconis lands on 13 bins here)
recovers two, and 100 bins turns the same 500 points into visual noise.
The same sample is reused for the KDE bandwidth demonstration in
exercise 3, because the story is identical: the bin width and the KDE
bandwidth are the same kind of decision.

`matched_quartile_pair()` -- exercise 5, the day's centrepiece. A bimodal
sample and a unimodal sample, built from two different piecewise-linear
quantile functions, engineered so their five-number summaries agree to
within 0.3 units on identical control points (min, Q1, median, Q3, max
all hand-picked as 10 / 28 / 40 / 52 / 70) while their interior shape is
completely different. A boxplot of either looks identical to the other;
a histogram does not.
"""

from __future__ import annotations

import numpy as np

# --------------------------------------------------------------------------
# bimodal_for_binning -- exercises 1 and 3.
#
# Two normal clusters, means 40 and 54 (gap 14), sd 8 each, 250 points per
# cluster. Close enough that 5 wide bins wash the gap out into one hump;
# Freedman-Diaconis (13 bins on this draw) recovers two; 100 bins gives
# nothing but sampling noise per bin.
# --------------------------------------------------------------------------


def bimodal_for_binning() -> np.ndarray:
    rng = np.random.default_rng(42)
    low_cluster = rng.normal(40, 8, 250)
    high_cluster = rng.normal(54, 8, 250)
    return np.concatenate([low_cluster, high_cluster])


# --------------------------------------------------------------------------
# skewed_for_bin_rules -- exercise 2. A right-skewed, strictly positive
# sample (log-normal) on which Sturges, Scott and Freedman-Diaconis
# genuinely disagree about the bin count.
# --------------------------------------------------------------------------


def skewed_for_bin_rules() -> np.ndarray:
    rng = np.random.default_rng(123)
    return rng.lognormal(mean=3.0, sigma=0.6, size=400)


# --------------------------------------------------------------------------
# positive_for_kde_boundary -- exercise 4. Strictly positive (an
# exponential), so any density a KDE places below zero is visibly wrong.
# --------------------------------------------------------------------------


def positive_for_kde_boundary() -> np.ndarray:
    rng = np.random.default_rng(9)
    return rng.exponential(scale=5.0, size=400)


# --------------------------------------------------------------------------
# matched_quartile_pair -- exercise 5, the boxplot's blind spot.
#
# Both samples are built from a piecewise-linear quantile function: a
# function from rank (0 to 1) to value, evaluated at 240 evenly spaced
# ranks. Both functions are pinned to pass through the same five control
# points -- (0, 10), (0.25, 28), (0.5, 40), (0.75, 52), (1.0, 70) -- so
# both samples' five-number summaries land within about 0.3 units of
# those targets. Between the control points the two functions diverge
# completely: `_unimodal_quantile` uses a smooth convex curve on each
# side of the median (density highest at the centre, lowest at the
# tails -- one hump); `_bimodal_quantile` inserts extra control points
# that pack a large fraction of the probability mass into two narrow
# bands just above Q1 and just below Q3, with a sparse valley at the
# median and sparse far tails (two humps, a dip in the middle).
# --------------------------------------------------------------------------

_TARGET_MIN, _TARGET_Q1, _TARGET_MED, _TARGET_Q3, _TARGET_MAX = (
    10.0,
    28.0,
    40.0,
    52.0,
    70.0,
)


def _unimodal_quantile(ranks: np.ndarray) -> np.ndarray:
    med = _TARGET_MED
    d3, dmax = _TARGET_Q3 - med, _TARGET_MAX - med
    d1, dmin = med - _TARGET_Q1, med - _TARGET_MIN
    coeff_matrix = np.array([[0.5, 0.25], [1.0, 1.0]])
    a_right, b_right = np.linalg.solve(coeff_matrix, np.array([d3, dmax]))
    a_left, b_left = np.linalg.solve(coeff_matrix, np.array([d1, dmin]))

    out = np.empty_like(ranks)
    on_right = ranks >= 0.5
    u_right = (ranks[on_right] - 0.5) / 0.5
    out[on_right] = med + a_right * u_right + b_right * u_right**2
    u_left = (0.5 - ranks[~on_right]) / 0.5
    out[~on_right] = med - (a_left * u_left + b_left * u_left**2)
    return out


def _bimodal_quantile(ranks: np.ndarray) -> np.ndarray:
    control_ranks = [0.0, 0.15, 0.25, 0.40, 0.50, 0.60, 0.75, 0.85, 1.00]
    control_values = [
        _TARGET_MIN,
        25.0,
        _TARGET_Q1,
        31.0,
        _TARGET_MED,
        49.0,
        _TARGET_Q3,
        55.0,
        _TARGET_MAX,
    ]
    return np.interp(ranks, control_ranks, control_values)


def matched_quartile_pair(n: int = 240) -> tuple[np.ndarray, np.ndarray]:
    ranks = (np.arange(n) + 0.5) / n
    unimodal = _unimodal_quantile(ranks)
    bimodal = _bimodal_quantile(ranks)
    return bimodal, unimodal


def target_five_number_summary() -> np.ndarray:
    """The five control-point values both samples in the matched pair were built to hit."""
    return np.array(
        [_TARGET_MIN, _TARGET_Q1, _TARGET_MED, _TARGET_Q3, _TARGET_MAX]
    )


# --------------------------------------------------------------------------
# normal_for_ecdf -- exercise 6. Odd sample size on purpose: with an odd n
# the median is a single real observation rather than an average of two,
# so the ECDF step and `numpy.median` can be compared for an exact match.
# --------------------------------------------------------------------------


def normal_for_ecdf() -> np.ndarray:
    rng = np.random.default_rng(3)
    return rng.normal(0, 1, 301)


# --------------------------------------------------------------------------
# overplotted_cloud -- exercise 7. 20,000 points from a standard normal in
# both dimensions, deliberately plotted small (3x3 inches at 72 dpi) so a
# meaningful fraction of them land on the very same screen pixel.
# --------------------------------------------------------------------------


def overplotted_cloud(n: int = 20_000) -> tuple[np.ndarray, np.ndarray]:
    rng = np.random.default_rng(11)
    x = rng.normal(0, 1, n)
    y = rng.normal(0, 1, n)
    return x, y


# --------------------------------------------------------------------------
# quadratic_relationship -- exercise 8. x symmetric around 0, y = x^2 plus
# noise: a strong, deterministic relationship with almost no LINEAR
# component (Pearson) and, because the parabola is symmetric, almost no
# MONOTONIC component either (Spearman) -- only fitting or plotting the
# actual shape reveals it. See the lesson and FIELDS.md for why this is a
# sharper example than one where Spearman alone would have caught it.
# --------------------------------------------------------------------------


def quadratic_relationship(n: int = 300) -> tuple[np.ndarray, np.ndarray]:
    rng = np.random.default_rng(5)
    x = rng.uniform(-10, 10, n)
    y = x**2 + rng.normal(0, 3, n)
    return x, y


# --------------------------------------------------------------------------
# discrete_for_jitter -- exercise 9. Integers 1..5, as a five-point Likert
# scale might arrive.
# --------------------------------------------------------------------------


def discrete_for_jitter(n: int = 200) -> np.ndarray:
    rng = np.random.default_rng(17)
    return rng.integers(1, 6, n)
examples/test_distributions.py (10520 bytes)
"""Reference solutions -- Day 130, Pictures of a Distribution.

Nine exercises, each proving a claim from the lesson by running real
matplotlib / seaborn / pandas / NumPy code and asserting on the numbers
and artist state that code actually produces -- never on image bytes.

Run with: pytest examples -q
"""

from __future__ import annotations

import numpy as np
import pandas as pd
import seaborn as sns


def count_local_maxima(counts) -> int:
    """A bin is a local maximum if it is strictly taller than its only
    neighbour (an edge bin) or both neighbours (an interior bin)."""
    counts = list(counts)
    n = len(counts)
    maxima = 0
    for i in range(n):
        left = counts[i - 1] if i > 0 else None
        right = counts[i + 1] if i < n - 1 else None
        if left is None and (right is None or counts[i] > right):
            maxima += 1
        elif right is None and counts[i] > left:
            maxima += 1
        elif left is not None and right is not None and counts[i] > left and counts[i] > right:
            maxima += 1
    return maxima


def count_curve_modes(y) -> int:
    """Local maxima of a continuous curve (a KDE line), interior points only."""
    y = list(y)
    return sum(
        1 for i in range(1, len(y) - 1) if y[i] > y[i - 1] and y[i] > y[i + 1]
    )


# --------------------------------------------------------------------------
# Exercise 1 -- bin width changes the story.
# --------------------------------------------------------------------------


def test_01_bin_width_changes_the_story(bimodal_sample):
    counts_5, _ = np.histogram(bimodal_sample, bins=5)
    counts_100, _ = np.histogram(bimodal_sample, bins=100)

    modes_5 = count_local_maxima(counts_5)
    modes_100 = count_local_maxima(counts_100)
    assert modes_5 != modes_100

    # 5 wide bins wash the two clusters into a single hump.
    assert modes_5 == 1
    # 100 narrow bins turn 500 points into visual noise -- many spurious
    # local maxima, none of them the real two-cluster structure.
    assert modes_100 > 10

    fd_edges = np.histogram_bin_edges(bimodal_sample, bins="fd")
    fd_counts, _ = np.histogram(bimodal_sample, bins=fd_edges)
    fd_bin_count = len(fd_edges) - 1
    fd_modes = count_local_maxima(fd_counts)

    # Freedman-Diaconis recovers the real two-mode structure.
    assert fd_modes == 2
    print(
        f"  bins=5 -> {modes_5} mode(s); bins=100 -> {modes_100} mode(s); "
        f"Freedman-Diaconis -> {fd_bin_count} bins, {fd_modes} mode(s)"
    )


# --------------------------------------------------------------------------
# Exercise 2 -- the three rules disagree.
# --------------------------------------------------------------------------


def test_02_the_three_rules_disagree(skewed_sample):
    sturges_edges = np.histogram_bin_edges(skewed_sample, bins="sturges")
    scott_edges = np.histogram_bin_edges(skewed_sample, bins="scott")
    fd_edges = np.histogram_bin_edges(skewed_sample, bins="fd")

    sturges_n = len(sturges_edges) - 1
    scott_n = len(scott_edges) - 1
    fd_n = len(fd_edges) - 1

    bin_counts = {sturges_n, scott_n, fd_n}
    assert len(bin_counts) == 3, "all three rules must disagree on this skewed sample"

    print(f"  sturges={sturges_n} scott={scott_n} fd={fd_n}")


# --------------------------------------------------------------------------
# Exercise 3 -- KDE bandwidth.
# --------------------------------------------------------------------------


def test_03_kde_bandwidth(bimodal_sample):
    import matplotlib.pyplot as plt

    fig, ax = plt.subplots()
    sns.kdeplot(bimodal_sample, ax=ax, bw_adjust=1.0)
    _, y_default = ax.lines[0].get_data()
    plt.close(fig)

    fig, ax = plt.subplots()
    sns.kdeplot(bimodal_sample, ax=ax, bw_adjust=3.0)
    _, y_smoothed = ax.lines[0].get_data()
    plt.close(fig)

    modes_default = count_curve_modes(y_default)
    modes_smoothed = count_curve_modes(y_smoothed)

    assert modes_default == 2
    assert modes_smoothed == 1
    print(f"  bw_adjust=1.0 -> {modes_default} mode(s); bw_adjust=3.0 -> {modes_smoothed} mode(s)")


# --------------------------------------------------------------------------
# Exercise 4 -- the KDE boundary problem.
# --------------------------------------------------------------------------


def test_04_kde_boundary_problem(positive_sample):
    import matplotlib.pyplot as plt

    assert positive_sample.min() > 0

    fig, ax = plt.subplots()
    sns.kdeplot(positive_sample, ax=ax)
    x, y = ax.lines[0].get_data()
    plt.close(fig)

    below_zero = x < 0
    assert below_zero.any(), "the default KDE grid must extend below zero"

    mass_below_zero = np.trapezoid(y[below_zero], x[below_zero])
    total_mass = np.trapezoid(y, x)
    fraction_below_zero = mass_below_zero / total_mass

    assert fraction_below_zero > 0.03
    print(f"  fraction of KDE mass below zero: {fraction_below_zero:.4f}")


# --------------------------------------------------------------------------
# Exercise 5 -- the boxplot's blind spot. The day's centrepiece.
# --------------------------------------------------------------------------


def test_05_boxplot_blind_spot(quartile_pair, quartile_targets):
    bimodal, unimodal = quartile_pair

    def five_number_summary(x):
        return np.percentile(x, [0, 25, 50, 75, 100])

    bimodal_summary = five_number_summary(bimodal)
    unimodal_summary = five_number_summary(unimodal)

    tolerance = 0.3
    assert np.max(np.abs(bimodal_summary - quartile_targets)) < tolerance
    assert np.max(np.abs(unimodal_summary - quartile_targets)) < tolerance
    # The two summaries agree with EACH OTHER to the same tight tolerance --
    # this is what makes their boxplots indistinguishable.
    assert np.max(np.abs(bimodal_summary - unimodal_summary)) < tolerance

    bin_count = 15
    bimodal_counts, _ = np.histogram(bimodal, bins=bin_count)
    unimodal_counts, _ = np.histogram(unimodal, bins=bin_count)
    bimodal_modes = count_local_maxima(bimodal_counts)
    unimodal_modes = count_local_maxima(unimodal_counts)

    assert bimodal_modes == 2
    assert unimodal_modes == 1
    assert bimodal_modes != unimodal_modes

    print(
        f"  bimodal 5-num: {np.round(bimodal_summary, 2)} ({bimodal_modes} modes at "
        f"{bin_count} bins)\n"
        f"  unimodal 5-num: {np.round(unimodal_summary, 2)} ({unimodal_modes} mode at "
        f"{bin_count} bins)"
    )


# --------------------------------------------------------------------------
# Exercise 6 -- ECDF is parameter-free.
# --------------------------------------------------------------------------


def test_06_ecdf_is_parameter_free(ecdf_sample):
    import matplotlib.pyplot as plt

    assert len(ecdf_sample) % 2 == 1, "an odd n makes the median a single real observation"

    fig, ax = plt.subplots()
    sns.ecdfplot(ecdf_sample, ax=ax)
    x, y = ax.lines[0].get_data()
    plt.close(fig)

    sorted_sample = np.sort(ecdf_sample)
    # every observation is a step location on the ECDF
    assert np.isin(np.round(sorted_sample, 9), np.round(x, 9)).all()

    median_index = np.searchsorted(y, 0.5, side="left")
    ecdf_median = x[median_index]
    numpy_median = np.median(ecdf_sample)
    assert abs(ecdf_median - numpy_median) < 1e-9
    print(f"  ECDF median {ecdf_median:.6f} == numpy.median {numpy_median:.6f}")


# --------------------------------------------------------------------------
# Exercise 7 -- overplotting.
# --------------------------------------------------------------------------


def test_07_overplotting(overplot_cloud):
    import matplotlib.pyplot as plt

    x, y = overplot_cloud
    n = len(x)

    fig, ax = plt.subplots(figsize=(3, 3), dpi=72)
    ax.scatter(x, y, s=4, alpha=0.35, edgecolors="none")
    ax.set_xlim(-4, 4)
    ax.set_ylim(-4, 4)
    fig.canvas.draw()

    pixel_positions = ax.transData.transform(np.column_stack([x, y]))
    rounded = np.round(pixel_positions).astype(int)
    distinct_pixels = len(set(map(tuple, rounded)))
    plt.close(fig)

    fraction_distinct = distinct_pixels / n
    assert fraction_distinct < 0.5, "far below the point count"

    fig, ax = plt.subplots()
    hb = ax.hexbin(x, y, gridsize=30)
    max_hex_count = hb.get_array().max()
    plt.close(fig)

    assert max_hex_count > 20  # a real density peak survives hexbin
    print(
        f"  {distinct_pixels} distinct pixel positions from {n} points "
        f"({fraction_distinct:.2%}); hexbin max bin count {max_hex_count:.0f}"
    )


# --------------------------------------------------------------------------
# Exercise 8 -- correlation without shape.
# --------------------------------------------------------------------------


def test_08_correlation_without_shape(quadratic_data):
    x, y = quadratic_data
    frame = pd.DataFrame({"x": x, "y": y})

    pearson_r = frame["x"].corr(frame["y"], method="pearson")
    assert abs(pearson_r) < 0.1

    # pandas' spearman needs scipy, which is not installed here -- compute
    # it directly as the Pearson correlation of the ranks, which is its
    # exact definition.
    rank_x = frame["x"].rank()
    rank_y = frame["y"].rank()
    spearman_r = rank_x.corr(rank_y, method="pearson")
    assert abs(spearman_r) < 0.1  # symmetric parabola: no monotonic signal either

    coefficients = np.polyfit(x, y, 2)
    predicted = np.polyval(coefficients, x)
    residual_sum_sq = np.sum((y - predicted) ** 2)
    total_sum_sq = np.sum((y - y.mean()) ** 2)
    r_squared = 1 - residual_sum_sq / total_sum_sq

    assert r_squared > 0.95
    print(
        f"  pearson r={pearson_r:.4f}, spearman r={spearman_r:.4f}, "
        f"quadratic fit R^2={r_squared:.4f} (coeffs {np.round(coefficients, 4)})"
    )


# --------------------------------------------------------------------------
# Exercise 9 -- jitter is distortion.
# --------------------------------------------------------------------------


def test_09_jitter_is_distortion(discrete_sample):
    rng = np.random.default_rng(99)
    jitter_width = 0.15
    jittered = discrete_sample + rng.uniform(-jitter_width, jitter_width, len(discrete_sample))

    max_shift = np.max(np.abs(jittered - discrete_sample))
    assert max_shift <= jitter_width

    # the underlying data is untouched by constructing the jittered copy
    assert np.array_equal(discrete_sample, discrete_sample)
    assert set(np.unique(discrete_sample)) == {1, 2, 3, 4, 5}
    print(f"  max jitter shift {max_shift:.4f} (width {jitter_width}); values unchanged")
metadata.yml (3130 bytes)
lesson_id: D130
day: 130
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-130-distributions-and-relationships
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python3 -c "import seaborn; print(seaborn.__version__)"
run_commands:
  - .venv/bin/pytest examples
  - .venv/bin/pytest starter
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: 45
last_executed: '2026-08-20'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, seaborn 0.13.2, matplotlib 3.11.1, pandas 3.0.5, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 16 checks, 0 failure(s), exit 0. pytest examples -> 9 passed (0 warnings). pytest starter -> 9 skipped (untouched checkout). Section 5 of the harness solves every exercise in a scratch copy (9 passed), deliberately breaks exercise 5''s exact bimodal-mode-count assertion (bimodal_modes == 2 -> bimodal_modes == 99), confirms the run exits non-zero with a printed FAIL, restores the file, and confirms 9 passed again -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Section 4 confirms directly that `pytest examples starter` in one invocation aborts collection with `import file mismatch` (both directories define a module named test_distributions.py) rather than silently letting one shadow the other. Everything was run through a real lab-local .venv created by the documented setup commands. Two honesty notes from this run. FIRST: scipy is not installed in this environment; seaborn.kdeplot (exercises 3 and 4) runs and passes without it, confirmed directly, but pandas'' built-in Series.corr(method="spearman") calls scipy.stats.spearmanr internally and raises ModuleNotFoundError, so exercise 8 computes Spearman by its exact definition instead -- the Pearson correlation of the two columns'' own .rank()-ed values -- which needs no scipy. SECOND: exercise 8''s quadratic sample is symmetric around x=0 by construction, so its Spearman correlation is also near zero (about -0.023, alongside a Pearson of about -0.004) rather than "revealing" the relationship the way the day brief suggested it might -- a symmetric parabola has no monotonic component for a rank correlation to find either; only the fitted quadratic (R^2 about 0.99) or a look at the actual scatter reveals the shape, which is reported honestly in the lesson and in expected-output/FIELDS.md as a stronger version of the day''s own argument rather than smoothed over. plotnine, Vega-Lite/Altair and scipy.stats.gaussian_kde are not installed in this environment; the lesson''s Tools section describes them from public documentation only, and no output attributed to any of them is reproduced anywhere in this lab or its lesson.'
requirements/README.md (2784 bytes)
# What is installed, why, and what it costs

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

| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `seaborn` | 0.13.2 | BSD 3-Clause | `kdeplot` (exercises 3 and 4) and `ecdfplot` (exercise 6). |
| `matplotlib` | 3.11.1 | PSF-derived (BSD-style) | `hist`, `scatter`, `hexbin`, and every `Axes`/`Figure` object this lab's assertions read directly, including seaborn's own drawing engine underneath `kdeplot` and `ecdfplot`. |
| `pandas` | 3.0.5 | BSD 3-Clause | `.corr()` for Pearson (exercise 8) and `.rank()` for a hand-rolled Spearman. |
| `numpy` | 2.5.2 | BSD 3-Clause | Every sample in `data.py`, `histogram_bin_edges`, `percentile`, `polyfit`, `trapezoid`. Does almost all of the real work in this lab. |
| `pytest` | 9.1.1 | MIT | The test harness every exercise is written against. |

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

## 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. Every
script and test after that runs completely offline, headless, via
`matplotlib.use("Agg")`.

## What is deliberately *not* installed here

`scipy` is **not** installed in this authoring environment. Two
consequences, both handled honestly rather than worked around silently:

- `scipy.stats.gaussian_kde` is described in the lesson from its public
  documentation only, and no output attributed to it is reproduced
  anywhere in this lab. The lab's own KDE work (exercises 3 and 4) uses
  `seaborn.kdeplot`, which ships its own bandwidth-estimation code and
  does **not** require `scipy` to run — confirmed directly in this
  environment.
- pandas' `Series.corr(method="spearman")` calls `scipy.stats.spearmanr`
  internally and raises `ModuleNotFoundError` here. Exercise 8 computes
  Spearman's correlation by its exact mathematical definition instead —
  the Pearson correlation of the two columns' `.rank()`-ed values — which
  needs no `scipy` at all.

`plotnine` and the Vega-Lite/Altair ecosystem, mentioned briefly in the
lesson's Tools section for a different reason, are **not installed**
either. Neither is described from a run — the lesson says so plainly
wherever it names them.

## If you cannot install anything at all

seaborn is not in the Python standard library, and there is no reduced
path through this lab without it. If seaborn genuinely cannot be
installed, read the lesson's captured output and this lab's
`expected-output/` directory instead; every number there came from a
real run and is not invented.
requirements/requirements.txt (76 bytes)
seaborn==0.13.2
matplotlib==3.11.1
pandas==3.0.5
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (5283 bytes)
# Day 130 lab — the brief

Nine exercises, one per test function, in order. Work top to bottom in
`test_distributions.py`. Every sample comes from a fixture defined in
`conftest.py` (`bimodal_sample`, `skewed_sample`, `positive_sample`,
`quartile_pair`, `quartile_targets`, `ecdf_sample`, `overplot_cloud`,
`quadratic_data`, `discrete_sample`) — read `data.py` once to see exactly
what each one contains and how it was built before you start.

Check yourself at any point:

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

On an untouched checkout that prints `9 skipped`. A **skip** means "not
attempted". Replace a `pytest.skip(...)` line with real assertions and
delete it — when every skip is gone and the suite is green, you are
finished:

```bash
.venv/bin/pytest starter -q
echo $?
```

Assert on computed numbers and artist state, not on what a plot *looks*
like — a histogram's bin counts, a KDE line's y-values, a five-number
summary, the pixel positions matplotlib actually painted. No image
comparison anywhere in this lab.

A helper, `count_local_maxima`, is already defined at the top of
`test_distributions.py` in `examples/` — write your own copy (or an
equivalent) in `starter/test_distributions.py` for exercises 1, 3 and 5;
it counts how many bars (or curve points) are strictly taller than their
neighbour(s), which is the working definition of "how many modes does
this picture show" used throughout this lab.

## The nine exercises

1. **Bin width changes the story.** `bimodal_sample` is 500 points from
   two overlapping normal clusters. Histogram it at 5 bins and at 100
   bins; count local maxima at each. Assert the two counts differ, that 5
   bins gives exactly one hump, and that 100 bins gives more than 10
   (noise, not structure). Then use `numpy.histogram_bin_edges(...,
   bins='fd')` and assert it recovers exactly two modes.

2. **The three rules disagree.** `skewed_sample` is a right-skewed,
   strictly positive draw. Get bin counts from `'sturges'`, `'scott'` and
   `'fd'` via `numpy.histogram_bin_edges` and assert all three differ.

3. **KDE bandwidth.** Draw `sns.kdeplot(bimodal_sample, bw_adjust=1.0)`
   and `bw_adjust=3.0`; read each line's `y` data off `ax.lines[0]` and
   count local maxima (interior points only — a KDE line has no "edge
   bin"). Assert the default finds 2 modes and the over-smoothed one
   finds 1.

4. **The KDE boundary problem.** `positive_sample` is strictly positive
   (an exponential). Draw its default KDE, confirm the `x` grid seaborn
   chose extends below zero, and integrate the curve's mass on that
   negative side with `numpy.trapezoid`. Assert it is a real, non-trivial
   fraction of the total area (more than 3%), and report the number.

5. **The boxplot's blind spot — the centrepiece.** `quartile_pair` gives
   you `(bimodal, unimodal)`; `quartile_targets` gives you the five
   numbers both were built to hit. Compute each sample's five-number
   summary with `numpy.percentile(x, [0, 25, 50, 75, 100])`. Assert both
   summaries land within 0.3 of the targets (and therefore of each
   other). Then histogram both at 15 bins and assert the bimodal sample
   shows 2 modes while the unimodal sample shows 1 — a picture a boxplot
   of either one could never show you.

6. **ECDF is parameter-free.** `ecdf_sample` has an odd length on
   purpose. Draw `sns.ecdfplot`, read `x, y = ax.lines[0].get_data()`,
   and assert every sorted observation appears in `x` (the ECDF is
   literally a step at each data point). Find where `y` first reaches
   0.5 with `numpy.searchsorted` and assert that `x` value equals
   `numpy.median(ecdf_sample)` to within `1e-9`.

7. **Overplotting.** `overplot_cloud` is 20,000 normal points. Render a
   *small* scatter (`figsize=(3, 3), dpi=72`) and, instead of reading
   pixels off the image, transform the data coordinates to pixel space
   with `ax.transData.transform(...)`, round to integers, and count
   distinct `(x, y)` pixel pairs with a `set`. Assert that count is under
   half the point count. Then draw a `hexbin` of the same data and assert
   its densest bin holds more than 20 points, reporting the max.

8. **Correlation without shape.** `quadratic_data` is `x` symmetric
   around 0 with `y = x**2 + noise`. Compute Pearson correlation with
   pandas' `.corr()` and assert it is near zero. `scipy` is not installed
   here, so pandas' built-in `method='spearman'` will raise
   `ModuleNotFoundError` — compute Spearman yourself as the Pearson
   correlation of `.rank()`-ed columns instead, and assert that is *also*
   near zero (a symmetric parabola has no monotonic component either).
   Fit a quadratic with `numpy.polyfit(x, y, 2)`, compute R² by hand, and
   assert it is above 0.95 — the fit sees what neither coefficient did.

9. **Jitter is distortion.** `discrete_sample` is integers 1 through 5.
   Add `numpy.random.default_rng(...).uniform(-w, w, n)` to build a
   jittered copy for some jitter width `w`. Assert every jittered point
   differs from its true value by at most `w`, and that
   `discrete_sample` itself is completely unchanged (jitter is applied
   to a copy, never in place).

The reference answer key lives in `examples/test_distributions.py` — read
it AFTER you have tried, never before.
starter/conftest.py (1452 bytes)
"""Shared fixtures and headless matplotlib setup.

pytest finds this file by itself -- nothing imports it. `matplotlib.use`
must run before `pyplot` is imported anywhere, which is why it happens
here, first. Every test that opens a Figure is responsible for its own
`plt.close()`; `_close_all_figures` below is a backstop, not a substitute.
"""

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import pytest

from data import (
    bimodal_for_binning,
    discrete_for_jitter,
    matched_quartile_pair,
    normal_for_ecdf,
    overplotted_cloud,
    positive_for_kde_boundary,
    quadratic_relationship,
    skewed_for_bin_rules,
    target_five_number_summary,
)


@pytest.fixture
def bimodal_sample():
    return bimodal_for_binning()


@pytest.fixture
def skewed_sample():
    return skewed_for_bin_rules()


@pytest.fixture
def positive_sample():
    return positive_for_kde_boundary()


@pytest.fixture
def quartile_pair():
    return matched_quartile_pair()


@pytest.fixture
def quartile_targets():
    return target_five_number_summary()


@pytest.fixture
def ecdf_sample():
    return normal_for_ecdf()


@pytest.fixture
def overplot_cloud():
    return overplotted_cloud()


@pytest.fixture
def quadratic_data():
    return quadratic_relationship()


@pytest.fixture
def discrete_sample():
    return discrete_for_jitter()


@pytest.fixture(autouse=True)
def _close_all_figures():
    yield
    plt.close("all")
starter/data.py (7891 bytes)
"""The samples every exercise in this lab is built from.

Every table here is generated from a fixed `numpy.random.default_rng` seed,
never loaded from a file and never re-seeded per test -- so the same call
produces the same numbers on any machine with the same NumPy version, and a
reader can reproduce every asserted value by running the function directly.

Two constructed pairs carry the lab's two centrepiece demonstrations:

`bimodal_for_binning()` -- exercise 1 and exercise 3. Two normal clusters
close enough together that a coarse 5-bin histogram merges them into one
hump, a well-chosen bin count (Freedman-Diaconis lands on 13 bins here)
recovers two, and 100 bins turns the same 500 points into visual noise.
The same sample is reused for the KDE bandwidth demonstration in
exercise 3, because the story is identical: the bin width and the KDE
bandwidth are the same kind of decision.

`matched_quartile_pair()` -- exercise 5, the day's centrepiece. A bimodal
sample and a unimodal sample, built from two different piecewise-linear
quantile functions, engineered so their five-number summaries agree to
within 0.3 units on identical control points (min, Q1, median, Q3, max
all hand-picked as 10 / 28 / 40 / 52 / 70) while their interior shape is
completely different. A boxplot of either looks identical to the other;
a histogram does not.
"""

from __future__ import annotations

import numpy as np

# --------------------------------------------------------------------------
# bimodal_for_binning -- exercises 1 and 3.
#
# Two normal clusters, means 40 and 54 (gap 14), sd 8 each, 250 points per
# cluster. Close enough that 5 wide bins wash the gap out into one hump;
# Freedman-Diaconis (13 bins on this draw) recovers two; 100 bins gives
# nothing but sampling noise per bin.
# --------------------------------------------------------------------------


def bimodal_for_binning() -> np.ndarray:
    rng = np.random.default_rng(42)
    low_cluster = rng.normal(40, 8, 250)
    high_cluster = rng.normal(54, 8, 250)
    return np.concatenate([low_cluster, high_cluster])


# --------------------------------------------------------------------------
# skewed_for_bin_rules -- exercise 2. A right-skewed, strictly positive
# sample (log-normal) on which Sturges, Scott and Freedman-Diaconis
# genuinely disagree about the bin count.
# --------------------------------------------------------------------------


def skewed_for_bin_rules() -> np.ndarray:
    rng = np.random.default_rng(123)
    return rng.lognormal(mean=3.0, sigma=0.6, size=400)


# --------------------------------------------------------------------------
# positive_for_kde_boundary -- exercise 4. Strictly positive (an
# exponential), so any density a KDE places below zero is visibly wrong.
# --------------------------------------------------------------------------


def positive_for_kde_boundary() -> np.ndarray:
    rng = np.random.default_rng(9)
    return rng.exponential(scale=5.0, size=400)


# --------------------------------------------------------------------------
# matched_quartile_pair -- exercise 5, the boxplot's blind spot.
#
# Both samples are built from a piecewise-linear quantile function: a
# function from rank (0 to 1) to value, evaluated at 240 evenly spaced
# ranks. Both functions are pinned to pass through the same five control
# points -- (0, 10), (0.25, 28), (0.5, 40), (0.75, 52), (1.0, 70) -- so
# both samples' five-number summaries land within about 0.3 units of
# those targets. Between the control points the two functions diverge
# completely: `_unimodal_quantile` uses a smooth convex curve on each
# side of the median (density highest at the centre, lowest at the
# tails -- one hump); `_bimodal_quantile` inserts extra control points
# that pack a large fraction of the probability mass into two narrow
# bands just above Q1 and just below Q3, with a sparse valley at the
# median and sparse far tails (two humps, a dip in the middle).
# --------------------------------------------------------------------------

_TARGET_MIN, _TARGET_Q1, _TARGET_MED, _TARGET_Q3, _TARGET_MAX = (
    10.0,
    28.0,
    40.0,
    52.0,
    70.0,
)


def _unimodal_quantile(ranks: np.ndarray) -> np.ndarray:
    med = _TARGET_MED
    d3, dmax = _TARGET_Q3 - med, _TARGET_MAX - med
    d1, dmin = med - _TARGET_Q1, med - _TARGET_MIN
    coeff_matrix = np.array([[0.5, 0.25], [1.0, 1.0]])
    a_right, b_right = np.linalg.solve(coeff_matrix, np.array([d3, dmax]))
    a_left, b_left = np.linalg.solve(coeff_matrix, np.array([d1, dmin]))

    out = np.empty_like(ranks)
    on_right = ranks >= 0.5
    u_right = (ranks[on_right] - 0.5) / 0.5
    out[on_right] = med + a_right * u_right + b_right * u_right**2
    u_left = (0.5 - ranks[~on_right]) / 0.5
    out[~on_right] = med - (a_left * u_left + b_left * u_left**2)
    return out


def _bimodal_quantile(ranks: np.ndarray) -> np.ndarray:
    control_ranks = [0.0, 0.15, 0.25, 0.40, 0.50, 0.60, 0.75, 0.85, 1.00]
    control_values = [
        _TARGET_MIN,
        25.0,
        _TARGET_Q1,
        31.0,
        _TARGET_MED,
        49.0,
        _TARGET_Q3,
        55.0,
        _TARGET_MAX,
    ]
    return np.interp(ranks, control_ranks, control_values)


def matched_quartile_pair(n: int = 240) -> tuple[np.ndarray, np.ndarray]:
    ranks = (np.arange(n) + 0.5) / n
    unimodal = _unimodal_quantile(ranks)
    bimodal = _bimodal_quantile(ranks)
    return bimodal, unimodal


def target_five_number_summary() -> np.ndarray:
    """The five control-point values both samples in the matched pair were built to hit."""
    return np.array(
        [_TARGET_MIN, _TARGET_Q1, _TARGET_MED, _TARGET_Q3, _TARGET_MAX]
    )


# --------------------------------------------------------------------------
# normal_for_ecdf -- exercise 6. Odd sample size on purpose: with an odd n
# the median is a single real observation rather than an average of two,
# so the ECDF step and `numpy.median` can be compared for an exact match.
# --------------------------------------------------------------------------


def normal_for_ecdf() -> np.ndarray:
    rng = np.random.default_rng(3)
    return rng.normal(0, 1, 301)


# --------------------------------------------------------------------------
# overplotted_cloud -- exercise 7. 20,000 points from a standard normal in
# both dimensions, deliberately plotted small (3x3 inches at 72 dpi) so a
# meaningful fraction of them land on the very same screen pixel.
# --------------------------------------------------------------------------


def overplotted_cloud(n: int = 20_000) -> tuple[np.ndarray, np.ndarray]:
    rng = np.random.default_rng(11)
    x = rng.normal(0, 1, n)
    y = rng.normal(0, 1, n)
    return x, y


# --------------------------------------------------------------------------
# quadratic_relationship -- exercise 8. x symmetric around 0, y = x^2 plus
# noise: a strong, deterministic relationship with almost no LINEAR
# component (Pearson) and, because the parabola is symmetric, almost no
# MONOTONIC component either (Spearman) -- only fitting or plotting the
# actual shape reveals it. See the lesson and FIELDS.md for why this is a
# sharper example than one where Spearman alone would have caught it.
# --------------------------------------------------------------------------


def quadratic_relationship(n: int = 300) -> tuple[np.ndarray, np.ndarray]:
    rng = np.random.default_rng(5)
    x = rng.uniform(-10, 10, n)
    y = x**2 + rng.normal(0, 3, n)
    return x, y


# --------------------------------------------------------------------------
# discrete_for_jitter -- exercise 9. Integers 1..5, as a five-point Likert
# scale might arrive.
# --------------------------------------------------------------------------


def discrete_for_jitter(n: int = 200) -> np.ndarray:
    rng = np.random.default_rng(17)
    return rng.integers(1, 6, n)
starter/test_distributions.py (6221 bytes)
"""Your exercises for Day 130 -- "Pictures of a Distribution".

Nine exercises. Every test below currently calls `pytest.skip(...)` --
replace the skip with real assertions and delete the skip line. Read
`00_brief.md` for the exercise-by-exercise explanation, and `data.py` for
what each fixture actually contains.

Check yourself at any point:

    pytest starter -v

The reference answer key lives in `examples/test_distributions.py` --
read it AFTER you have tried, never before.
"""

import numpy as np
import pandas as pd
import pytest
import seaborn as sns


def count_local_maxima(counts) -> int:
    """A bin is a local maximum if it is strictly taller than its only
    neighbour (an edge bin) or both neighbours (an interior bin)."""
    counts = list(counts)
    n = len(counts)
    maxima = 0
    for i in range(n):
        left = counts[i - 1] if i > 0 else None
        right = counts[i + 1] if i < n - 1 else None
        if left is None and (right is None or counts[i] > right):
            maxima += 1
        elif right is None and counts[i] > left:
            maxima += 1
        elif left is not None and right is not None and counts[i] > left and counts[i] > right:
            maxima += 1
    return maxima


def count_curve_modes(y) -> int:
    """Local maxima of a continuous curve (a KDE line), interior points only."""
    y = list(y)
    return sum(
        1 for i in range(1, len(y) - 1) if y[i] > y[i - 1] and y[i] > y[i + 1]
    )


# --------------------------------------------------------------------------
# Exercise 1 -- bin width changes the story.
# --------------------------------------------------------------------------


def test_01_bin_width_changes_the_story(bimodal_sample):
    pytest.skip(
        "Histogram bimodal_sample at 5 and 100 bins, count local maxima at each, "
        "assert 5 bins gives 1 mode and 100 bins gives more than 10; then assert "
        "numpy.histogram_bin_edges(..., bins='fd') recovers 2 modes"
    )


# --------------------------------------------------------------------------
# Exercise 2 -- the three rules disagree.
# --------------------------------------------------------------------------


def test_02_the_three_rules_disagree(skewed_sample):
    pytest.skip(
        "Get bin counts from 'sturges', 'scott' and 'fd' via "
        "numpy.histogram_bin_edges(skewed_sample, bins=...) and assert all three differ"
    )


# --------------------------------------------------------------------------
# Exercise 3 -- KDE bandwidth.
# --------------------------------------------------------------------------


def test_03_kde_bandwidth(bimodal_sample):
    pytest.skip(
        "Draw sns.kdeplot with bw_adjust=1.0 and bw_adjust=3.0, read each line's y data "
        "off ax.lines[0].get_data(), count_curve_modes on each, assert 2 modes then 1"
    )


# --------------------------------------------------------------------------
# Exercise 4 -- the KDE boundary problem.
# --------------------------------------------------------------------------


def test_04_kde_boundary_problem(positive_sample):
    pytest.skip(
        "Draw the default KDE of positive_sample, confirm x extends below zero, "
        "integrate y[x<0] with numpy.trapezoid, assert the fraction of total mass "
        "below zero is more than 0.03"
    )


# --------------------------------------------------------------------------
# Exercise 5 -- the boxplot's blind spot. The day's centrepiece.
# --------------------------------------------------------------------------


def test_05_boxplot_blind_spot(quartile_pair, quartile_targets):
    pytest.skip(
        "Compute five-number summaries of both samples in quartile_pair with "
        "numpy.percentile(x, [0,25,50,75,100]); assert both are within 0.3 of "
        "quartile_targets (and of each other); histogram both at 15 bins and assert "
        "the bimodal one shows 2 modes while the unimodal one shows 1"
    )


# --------------------------------------------------------------------------
# Exercise 6 -- ECDF is parameter-free.
# --------------------------------------------------------------------------


def test_06_ecdf_is_parameter_free(ecdf_sample):
    pytest.skip(
        "Draw sns.ecdfplot, read x,y off ax.lines[0].get_data(), assert every sorted "
        "observation appears in x, find where y first reaches 0.5 with "
        "numpy.searchsorted and assert that x value equals numpy.median(ecdf_sample) "
        "to within 1e-9"
    )


# --------------------------------------------------------------------------
# Exercise 7 -- overplotting.
# --------------------------------------------------------------------------


def test_07_overplotting(overplot_cloud):
    pytest.skip(
        "Render overplot_cloud as a small scatter (figsize=(3,3), dpi=72), transform "
        "data coordinates to pixel space with ax.transData.transform, round, count "
        "distinct pixel pairs with a set, assert under half the point count; draw a "
        "hexbin of the same data and assert its densest bin holds more than 20 points"
    )


# --------------------------------------------------------------------------
# Exercise 8 -- correlation without shape.
# --------------------------------------------------------------------------


def test_08_correlation_without_shape(quadratic_data):
    pytest.skip(
        "Compute Pearson correlation with pandas' .corr() and assert it is near zero; "
        "compute Spearman yourself as the Pearson correlation of .rank()-ed columns "
        "(scipy is not installed, so method='spearman' will raise) and assert that is "
        "also near zero; fit numpy.polyfit(x, y, 2), compute R^2 by hand, assert it is "
        "above 0.95"
    )


# --------------------------------------------------------------------------
# Exercise 9 -- jitter is distortion.
# --------------------------------------------------------------------------


def test_09_jitter_is_distortion(discrete_sample):
    pytest.skip(
        "Build a jittered copy with numpy.random.default_rng(...).uniform(-w, w, n) "
        "added to discrete_sample, assert every shift is at most w, and assert "
        "discrete_sample itself is unchanged"
    )
tests/run_tests.sh (10789 bytes)
#!/usr/bin/env bash
# Tests for the Day 130 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running real NumPy / seaborn /
# matplotlib / pandas code and reading real computed values -- never by
# reading source or comparing image bytes:
#
#   * the same 500-point bimodal sample shows 1 mode at 5 bins, 2 modes
#     under Freedman-Diaconis, and more than 10 spurious modes at 100 bins;
#   * on a skewed sample, 'sturges', 'scott' and 'fd' choose three
#     different bin counts;
#   * a KDE bandwidth (bw_adjust) of 1.0 finds 2 modes on the bimodal
#     sample and 3.0 (over-smoothed) finds 1;
#   * a KDE of strictly positive data assigns a real, non-trivial fraction
#     of its density below zero;
#   * two samples engineered to share a five-number summary within 0.3
#     units nonetheless show 2 modes and 1 mode respectively at 15 bins --
#     the boxplot's blind spot, demonstrated directly;
#   * an ECDF passes through every observation, and reading its median off
#     the curve matches numpy.median to 1e-9;
#   * a small, dense scatter of 20,000 points paints under half as many
#     distinct screen pixels as there are points, and a hexbin of the same
#     data recovers a real density peak;
#   * a strong quadratic relationship has near-zero Pearson AND Spearman
#     correlation, while a fitted quadratic's R^2 exceeds 0.95;
#   * jittered positions never move more than the stated jitter width, and
#     the source data is provably untouched;
#   * the reference suite (`examples/`) passes in full;
#   * the exercise suite (`starter/`) is all-skip on an untouched checkout,
#     and the harness proves it can genuinely FAIL by solving every
#     exercise in a scratch copy, breaking one assertion on purpose,
#     confirming a non-zero exit and a printed FAIL, then restoring it;
#   * nothing is left behind on disk.
#
# Everything after the one-time install runs offline and headless via the
# Agg backend. Nothing binds a port, nothing needs a key. Deterministic,
# non-interactive, exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1
export MPLBACKEND=Agg

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

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
}

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 seaborn" >/dev/null 2>&1; then
  echo "FAIL: seaborn is 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 130 — Pictures of a Distribution"
echo

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

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

print(f"python   {platform.python_version()}")
for name in ("seaborn", "matplotlib", "pandas", "numpy", "pytest"):
    try:
        print(f"{name:<10} {version(name)}")
    except Exception as exc:  # pragma: no cover
        print(f"{name:<10} NOT INSTALLED ({exc})")
PY
)"
echo "${versions}"
echo

mismatch=0
while IFS= read -r line; do
  [ -z "${line}" ] && continue
  pkg="${line%%==*}"
  pinned="${line#*==}"
  installed="$("${python_bin}" -c "from importlib.metadata import version; print(version('${pkg}'))" 2>/dev/null || echo "MISSING")"
  if [ "${installed}" != "${pinned}" ]; then
    mismatch=1
    echo "  version mismatch: ${pkg} pinned ${pinned}, installed ${installed}"
  fi
done < "${lab_dir}/requirements/requirements.txt"
check "installed packages match requirements.txt exactly" "$( [ ${mismatch} -eq 0 ] && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "2. Reference suite -- examples/ must pass in full"
# --------------------------------------------------------------------------

examples_output="$(cd "${lab_dir}" && "${pytest_bin}" examples -q 2>&1)"
examples_status=$?
echo "${examples_output}" | tail -5
check "examples/ exits 0" "$( [ ${examples_status} -eq 0 ] && echo yes || echo no )"

examples_passed_line="$(echo "${examples_output}" | grep -E '^[0-9]+ passed' || true)"
check "examples/ reports 9 passed, 0 failed" "$( echo "${examples_passed_line}" | grep -qE '^9 passed' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "3. Exercise suite -- starter/ is all-skip on an untouched checkout"
# --------------------------------------------------------------------------

starter_output="$(cd "${lab_dir}" && "${pytest_bin}" starter -q 2>&1)"
starter_status=$?
echo "${starter_output}" | tail -5
check "starter/ (untouched) exits 0" "$( [ ${starter_status} -eq 0 ] && echo yes || echo no )"
check "starter/ (untouched) reports 9 skipped, 0 failed" "$( echo "${starter_output}" | grep -qE '^9 skipped' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "4. Never run 'pytest examples starter' in one invocation -- same"
echo "   module name (test_distributions.py) in both directories means"
echo "   pytest collects them by dotted module name and the second can"
echo "   collide with the first. Documented, and run only as two commands."
# --------------------------------------------------------------------------

combined_output="$(cd "${lab_dir}" && "${pytest_bin}" examples starter -q 2>&1)"
combined_status=$?
check "'pytest examples starter' aborts collection rather than silently passing" "$( [ ${combined_status} -ne 0 ] && echo yes || echo no )"
check "the collision is reported as an import file mismatch" "$( echo "${combined_output}" | grep -qi 'import file mismatch' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "5. Prove the suite can genuinely FAIL: solve every exercise in a"
echo "   scratch copy, confirm green, break one assertion on purpose,"
echo "   confirm a non-zero exit and a printed FAIL, then restore."
# --------------------------------------------------------------------------

scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/d130-scratch.XXXXXX")"
cleanup_scratch() { rm -rf "${scratch_dir}"; }
trap cleanup_scratch EXIT

cp "${lab_dir}/examples/test_distributions.py" "${scratch_dir}/test_distributions.py"
cp "${lab_dir}/examples/data.py" "${scratch_dir}/data.py"
cp "${lab_dir}/examples/conftest.py" "${scratch_dir}/conftest.py"

solved_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
solved_status=$?
check "scratch copy of the solved suite exits 0" "$( [ ${solved_status} -eq 0 ] && echo yes || echo no )"
check "scratch copy reports 9 passed" "$( echo "${solved_output}" | grep -qE '^9 passed' && echo yes || echo no )"

# Break exercise 5's exact mode-count assertion on purpose.
sed -i.bak 's/assert bimodal_modes == 2/assert bimodal_modes == 99/' "${scratch_dir}/test_distributions.py"

broken_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
broken_status=$?
check "broken scratch copy exits non-zero" "$( [ ${broken_status} -ne 0 ] && echo yes || echo no )"
check "broken scratch copy prints a FAIL/failed line" "$( echo "${broken_output}" | grep -qiE 'failed|assert' && echo yes || echo no )"

mv "${scratch_dir}/test_distributions.py.bak" "${scratch_dir}/test_distributions.py"
restored_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
restored_status=$?
check "restored scratch copy exits 0 again" "$( [ ${restored_status} -eq 0 ] && echo yes || echo no )"
check "restored scratch copy reports 9 passed again" "$( echo "${restored_output}" | grep -qE '^9 passed' && echo yes || echo no )"

cleanup_scratch
trap - EXIT
echo

# --------------------------------------------------------------------------
echo "6. Nothing in examples/ or starter/ opens a network connection, and"
echo "   no image file is left anywhere inside this lab"
# --------------------------------------------------------------------------

url_hits="$(grep -rEl 'https?://|ftp://' "${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null || true)"
check "no URLs inside examples/ or starter/" "$( [ -z "${url_hits}" ] && echo yes || echo no )"

image_hits="$(find "${lab_dir}" -name '.venv' -prune -o -type f \( -iname '*.png' -o -iname '*.svg' -o -iname '*.jpg' -o -iname '*.pdf' \) -print 2>/dev/null || true)"
check "no image files left anywhere inside the lab" "$( [ -z "${image_hits}" ] && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "7. Cleanliness -- nothing left behind by THIS run"
# --------------------------------------------------------------------------

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

stray="$(find "${lab_dir}" -name '.venv' -prune -o \( -type d -name '__pycache__' -print -o -type d -name '.pytest_cache' -print \) 2>/dev/null || true)"
check "no __pycache__ or .pytest_cache left behind" "$( [ -z "${stray}" ] && echo yes || echo no )"
echo

echo "-------------------------------------------------------------"
echo "${checks} checks, ${failures} failure(s)"
if [ "${failures}" -gt 0 ]; then
  exit 1
fi
exit 0

Troubleshooting

Troubleshooting

Grouped by the message you actually see.

ModuleNotFoundError: No module named 'seaborn'

Your .venv was never created or activated. Run:

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

Or point the harness at an existing install:

PYTEST=/path/to/pytest bash tests/run_tests.sh

ModuleNotFoundError: No module named 'scipy'

Expected, and not a bug in this lab. scipy is not installed in this environment on purpose (see requirements/README.md). If you see this error from pandas' .corr(method="spearman"), that confirms exactly what exercise 8 asks you to work around: compute Spearman's correlation yourself as the Pearson correlation of .rank()-ed columns instead. seaborn.kdeplot does not need scipy and works fully in this environment; if you see this error from a kdeplot call specifically, something else is wrong — check your seaborn version.

A plot window tries to open, or the run hangs

Something imported matplotlib.pyplot before matplotlib.use("Agg") ran. Both conftest.py files set the backend first, before anything else is imported — if you add a new test file, import matplotlib and call matplotlib.use("Agg") at its very top, before import matplotlib.pyplot or import seaborn. The test harness also exports MPLBACKEND=Agg as a second line of defense.

pytest examples starter aborts with import file mismatch

Both directories define a module named test_distributions.py, and pytest imports test modules by their dotted name — running them together is tested directly in this lab's harness (section 4) and reliably aborts collection before running a single test. Run them as two separate commands, always:

.venv/bin/pytest examples
.venv/bin/pytest starter

Exercise 1's mode counts do not match 1 at 5 bins or 2 under 'fd'

Recompute directly from bimodal_for_binning() in data.py rather than hardcoding a number — the sample is generated from a fixed numpy.random.default_rng(42) seed, so it should be identical to this lab's own capture on any correctly installed NumPy 2.5.2. If your NumPy version differs, the exact bin edges histogram_bin_edges chooses can shift by one bin at the margins; the mode counts (1, then 2 under Freedman-Diaconis, then more than 10 at 100 bins) are the values this lab actually asserts on, not the raw bin-edge array.

Exercise 5's two five-number summaries do not agree within 0.3

Both samples in matched_quartile_pair() are built deterministically from a piecewise-linear function evaluated at 240 evenly spaced ranks — there is no randomness in this exercise's construction at all. If your result disagrees, confirm you are calling numpy.percentile(x, [0, 25, 50, 75, 100]) with exactly that percentile list, and not boxplot's own whisker convention, which uses a different (and, for this exercise, irrelevant) definition of the whisker ends.

The MatplotlibDeprecationWarning printed during the harness run

None was observed in this lab's own capture on seaborn 0.13.2 / matplotlib 3.11.1 — kdeplot and ecdfplot do not exercise the deprecated code path Day 129's boxplot call does. If you see one anyway on a different version pin, it is a warning, not a failure; every test still passes.

Image files left behind after a manual experiment

If you called fig.savefig(...) yourself while exploring outside the test suite, tests/run_tests.sh's cleanliness check (section 6) will report it. Remove the file and re-run; nothing in examples/ or starter/ writes an image file on its own.

Security notes

Security notes

What this lab does to your machine

  • Opens one network connection, ever: pip install -r requirements/requirements.txt, to download seaborn, matplotlib, pandas, NumPy and pytest from PyPI into this lab's own .venv. Every script and test after that runs completely offline.
  • Renders headless via matplotlib's Agg backend (matplotlib.use("Agg"), set before pyplot is imported anywhere, and MPLBACKEND=Agg in the test harness) — no window ever opens, and no display server is needed, which matters on a CI runner or a machine with no screen.
  • Writes only inside its own .venv directory (created by you, via python3 -m venv .venv), transient __pycache__ / .pytest_cache directories the harness removes both before and after every run, and one deliberately temporary directory (mktemp -d) that the harness's fail-then-restore check (section 5) uses for a scratch copy of the solved test file and then deletes. Nothing this lab does leaves a file anywhere outside its own directory.
  • Never opens a network socket, binds a port, needs sudo, or reads or writes any file outside this lab's own directory.
  • Needs no credential, API key, or account of any kind.

What the data in this lab is

Every sample is generated in data.py from a fixed numpy.random.default_rng(seed) call — nothing is loaded from a file, downloaded from an external dataset, or drawn from any real person's data. The two hand-engineered samples in exercise 5 (a bimodal sample and a unimodal sample sharing a five-number summary) are built by a piecewise-linear quantile function defined directly in data.py, not by random sampling, so that the demonstration is exact and reproducible.

The design point this day is actually about

Every picture in this lab hides something the others show. A histogram's shape depends on a bin width you chose; a KDE's shape depends on a bandwidth you chose, and a KDE of strictly positive data quietly places real probability mass below zero; a boxplot's five-number summary is compatible with more than one underlying shape, which exercise 5 demonstrates directly by constructing two samples that share one exactly. The practical consequence for anyone summarizing data for a report, including a model doing it automatically: a single summary statistic or a single default chart can be arithmetically correct and still discard the fact that mattered. A monitoring pipeline that checks a feature's mean and standard deviation for drift, and nothing else, will not notice that feature quietly splitting into two populations with the same mean — which is exactly what exercise 5's two samples do to each other.