Math, Statistics, and Data › Data Visualization › Day 130
Hands-on lab — Day 130: Distributions and Relationships
- ← Back to the Day 130 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/math-statistics-and-data/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
- Lesson title: Distributions and Relationships
- Day number: 130 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-130-distributions-and-relationships
- 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-130-distributions-and-relationshipswhen the site is running.
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_adjustin 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.medianexactly. - 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 ahexbin'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
python3on yourPATHto 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
bash tests/run_tests.shends with16 checks, 0 failure(s)and exits 0.- 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. numpy.histogram_bin_edgeswith'sturges','scott'and'fd'on a skewed sample produce three different bin counts.sns.kdeplot(..., bw_adjust=1.0)finds 2 modes on the bimodal sample;bw_adjust=3.0(over-smoothed) finds 1.- A KDE of a strictly positive sample places more than 3% of its total density below zero.
- 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.
- An ECDF's x-values include every observation, and the x-value where
the ECDF first reaches 0.5 equals
numpy.medianto within1e-9. - 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'shexbindensest bin holds more than 20 points. - A quadratic relationship's Pearson AND Spearman correlations are both under 0.1 in magnitude, while a fitted quadratic's R² exceeds 0.95.
- 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 starteraborts withimport file mismatch— do not run both directories in one invocation; they share a module name.ModuleNotFoundError: No module named 'scipy'— expected.scipyis 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
pyplotbeforematplotlib.use("Agg")ran; bothconftest.pyfiles set the backend first, and the harness also exportsMPLBACKEND=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 callingnumpy.percentilewith[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
- 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.
- A second matched-quartile pair. Using
matched_quartile_pair's approach indata.pyas 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. sns.rugplotas 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.- 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 bandwidthseaborn.kdeplotchooses by default (readable via itsbw_methodparameter) against what those named rules would choose on this lab'sbimodal_for_binningsample. - A log-scaled hexbin. Redraw exercise 7's
hexbinwithbins='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.
Navigation
- 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
Aggbackend (matplotlib.use("Agg"), set beforepyplotis imported anywhere, andMPLBACKEND=Aggin 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
.venvdirectory (created by you, viapython3 -m venv .venv), transient__pycache__/.pytest_cachedirectories 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.