Math, Statistics, and Data › Data Visualization › Day 133
Hands-on lab — Day 133: Building an EDA Report
- ← Back to the Day 133 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-133-building-an-eda-report/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import matplotlib, pandas; print(matplotlib.__version__, pandas.__version__)" Run
.venv/bin/pytest examples
.venv/bin/pytest starter Test
bash tests/run_tests.sh File tree
examples/analysis.py examples/conftest.py examples/data.py examples/report.py examples/test_report.py expected-output/examples-run.txt expected-output/FIELDS.md expected-output/report-sample.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/analysis.py starter/conftest.py starter/data.py starter/report.py starter/test_report.py tests/run_tests.sh troubleshooting.md
Lab README
Day 133 lab — A Report That Argues
Lesson
- Lesson title: Building an EDA Report
- Day number: 133 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-133-building-an-eda-report
- 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-133-building-an-eda-reportwhen the site is running.
Purpose
You build the checks that decide whether an exploratory analysis is fit to be read, and you wire them into a report generator that refuses to produce a report that fails them.
The generator in report.py takes a dataset and a list of candidate
figures, and writes a Markdown report with each figure embedded beside
its caption and its claim. It will not let you add a figure that has no
stated question. It will not accept a caption that is a label rather than
a claim. It renders the conclusion before the evidence, keeps the
discarded candidates as one-line null results, and produces byte-identical
Markdown on two runs over the same input.
Twelve candidate figures go into analysis.candidate_figures(). Five come
out. That ratio is the lesson: exploration is wide and private, a report
is narrow and public, and most of the charts you make should not survive
into the second one.
Nine numbered exercises, all headless via the Agg backend, all writing
into temporary directories that are deleted when the test finishes.
Learning objectives
By the end of this lab you will be able to:
- Enforce, in code, that every figure in a report answers a stated question, and explain why a figure whose question you cannot state does not belong in the report.
- Distinguish a descriptive caption from a caption that carries a claim, implement the distinction as a check, and state honestly what such a check can and cannot detect.
- Generate a report whose prose numbers are interpolated from computed values, and prove that changing one input value changes the sentence.
- Detect an orphan figure — an image on disk the text never refers to.
- Require that every reported estimate carries a 95% interval or an explicit note saying why none is available, and catch a bare point estimate.
- Prove that two runs over the same input produce byte-identical Markdown, and say precisely what that guarantee does and does not cover for the figure files.
- Assert the reader's ordering: conclusion first, evidence beneath it, caveats and provenance where they can be found.
- Apply a "so what" filter to a set of candidate figures, measure the survival rate, and keep the discarded ones as null results.
- Turn a colourblind-safe palette and labelled axes into a build check that fails a red-against-green chart.
Prerequisites
- Day 126 — the reproducible cleaning pipeline, whose idempotence this lab applies to prose rather than to data.
- Day 127 — chart choice and the perceptual ranking.
- Day 128 — matplotlib's Figure/Axes/Artist object model. The accessibility check here reads colours straight off the artists.
- Days 117 and 118 — sampling, standard error and intervals. Every interval in this lab is a percentile bootstrap.
- Day 132 — chart honesty, which this lab turns into a check that runs.
- Comfort with
pytest, and a workingpython3on your PATH.
Supported operating systems
- macOS (Intel or Apple Silicon) — the machine this lab was written and run on: macOS 26.5.2, arm64.
- Linux — any distribution with Python 3.11 or newer. Every command below is identical.
- Windows — use WSL2 and follow the Linux path. Native PowerShell
works too if you substitute
.venv\Scripts\python.exefor.venv/bin/python3and run the harness under Git Bash; the harness is a bash script and will not run incmd.exe.
Hardware requirements
Nothing special. The dataset is 192 rows and every figure is 600 by 350 pixels. The full harness runs in a few seconds on a laptop and needs well under 500 MB of memory. No GPU, no display, no network after install.
Required software
- Python 3.11 or newer (3.14.0 here).
- The pins in
requirements/requirements.txt: matplotlib 3.11.1, seaborn 0.13.2, pandas 3.0.5, NumPy 2.5.2, pytest 9.1.1. bashfor the test harness (3.2 or newer; macOS's system bash is fine).
Everything else the lab uses — hashlib, re, shutil, tempfile,
dataclasses, pathlib — is standard library.
Free and open-source options
Every tool in this lab is free and open source, and there is no paid tier anywhere in it. matplotlib and NumPy are BSD-licensed, seaborn and pandas BSD-3-Clause, pytest MIT. Python itself is under the PSF licence.
The report generator writes plain Markdown on purpose. Markdown renders in every code host, every static-site generator, every wiki and every text editor, and it diffs line by line in Git, so a report that changed its conclusion shows you which sentence changed. The commercial and semi-commercial alternatives in this space — hosted notebook services, BI dashboards with a report export — are discussed in the lesson; none of them is needed here, and none is used.
Installation
cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import matplotlib, pandas; print(matplotlib.__version__, pandas.__version__)"
That last line should print 3.11.1 3.0.5. The pip install is the only
step that touches the network.
File structure
day-133-building-an-eda-report/
├── README.md this file
├── metadata.yml lab metadata and the literal result of the real run
├── security.md what this lab does to your machine
├── troubleshooting.md the failures you are most likely to hit
├── requirements/
│ ├── README.md why the pins are exact
│ └── requirements.txt matplotlib, seaborn, pandas, NumPy, pytest
├── starter/ your work
│ ├── 00_brief.md the nine exercises, explained
│ ├── conftest.py Agg backend, fixtures, temporary directories
│ ├── data.py the dataset, deterministic under default_rng(133)
│ ├── report.py the report generator and its six checks
│ ├── analysis.py twelve candidate figures; five survive
│ └── test_report.py nine exercises, each currently a pytest.skip
├── examples/ the reference answers — read after you try
│ ├── conftest.py identical to starter/conftest.py
│ ├── data.py identical to starter/data.py
│ ├── report.py identical to starter/report.py
│ ├── analysis.py identical to starter/analysis.py
│ └── test_report.py the nine exercises, solved
├── tests/
│ └── run_tests.sh the bash harness: 42 checks
└── expected-output/
├── FIELDS.md what is exact, what may differ, and why
├── report-sample.md the Markdown the generator really produced
├── examples-run.txt pytest examples -q
├── starter-run.txt pytest starter -q
└── test-run.txt bash tests/run_tests.sh
How to run
Read starter/00_brief.md first, then starter/report.py. Then:
## your work, from the lab directory
.venv/bin/pytest starter -v
## the reference answers, once you have tried
.venv/bin/pytest examples -q
## everything, including the proof that the suite can fail
bash tests/run_tests.sh
Run pytest starter and pytest examples as two separate commands.
Both directories contain a module named test_report.py, and pytest
collects test modules by dotted name; a single pytest examples starter
aborts collection with an import file mismatch. Section 5 of the
harness runs that combined form on purpose and asserts it fails, so the
warning in this README is checked rather than merely stated.
What the commands do
| Command | What happens |
|---|---|
.venv/bin/pytest starter -v |
Runs your nine exercises. On an untouched checkout all nine skip, and each skip message tells you what to assert |
.venv/bin/pytest examples -q |
Runs the reference answers. Should print 9 passed |
bash tests/run_tests.sh |
The full harness: version pins, the generator's own rules driven directly from Python, both suites, the collision check, the fail-then-restore proof, and the cleanliness checks |
.venv/bin/python -c "import data, analysis; ..." |
Renders the report yourself — see "Extension exercises" |
Section 2 of the harness is the interesting one. It does not read source code: it builds reports, renders them into temporary directories, and reads the documents back to check that the generator really refuses what it claims to refuse.
Expected output
The final section of a green run:
7. Offline, and nothing left behind
ok: no URLs inside examples/ or starter/
ok: no image files anywhere inside the lab
ok: no generated report.md left inside the lab
ok: no __pycache__ or .pytest_cache left behind
ok: no d133 temporary directory left in the system temp directory
-------------------------------------------------------------
42 checks, 0 failure(s)
The full capture is in expected-output/test-run.txt, and the Markdown
the generator actually produced is in expected-output/report-sample.md.
expected-output/FIELDS.md records which captured numbers are exact
everywhere and which are specific to this machine — notably that the
byte-identity of the figure PNGs is asserted only across two runs on one
machine, never across machines.
Validation steps
bash tests/run_tests.shprints42 checks, 0 failure(s)and exits 0 (echo $?immediately after, with no pipe in between — a pipeline reports the last command's status and will hide a real failure)..venv/bin/pytest examples -qprints9 passed..venv/bin/pytest starter -qprints9 skippedbefore you start, and9 passedwhen you are done.- Open
expected-output/report-sample.mdand read it as a reader would. Every claim in the conclusion is a caption from a figure below it. - Render the report yourself and confirm that deleting one figure's
image and re-checking makes
orphan_figurescomplain.
Tests
tests/run_tests.sh is a bash assert harness. It prints N checks, M failure(s), exits 0 only when M is zero, and covers:
- the installed versions against
requirements/requirements.txt; - the generator's own rules, driven directly: it refuses a question-less figure and a label caption, keeps 5 of 12 candidates, renders byte-identical Markdown, moves its numbers when the input moves, detects an orphan, catches a bare point estimate, orders the sections for the reader, and fails a red-against-green chart with four named problems;
examples/passing in full;starter/skipping in full on an untouched checkout;- the
pytest examples startercollision, run and asserted; - the proof that the suite can fail — the harness copies the solved
suite into a scratch directory, confirms 9 passed, rewrites
assert len(kept) == 5to== 99, confirms a non-zero exit and a printed failure, restores the file, and confirms 9 passed again; - no URLs in the exercise code, and no image, no generated
report.md, no__pycache__and no stray temporary directory left behind.
Cleanup
The harness cleans up after itself, before and after every run. To reset completely:
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: throws away your work and restores the skips
Nothing else exists to remove. Every report this lab renders goes into a
tempfile.TemporaryDirectory that is deleted when the test that made it
finishes, which is why the harness can assert that no image file exists
anywhere inside the lab.
Troubleshooting
troubleshooting.md covers the failures in detail. The three most common:
pytest: command not found— you have not created the.venv, or you are calling barepytestinstead of.venv/bin/pytest. The harness also acceptsPYTEST=/path/to/pytest bash tests/run_tests.sh.import file mismatch— you ranpytest examples starterin one command. Run them as two.- A window tries to open, or matplotlib complains about a display —
something imported
pyplotbeforematplotlib.use("Agg")ran. Theconftest.pyin each directory sets the backend first; if you added an import above it, move it back down.
Security notes
security.md has the full account. In short: one network connection ever
(the pip install), no port bound, no sudo, no credential, no API key,
nothing written outside this lab's own directory and the temporary
directories it deletes. The dataset is synthetic and generated in
data.py — no real person's data is anywhere in this lab.
Extension exercises
- Render the report and read it. From
examples/:python -c "import data, analysis; f=data.monthly_sales(); print(analysis.build_report(f).render('out', f))". Openout/report.md. Then deleteout/— the lab's own runs never leave it behind, and neither should yours. - Break the claim rule. Change one caption in
analysis.pyto a bare label and watchbuild_reportraise before a single figure is drawn. - Sharpen the claim heuristic. Add "tripled" and "trebled" to
CLAIM_WORDS, then find another real claim it still refuses. You will not run out. That is the honest ceiling on this kind of check. - Promote a discarded candidate. Give one of the seven a question, a
drawand ananalyse, and watch the survival rate move from 5/12 to 6/12 — and the null-results section shrink by exactly one line. - Add a check of your own. For example: no figure may be referenced before the conclusion mentions its number, or no caption may exceed 200 characters. Write the check, then write the test that proves it fails.
- Point the generator at your own data.
build_reporttakes a DataFrame. The candidates are yours to rewrite; the six checks are not meant to change.
Navigation
- Previous day: Day 132 — Visual Storytelling and Chart Honesty
(
labs/sections/math-statistics-and-data/day-132-visual-storytelling-and-chart-honesty/). - Next day: Day 134, opening Week 20
(
labs/sections/math-statistics-and-data/). - Week 19 project: the week's project directory
(
labs/sections/math-statistics-and-data/projects/week-19/), where you produce a narrated exploratory report on a dataset of your own. This lab is the method and the machinery; the project is your analysis.
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
matplotlib 3.11.1, seaborn 0.13.2, pandas 3.0.5, NumPy 2.5.2, pytest
9.1.1, Python 3.14.0, macOS (arm64). scipy is not installed on this
machine, which is why every interval in this lab is a hand-written
percentile bootstrap rather than a call into `scipy.stats`.
Four files sit here:
| File | What it is |
| --- | --- |
| `test-run.txt` | The full `bash tests/run_tests.sh` output |
| `examples-run.txt` | `pytest examples -q` |
| `starter-run.txt` | `pytest starter -q` on an untouched checkout |
| `report-sample.md` | The Markdown the generator actually produced, captured verbatim |
`report-sample.md` links to `figures/*.png` that are **not** in this
directory. That is deliberate: the harness asserts no image file exists
anywhere inside the lab, because the lab renders into a temporary
directory and deletes it. Render it yourself to see the figures.
## Exact everywhere, on any correct install
- `test-run.txt` ends with `42 checks, 0 failure(s)` and exit 0.
`examples-run.txt` ends with `9 passed`; `starter-run.txt` ends with
`9 skipped`. All three counts are structural — 9 test functions per
file, 42 `check` calls in the harness — and do not depend on the
machine.
- **12 candidate figures, 5 survivors, 7 discarded.** These are
hand-authored in `analysis.candidate_figures()`, not sampled. The
survival rate is 41.7%.
- **8 of 192 rows (4.2%) have no revenue, and 100% of them are partner
rows.** `MISSING_RATE * 192` rounds to 8, and `data.monthly_sales`
draws the blanked rows only from partner positions, so the 100% is by
construction and exact.
- The partner channel's median region-month is **45%** of the direct
median. `PARTNER_SHARE` is 0.45 and the multiplicative noise is
symmetric around 1.0, so this holds to the printed precision.
- `East month 18 as a multiple of the region median` carries **no
interval**, and the report says why: one observation has no sampling
interval. That branch of `Estimate` is exercised by the real report,
not only by a test.
- The accessibility check reports **exactly four** problems on
`analysis.draw_inaccessible`: an unlabelled x axis, an unlabelled y
axis, `#ff0000` and `#008000`. Both colours are literal matplotlib
named colours, so the hex values are fixed.
## Deterministic given the seed, and exact on this NumPy
Everything below comes from `numpy.random.default_rng(133)` in
`data.monthly_sales`, or from a bootstrap seeded at 133. `default_rng`'s
bit generator is part of NumPy's stable public API, so these should
reproduce identically on any NumPy 2.x; only the values on NumPy 2.5.2
were directly verified here.
| Quantity | Captured value |
| --- | --- |
| Data fingerprint (sha256, first 12) | `2ba806a5cbf5` |
| Share of rows with missing revenue | 4.2% (95% interval 1.6% to 7.3%) |
| Median partner region-month revenue | 16468 USD (95% interval 15826 to 17001) |
| Fitted slope, revenue per extra order | 174 USD |
| R-squared of the straight-line fit | 97.4% |
| Mean revenue per order | 180.1 USD (95% interval 178.4 to 181.8) |
| Region change across month 13 | North +12.5%, South +1.7%, East +47.5%, West -8.6% |
| East change with month 18 removed | +8.0% |
| West change, 95% bootstrap interval | -11.6% to -5.1% |
| West change on the perturbed frame | -54.3% |
| East month 18, as a multiple of the region median | 3.3x (neighbouring months 1.05x) |
## Measured on this machine only
- **Figure PNG bytes are byte-identical across two runs.** Exercise 6 and
harness check `figure_bytes_identical_same_machine` both assert this,
and both compare **two runs on the same machine within one test
session** — which is where the guarantee actually holds. PNG bytes from
matplotlib depend on the font files installed and on the FreeType build
that rasterises them, so two different machines can legitimately produce
different bytes from identical code. Nothing here promises cross-machine
byte-identity for images, and you should not build a pipeline that
depends on it.
- **Markdown byte-identity is the stronger claim**, and it is the one the
lesson leans on. The rendered Markdown contains no clock reading, no
hostname and no unseeded random number — provenance is a hash of the
input data, not a note about the run — so two runs over the same input
produce the same bytes. Harness check
`no_clock_reading_in_output` asserts the absence directly.
## Honesty notes from this run
**One. The claim heuristic is crude in both directions, and the lab
asserts both.** `report.carries_claim` passes
`"revenue doubled in every region"` on data where revenue halved, because
it cannot read the data. It also *refuses*
`"revenue tripled in all four regions"`, which is a perfectly good claim
written with a word that is not on `CLAIM_WORDS`. The first limit was
expected; the second was found by writing the test and watching it fail,
and it is kept rather than patched away by adding "tripled" to the list,
because the false negative is the more instructive half. What the check
actually buys is narrow and worth having: it makes the **absence** of a
claim impossible to ship by accident. It is not, and cannot be, a judge
of whether a claim is true.
**Two. Six observations per side is a thin bootstrap, and the report says
so.** The West's -8.6% is a comparison of two six-month windows, and its
interval (-11.6% to -5.1%) is correspondingly wide. That width is
reported in the document and named in the report's own caveats rather
than being quietly dropped, which is the whole point of exercise 5.
**Three. The step change is real in this data because it was put there.**
`data.py` scales the West by `WEST_PRICING_FACTOR` from month 13. The
report states plainly in its caveats that this is observational data and
that an association in time is not a causal claim — the generated
document argues honestly about data whose truth we happen to know, which
is the only way to check that the argument is honest.
**Four. The East's +47.5% is one month.** The generated report quotes the
figure and then immediately gives the number with the single month-18
observation removed (+8.0%), computed from the same frame. Quoting the
inflated figure alone would have been exactly the Day 132 failure this
week is about, so `analyse_segments` computes both.
**Five. pandas' Styler could not be run here, and the brief was wrong
about that.** The day brief this lab was written from said pandas Styler
was installed and could be used. Measured directly on this machine:
```
>>> df.style
AttributeError: The '.style' accessor requires jinja2
```
pandas 3.0.5 is installed, but `.style` is an optional accessor that
imports jinja2 to render its templates, and jinja2 is not in this
environment. So the lesson describes Styler from the pandas documentation
and says plainly that no Styler output is reproduced. Nothing in this lab
uses it.
**Six. Nothing else in this lab is attributed to a tool that is not
installed either.** Jupyter, nbconvert, Quarto and scipy are all absent
from this machine — `jupyter` and `quarto` are not on PATH and
`import nbconvert`, `import nbformat`, `import IPython` and
`import scipy` all fail. Every one of them is described in the lesson
from public documentation only, and no output is reproduced from any of
them anywhere in this lab or its lesson.
examples-run.txt
......... [100%]
9 passed in 1.95s
report-sample.md
# Where did the West's revenue go?
**Question.** Four regions sell through two channels. Did any region's revenue trajectory change around the month-13 pricing change, and is the change big enough and clean enough to act on?
**Decision this feeds.** Whether to roll the month-13 pricing change back in the West before it is extended to the other three regions.
## Conclusion
1. 8 of 192 rows (4.2%) have no revenue, and 100% of those gaps are partner rows -- the missingness is a channel problem, not random loss (Figure 1)
2. Revenue is two populations rather than one: the median partner region-month is 45% of the median direct region-month, so any average taken across both channels describes a mixture nobody sells into (Figure 2)
3. Revenue rises about 174 USD per additional order and the straight-line fit accounts for 97.4% of the variation, so a region-month that missed its revenue missed its order count (Figure 3)
4. 3 regions grew across the pricing change while the West fell 8.6%, and the break lands in month 13 in that region only (Figure 4)
5. East month 18 is 3.3 times the region's median month while the months either side sit at 1.05 times it, so this is one observation and not a level change (Figure 5)
- share of rows with missing revenue: 4.2% (95% interval 1.6% to 7.3%)
- median partner region-month revenue: 16468 USD (95% interval 15826 USD to 17001 USD)
- mean revenue per order: 180.1 USD (95% interval 178.4 USD to 181.8 USD)
- West change across the pricing change (six months either side): -8.6% (95% interval -11.6% to -5.1%)
- East month 18 as a multiple of the region median: 3.3x (no interval: a single observation has no sampling interval; one point is one point)
## What we looked at and found nothing in
- A cumulative revenue curve was drawn and discarded: a cumulative series rises whatever the underlying months do, so it answered no question the monthly series had not already answered
- The order-count distribution was checked for a second population; it shows the same channel split the revenue column already shows, so it adds no evidence of its own
- Region-by-channel interaction was checked; the partner channel runs at the same share of direct in all four regions, so there is no interaction to report
- Revenue per order was compared across the four regions; they are indistinguishable on this measure, which is worth one line here so the next reader does not spend an afternoon on it
- Calendar seasonality was checked by lining the two years up month against month; nothing stood out above the month-to-month noise
- The missing revenue rows were checked for a time pattern as well as a channel pattern; they are scattered across the two years rather than clustered in any single month
- A region-by-month heatmap was drawn and discarded: it held the same information as the trend lines while making the month-13 break harder to see, which is the wrong trade for a report
## Evidence
### Figure 1 — Which rows have no revenue, and is the missingness concentrated anywhere?

**Figure 1.** 8 of 192 rows (4.2%) have no revenue, and 100% of those gaps are partner rows -- the missingness is a channel problem, not random loss
Every other column is complete. Because the gaps sit entirely in one channel, any figure that pools the two channels and drops missing rows silently under-counts partner activity, so the rest of this report uses direct-channel rows wherever a level is being compared.
*share of rows with missing revenue: 4.2% (95% interval 1.6% to 7.3%)*
### Figure 2 — Is monthly revenue one population, or several stacked on top of each other?

**Figure 2.** Revenue is two populations rather than one: the median partner region-month is 45% of the median direct region-month, so any average taken across both channels describes a mixture nobody sells into
The two histograms barely overlap. A single mean over this column would land in the empty gap between them and describe no real region-month at all -- Day 116's warning about a summary that discards the thing you needed, met again in a column you would have been tempted to average.
*median partner region-month revenue: 16468 USD (95% interval 15826 USD to 17001 USD)*
### Figure 3 — How tightly do orders and revenue move together, and what is one extra order worth?

**Figure 3.** Revenue rises about 174 USD per additional order and the straight-line fit accounts for 97.4% of the variation, so a region-month that missed its revenue missed its order count
There is no second cluster off the line and no curvature worth naming. That is a boring finding, and it is in the report precisely because it closes a question the reader would otherwise have to ask: revenue here is not being moved by price. The fitted slope (174 USD) sits a little below the mean revenue per order (180 USD) because the fit carries a non-zero intercept of 778 USD; the two numbers answer slightly different questions and the report says which is which rather than quoting whichever is larger.
*mean revenue per order: 180.1 USD (95% interval 178.4 USD to 181.8 USD)*
### Figure 4 — Did any region's trajectory change at the month-13 pricing change?

**Figure 4.** 3 regions grew across the pricing change while the West fell 8.6%, and the break lands in month 13 in that region only
Comparing the six months before month 13 with the six months after, the four regions move North +12.5%, South +1.7%, East +47.5%, West -8.6%. The West is the only one that changes direction, and it changes it at the month the price moved. This is an association in observational data, not a controlled comparison: nothing here rules out a third cause that happened to the West in the same month. East's +47.5% is not what it looks like either: drop the single month-18 observation and it falls to +8.0%, which is why Figure 5 exists.
*West change across the pricing change (six months either side): -8.6% (95% interval -11.6% to -5.1%)*
### Figure 5 — Is the East's month-18 jump a level change or a single outlier?

**Figure 5.** East month 18 is 3.3 times the region's median month while the months either side sit at 1.05 times it, so this is one observation and not a level change
The distinction matters for what you do next. A level change is a fact about the business and belongs in the forecast; a single spike is a fact about one month and belongs with whoever can explain it. Until someone does, the honest move is to report both the figure including it and the figure excluding it, and to say which one the decision was made on.
*East month 18 as a multiple of the region median: 3.3x (no interval: a single observation has no sampling interval; one point is one point)*
## Caveats
- This is observational data, not an experiment. The month-13 break is an association in time; it is not proof that the pricing change caused it.
- The regional comparison uses six months either side of the change. Six observations per side is a small window, and the interval on that number is correspondingly wide -- read the interval, not the point estimate.
- Level comparisons use direct-channel rows only, because the partner channel is the one with missing revenue. Partner totals in this report are therefore lower bounds.
- Every figure here uses a colourblind-safe palette and labelled axes, and no axis in this report is truncated below zero.
## Provenance
- Source: synthetic monthly sales, 4 regions x 2 channels x 24 months, generated by data.monthly_sales() with numpy default_rng(133)
- Shape: 192 rows, 5 columns
- Data fingerprint (sha256, first 12): `2ba806a5cbf5`
- This document was generated by code from the input above. Nothing in it was typed by hand, so re-running it on new data cannot leave the prose disagreeing with the figures.
starter-run.txt
sssssssss [100%]
9 skipped in 0.01s
test-run.txt
Day 133 — A Report That Argues
1. The tools and the versions this lab was written against
python 3.14.0
matplotlib 3.11.1
seaborn 0.13.2
pandas 3.0.5
numpy 2.5.2
pytest 9.1.1
ok: installed packages match requirements.txt exactly
2. The generator's own rules, exercised directly
refuses_questionless=yes
questionless_left_no_panel=yes
accepts_with_question=yes
rejects_label_caption=yes
accepts_claim_caption=yes
candidates_total=12
candidates_kept=5
candidates_dropped=7
markdown_byte_identical=yes
figure_bytes_identical_same_machine=yes
changed_input_changes_document=yes
no_clock_reading_in_output=yes
west_change_original=-8.6
west_change_perturbed=-54.3
sections_in_reader_order=yes
conclusion_before_first_figure=yes
figures_written=5
no_orphans_in_report=yes
orphan_is_detected=yes
dropped_slugs_absent_from_report=yes
null_results_kept_as_one_line_each=yes
every_estimate_has_uncertainty=yes
estimates_with_interval=4
estimates_with_explicit_note=1
bare_point_estimate_is_caught=yes
every_figure_passes_accessibility=yes
inaccessible_chart_problem_count=4
inaccessible_chart_names_both_colours=yes
ok: the generator ran without error
ok: refuses a figure with no stated question
ok: a refused figure leaves no half-added panel
ok: accepts the identical figure once it has a question
ok: rejects the caption 'revenue by region' as a label
ok: accepts a caption carrying a number
ok: 12 candidate figures go in
ok: 5 survive the 'so what' filter and 7 do not
ok: two runs over the same input produce byte-identical Markdown
ok: figure PNG bytes match across two runs on this machine
ok: changing one input value changes the document
ok: nothing in the output is a clock reading
ok: the West figure in the prose moved with the data
ok: conclusion, omissions, evidence, caveats, provenance are in reader order
ok: the conclusion is rendered before Figure 1
ok: five figures written, none of them orphaned
ok: a deliberately orphaned figure is detected
ok: no discarded slug reaches the rendered report
ok: every discarded candidate survives as a one-line null result
ok: every reported estimate carries an interval or an explicit note
ok: four estimates carry an interval and one carries an explicit note
ok: a bare point estimate is caught
ok: every figure passes the accessibility contract
ok: the red-against-green chart fails with four named problems
3. Reference suite -- examples/ must pass in full
......... [100%]
9 passed in 1.98s
ok: examples/ exits 0
ok: examples/ reports 9 passed, 0 failed
4. Exercise suite -- starter/ is all-skip on an untouched checkout
sssssssss [100%]
9 skipped in 0.01s
ok: starter/ (untouched) exits 0
ok: starter/ (untouched) reports 9 skipped, 0 failed
5. Never run 'pytest examples starter' in one invocation -- both
directories define a module named test_report.py, and pytest
collects by dotted module name. Documented, and run as two commands.
ok: 'pytest examples starter' aborts rather than silently passing
ok: the collision is reported as an import file mismatch
6. 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 failure, 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 failure
ok: restored scratch copy exits 0 again
ok: restored scratch copy reports 9 passed again
7. Offline, and nothing left behind
ok: no URLs inside examples/ or starter/
ok: no image files anywhere inside the lab
ok: no generated report.md left inside the lab
ok: no __pycache__ or .pytest_cache left behind
ok: no d133 temporary directory left in the system temp directory
-------------------------------------------------------------
42 checks, 0 failure(s)
Source files
examples/analysis.py (21952 bytes)
"""The twelve candidate figures, and the five that survive the filter.
This module is the *content* of the Day 133 report; `report.py` is the
machinery. Reading them together is the point of the day: exploration
produced twelve candidate figures, seven of them answer no stated
question, and `report.survivors` throws those seven out before they can
reach the reader. Their `dropped_because` lines survive, though, in the
report's "what we looked at and found nothing in" section -- because a
null result you keep to yourself is a null result the next person has to
rediscover.
Every number in every caption is computed from the frame that is passed
in. Nothing is a typed literal. Change the input and the sentences change
with it, which is what exercises 3 and 6 check.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from data import ANOMALY_MONTH, ANOMALY_REGION, PRICING_CHANGE_MONTH, REGIONS, source_description
from report import (
SAFE_PALETTE,
Candidate,
Estimate,
Finding,
Report,
bootstrap_interval,
discarded,
survivors,
)
#: The two six-month windows either side of the pricing change.
BEFORE_WINDOW = (7, 12)
AFTER_WINDOW = (13, 18)
def _direct(frame: pd.DataFrame) -> pd.DataFrame:
"""Direct-channel rows only -- the subset with no missing revenue."""
return frame[frame["channel"] == "direct"]
def _window_mean(frame: pd.DataFrame, region: str, window: tuple[int, int]) -> float:
rows = _direct(frame)
low, high = window
selected = rows[(rows["region"] == region) & rows["month"].between(low, high)]
return float(selected["revenue"].mean())
def _window_values(frame: pd.DataFrame, region: str, window: tuple[int, int]) -> np.ndarray:
rows = _direct(frame)
low, high = window
selected = rows[(rows["region"] == region) & rows["month"].between(low, high)]
return selected["revenue"].to_numpy(dtype=float)
def _ratio_interval(
before: np.ndarray, after: np.ndarray, *, resamples: int = 2000, seed: int = 133
) -> tuple[float, float]:
"""Percentile bootstrap for a percentage change between two small windows.
Six observations on each side is not much, and the interval this returns
is correspondingly wide. That width is information, not an embarrassment:
it is the report telling the reader how hard the number should be leaned on.
"""
rng = np.random.default_rng(seed)
draws = []
for _ in range(resamples):
b = rng.choice(before, size=before.size, replace=True).mean()
a = rng.choice(after, size=after.size, replace=True).mean()
draws.append(100.0 * (a / b - 1.0))
values = np.asarray(draws)
return float(np.quantile(values, 0.025)), float(np.quantile(values, 0.975))
# ---------------------------------------------------------------------------
# Figure 1 -- data quality
# ---------------------------------------------------------------------------
def draw_missing_by_column(ax: Axes, frame: pd.DataFrame) -> None:
counts = frame.isna().sum()
ax.bar(list(counts.index), list(counts.to_numpy()), color=SAFE_PALETTE[0])
ax.set_xlabel("column")
ax.set_ylabel("rows with no value")
ax.set_title("Missing values by column")
def analyse_missing(frame: pd.DataFrame) -> Finding:
missing = frame["revenue"].isna()
n_missing = int(missing.sum())
pct = 100.0 * n_missing / len(frame)
partner_share = 100.0 * float((frame.loc[missing, "channel"] == "partner").mean())
indicator = missing.to_numpy(dtype=float)
low, high = bootstrap_interval(indicator, statistic=lambda a: 100.0 * a.mean())
return Finding(
caption=(
f"{n_missing} of {len(frame)} rows ({pct:.1f}%) have no revenue, and "
f"{partner_share:.0f}% of those gaps are partner rows -- the missingness "
"is a channel problem, not random loss"
),
prose=(
"Every other column is complete. Because the gaps sit entirely in one "
"channel, any figure that pools the two channels and drops missing rows "
"silently under-counts partner activity, so the rest of this report uses "
"direct-channel rows wherever a level is being compared."
),
estimate=Estimate(
label="share of rows with missing revenue",
value=pct,
unit="%",
low=low,
high=high,
),
)
# ---------------------------------------------------------------------------
# Figure 2 -- univariate
# ---------------------------------------------------------------------------
def draw_channel_populations(ax: Axes, frame: pd.DataFrame) -> None:
direct = frame.loc[frame["channel"] == "direct", "revenue"].dropna()
partner = frame.loc[frame["channel"] == "partner", "revenue"].dropna()
bins = np.histogram_bin_edges(frame["revenue"].dropna(), bins="fd")
ax.hist(direct, bins=bins, color=SAFE_PALETTE[0], alpha=0.75, label="direct")
ax.hist(partner, bins=bins, color=SAFE_PALETTE[1], alpha=0.75, label="partner")
ax.set_xlabel("monthly revenue for one region and channel")
ax.set_ylabel("number of region-months")
ax.set_title("Revenue is a mixture of two channels")
ax.legend()
def analyse_populations(frame: pd.DataFrame) -> Finding:
direct = frame.loc[frame["channel"] == "direct", "revenue"].dropna().to_numpy(float)
partner = frame.loc[frame["channel"] == "partner", "revenue"].dropna().to_numpy(float)
ratio = 100.0 * float(np.median(partner)) / float(np.median(direct))
low, high = bootstrap_interval(partner, statistic=np.median)
return Finding(
caption=(
f"Revenue is two populations rather than one: the median partner "
f"region-month is {ratio:.0f}% of the median direct region-month, so any "
"average taken across both channels describes a mixture nobody sells into"
),
prose=(
"The two histograms barely overlap. A single mean over this column would "
"land in the empty gap between them and describe no real region-month at "
"all -- Day 116's warning about a summary that discards the thing you "
"needed, met again in a column you would have been tempted to average."
),
estimate=Estimate(
label="median partner region-month revenue",
value=float(np.median(partner)),
unit=" USD",
low=low,
high=high,
decimals=0,
),
)
# ---------------------------------------------------------------------------
# Figure 3 -- relationship
# ---------------------------------------------------------------------------
def draw_orders_vs_revenue(ax: Axes, frame: pd.DataFrame) -> None:
rows = frame.dropna(subset=["revenue"])
x = rows["orders"].to_numpy(dtype=float)
y = rows["revenue"].to_numpy(dtype=float)
slope, intercept = np.polyfit(x, y, 1)
grid = np.linspace(x.min(), x.max(), 50)
ax.scatter(x, y, s=12, color=SAFE_PALETTE[0], label="region-month")
ax.plot(grid, slope * grid + intercept, color=SAFE_PALETTE[3], label="least-squares fit")
ax.set_xlabel("orders in the month")
ax.set_ylabel("revenue in the month (USD)")
ax.set_title("Orders and revenue")
ax.legend()
def analyse_relationship(frame: pd.DataFrame) -> Finding:
rows = frame.dropna(subset=["revenue"])
x = rows["orders"].to_numpy(dtype=float)
y = rows["revenue"].to_numpy(dtype=float)
slope, intercept = np.polyfit(x, y, 1)
predicted = slope * x + intercept
r_squared = 1.0 - float(np.sum((y - predicted) ** 2) / np.sum((y - y.mean()) ** 2))
per_order = y / x
low, high = bootstrap_interval(per_order)
return Finding(
caption=(
f"Revenue rises about {slope:.0f} USD per additional order and the "
f"straight-line fit accounts for {100.0 * r_squared:.1f}% of the variation, "
"so a region-month that missed its revenue missed its order count"
),
prose=(
"There is no second cluster off the line and no curvature worth naming. "
"That is a boring finding, and it is in the report precisely because it "
"closes a question the reader would otherwise have to ask: revenue here "
"is not being moved by price. The fitted slope "
f"({slope:.0f} USD) sits a little below the mean revenue per order "
f"({per_order.mean():.0f} USD) because the fit carries a non-zero "
f"intercept of {intercept:,.0f} USD; the two numbers answer slightly "
"different questions and the report says which is which rather than "
"quoting whichever is larger."
),
estimate=Estimate(
label="mean revenue per order",
value=float(per_order.mean()),
unit=" USD",
low=low,
high=high,
),
)
# ---------------------------------------------------------------------------
# Figure 4 -- segments
# ---------------------------------------------------------------------------
def draw_region_trend(ax: Axes, frame: pd.DataFrame) -> None:
rows = _direct(frame)
for index, region in enumerate(REGIONS):
series = rows[rows["region"] == region].sort_values("month")
ax.plot(
series["month"].to_numpy(),
series["revenue"].to_numpy(),
color=SAFE_PALETTE[index],
label=region,
)
ax.axvline(
PRICING_CHANGE_MONTH - 0.5,
color=SAFE_PALETTE[7],
linestyle="--",
label=f"pricing change (month {PRICING_CHANGE_MONTH})",
)
ax.set_xlabel("month")
ax.set_ylabel("direct-channel revenue (USD)")
ax.set_title("Direct revenue by region")
ax.legend(fontsize=7, ncols=2)
def analyse_segments(frame: pd.DataFrame) -> Finding:
changes: dict[str, float] = {}
for region in REGIONS:
before = _window_mean(frame, region, BEFORE_WINDOW)
after = _window_mean(frame, region, AFTER_WINDOW)
changes[region] = 100.0 * (after / before - 1.0)
west = changes["West"]
grew = [region for region, change in changes.items() if change > 0]
low, high = _ratio_interval(
_window_values(frame, "West", BEFORE_WINDOW),
_window_values(frame, "West", AFTER_WINDOW),
)
# The East's after-window contains the single month-18 spike. Recompute it
# without that one observation, because quoting the inflated figure without
# saying so would be exactly the failure Day 132 named.
clean_after = _direct(frame)
clean_after = clean_after[
(clean_after["region"] == ANOMALY_REGION)
& clean_after["month"].between(*AFTER_WINDOW)
& (clean_after["month"] != ANOMALY_MONTH)
]
east_without_anomaly = 100.0 * (
float(clean_after["revenue"].mean()) / _window_mean(frame, ANOMALY_REGION, BEFORE_WINDOW)
- 1.0
)
direction = "fell" if west < 0 else "rose"
return Finding(
caption=(
f"{len(grew)} regions grew across the pricing change while the West "
f"{direction} {abs(west):.1f}%, and the break lands in month "
f"{PRICING_CHANGE_MONTH} in that region only"
),
prose=(
"Comparing the six months before month "
f"{PRICING_CHANGE_MONTH} with the six months after, the four regions move "
+ ", ".join(f"{region} {change:+.1f}%" for region, change in changes.items())
+ ". The West is the only one that changes direction, and it changes it at "
"the month the price moved. This is an association in observational data, "
"not a controlled comparison: nothing here rules out a third cause that "
f"happened to the West in the same month. {ANOMALY_REGION}'s "
f"{changes[ANOMALY_REGION]:+.1f}% is not what it looks like either: drop the "
f"single month-{ANOMALY_MONTH} observation and it falls to "
f"{east_without_anomaly:+.1f}%, which is why Figure 5 exists."
),
estimate=Estimate(
label="West change across the pricing change (six months either side)",
value=west,
unit="%",
low=low,
high=high,
),
)
# ---------------------------------------------------------------------------
# Figure 5 -- anomaly
# ---------------------------------------------------------------------------
def draw_east_anomaly(ax: Axes, frame: pd.DataFrame) -> None:
rows = _direct(frame)
series = rows[rows["region"] == ANOMALY_REGION].sort_values("month")
months = series["month"].to_numpy()
colours = [
SAFE_PALETTE[3] if month == ANOMALY_MONTH else SAFE_PALETTE[0] for month in months
]
ax.bar(months, series["revenue"].to_numpy(), color=colours)
ax.set_xlabel("month")
ax.set_ylabel("direct-channel revenue (USD)")
ax.set_title(f"{ANOMALY_REGION}: one month is not like the others")
def analyse_anomaly(frame: pd.DataFrame) -> Finding:
rows = _direct(frame)
series = rows[rows["region"] == ANOMALY_REGION]
spike = float(series.loc[series["month"] == ANOMALY_MONTH, "revenue"].iloc[0])
others = series.loc[series["month"] != ANOMALY_MONTH, "revenue"].to_numpy(float)
multiple = spike / float(np.median(others))
neighbours = series[series["month"].isin([ANOMALY_MONTH - 1, ANOMALY_MONTH + 1])]
neighbour_multiple = float(neighbours["revenue"].mean()) / float(np.median(others))
return Finding(
caption=(
f"{ANOMALY_REGION} month {ANOMALY_MONTH} is {multiple:.1f} times the "
f"region's median month while the months either side sit at "
f"{neighbour_multiple:.2f} times it, so this is one observation and not a "
"level change"
),
prose=(
"The distinction matters for what you do next. A level change is a fact "
"about the business and belongs in the forecast; a single spike is a fact "
"about one month and belongs with whoever can explain it. Until someone "
"does, the honest move is to report both the figure including it and the "
"figure excluding it, and to say which one the decision was made on."
),
estimate=Estimate(
label=f"{ANOMALY_REGION} month {ANOMALY_MONTH} as a multiple of the region median",
value=multiple,
unit="x",
no_interval_note=(
"a single observation has no sampling interval; one point is one point"
),
),
)
# ---------------------------------------------------------------------------
# The twelve candidates
# ---------------------------------------------------------------------------
def candidate_figures() -> list[Candidate]:
"""Everything exploration produced, in the order it was produced.
Five carry a question. Seven do not, and `report.survivors` drops them.
"""
return [
Candidate(
slug="missing-revenue",
question="Which rows have no revenue, and is the missingness concentrated anywhere?",
draw=draw_missing_by_column,
analyse=analyse_missing,
),
Candidate(
slug="cumulative-revenue",
dropped_because=(
"A cumulative revenue curve was drawn and discarded: a cumulative "
"series rises whatever the underlying months do, so it answered no "
"question the monthly series had not already answered"
),
),
Candidate(
slug="channel-populations",
question="Is monthly revenue one population, or several stacked on top of each other?",
draw=draw_channel_populations,
analyse=analyse_populations,
),
Candidate(
slug="orders-histogram",
dropped_because=(
"The order-count distribution was checked for a second population; it "
"shows the same channel split the revenue column already shows, so it "
"adds no evidence of its own"
),
),
Candidate(
slug="orders-vs-revenue",
question="How tightly do orders and revenue move together, and what is one extra order worth?",
draw=draw_orders_vs_revenue,
analyse=analyse_relationship,
),
Candidate(
slug="region-channel-interaction",
dropped_because=(
"Region-by-channel interaction was checked; the partner channel runs "
"at the same share of direct in all four regions, so there is no "
"interaction to report"
),
),
Candidate(
slug="region-trend",
question="Did any region's trajectory change at the month-13 pricing change?",
draw=draw_region_trend,
analyse=analyse_segments,
),
Candidate(
slug="revenue-per-order-by-region",
dropped_because=(
"Revenue per order was compared across the four regions; they are "
"indistinguishable on this measure, which is worth one line here so "
"the next reader does not spend an afternoon on it"
),
),
Candidate(
slug="east-anomaly",
question="Is the East's month-18 jump a level change or a single outlier?",
draw=draw_east_anomaly,
analyse=analyse_anomaly,
),
Candidate(
slug="calendar-seasonality",
dropped_because=(
"Calendar seasonality was checked by lining the two years up month "
"against month; nothing stood out above the month-to-month noise"
),
),
Candidate(
slug="missing-by-month",
dropped_because=(
"The missing revenue rows were checked for a time pattern as well as "
"a channel pattern; they are scattered across the two years rather "
"than clustered in any single month"
),
),
Candidate(
slug="revenue-heatmap",
dropped_because=(
"A region-by-month heatmap was drawn and discarded: it held the same "
"information as the trend lines while making the month-13 break "
"harder to see, which is the wrong trade for a report"
),
),
]
def build_report(frame: pd.DataFrame) -> Report:
"""Run the whole pipeline: candidates, filter, panels, ready to render."""
candidates = candidate_figures()
report = Report(
title="Where did the West's revenue go?",
question=(
"Four regions sell through two channels. Did any region's revenue "
"trajectory change around the month-13 pricing change, and is the change "
"big enough and clean enough to act on?"
),
decision=(
"Whether to roll the month-13 pricing change back in the West before it "
"is extended to the other three regions."
),
provenance=source_description(),
caveats=[
"This is observational data, not an experiment. The month-13 break is an "
"association in time; it is not proof that the pricing change caused it.",
"The regional comparison uses six months either side of the change. Six "
"observations per side is a small window, and the interval on that number "
"is correspondingly wide -- read the interval, not the point estimate.",
"Level comparisons use direct-channel rows only, because the partner "
"channel is the one with missing revenue. Partner totals in this report "
"are therefore lower bounds.",
"Every figure here uses a colourblind-safe palette and labelled axes, and "
"no axis in this report is truncated below zero.",
],
null_results=[candidate.dropped_because for candidate in discarded(candidates)],
)
for candidate in survivors(candidates):
report.add_panel(candidate, frame)
return report
# ---------------------------------------------------------------------------
# Two deliberately broken specimens, used by the exercises
# ---------------------------------------------------------------------------
def draw_inaccessible(ax: Axes, frame: pd.DataFrame) -> None:
"""A chart that breaks the accessibility contract in three ways at once.
Red against green is the classic pair that vanishes for the commonest
form of colour vision deficiency, and neither axis is labelled. Exercise 9
asserts that the build check catches all of it.
"""
rows = _direct(frame)
for region, colour in zip(REGIONS[:2], ("red", "green")):
series = rows[rows["region"] == region].sort_values("month")
ax.plot(series["month"].to_numpy(), series["revenue"].to_numpy(), color=colour)
def bare_point_estimate_candidate() -> Candidate:
"""A candidate whose finding reports a number with no uncertainty at all."""
def analyse(frame: pd.DataFrame) -> Finding:
total = float(frame["revenue"].sum())
return Finding(
caption=f"Total recorded revenue across the two years is {total:,.0f} USD",
prose="A number with nothing attached to say how firm it is.",
estimate=Estimate(label="total recorded revenue", value=total, unit=" USD", decimals=0),
)
return Candidate(
slug="bare-total",
question="What is the total recorded revenue?",
draw=draw_missing_by_column,
analyse=analyse,
)
examples/conftest.py (1390 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 and unconditionally. Every figure this lab opens is closed by
the code that opened it; `_close_all_figures` is a backstop, not a
substitute.
Every directory fixture is a real temporary directory that is deleted when
the test finishes, so a full run of this lab leaves no image and no
Markdown file behind anywhere on your disk.
"""
import tempfile
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pytest
from analysis import candidate_figures
from data import monthly_sales, perturbed
@pytest.fixture
def frame():
return monthly_sales()
@pytest.fixture
def perturbed_frame():
return perturbed()
@pytest.fixture
def candidates():
return candidate_figures()
def _temporary_directory():
with tempfile.TemporaryDirectory(prefix="d133-report-") as name:
yield Path(name)
@pytest.fixture
def report_dir():
yield from _temporary_directory()
@pytest.fixture
def second_report_dir():
yield from _temporary_directory()
@pytest.fixture
def third_report_dir():
yield from _temporary_directory()
@pytest.fixture(autouse=True)
def _close_all_figures():
yield
plt.close("all")
examples/data.py (4679 bytes)
"""The dataset the Day 133 report is built from, and one perturbed copy.
Everything here is deterministic. `monthly_sales()` draws from a fixed
`numpy.random.default_rng(133)`, so two calls return identical frames and
two runs of the lab produce identical numbers. That determinism is not a
convenience -- exercise 6 asserts byte-identical output across two runs,
and a report generator that cannot be re-run to the same answer is not a
report generator, it is a one-off.
The frame deliberately contains four things a real exploratory analysis
would have to find:
1. **Missing revenue, not spread evenly.** About 4% of rows have no
revenue, and every one of them is a `partner` row. Week 18's damage
report exists to catch exactly this shape.
2. **Two populations in one column.** `partner` rows run at roughly 45%
of the `direct` row for the same region and month, so the revenue
column is a mixture, not a single distribution.
3. **A step change in one segment.** From month 13 the West is scaled by
`WEST_PRICING_FACTOR`, standing in for a pricing change. The other
three regions keep their steady monthly growth.
4. **One anomaly.** East, month 18, is scaled by `ANOMALY_FACTOR`. It is
a single point, which is why the report that quotes it says plainly
that no interval is available for it.
`perturbed()` returns a copy with one input changed. Exercises 3 and 6
use it to prove that the numbers printed in the report's prose come from
the data rather than from typed literals.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
REGIONS: tuple[str, ...] = ("North", "South", "East", "West")
CHANNELS: tuple[str, ...] = ("direct", "partner")
N_MONTHS = 24
SEED = 133
#: From this month onward the West is scaled by WEST_PRICING_FACTOR.
PRICING_CHANGE_MONTH = 13
WEST_PRICING_FACTOR = 0.88
#: The single anomalous observation.
ANOMALY_REGION = "East"
ANOMALY_MONTH = 18
ANOMALY_FACTOR = 3.0
#: Share of rows whose revenue is missing. Every one is a partner row.
MISSING_RATE = 0.04
#: Partner rows run at this share of the same region-month direct row.
PARTNER_SHARE = 0.45
_BASE_LEVEL = {"North": 42000.0, "South": 31000.0, "East": 27000.0, "West": 36000.0}
_MONTHLY_GROWTH = {"North": 1.012, "South": 1.008, "East": 1.015, "West": 1.010}
def monthly_sales() -> pd.DataFrame:
"""Twenty-four months of revenue and orders for four regions and two
channels: 192 rows, eight of them missing their revenue."""
rng = np.random.default_rng(SEED)
records: list[dict[str, object]] = []
for month in range(1, N_MONTHS + 1):
for region in REGIONS:
for channel in CHANNELS:
level = _BASE_LEVEL[region] * _MONTHLY_GROWTH[region] ** (month - 1)
if channel == "partner":
level *= PARTNER_SHARE
if region == "West" and month >= PRICING_CHANGE_MONTH:
level *= WEST_PRICING_FACTOR
if region == ANOMALY_REGION and month == ANOMALY_MONTH:
level *= ANOMALY_FACTOR
revenue = float(level * rng.normal(1.0, 0.05))
order_value = float(rng.normal(180.0, 12.0))
records.append(
{
"month": month,
"region": region,
"channel": channel,
"orders": int(round(revenue / order_value)),
"revenue": revenue,
}
)
frame = pd.DataFrame.from_records(records)
partner_positions = np.asarray(frame.index[frame["channel"] == "partner"])
n_missing = int(round(MISSING_RATE * len(frame)))
blanked = rng.choice(partner_positions, size=n_missing, replace=False)
frame.loc[blanked, "revenue"] = np.nan
return frame
def perturbed() -> pd.DataFrame:
"""The same frame with exactly one input changed: the West's post-change
scaling is halved again.
Nothing else moves. If a number printed in the report's prose is the
same for `monthly_sales()` and for this frame, that number was typed
rather than computed -- which is the whole point of exercise 3.
"""
frame = monthly_sales()
hit = (frame["region"] == "West") & (frame["month"] >= PRICING_CHANGE_MONTH)
frame.loc[hit, "revenue"] = frame.loc[hit, "revenue"] * 0.5
return frame
def source_description() -> str:
"""One line of provenance, quoted verbatim in the rendered report."""
return (
"synthetic monthly sales, 4 regions x 2 channels x 24 months, "
"generated by data.monthly_sales() with numpy default_rng(133)"
)
examples/report.py (17065 bytes)
"""A small EDA report generator that refuses to build a bad report.
This is the course-supplied tool for Day 133. It is deliberately small
enough to read in one sitting, because the point is not the code -- it is
the four rules the code refuses to bend:
* **A figure must have a stated question.** `Report.add_panel` raises
`ReportError` on a candidate whose `question` is missing or blank.
* **A caption must carry a claim.** `carries_claim` demands a number or a
comparative word, so "revenue by region" is rejected and "the West fell
8.4% after the pricing change" is accepted. The heuristic is crude on
purpose: it can tell that a claim was *made*, never whether the claim is
*true*. Judging truth is the reader's job and yours.
* **Every figure is referenced.** `orphan_figures` compares what was
written to disk against what the markdown links to.
* **Every reported estimate carries its uncertainty**, either as an
interval or as an explicit note that no interval is available.
`missing_uncertainty` lists the ones that carry neither.
Two more checks sit alongside those: `check_axes` enforces the
accessibility contract (a colourblind-safe palette and labelled axes), and
`survivors` is the "so what" filter that drops candidate figures with no
question before they ever reach the report.
The renderer writes Markdown with embedded figures. It contains no clock
reading and no random number, so two runs over the same input produce
byte-identical Markdown -- which exercise 6 asserts directly.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Sequence
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.colors import to_hex
__all__ = [
"SAFE_PALETTE",
"ReportError",
"Estimate",
"Finding",
"Candidate",
"Panel",
"Report",
"carries_claim",
"claim_problem",
"survivors",
"orphan_figures",
"missing_uncertainty",
"axes_colours",
"check_axes",
"bootstrap_interval",
]
#: seaborn's "colorblind" palette, as hex. Read off
#: `seaborn.color_palette("colorblind")` on seaborn 0.13.2 and frozen here so
#: the accessibility check has an exact set to compare against.
SAFE_PALETTE: tuple[str, ...] = (
"#0173b2",
"#de8f05",
"#029e73",
"#d55e00",
"#cc78bc",
"#ca9161",
"#fbafe4",
"#949494",
"#ece133",
"#56b4e9",
)
class ReportError(ValueError):
"""Raised when a figure would break one of the report's own rules."""
# ---------------------------------------------------------------------------
# The caption rule: a caption must carry a claim
# ---------------------------------------------------------------------------
#: Words that turn a label into a comparison. Not exhaustive, and not meant
#: to be -- see `carries_claim` for what this check can and cannot do.
CLAIM_WORDS: frozenset[str] = frozenset(
{
"above",
"below",
"beyond",
"concentrated",
"decrease",
"decreased",
"double",
"doubled",
"drop",
"dropped",
"every",
"exceeds",
"fall",
"fell",
"grew",
"growth",
"half",
"halved",
"higher",
"increase",
"increased",
"less",
"lower",
"more",
"no",
"none",
"only",
"outgrew",
"rise",
"rose",
"shrank",
"than",
"times",
"unchanged",
"versus",
"while",
}
)
def claim_problem(caption: str) -> str | None:
"""Return why `caption` fails the claim rule, or None if it passes.
A caption passes if it contains a digit, a percent sign, or one of
`CLAIM_WORDS`. That is a crude test and it is meant to be: it detects
whether a *claim was made*, and it has no way at all to judge whether
the claim is *true*. A caption reading "revenue tripled" passes this
check on data where revenue halved. The check buys you one thing --
it makes the absence of a claim impossible to ship by accident.
"""
text = caption.strip()
if not text:
return "the caption is empty"
if any(character.isdigit() for character in text) or "%" in text:
return None
words = set(re.findall(r"[a-z]+", text.lower()))
if words & CLAIM_WORDS:
return None
return (
"the caption is a label, not a claim: it contains no number and no "
"comparative word, so there is nothing in it a reader could disagree with"
)
def carries_claim(caption: str) -> bool:
"""True when `caption` states something a reader could disagree with."""
return claim_problem(caption) is None
# ---------------------------------------------------------------------------
# Estimates and their uncertainty
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Estimate:
"""One number the report asserts, with its uncertainty attached.
Either give it an interval (`low` and `high`, from Day 118's machinery
or `bootstrap_interval` below) or an explicit `no_interval_note` saying
why one is not available. A bare point estimate carries neither, and
`missing_uncertainty` will find it.
"""
label: str
value: float
unit: str = ""
low: float | None = None
high: float | None = None
no_interval_note: str | None = None
decimals: int = 1
def has_uncertainty(self) -> bool:
interval = self.low is not None and self.high is not None
return interval or bool(self.no_interval_note)
def text(self) -> str:
value = f"{self.value:.{self.decimals}f}{self.unit}"
if self.low is not None and self.high is not None:
low = f"{self.low:.{self.decimals}f}{self.unit}"
high = f"{self.high:.{self.decimals}f}{self.unit}"
return f"{self.label}: {value} (95% interval {low} to {high})"
if self.no_interval_note:
return f"{self.label}: {value} (no interval: {self.no_interval_note})"
return f"{self.label}: {value}"
def bootstrap_interval(
values: Sequence[float] | np.ndarray,
*,
statistic: Callable[[np.ndarray], float] = np.mean,
resamples: int = 2000,
level: float = 0.95,
seed: int = 133,
) -> tuple[float, float]:
"""A percentile bootstrap interval, seeded so the report stays reproducible.
Day 118's interval, computed by resampling rather than by formula, which
is what you reach for when the statistic has no tidy standard error.
"""
sample = np.asarray(values, dtype=float)
sample = sample[~np.isnan(sample)]
if sample.size < 2:
raise ReportError("a bootstrap interval needs at least two observations")
rng = np.random.default_rng(seed)
draws = rng.choice(sample, size=(resamples, sample.size), replace=True)
stats = np.array([float(statistic(row)) for row in draws])
tail = (1.0 - level) / 2.0
return float(np.quantile(stats, tail)), float(np.quantile(stats, 1.0 - tail))
# ---------------------------------------------------------------------------
# Candidates, findings and panels
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Finding:
"""What one figure turned out to say, computed from the data."""
caption: str
prose: str
estimate: Estimate | None = None
@dataclass(frozen=True)
class Candidate:
"""A figure someone made during exploration. Most do not survive.
`question` is the whole filter. A candidate with no question answers no
question, and `survivors` drops it before it can reach the report --
but `dropped_because` is kept, because "we looked and found nothing"
is worth one line to the next reader.
"""
slug: str
question: str | None = None
draw: Callable[[Axes, pd.DataFrame], None] | None = None
analyse: Callable[[pd.DataFrame], Finding] | None = None
dropped_because: str = ""
@dataclass
class Panel:
"""A candidate that survived, with its finding resolved against the data."""
slug: str
question: str
finding: Finding
draw: Callable[[Axes, pd.DataFrame], None]
number: int = 0
image: str = ""
def survivors(candidates: Iterable[Candidate]) -> list[Candidate]:
"""The "so what" filter: keep only candidates that answer a stated question."""
return [c for c in candidates if c.question and c.question.strip()]
def discarded(candidates: Iterable[Candidate]) -> list[Candidate]:
"""The complement of `survivors` -- what the report will not show."""
return [c for c in candidates if not (c.question and c.question.strip())]
# ---------------------------------------------------------------------------
# Accessibility contract
# ---------------------------------------------------------------------------
def axes_colours(ax: Axes) -> list[str]:
"""Every colour actually painted on `ax`, as lowercase hex, alpha dropped."""
found: list[str] = []
for patch in ax.patches:
found.append(to_hex(patch.get_facecolor()))
for line in ax.get_lines():
found.append(to_hex(line.get_color()))
for collection in ax.collections:
for rgba in np.atleast_2d(collection.get_facecolor()):
found.append(to_hex(rgba))
return [colour.lower() for colour in found]
def check_axes(ax: Axes) -> list[str]:
"""Problems with `ax` against the report's accessibility contract.
Days 127 and 132, turned into a build check: every mark must be drawn in
a colourblind-safe colour, and both axes must be labelled. Returns an
empty list when the axes pass.
"""
problems: list[str] = []
if not ax.get_xlabel().strip():
problems.append("the x axis has no label")
if not ax.get_ylabel().strip():
problems.append("the y axis has no label")
safe = {colour.lower() for colour in SAFE_PALETTE}
for colour in sorted(set(axes_colours(ax))):
if colour not in safe:
problems.append(f"colour {colour} is not in the colourblind-safe palette")
return problems
def accessibility_problems(
draw: Callable[[Axes, pd.DataFrame], None], frame: pd.DataFrame
) -> list[str]:
"""Draw into a throwaway figure and run `check_axes` on the result."""
figure, ax = plt.subplots(figsize=(6.0, 3.5), dpi=100)
try:
draw(ax, frame)
return check_axes(ax)
finally:
plt.close(figure)
# ---------------------------------------------------------------------------
# The report
# ---------------------------------------------------------------------------
_IMAGE_LINK = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")
def orphan_figures(markdown: str, figure_dir: Path | str) -> list[str]:
"""Image files on disk that the markdown never links to.
An orphan is not a cosmetic problem. It is either a figure you meant to
discuss and forgot, or a leftover from a previous run that will confuse
whoever opens the directory next.
"""
referenced = {Path(target).name for target in _IMAGE_LINK.findall(markdown)}
directory = Path(figure_dir)
if not directory.is_dir():
return []
return sorted(p.name for p in directory.glob("*.png") if p.name not in referenced)
def missing_uncertainty(report: "Report") -> list[str]:
"""Slugs of panels whose estimate carries neither an interval nor a note."""
return [
panel.slug
for panel in report.panels
if panel.finding.estimate is not None
and not panel.finding.estimate.has_uncertainty()
]
@dataclass
class Report:
"""An ordered argument with evidence attached."""
title: str
question: str
decision: str
provenance: str
caveats: list[str] = field(default_factory=list)
null_results: list[str] = field(default_factory=list)
panels: list[Panel] = field(default_factory=list)
data_fingerprint: str = ""
def add_panel(self, candidate: Candidate, frame: pd.DataFrame) -> Panel:
"""Resolve one candidate against the data and admit it to the report.
Raises `ReportError` when the candidate has no stated question, when
its caption is a label rather than a claim, or when it has no way to
draw itself or to compute its finding.
"""
if not candidate.question or not candidate.question.strip():
raise ReportError(
f"figure {candidate.slug!r} has no stated question; a figure whose "
"question you cannot state is a figure that does not belong in the report"
)
if candidate.draw is None or candidate.analyse is None:
raise ReportError(
f"figure {candidate.slug!r} has a question but no way to answer it"
)
finding = candidate.analyse(frame)
problem = claim_problem(finding.caption)
if problem is not None:
raise ReportError(f"figure {candidate.slug!r}: {problem}")
panel = Panel(
slug=candidate.slug,
question=candidate.question.strip(),
finding=finding,
draw=candidate.draw,
number=len(self.panels) + 1,
)
panel.image = f"figures/{panel.number:02d}-{panel.slug}.png"
self.panels.append(panel)
return panel
# -- rendering ---------------------------------------------------------
def render(self, outdir: Path | str, frame: pd.DataFrame) -> str:
"""Write the figures and `report.md` into `outdir`, and return the markdown.
The output contains no clock reading and no unseeded random number,
so two runs over the same input are byte-identical. Provenance is a
fingerprint of the data, not a timestamp of the run.
"""
directory = Path(outdir)
figure_dir = directory / "figures"
figure_dir.mkdir(parents=True, exist_ok=True)
for panel in self.panels:
figure, ax = plt.subplots(figsize=(6.0, 3.5), dpi=100)
try:
panel.draw(ax, frame)
figure.tight_layout()
figure.savefig(directory / panel.image)
finally:
plt.close(figure)
markdown = self._markdown(frame)
(directory / "report.md").write_text(markdown, encoding="utf-8")
return markdown
def fingerprint(self, frame: pd.DataFrame) -> str:
"""A short content hash of the input, so provenance names the data."""
payload = frame.to_csv(index=False).encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:12]
def _markdown(self, frame: pd.DataFrame) -> str:
lines: list[str] = []
add = lines.append
add(f"# {self.title}")
add("")
add(f"**Question.** {self.question}")
add("")
add(f"**Decision this feeds.** {self.decision}")
add("")
add("## Conclusion")
add("")
if not self.panels:
add("No figure in this analysis answered a stated question.")
for panel in self.panels:
add(f"{panel.number}. {panel.finding.caption} (Figure {panel.number})")
add("")
for panel in self.panels:
if panel.finding.estimate is not None:
add(f"- {panel.finding.estimate.text()}")
add("")
add("## What we looked at and found nothing in")
add("")
if self.null_results:
for note in self.null_results:
add(f"- {note}")
else:
add("- Nothing was set aside; every candidate figure answered a question.")
add("")
add("## Evidence")
add("")
for panel in self.panels:
add(f"### Figure {panel.number} — {panel.question}")
add("")
add(f"")
add("")
add(f"**Figure {panel.number}.** {panel.finding.caption}")
add("")
add(panel.finding.prose)
add("")
if panel.finding.estimate is not None:
add(f"*{panel.finding.estimate.text()}*")
add("")
add("## Caveats")
add("")
for caveat in self.caveats:
add(f"- {caveat}")
add("")
add("## Provenance")
add("")
add(f"- Source: {self.provenance}")
add(f"- Shape: {len(frame)} rows, {len(frame.columns)} columns")
add(f"- Data fingerprint (sha256, first 12): `{self.fingerprint(frame)}`")
add(
"- This document was generated by code from the input above. Nothing in "
"it was typed by hand, so re-running it on new data cannot leave the "
"prose disagreeing with the figures."
)
add("")
return "\n".join(lines)
examples/test_report.py (11647 bytes)
"""Reference solutions -- Day 133, A Report That Argues.
Nine exercises. Each one asserts on real behaviour of the report generator
in `report.py` running over the real frame in `data.py` -- never on image
bytes, and never on the presence of a file alone.
Run with: pytest examples -q
"""
from __future__ import annotations
import re
import shutil
from pathlib import Path
import pytest
from analysis import (
analyse_missing,
bare_point_estimate_candidate,
build_report,
candidate_figures,
draw_inaccessible,
draw_missing_by_column,
)
from report import (
Candidate,
Finding,
Report,
ReportError,
accessibility_problems,
carries_claim,
claim_problem,
discarded,
missing_uncertainty,
orphan_figures,
survivors,
)
def line_containing(markdown: str, needle: str) -> str:
"""The one line of `markdown` that contains `needle`."""
hits = [line for line in markdown.splitlines() if needle in line]
assert len(hits) >= 1, f"no line contains {needle!r}"
return hits[0]
def percentage_in(text: str) -> float:
"""The first signed percentage in `text`, as a float."""
match = re.search(r"(-?\d+(?:\.\d+)?)%", text)
assert match is not None, f"no percentage in {text!r}"
return float(match.group(1))
def blank_report() -> Report:
return Report(
title="A scratch report",
question="Does the generator hold its own line?",
decision="Whether to trust anything this generator produces.",
provenance="the same synthetic frame the real report uses",
)
# --------------------------------------------------------------------------
# Exercise 1 -- a figure must have a question.
# --------------------------------------------------------------------------
def test_01_a_figure_must_have_a_question(frame):
report = blank_report()
nameless = Candidate(
slug="pretty-chart",
draw=draw_missing_by_column,
analyse=analyse_missing,
)
with pytest.raises(ReportError) as raised:
report.add_panel(nameless, frame)
assert "no stated question" in str(raised.value)
assert report.panels == [], "a refused figure must not be half-admitted"
blank = Candidate(
slug="pretty-chart",
question=" ",
draw=draw_missing_by_column,
analyse=analyse_missing,
)
with pytest.raises(ReportError):
report.add_panel(blank, frame)
asked = Candidate(
slug="pretty-chart",
question="Which rows have no revenue?",
draw=draw_missing_by_column,
analyse=analyse_missing,
)
panel = report.add_panel(asked, frame)
assert panel.question == "Which rows have no revenue?"
assert len(report.panels) == 1
assert panel.number == 1
assert panel.image == "figures/01-pretty-chart.png"
# --------------------------------------------------------------------------
# Exercise 2 -- the caption carries the claim.
# --------------------------------------------------------------------------
def test_02_caption_carries_a_claim(frame):
# A label. Nothing in it a reader could disagree with.
assert carries_claim("revenue by region") is False
assert "label, not a claim" in claim_problem("revenue by region")
assert carries_claim("monthly revenue, all regions") is False
assert carries_claim("") is False
# A claim: it has a number in it.
assert carries_claim(
"three regions grew, and the fourth fell by 12% after the March pricing change"
)
# A claim: no number, but a comparison.
assert carries_claim("partner revenue is lower than direct revenue in every region")
# The generator refuses a panel whose caption is only a label.
report = blank_report()
labelled = Candidate(
slug="labelled",
question="What does revenue look like by region?",
draw=draw_missing_by_column,
analyse=lambda _frame: Finding(caption="revenue by region", prose="No claim here."),
)
with pytest.raises(ReportError) as raised:
report.add_panel(labelled, frame)
assert "label, not a claim" in str(raised.value)
# The honest limits of the check, in both directions. It passes a caption
# that is flatly false, because it cannot read the data ...
assert carries_claim("revenue doubled in every region")
# ... and it refuses a genuine claim written without a number and without
# one of its comparative words. "tripled" is simply not on the list. The
# check makes the ABSENCE of a claim impossible to ship by accident; it is
# not, and cannot be, a judge of whether the claim is right.
assert carries_claim("revenue tripled in all four regions") is False
# --------------------------------------------------------------------------
# Exercise 3 -- numbers in the prose come from the data.
# --------------------------------------------------------------------------
def test_03_numbers_come_from_the_data(frame, perturbed_frame, report_dir, second_report_dir):
original = build_report(frame).render(report_dir, frame)
changed = build_report(perturbed_frame).render(second_report_dir, perturbed_frame)
needle = "West change across the pricing change"
original_line = line_containing(original, needle)
changed_line = line_containing(changed, needle)
assert original_line != changed_line
original_pct = percentage_in(original_line)
changed_pct = percentage_in(changed_line)
assert original_pct < 0.0
# One input value moved; the sentence moved with it by a wide margin.
assert changed_pct < original_pct - 20.0
# The fingerprint in the provenance section moved too, because it is a
# hash of the input rather than a note about the run.
assert line_containing(original, "Data fingerprint") != line_containing(
changed, "Data fingerprint"
)
# --------------------------------------------------------------------------
# Exercise 4 -- every figure is referenced.
# --------------------------------------------------------------------------
def test_04_no_orphan_figures(frame, report_dir):
markdown = build_report(frame).render(report_dir, frame)
figure_dir = report_dir / "figures"
written = sorted(p.name for p in figure_dir.glob("*.png"))
assert len(written) == 5
assert orphan_figures(markdown, figure_dir) == []
for name in written:
assert f"figures/{name}" in markdown
# A leftover from an earlier run is exactly what this check is for.
shutil.copy(figure_dir / written[0], figure_dir / "99-left-over.png")
assert orphan_figures(markdown, figure_dir) == ["99-left-over.png"]
# --------------------------------------------------------------------------
# Exercise 5 -- uncertainty is stated, not implied.
# --------------------------------------------------------------------------
def test_05_uncertainty_is_present(frame):
report = build_report(frame)
assert missing_uncertainty(report) == []
estimates = [panel.finding.estimate for panel in report.panels]
assert all(estimate is not None for estimate in estimates)
with_interval = [e for e in estimates if e.low is not None and e.high is not None]
with_note = [e for e in estimates if e.no_interval_note]
assert len(with_interval) == 4
assert len(with_note) == 1
assert "single observation" in with_note[0].no_interval_note
for estimate in with_interval:
assert estimate.low < estimate.value < estimate.high
assert "95% interval" in estimate.text()
# A bare point estimate carries neither, and the check finds it.
report.add_panel(bare_point_estimate_candidate(), frame)
assert missing_uncertainty(report) == ["bare-total"]
# --------------------------------------------------------------------------
# Exercise 6 -- reproducibility.
# --------------------------------------------------------------------------
def test_06_two_runs_are_byte_identical(
frame, perturbed_frame, report_dir, second_report_dir, third_report_dir
):
first = build_report(frame).render(report_dir, frame)
second = build_report(frame).render(second_report_dir, frame)
assert first == second
assert (report_dir / "report.md").read_bytes() == (
second_report_dir / "report.md"
).read_bytes()
# Figure bytes too, on this machine: same backend, same fonts, same run.
for path in sorted((report_dir / "figures").glob("*.png")):
twin = second_report_dir / "figures" / path.name
assert path.read_bytes() == twin.read_bytes()
# Change one input value and the document changes.
third = build_report(perturbed_frame).render(third_report_dir, perturbed_frame)
assert third != first
# Nothing in the output is a clock reading.
assert not re.search(r"\b20\d\d-\d\d-\d\d\b", first)
# --------------------------------------------------------------------------
# Exercise 7 -- ordering for the reader.
# --------------------------------------------------------------------------
def test_07_conclusion_comes_before_the_evidence(frame, report_dir):
markdown = build_report(frame).render(report_dir, frame)
conclusion = markdown.index("## Conclusion")
omissions = markdown.index("## What we looked at and found nothing in")
evidence = markdown.index("## Evidence")
caveats = markdown.index("## Caveats")
provenance = markdown.index("## Provenance")
assert conclusion < omissions < evidence < caveats < provenance
assert conclusion < markdown.index("### Figure 1")
# The conclusion is literally the list of captions: the claim the caption
# carries is the finding, so the two can never drift apart.
for panel in build_report(frame).panels:
assert f"{panel.number}. {panel.finding.caption} (Figure {panel.number})" in markdown
# --------------------------------------------------------------------------
# Exercise 8 -- the "so what" filter.
# --------------------------------------------------------------------------
def test_08_the_so_what_filter(frame, candidates, report_dir):
kept = survivors(candidates)
dropped = discarded(candidates)
assert len(candidates) == 12
assert len(kept) == 5
assert len(dropped) == 7
assert len(kept) + len(dropped) == len(candidates)
# Most of what exploration produced does not survive. That is the ratio.
assert len(kept) / len(candidates) < 0.5
markdown = build_report(frame).render(report_dir, frame)
figure_dir = report_dir / "figures"
for candidate in dropped:
assert candidate.slug not in markdown
assert list(figure_dir.glob(f"*{candidate.slug}*")) == []
# But the null result survives as one line, so nobody repeats it.
assert candidate.dropped_because in markdown
for candidate in kept:
assert any(candidate.slug in p.name for p in figure_dir.glob("*.png"))
# --------------------------------------------------------------------------
# Exercise 9 -- the accessibility contract, as a build check.
# --------------------------------------------------------------------------
def test_09_accessibility_contract(frame, candidates):
for candidate in survivors(candidates):
assert accessibility_problems(candidate.draw, frame) == [], candidate.slug
problems = accessibility_problems(draw_inaccessible, frame)
assert "the x axis has no label" in problems
assert "the y axis has no label" in problems
assert any("#ff0000" in problem for problem in problems)
assert any("#008000" in problem for problem in problems)
assert len(problems) == 4
metadata.yml (3810 bytes)
lesson_id: D133
day: 133
kind: guided-build
languages:
- python
- bash
setup_commands:
- cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- >-
.venv/bin/python3 -c "import matplotlib, pandas; print(matplotlib.__version__,
pandas.__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: 50
last_executed: '2026-08-20'
executed_on: >-
macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, matplotlib 3.11.1, seaborn 0.13.2,
pandas 3.0.5, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 42
checks, 0 failure(s), exit 0. pytest examples -> 9 passed. pytest starter -> 9 skipped
(untouched checkout). Everything was run through a real lab-local .venv created by the
documented setup commands. Section 6 of the harness solves every exercise in a scratch
copy (9 passed), deliberately breaks exercise 8s survivor-count assertion (len(kept)
== 5 -> len(kept) == 99), confirms a non-zero exit with a printed failure, restores
the file and confirms 9 passed again, so the suite is demonstrated to be capable of
failing rather than merely claimed to be. Separately, exercise 8s assertion was broken
in examples/ itself (== 5 -> == 4) and the whole harness was re-run: it reported 42
checks, 6 failure(s) and exited 1; the file was restored and the harness returned to
42 checks, 0 failure(s), exit 0. Section 5 confirms directly that `pytest examples
starter` in one invocation aborts collection with `import file mismatch` (both
directories define a module named test_report.py) rather than silently letting one
shadow the other. MEASURED RESULTS: 12 candidate figures enter the pipeline and 5
survive the so-what filter (41.7 per cent); the rendered Markdown is byte-identical
across two runs, and so are all five figure PNGs on this machine within one session.
THREE HONESTY CALLS. FIRST: the day brief said pandas Styler was installed and could
be used; measured directly, df.style raises AttributeError: The .style accessor
requires jinja2, because jinja2 is not in this environment. The lesson therefore
describes Styler from the pandas documentation and reproduces no Styler output;
nothing in the lab uses it. SECOND: the claim heuristic has false negatives as well as
false positives, and the lab asserts both -- it passes the caption revenue doubled in
every region on data where revenue halved, and it refuses revenue tripled in all four
regions, which is a real claim written with a word that is not on its list. The false
negative was found by writing the test and watching it fail, and it was kept rather
than patched away, because it is the more instructive half. THIRD: the byte-identity
of the figure PNGs is asserted only across two runs on the same machine in one
session, never across machines -- matplotlib rasterises text through FreeType, so a
different FreeType build or font set can legitimately produce different bytes from
identical code. Only the Markdown byte-identity is claimed generally. Jupyter,
nbconvert, Quarto and scipy are not installed here (jupyter and quarto are not on
PATH; import nbconvert, import nbformat, import IPython and import scipy all fail);
the lesson describes each from public documentation only and reproduces no output from
any of them.
requirements/README.md (1777 bytes)
# Requirements
`requirements.txt` pins the exact versions this lab was written and run
against on 2026-08-20. Everything else it uses — `hashlib`, `re`,
`shutil`, `tempfile`, `dataclasses`, `pathlib` — is in the Python
standard library.
Install into a lab-local virtual environment so the pins cannot collide
with anything else on your machine:
```bash
cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
```
Only that install step needs the network. Everything after it runs
offline: the lab reads no URL, opens no socket, and needs no API key.
## Why the pins are exact
Section 1 of `tests/run_tests.sh` compares every installed version against
this file and fails on a mismatch. That is deliberate. Two of this lab's
measured facts are version-sensitive:
- the colourblind-safe palette in `report.SAFE_PALETTE` is
`seaborn.color_palette("colorblind")` as it stands in seaborn 0.13.2,
frozen to hex so the accessibility check has an exact set to compare
against;
- the byte-identity of the rendered PNGs in exercise 6 is a property of
one matplotlib build with one font stack. It is asserted only across two
runs on the same machine, which is where the guarantee actually holds.
`seaborn` is pinned because `SAFE_PALETTE` came from it, even though the
lab's own drawing code calls matplotlib directly.
## If a pin will not install
Any recent matplotlib 3.x, pandas 2.2+ or NumPy 2.x will almost certainly
run the lab. The version check in section 1 of the harness will complain;
the nine exercises should still pass. If they do not, `expected-output/FIELDS.md`
records exactly which captured values are version-sensitive and which are
exact everywhere.
requirements/requirements.txt (76 bytes)
matplotlib==3.11.1
seaborn==0.13.2
pandas==3.0.5
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (5155 bytes)
# A Report That Argues — the nine exercises
You are not writing an analysis today. You are writing the **checks that
decide whether an analysis is fit to be read**, and you are wiring them
into a generator that refuses to build a report that fails them.
Four files sit in this directory:
| File | What it is |
| --- | --- |
| `data.py` | The dataset. Deterministic: `numpy.random.default_rng(133)`, so every number below is the same on your machine as on the machine this lab was written on |
| `report.py` | The generator: `Candidate`, `Report`, `Estimate`, and the six checks. Read this first |
| `analysis.py` | The twelve candidate figures exploration produced, and `build_report`, which filters them down to five |
| `test_report.py` | Your nine exercises. Each one currently calls `pytest.skip` |
Replace each `pytest.skip(...)` with real assertions, and delete the skip
line. Run `pytest starter -v` as often as you like. Never run
`pytest examples starter` in one command — both directories hold a module
named `test_report.py` and pytest aborts collection on the clash. Run the
two directories as two separate commands.
Every directory fixture is a real temporary directory that is deleted the
moment the test finishes. When you are done, no image and no Markdown file
exists anywhere outside this repository's checkout.
---
## Exercise 1 — a figure must have a question
`Report.add_panel` raises `ReportError` on a `Candidate` whose `question`
is `None` or blank. Prove both, prove that the refused figure is not
half-admitted (`report.panels` is still empty), and then prove the same
candidate is accepted once it has a question.
This is the day's thesis compiled into an exception. A figure whose
question you cannot state is a figure that does not belong in the report,
and the generator will not let you add one by accident.
## Exercise 2 — the caption carries the claim
`carries_claim` demands a number, a percent sign, or a comparative word.
Show it rejects `"revenue by region"` and accepts a caption with a figure
in it, and show `add_panel` refusing a `Finding` whose caption is only a
label.
Then prove **both** honest limits of the heuristic. It passes
`"revenue doubled in every region"` even on data where revenue halved —
it cannot read the data, so it cannot judge truth. And it refuses
`"revenue tripled in all four regions"`, which is a real claim written
with a word that is simply not on the list. The check buys you exactly
one thing: it makes the *absence* of a claim impossible to ship by
accident.
## Exercise 3 — the numbers in the prose come from the data
`data.perturbed()` returns the same frame with one input changed. Render
the report from both frames and prove the sentence about the West's
change moved with the data. If a number is the same in both documents, it
was typed rather than computed, and it will be wrong the first time the
data is refreshed.
## Exercise 4 — every figure is referenced
`orphan_figures` compares the PNG files on disk against the image links in
the Markdown. Prove the generated report has none, then copy one figure to
a new name and prove the check finds it. An orphan is either a figure you
meant to discuss and forgot, or a leftover from a previous run.
## Exercise 5 — uncertainty is stated, not implied
Days 117 and 118 put the interval in the error bar. This exercise puts it
in the sentence. Prove every one of the five panels reports an `Estimate`,
that four of them carry a 95% interval and one carries an explicit note
saying why no interval is available, and that a bare point estimate is
caught by `missing_uncertainty`.
## Exercise 6 — reproducibility
Day 126's idempotence, applied to prose. Render the same input twice into
two directories and prove the Markdown is byte-identical — and that each
figure's PNG bytes are identical too, on this machine and this run. Then
prove that changing the input changes the document, and that nothing in
the output is a clock reading. Provenance here is a hash of the data, not
a timestamp of the run, which is exactly why two runs agree.
## Exercise 7 — ordering for the reader
Prove the conclusion appears before the evidence, and the caveats before
the provenance. Then prove something stronger: each panel's caption
appears verbatim as its numbered line in the conclusion. The conclusion is
not a separate summary that can drift; it *is* the list of captions.
## Exercise 8 — the "so what" filter
Twelve candidate figures went in. Prove that five survive and seven do
not, that fewer than half survive at all, that no discarded slug reaches
the Markdown and no file was written for one — and that every discarded
candidate's one-line reason **is** in the report, under "what we looked at
and found nothing in". Deleting the chart and keeping the sentence is the
whole move.
## Exercise 9 — the accessibility contract
Days 127 and 132 as a build check. Prove all five surviving figures pass
`accessibility_problems` — colourblind-safe palette, both axes labelled —
and that `draw_inaccessible` (red against green, no axis labels) fails
with exactly four named problems.
starter/analysis.py (21952 bytes)
"""The twelve candidate figures, and the five that survive the filter.
This module is the *content* of the Day 133 report; `report.py` is the
machinery. Reading them together is the point of the day: exploration
produced twelve candidate figures, seven of them answer no stated
question, and `report.survivors` throws those seven out before they can
reach the reader. Their `dropped_because` lines survive, though, in the
report's "what we looked at and found nothing in" section -- because a
null result you keep to yourself is a null result the next person has to
rediscover.
Every number in every caption is computed from the frame that is passed
in. Nothing is a typed literal. Change the input and the sentences change
with it, which is what exercises 3 and 6 check.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from data import ANOMALY_MONTH, ANOMALY_REGION, PRICING_CHANGE_MONTH, REGIONS, source_description
from report import (
SAFE_PALETTE,
Candidate,
Estimate,
Finding,
Report,
bootstrap_interval,
discarded,
survivors,
)
#: The two six-month windows either side of the pricing change.
BEFORE_WINDOW = (7, 12)
AFTER_WINDOW = (13, 18)
def _direct(frame: pd.DataFrame) -> pd.DataFrame:
"""Direct-channel rows only -- the subset with no missing revenue."""
return frame[frame["channel"] == "direct"]
def _window_mean(frame: pd.DataFrame, region: str, window: tuple[int, int]) -> float:
rows = _direct(frame)
low, high = window
selected = rows[(rows["region"] == region) & rows["month"].between(low, high)]
return float(selected["revenue"].mean())
def _window_values(frame: pd.DataFrame, region: str, window: tuple[int, int]) -> np.ndarray:
rows = _direct(frame)
low, high = window
selected = rows[(rows["region"] == region) & rows["month"].between(low, high)]
return selected["revenue"].to_numpy(dtype=float)
def _ratio_interval(
before: np.ndarray, after: np.ndarray, *, resamples: int = 2000, seed: int = 133
) -> tuple[float, float]:
"""Percentile bootstrap for a percentage change between two small windows.
Six observations on each side is not much, and the interval this returns
is correspondingly wide. That width is information, not an embarrassment:
it is the report telling the reader how hard the number should be leaned on.
"""
rng = np.random.default_rng(seed)
draws = []
for _ in range(resamples):
b = rng.choice(before, size=before.size, replace=True).mean()
a = rng.choice(after, size=after.size, replace=True).mean()
draws.append(100.0 * (a / b - 1.0))
values = np.asarray(draws)
return float(np.quantile(values, 0.025)), float(np.quantile(values, 0.975))
# ---------------------------------------------------------------------------
# Figure 1 -- data quality
# ---------------------------------------------------------------------------
def draw_missing_by_column(ax: Axes, frame: pd.DataFrame) -> None:
counts = frame.isna().sum()
ax.bar(list(counts.index), list(counts.to_numpy()), color=SAFE_PALETTE[0])
ax.set_xlabel("column")
ax.set_ylabel("rows with no value")
ax.set_title("Missing values by column")
def analyse_missing(frame: pd.DataFrame) -> Finding:
missing = frame["revenue"].isna()
n_missing = int(missing.sum())
pct = 100.0 * n_missing / len(frame)
partner_share = 100.0 * float((frame.loc[missing, "channel"] == "partner").mean())
indicator = missing.to_numpy(dtype=float)
low, high = bootstrap_interval(indicator, statistic=lambda a: 100.0 * a.mean())
return Finding(
caption=(
f"{n_missing} of {len(frame)} rows ({pct:.1f}%) have no revenue, and "
f"{partner_share:.0f}% of those gaps are partner rows -- the missingness "
"is a channel problem, not random loss"
),
prose=(
"Every other column is complete. Because the gaps sit entirely in one "
"channel, any figure that pools the two channels and drops missing rows "
"silently under-counts partner activity, so the rest of this report uses "
"direct-channel rows wherever a level is being compared."
),
estimate=Estimate(
label="share of rows with missing revenue",
value=pct,
unit="%",
low=low,
high=high,
),
)
# ---------------------------------------------------------------------------
# Figure 2 -- univariate
# ---------------------------------------------------------------------------
def draw_channel_populations(ax: Axes, frame: pd.DataFrame) -> None:
direct = frame.loc[frame["channel"] == "direct", "revenue"].dropna()
partner = frame.loc[frame["channel"] == "partner", "revenue"].dropna()
bins = np.histogram_bin_edges(frame["revenue"].dropna(), bins="fd")
ax.hist(direct, bins=bins, color=SAFE_PALETTE[0], alpha=0.75, label="direct")
ax.hist(partner, bins=bins, color=SAFE_PALETTE[1], alpha=0.75, label="partner")
ax.set_xlabel("monthly revenue for one region and channel")
ax.set_ylabel("number of region-months")
ax.set_title("Revenue is a mixture of two channels")
ax.legend()
def analyse_populations(frame: pd.DataFrame) -> Finding:
direct = frame.loc[frame["channel"] == "direct", "revenue"].dropna().to_numpy(float)
partner = frame.loc[frame["channel"] == "partner", "revenue"].dropna().to_numpy(float)
ratio = 100.0 * float(np.median(partner)) / float(np.median(direct))
low, high = bootstrap_interval(partner, statistic=np.median)
return Finding(
caption=(
f"Revenue is two populations rather than one: the median partner "
f"region-month is {ratio:.0f}% of the median direct region-month, so any "
"average taken across both channels describes a mixture nobody sells into"
),
prose=(
"The two histograms barely overlap. A single mean over this column would "
"land in the empty gap between them and describe no real region-month at "
"all -- Day 116's warning about a summary that discards the thing you "
"needed, met again in a column you would have been tempted to average."
),
estimate=Estimate(
label="median partner region-month revenue",
value=float(np.median(partner)),
unit=" USD",
low=low,
high=high,
decimals=0,
),
)
# ---------------------------------------------------------------------------
# Figure 3 -- relationship
# ---------------------------------------------------------------------------
def draw_orders_vs_revenue(ax: Axes, frame: pd.DataFrame) -> None:
rows = frame.dropna(subset=["revenue"])
x = rows["orders"].to_numpy(dtype=float)
y = rows["revenue"].to_numpy(dtype=float)
slope, intercept = np.polyfit(x, y, 1)
grid = np.linspace(x.min(), x.max(), 50)
ax.scatter(x, y, s=12, color=SAFE_PALETTE[0], label="region-month")
ax.plot(grid, slope * grid + intercept, color=SAFE_PALETTE[3], label="least-squares fit")
ax.set_xlabel("orders in the month")
ax.set_ylabel("revenue in the month (USD)")
ax.set_title("Orders and revenue")
ax.legend()
def analyse_relationship(frame: pd.DataFrame) -> Finding:
rows = frame.dropna(subset=["revenue"])
x = rows["orders"].to_numpy(dtype=float)
y = rows["revenue"].to_numpy(dtype=float)
slope, intercept = np.polyfit(x, y, 1)
predicted = slope * x + intercept
r_squared = 1.0 - float(np.sum((y - predicted) ** 2) / np.sum((y - y.mean()) ** 2))
per_order = y / x
low, high = bootstrap_interval(per_order)
return Finding(
caption=(
f"Revenue rises about {slope:.0f} USD per additional order and the "
f"straight-line fit accounts for {100.0 * r_squared:.1f}% of the variation, "
"so a region-month that missed its revenue missed its order count"
),
prose=(
"There is no second cluster off the line and no curvature worth naming. "
"That is a boring finding, and it is in the report precisely because it "
"closes a question the reader would otherwise have to ask: revenue here "
"is not being moved by price. The fitted slope "
f"({slope:.0f} USD) sits a little below the mean revenue per order "
f"({per_order.mean():.0f} USD) because the fit carries a non-zero "
f"intercept of {intercept:,.0f} USD; the two numbers answer slightly "
"different questions and the report says which is which rather than "
"quoting whichever is larger."
),
estimate=Estimate(
label="mean revenue per order",
value=float(per_order.mean()),
unit=" USD",
low=low,
high=high,
),
)
# ---------------------------------------------------------------------------
# Figure 4 -- segments
# ---------------------------------------------------------------------------
def draw_region_trend(ax: Axes, frame: pd.DataFrame) -> None:
rows = _direct(frame)
for index, region in enumerate(REGIONS):
series = rows[rows["region"] == region].sort_values("month")
ax.plot(
series["month"].to_numpy(),
series["revenue"].to_numpy(),
color=SAFE_PALETTE[index],
label=region,
)
ax.axvline(
PRICING_CHANGE_MONTH - 0.5,
color=SAFE_PALETTE[7],
linestyle="--",
label=f"pricing change (month {PRICING_CHANGE_MONTH})",
)
ax.set_xlabel("month")
ax.set_ylabel("direct-channel revenue (USD)")
ax.set_title("Direct revenue by region")
ax.legend(fontsize=7, ncols=2)
def analyse_segments(frame: pd.DataFrame) -> Finding:
changes: dict[str, float] = {}
for region in REGIONS:
before = _window_mean(frame, region, BEFORE_WINDOW)
after = _window_mean(frame, region, AFTER_WINDOW)
changes[region] = 100.0 * (after / before - 1.0)
west = changes["West"]
grew = [region for region, change in changes.items() if change > 0]
low, high = _ratio_interval(
_window_values(frame, "West", BEFORE_WINDOW),
_window_values(frame, "West", AFTER_WINDOW),
)
# The East's after-window contains the single month-18 spike. Recompute it
# without that one observation, because quoting the inflated figure without
# saying so would be exactly the failure Day 132 named.
clean_after = _direct(frame)
clean_after = clean_after[
(clean_after["region"] == ANOMALY_REGION)
& clean_after["month"].between(*AFTER_WINDOW)
& (clean_after["month"] != ANOMALY_MONTH)
]
east_without_anomaly = 100.0 * (
float(clean_after["revenue"].mean()) / _window_mean(frame, ANOMALY_REGION, BEFORE_WINDOW)
- 1.0
)
direction = "fell" if west < 0 else "rose"
return Finding(
caption=(
f"{len(grew)} regions grew across the pricing change while the West "
f"{direction} {abs(west):.1f}%, and the break lands in month "
f"{PRICING_CHANGE_MONTH} in that region only"
),
prose=(
"Comparing the six months before month "
f"{PRICING_CHANGE_MONTH} with the six months after, the four regions move "
+ ", ".join(f"{region} {change:+.1f}%" for region, change in changes.items())
+ ". The West is the only one that changes direction, and it changes it at "
"the month the price moved. This is an association in observational data, "
"not a controlled comparison: nothing here rules out a third cause that "
f"happened to the West in the same month. {ANOMALY_REGION}'s "
f"{changes[ANOMALY_REGION]:+.1f}% is not what it looks like either: drop the "
f"single month-{ANOMALY_MONTH} observation and it falls to "
f"{east_without_anomaly:+.1f}%, which is why Figure 5 exists."
),
estimate=Estimate(
label="West change across the pricing change (six months either side)",
value=west,
unit="%",
low=low,
high=high,
),
)
# ---------------------------------------------------------------------------
# Figure 5 -- anomaly
# ---------------------------------------------------------------------------
def draw_east_anomaly(ax: Axes, frame: pd.DataFrame) -> None:
rows = _direct(frame)
series = rows[rows["region"] == ANOMALY_REGION].sort_values("month")
months = series["month"].to_numpy()
colours = [
SAFE_PALETTE[3] if month == ANOMALY_MONTH else SAFE_PALETTE[0] for month in months
]
ax.bar(months, series["revenue"].to_numpy(), color=colours)
ax.set_xlabel("month")
ax.set_ylabel("direct-channel revenue (USD)")
ax.set_title(f"{ANOMALY_REGION}: one month is not like the others")
def analyse_anomaly(frame: pd.DataFrame) -> Finding:
rows = _direct(frame)
series = rows[rows["region"] == ANOMALY_REGION]
spike = float(series.loc[series["month"] == ANOMALY_MONTH, "revenue"].iloc[0])
others = series.loc[series["month"] != ANOMALY_MONTH, "revenue"].to_numpy(float)
multiple = spike / float(np.median(others))
neighbours = series[series["month"].isin([ANOMALY_MONTH - 1, ANOMALY_MONTH + 1])]
neighbour_multiple = float(neighbours["revenue"].mean()) / float(np.median(others))
return Finding(
caption=(
f"{ANOMALY_REGION} month {ANOMALY_MONTH} is {multiple:.1f} times the "
f"region's median month while the months either side sit at "
f"{neighbour_multiple:.2f} times it, so this is one observation and not a "
"level change"
),
prose=(
"The distinction matters for what you do next. A level change is a fact "
"about the business and belongs in the forecast; a single spike is a fact "
"about one month and belongs with whoever can explain it. Until someone "
"does, the honest move is to report both the figure including it and the "
"figure excluding it, and to say which one the decision was made on."
),
estimate=Estimate(
label=f"{ANOMALY_REGION} month {ANOMALY_MONTH} as a multiple of the region median",
value=multiple,
unit="x",
no_interval_note=(
"a single observation has no sampling interval; one point is one point"
),
),
)
# ---------------------------------------------------------------------------
# The twelve candidates
# ---------------------------------------------------------------------------
def candidate_figures() -> list[Candidate]:
"""Everything exploration produced, in the order it was produced.
Five carry a question. Seven do not, and `report.survivors` drops them.
"""
return [
Candidate(
slug="missing-revenue",
question="Which rows have no revenue, and is the missingness concentrated anywhere?",
draw=draw_missing_by_column,
analyse=analyse_missing,
),
Candidate(
slug="cumulative-revenue",
dropped_because=(
"A cumulative revenue curve was drawn and discarded: a cumulative "
"series rises whatever the underlying months do, so it answered no "
"question the monthly series had not already answered"
),
),
Candidate(
slug="channel-populations",
question="Is monthly revenue one population, or several stacked on top of each other?",
draw=draw_channel_populations,
analyse=analyse_populations,
),
Candidate(
slug="orders-histogram",
dropped_because=(
"The order-count distribution was checked for a second population; it "
"shows the same channel split the revenue column already shows, so it "
"adds no evidence of its own"
),
),
Candidate(
slug="orders-vs-revenue",
question="How tightly do orders and revenue move together, and what is one extra order worth?",
draw=draw_orders_vs_revenue,
analyse=analyse_relationship,
),
Candidate(
slug="region-channel-interaction",
dropped_because=(
"Region-by-channel interaction was checked; the partner channel runs "
"at the same share of direct in all four regions, so there is no "
"interaction to report"
),
),
Candidate(
slug="region-trend",
question="Did any region's trajectory change at the month-13 pricing change?",
draw=draw_region_trend,
analyse=analyse_segments,
),
Candidate(
slug="revenue-per-order-by-region",
dropped_because=(
"Revenue per order was compared across the four regions; they are "
"indistinguishable on this measure, which is worth one line here so "
"the next reader does not spend an afternoon on it"
),
),
Candidate(
slug="east-anomaly",
question="Is the East's month-18 jump a level change or a single outlier?",
draw=draw_east_anomaly,
analyse=analyse_anomaly,
),
Candidate(
slug="calendar-seasonality",
dropped_because=(
"Calendar seasonality was checked by lining the two years up month "
"against month; nothing stood out above the month-to-month noise"
),
),
Candidate(
slug="missing-by-month",
dropped_because=(
"The missing revenue rows were checked for a time pattern as well as "
"a channel pattern; they are scattered across the two years rather "
"than clustered in any single month"
),
),
Candidate(
slug="revenue-heatmap",
dropped_because=(
"A region-by-month heatmap was drawn and discarded: it held the same "
"information as the trend lines while making the month-13 break "
"harder to see, which is the wrong trade for a report"
),
),
]
def build_report(frame: pd.DataFrame) -> Report:
"""Run the whole pipeline: candidates, filter, panels, ready to render."""
candidates = candidate_figures()
report = Report(
title="Where did the West's revenue go?",
question=(
"Four regions sell through two channels. Did any region's revenue "
"trajectory change around the month-13 pricing change, and is the change "
"big enough and clean enough to act on?"
),
decision=(
"Whether to roll the month-13 pricing change back in the West before it "
"is extended to the other three regions."
),
provenance=source_description(),
caveats=[
"This is observational data, not an experiment. The month-13 break is an "
"association in time; it is not proof that the pricing change caused it.",
"The regional comparison uses six months either side of the change. Six "
"observations per side is a small window, and the interval on that number "
"is correspondingly wide -- read the interval, not the point estimate.",
"Level comparisons use direct-channel rows only, because the partner "
"channel is the one with missing revenue. Partner totals in this report "
"are therefore lower bounds.",
"Every figure here uses a colourblind-safe palette and labelled axes, and "
"no axis in this report is truncated below zero.",
],
null_results=[candidate.dropped_because for candidate in discarded(candidates)],
)
for candidate in survivors(candidates):
report.add_panel(candidate, frame)
return report
# ---------------------------------------------------------------------------
# Two deliberately broken specimens, used by the exercises
# ---------------------------------------------------------------------------
def draw_inaccessible(ax: Axes, frame: pd.DataFrame) -> None:
"""A chart that breaks the accessibility contract in three ways at once.
Red against green is the classic pair that vanishes for the commonest
form of colour vision deficiency, and neither axis is labelled. Exercise 9
asserts that the build check catches all of it.
"""
rows = _direct(frame)
for region, colour in zip(REGIONS[:2], ("red", "green")):
series = rows[rows["region"] == region].sort_values("month")
ax.plot(series["month"].to_numpy(), series["revenue"].to_numpy(), color=colour)
def bare_point_estimate_candidate() -> Candidate:
"""A candidate whose finding reports a number with no uncertainty at all."""
def analyse(frame: pd.DataFrame) -> Finding:
total = float(frame["revenue"].sum())
return Finding(
caption=f"Total recorded revenue across the two years is {total:,.0f} USD",
prose="A number with nothing attached to say how firm it is.",
estimate=Estimate(label="total recorded revenue", value=total, unit=" USD", decimals=0),
)
return Candidate(
slug="bare-total",
question="What is the total recorded revenue?",
draw=draw_missing_by_column,
analyse=analyse,
)
starter/conftest.py (1390 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 and unconditionally. Every figure this lab opens is closed by
the code that opened it; `_close_all_figures` is a backstop, not a
substitute.
Every directory fixture is a real temporary directory that is deleted when
the test finishes, so a full run of this lab leaves no image and no
Markdown file behind anywhere on your disk.
"""
import tempfile
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pytest
from analysis import candidate_figures
from data import monthly_sales, perturbed
@pytest.fixture
def frame():
return monthly_sales()
@pytest.fixture
def perturbed_frame():
return perturbed()
@pytest.fixture
def candidates():
return candidate_figures()
def _temporary_directory():
with tempfile.TemporaryDirectory(prefix="d133-report-") as name:
yield Path(name)
@pytest.fixture
def report_dir():
yield from _temporary_directory()
@pytest.fixture
def second_report_dir():
yield from _temporary_directory()
@pytest.fixture
def third_report_dir():
yield from _temporary_directory()
@pytest.fixture(autouse=True)
def _close_all_figures():
yield
plt.close("all")
starter/data.py (4679 bytes)
"""The dataset the Day 133 report is built from, and one perturbed copy.
Everything here is deterministic. `monthly_sales()` draws from a fixed
`numpy.random.default_rng(133)`, so two calls return identical frames and
two runs of the lab produce identical numbers. That determinism is not a
convenience -- exercise 6 asserts byte-identical output across two runs,
and a report generator that cannot be re-run to the same answer is not a
report generator, it is a one-off.
The frame deliberately contains four things a real exploratory analysis
would have to find:
1. **Missing revenue, not spread evenly.** About 4% of rows have no
revenue, and every one of them is a `partner` row. Week 18's damage
report exists to catch exactly this shape.
2. **Two populations in one column.** `partner` rows run at roughly 45%
of the `direct` row for the same region and month, so the revenue
column is a mixture, not a single distribution.
3. **A step change in one segment.** From month 13 the West is scaled by
`WEST_PRICING_FACTOR`, standing in for a pricing change. The other
three regions keep their steady monthly growth.
4. **One anomaly.** East, month 18, is scaled by `ANOMALY_FACTOR`. It is
a single point, which is why the report that quotes it says plainly
that no interval is available for it.
`perturbed()` returns a copy with one input changed. Exercises 3 and 6
use it to prove that the numbers printed in the report's prose come from
the data rather than from typed literals.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
REGIONS: tuple[str, ...] = ("North", "South", "East", "West")
CHANNELS: tuple[str, ...] = ("direct", "partner")
N_MONTHS = 24
SEED = 133
#: From this month onward the West is scaled by WEST_PRICING_FACTOR.
PRICING_CHANGE_MONTH = 13
WEST_PRICING_FACTOR = 0.88
#: The single anomalous observation.
ANOMALY_REGION = "East"
ANOMALY_MONTH = 18
ANOMALY_FACTOR = 3.0
#: Share of rows whose revenue is missing. Every one is a partner row.
MISSING_RATE = 0.04
#: Partner rows run at this share of the same region-month direct row.
PARTNER_SHARE = 0.45
_BASE_LEVEL = {"North": 42000.0, "South": 31000.0, "East": 27000.0, "West": 36000.0}
_MONTHLY_GROWTH = {"North": 1.012, "South": 1.008, "East": 1.015, "West": 1.010}
def monthly_sales() -> pd.DataFrame:
"""Twenty-four months of revenue and orders for four regions and two
channels: 192 rows, eight of them missing their revenue."""
rng = np.random.default_rng(SEED)
records: list[dict[str, object]] = []
for month in range(1, N_MONTHS + 1):
for region in REGIONS:
for channel in CHANNELS:
level = _BASE_LEVEL[region] * _MONTHLY_GROWTH[region] ** (month - 1)
if channel == "partner":
level *= PARTNER_SHARE
if region == "West" and month >= PRICING_CHANGE_MONTH:
level *= WEST_PRICING_FACTOR
if region == ANOMALY_REGION and month == ANOMALY_MONTH:
level *= ANOMALY_FACTOR
revenue = float(level * rng.normal(1.0, 0.05))
order_value = float(rng.normal(180.0, 12.0))
records.append(
{
"month": month,
"region": region,
"channel": channel,
"orders": int(round(revenue / order_value)),
"revenue": revenue,
}
)
frame = pd.DataFrame.from_records(records)
partner_positions = np.asarray(frame.index[frame["channel"] == "partner"])
n_missing = int(round(MISSING_RATE * len(frame)))
blanked = rng.choice(partner_positions, size=n_missing, replace=False)
frame.loc[blanked, "revenue"] = np.nan
return frame
def perturbed() -> pd.DataFrame:
"""The same frame with exactly one input changed: the West's post-change
scaling is halved again.
Nothing else moves. If a number printed in the report's prose is the
same for `monthly_sales()` and for this frame, that number was typed
rather than computed -- which is the whole point of exercise 3.
"""
frame = monthly_sales()
hit = (frame["region"] == "West") & (frame["month"] >= PRICING_CHANGE_MONTH)
frame.loc[hit, "revenue"] = frame.loc[hit, "revenue"] * 0.5
return frame
def source_description() -> str:
"""One line of provenance, quoted verbatim in the rendered report."""
return (
"synthetic monthly sales, 4 regions x 2 channels x 24 months, "
"generated by data.monthly_sales() with numpy default_rng(133)"
)
starter/report.py (17065 bytes)
"""A small EDA report generator that refuses to build a bad report.
This is the course-supplied tool for Day 133. It is deliberately small
enough to read in one sitting, because the point is not the code -- it is
the four rules the code refuses to bend:
* **A figure must have a stated question.** `Report.add_panel` raises
`ReportError` on a candidate whose `question` is missing or blank.
* **A caption must carry a claim.** `carries_claim` demands a number or a
comparative word, so "revenue by region" is rejected and "the West fell
8.4% after the pricing change" is accepted. The heuristic is crude on
purpose: it can tell that a claim was *made*, never whether the claim is
*true*. Judging truth is the reader's job and yours.
* **Every figure is referenced.** `orphan_figures` compares what was
written to disk against what the markdown links to.
* **Every reported estimate carries its uncertainty**, either as an
interval or as an explicit note that no interval is available.
`missing_uncertainty` lists the ones that carry neither.
Two more checks sit alongside those: `check_axes` enforces the
accessibility contract (a colourblind-safe palette and labelled axes), and
`survivors` is the "so what" filter that drops candidate figures with no
question before they ever reach the report.
The renderer writes Markdown with embedded figures. It contains no clock
reading and no random number, so two runs over the same input produce
byte-identical Markdown -- which exercise 6 asserts directly.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Sequence
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.colors import to_hex
__all__ = [
"SAFE_PALETTE",
"ReportError",
"Estimate",
"Finding",
"Candidate",
"Panel",
"Report",
"carries_claim",
"claim_problem",
"survivors",
"orphan_figures",
"missing_uncertainty",
"axes_colours",
"check_axes",
"bootstrap_interval",
]
#: seaborn's "colorblind" palette, as hex. Read off
#: `seaborn.color_palette("colorblind")` on seaborn 0.13.2 and frozen here so
#: the accessibility check has an exact set to compare against.
SAFE_PALETTE: tuple[str, ...] = (
"#0173b2",
"#de8f05",
"#029e73",
"#d55e00",
"#cc78bc",
"#ca9161",
"#fbafe4",
"#949494",
"#ece133",
"#56b4e9",
)
class ReportError(ValueError):
"""Raised when a figure would break one of the report's own rules."""
# ---------------------------------------------------------------------------
# The caption rule: a caption must carry a claim
# ---------------------------------------------------------------------------
#: Words that turn a label into a comparison. Not exhaustive, and not meant
#: to be -- see `carries_claim` for what this check can and cannot do.
CLAIM_WORDS: frozenset[str] = frozenset(
{
"above",
"below",
"beyond",
"concentrated",
"decrease",
"decreased",
"double",
"doubled",
"drop",
"dropped",
"every",
"exceeds",
"fall",
"fell",
"grew",
"growth",
"half",
"halved",
"higher",
"increase",
"increased",
"less",
"lower",
"more",
"no",
"none",
"only",
"outgrew",
"rise",
"rose",
"shrank",
"than",
"times",
"unchanged",
"versus",
"while",
}
)
def claim_problem(caption: str) -> str | None:
"""Return why `caption` fails the claim rule, or None if it passes.
A caption passes if it contains a digit, a percent sign, or one of
`CLAIM_WORDS`. That is a crude test and it is meant to be: it detects
whether a *claim was made*, and it has no way at all to judge whether
the claim is *true*. A caption reading "revenue tripled" passes this
check on data where revenue halved. The check buys you one thing --
it makes the absence of a claim impossible to ship by accident.
"""
text = caption.strip()
if not text:
return "the caption is empty"
if any(character.isdigit() for character in text) or "%" in text:
return None
words = set(re.findall(r"[a-z]+", text.lower()))
if words & CLAIM_WORDS:
return None
return (
"the caption is a label, not a claim: it contains no number and no "
"comparative word, so there is nothing in it a reader could disagree with"
)
def carries_claim(caption: str) -> bool:
"""True when `caption` states something a reader could disagree with."""
return claim_problem(caption) is None
# ---------------------------------------------------------------------------
# Estimates and their uncertainty
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Estimate:
"""One number the report asserts, with its uncertainty attached.
Either give it an interval (`low` and `high`, from Day 118's machinery
or `bootstrap_interval` below) or an explicit `no_interval_note` saying
why one is not available. A bare point estimate carries neither, and
`missing_uncertainty` will find it.
"""
label: str
value: float
unit: str = ""
low: float | None = None
high: float | None = None
no_interval_note: str | None = None
decimals: int = 1
def has_uncertainty(self) -> bool:
interval = self.low is not None and self.high is not None
return interval or bool(self.no_interval_note)
def text(self) -> str:
value = f"{self.value:.{self.decimals}f}{self.unit}"
if self.low is not None and self.high is not None:
low = f"{self.low:.{self.decimals}f}{self.unit}"
high = f"{self.high:.{self.decimals}f}{self.unit}"
return f"{self.label}: {value} (95% interval {low} to {high})"
if self.no_interval_note:
return f"{self.label}: {value} (no interval: {self.no_interval_note})"
return f"{self.label}: {value}"
def bootstrap_interval(
values: Sequence[float] | np.ndarray,
*,
statistic: Callable[[np.ndarray], float] = np.mean,
resamples: int = 2000,
level: float = 0.95,
seed: int = 133,
) -> tuple[float, float]:
"""A percentile bootstrap interval, seeded so the report stays reproducible.
Day 118's interval, computed by resampling rather than by formula, which
is what you reach for when the statistic has no tidy standard error.
"""
sample = np.asarray(values, dtype=float)
sample = sample[~np.isnan(sample)]
if sample.size < 2:
raise ReportError("a bootstrap interval needs at least two observations")
rng = np.random.default_rng(seed)
draws = rng.choice(sample, size=(resamples, sample.size), replace=True)
stats = np.array([float(statistic(row)) for row in draws])
tail = (1.0 - level) / 2.0
return float(np.quantile(stats, tail)), float(np.quantile(stats, 1.0 - tail))
# ---------------------------------------------------------------------------
# Candidates, findings and panels
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Finding:
"""What one figure turned out to say, computed from the data."""
caption: str
prose: str
estimate: Estimate | None = None
@dataclass(frozen=True)
class Candidate:
"""A figure someone made during exploration. Most do not survive.
`question` is the whole filter. A candidate with no question answers no
question, and `survivors` drops it before it can reach the report --
but `dropped_because` is kept, because "we looked and found nothing"
is worth one line to the next reader.
"""
slug: str
question: str | None = None
draw: Callable[[Axes, pd.DataFrame], None] | None = None
analyse: Callable[[pd.DataFrame], Finding] | None = None
dropped_because: str = ""
@dataclass
class Panel:
"""A candidate that survived, with its finding resolved against the data."""
slug: str
question: str
finding: Finding
draw: Callable[[Axes, pd.DataFrame], None]
number: int = 0
image: str = ""
def survivors(candidates: Iterable[Candidate]) -> list[Candidate]:
"""The "so what" filter: keep only candidates that answer a stated question."""
return [c for c in candidates if c.question and c.question.strip()]
def discarded(candidates: Iterable[Candidate]) -> list[Candidate]:
"""The complement of `survivors` -- what the report will not show."""
return [c for c in candidates if not (c.question and c.question.strip())]
# ---------------------------------------------------------------------------
# Accessibility contract
# ---------------------------------------------------------------------------
def axes_colours(ax: Axes) -> list[str]:
"""Every colour actually painted on `ax`, as lowercase hex, alpha dropped."""
found: list[str] = []
for patch in ax.patches:
found.append(to_hex(patch.get_facecolor()))
for line in ax.get_lines():
found.append(to_hex(line.get_color()))
for collection in ax.collections:
for rgba in np.atleast_2d(collection.get_facecolor()):
found.append(to_hex(rgba))
return [colour.lower() for colour in found]
def check_axes(ax: Axes) -> list[str]:
"""Problems with `ax` against the report's accessibility contract.
Days 127 and 132, turned into a build check: every mark must be drawn in
a colourblind-safe colour, and both axes must be labelled. Returns an
empty list when the axes pass.
"""
problems: list[str] = []
if not ax.get_xlabel().strip():
problems.append("the x axis has no label")
if not ax.get_ylabel().strip():
problems.append("the y axis has no label")
safe = {colour.lower() for colour in SAFE_PALETTE}
for colour in sorted(set(axes_colours(ax))):
if colour not in safe:
problems.append(f"colour {colour} is not in the colourblind-safe palette")
return problems
def accessibility_problems(
draw: Callable[[Axes, pd.DataFrame], None], frame: pd.DataFrame
) -> list[str]:
"""Draw into a throwaway figure and run `check_axes` on the result."""
figure, ax = plt.subplots(figsize=(6.0, 3.5), dpi=100)
try:
draw(ax, frame)
return check_axes(ax)
finally:
plt.close(figure)
# ---------------------------------------------------------------------------
# The report
# ---------------------------------------------------------------------------
_IMAGE_LINK = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")
def orphan_figures(markdown: str, figure_dir: Path | str) -> list[str]:
"""Image files on disk that the markdown never links to.
An orphan is not a cosmetic problem. It is either a figure you meant to
discuss and forgot, or a leftover from a previous run that will confuse
whoever opens the directory next.
"""
referenced = {Path(target).name for target in _IMAGE_LINK.findall(markdown)}
directory = Path(figure_dir)
if not directory.is_dir():
return []
return sorted(p.name for p in directory.glob("*.png") if p.name not in referenced)
def missing_uncertainty(report: "Report") -> list[str]:
"""Slugs of panels whose estimate carries neither an interval nor a note."""
return [
panel.slug
for panel in report.panels
if panel.finding.estimate is not None
and not panel.finding.estimate.has_uncertainty()
]
@dataclass
class Report:
"""An ordered argument with evidence attached."""
title: str
question: str
decision: str
provenance: str
caveats: list[str] = field(default_factory=list)
null_results: list[str] = field(default_factory=list)
panels: list[Panel] = field(default_factory=list)
data_fingerprint: str = ""
def add_panel(self, candidate: Candidate, frame: pd.DataFrame) -> Panel:
"""Resolve one candidate against the data and admit it to the report.
Raises `ReportError` when the candidate has no stated question, when
its caption is a label rather than a claim, or when it has no way to
draw itself or to compute its finding.
"""
if not candidate.question or not candidate.question.strip():
raise ReportError(
f"figure {candidate.slug!r} has no stated question; a figure whose "
"question you cannot state is a figure that does not belong in the report"
)
if candidate.draw is None or candidate.analyse is None:
raise ReportError(
f"figure {candidate.slug!r} has a question but no way to answer it"
)
finding = candidate.analyse(frame)
problem = claim_problem(finding.caption)
if problem is not None:
raise ReportError(f"figure {candidate.slug!r}: {problem}")
panel = Panel(
slug=candidate.slug,
question=candidate.question.strip(),
finding=finding,
draw=candidate.draw,
number=len(self.panels) + 1,
)
panel.image = f"figures/{panel.number:02d}-{panel.slug}.png"
self.panels.append(panel)
return panel
# -- rendering ---------------------------------------------------------
def render(self, outdir: Path | str, frame: pd.DataFrame) -> str:
"""Write the figures and `report.md` into `outdir`, and return the markdown.
The output contains no clock reading and no unseeded random number,
so two runs over the same input are byte-identical. Provenance is a
fingerprint of the data, not a timestamp of the run.
"""
directory = Path(outdir)
figure_dir = directory / "figures"
figure_dir.mkdir(parents=True, exist_ok=True)
for panel in self.panels:
figure, ax = plt.subplots(figsize=(6.0, 3.5), dpi=100)
try:
panel.draw(ax, frame)
figure.tight_layout()
figure.savefig(directory / panel.image)
finally:
plt.close(figure)
markdown = self._markdown(frame)
(directory / "report.md").write_text(markdown, encoding="utf-8")
return markdown
def fingerprint(self, frame: pd.DataFrame) -> str:
"""A short content hash of the input, so provenance names the data."""
payload = frame.to_csv(index=False).encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:12]
def _markdown(self, frame: pd.DataFrame) -> str:
lines: list[str] = []
add = lines.append
add(f"# {self.title}")
add("")
add(f"**Question.** {self.question}")
add("")
add(f"**Decision this feeds.** {self.decision}")
add("")
add("## Conclusion")
add("")
if not self.panels:
add("No figure in this analysis answered a stated question.")
for panel in self.panels:
add(f"{panel.number}. {panel.finding.caption} (Figure {panel.number})")
add("")
for panel in self.panels:
if panel.finding.estimate is not None:
add(f"- {panel.finding.estimate.text()}")
add("")
add("## What we looked at and found nothing in")
add("")
if self.null_results:
for note in self.null_results:
add(f"- {note}")
else:
add("- Nothing was set aside; every candidate figure answered a question.")
add("")
add("## Evidence")
add("")
for panel in self.panels:
add(f"### Figure {panel.number} — {panel.question}")
add("")
add(f"")
add("")
add(f"**Figure {panel.number}.** {panel.finding.caption}")
add("")
add(panel.finding.prose)
add("")
if panel.finding.estimate is not None:
add(f"*{panel.finding.estimate.text()}*")
add("")
add("## Caveats")
add("")
for caveat in self.caveats:
add(f"- {caveat}")
add("")
add("## Provenance")
add("")
add(f"- Source: {self.provenance}")
add(f"- Shape: {len(frame)} rows, {len(frame.columns)} columns")
add(f"- Data fingerprint (sha256, first 12): `{self.fingerprint(frame)}`")
add(
"- This document was generated by code from the input above. Nothing in "
"it was typed by hand, so re-running it on new data cannot leave the "
"prose disagreeing with the figures."
)
add("")
return "\n".join(lines)
starter/test_report.py (8030 bytes)
"""Your exercises for Day 133 -- "A Report That Argues".
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, `report.py` for
the generator you are testing, and `analysis.py` for the twelve candidate
figures it is fed.
Check yourself at any point:
pytest starter -v
The reference answer key lives in `examples/test_report.py` -- read it
AFTER you have tried, never before.
"""
from __future__ import annotations
import re
import shutil
from pathlib import Path
import pytest
from analysis import (
analyse_missing,
bare_point_estimate_candidate,
build_report,
candidate_figures,
draw_inaccessible,
draw_missing_by_column,
)
from report import (
Candidate,
Finding,
Report,
ReportError,
accessibility_problems,
carries_claim,
claim_problem,
discarded,
missing_uncertainty,
orphan_figures,
survivors,
)
def line_containing(markdown: str, needle: str) -> str:
"""The one line of `markdown` that contains `needle`."""
hits = [line for line in markdown.splitlines() if needle in line]
assert len(hits) >= 1, f"no line contains {needle!r}"
return hits[0]
def percentage_in(text: str) -> float:
"""The first signed percentage in `text`, as a float."""
match = re.search(r"(-?\d+(?:\.\d+)?)%", text)
assert match is not None, f"no percentage in {text!r}"
return float(match.group(1))
def blank_report() -> Report:
return Report(
title="A scratch report",
question="Does the generator hold its own line?",
decision="Whether to trust anything this generator produces.",
provenance="the same synthetic frame the real report uses",
)
# --------------------------------------------------------------------------
# Exercise 1 -- a figure must have a question.
# --------------------------------------------------------------------------
def test_01_a_figure_must_have_a_question(frame):
pytest.skip(
"Build a Candidate with no question (and one with a blank ' ' question) "
"and assert blank_report().add_panel(...) raises ReportError mentioning "
"'no stated question' and leaves report.panels empty; then add the same "
"candidate WITH a question and assert the panel is admitted, numbered 1, "
"with image 'figures/01-pretty-chart.png'"
)
# --------------------------------------------------------------------------
# Exercise 2 -- the caption carries the claim.
# --------------------------------------------------------------------------
def test_02_caption_carries_a_claim(frame):
pytest.skip(
"Assert carries_claim('revenue by region') is False and that "
"claim_problem() explains it is a label rather than a claim; assert a "
"caption with a number in it passes; assert add_panel refuses a Finding "
"whose caption is only a label; then assert BOTH honest limits of the "
"heuristic -- 'revenue doubled in every region' passes even though the "
"check cannot know it is false, and 'revenue tripled in all four regions' "
"is refused even though it is a real claim"
)
# --------------------------------------------------------------------------
# Exercise 3 -- numbers in the prose come from the data.
# --------------------------------------------------------------------------
def test_03_numbers_come_from_the_data(frame, perturbed_frame, report_dir, second_report_dir):
pytest.skip(
"Render build_report(frame) and build_report(perturbed_frame) into the two "
"temporary directories, pull the line containing 'West change across the "
"pricing change' out of each with line_containing, and assert the two lines "
"differ, that the original percentage is negative, and that the perturbed "
"one is more than 20 points lower; assert the 'Data fingerprint' lines "
"differ too"
)
# --------------------------------------------------------------------------
# Exercise 4 -- every figure is referenced.
# --------------------------------------------------------------------------
def test_04_no_orphan_figures(frame, report_dir):
pytest.skip(
"Render the report, assert five PNG files were written and that "
"orphan_figures(markdown, report_dir / 'figures') is empty and every "
"written name appears in the markdown; then shutil.copy one figure to "
"'99-left-over.png' and assert orphan_figures now returns exactly that name"
)
# --------------------------------------------------------------------------
# Exercise 5 -- uncertainty is stated, not implied.
# --------------------------------------------------------------------------
def test_05_uncertainty_is_present(frame):
pytest.skip(
"Assert missing_uncertainty(build_report(frame)) is empty, that all five "
"panels carry an Estimate, that four of them carry a 95% interval "
"(low < value < high) and one carries an explicit no_interval_note; then "
"add bare_point_estimate_candidate() and assert missing_uncertainty now "
"returns ['bare-total']"
)
# --------------------------------------------------------------------------
# Exercise 6 -- reproducibility.
# --------------------------------------------------------------------------
def test_06_two_runs_are_byte_identical(
frame, perturbed_frame, report_dir, second_report_dir, third_report_dir
):
pytest.skip(
"Render the same frame twice into two directories and assert the returned "
"markdown strings are equal, the two report.md files are byte-identical, "
"and each figures/*.png pair is byte-identical; then render the perturbed "
"frame and assert it differs; finally assert no ISO date appears anywhere "
"in the output (re.search(r'\\b20\\d\\d-\\d\\d-\\d\\d\\b', markdown) is None)"
)
# --------------------------------------------------------------------------
# Exercise 7 -- ordering for the reader.
# --------------------------------------------------------------------------
def test_07_conclusion_comes_before_the_evidence(frame, report_dir):
pytest.skip(
"Render the report and assert markdown.index() puts '## Conclusion' before "
"'## What we looked at and found nothing in', before '## Evidence', before "
"'## Caveats', before '## Provenance', and before '### Figure 1'; then "
"assert each panel's numbered caption line appears verbatim in the "
"conclusion, so the claim and the finding cannot drift apart"
)
# --------------------------------------------------------------------------
# Exercise 8 -- the "so what" filter.
# --------------------------------------------------------------------------
def test_08_the_so_what_filter(frame, candidates, report_dir):
pytest.skip(
"Assert there are 12 candidates, that survivors() keeps 5 and discarded() "
"drops 7, and that fewer than half survive; render the report and assert "
"no dropped slug appears in the markdown and no figure file was written "
"for one, while every dropped candidate's dropped_because line IS in the "
"markdown, and every surviving slug has a figure file"
)
# --------------------------------------------------------------------------
# Exercise 9 -- the accessibility contract, as a build check.
# --------------------------------------------------------------------------
def test_09_accessibility_contract(frame, candidates):
pytest.skip(
"Assert accessibility_problems(candidate.draw, frame) is empty for every "
"surviving candidate; then assert accessibility_problems(draw_inaccessible, "
"frame) reports exactly four problems -- an unlabelled x axis, an "
"unlabelled y axis, and the two off-palette colours #ff0000 and #008000"
)
tests/run_tests.sh (20410 bytes)
#!/usr/bin/env bash
# Tests for the Day 133 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The harness proves the day's claims by running the real report generator
# over the real frame and reading the document it produces -- never by
# reading source and never by comparing image bytes across machines:
#
# * the generator REFUSES a figure with no stated question, and accepts
# the identical figure once a question is attached;
# * it REFUSES a caption that is a label ("revenue by region") and
# accepts one carrying a number;
# * the numbers printed in the prose move when one input value moves,
# so the text cannot drift away from the figures;
# * the rendered report has no orphan figure, and a deliberately
# orphaned file is detected;
# * every reported estimate carries a 95% interval or an explicit note
# saying why none is available, and a bare point estimate is caught;
# * two runs over the same input produce byte-identical Markdown, and
# changing one input value changes it;
# * the conclusion is rendered before the detailed evidence;
# * 12 candidate figures go in and 5 come out -- the "so what" filter,
# with the 7 discarded ones surviving only as one-line null results;
# * every figure passes the accessibility contract, and a red-against-
# green chart with unlabelled axes fails it with four named problems;
# * 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 failure, then restoring it;
# * no image and no generated Markdown is left behind anywhere.
#
# 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 matplotlib, pandas, numpy" >/dev/null 2>&1; then
echo "FAIL: matplotlib, pandas or numpy 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 133 — A Report That Argues"
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 ("matplotlib", "seaborn", "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. The generator's own rules, exercised directly"
# --------------------------------------------------------------------------
behaviour="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY'
"""Drive the report generator and print one machine-readable line per claim."""
import re
import shutil
import tempfile
from pathlib import Path
import data
import analysis
import report as R
frame = data.monthly_sales()
results = {}
def record(key, value):
results[key] = value
# -- a figure must have a question -----------------------------------------
blank = R.Report(title="t", question="q", decision="d", provenance="p")
nameless = R.Candidate(
slug="pretty", draw=analysis.draw_missing_by_column, analyse=analysis.analyse_missing
)
try:
blank.add_panel(nameless, frame)
record("refuses_questionless", "no")
except R.ReportError as exc:
record("refuses_questionless", "yes" if "no stated question" in str(exc) else "no")
record("questionless_left_no_panel", "yes" if not blank.panels else "no")
asked = R.Candidate(
slug="pretty",
question="Which rows have no revenue?",
draw=analysis.draw_missing_by_column,
analyse=analysis.analyse_missing,
)
blank.add_panel(asked, frame)
record("accepts_with_question", "yes" if len(blank.panels) == 1 else "no")
# -- the caption must carry a claim ----------------------------------------
record("rejects_label_caption", "no" if R.carries_claim("revenue by region") else "yes")
record(
"accepts_claim_caption",
"yes"
if R.carries_claim("three regions grew, and the fourth fell by 12% after the March change")
else "no",
)
# -- the so-what filter ----------------------------------------------------
candidates = analysis.candidate_figures()
kept = R.survivors(candidates)
dropped = R.discarded(candidates)
record("candidates_total", str(len(candidates)))
record("candidates_kept", str(len(kept)))
record("candidates_dropped", str(len(dropped)))
# -- render twice, and once from a perturbed frame -------------------------
first_dir = Path(tempfile.mkdtemp(prefix="d133-a-"))
second_dir = Path(tempfile.mkdtemp(prefix="d133-b-"))
third_dir = Path(tempfile.mkdtemp(prefix="d133-c-"))
try:
first = analysis.build_report(frame).render(first_dir, frame)
second = analysis.build_report(frame).render(second_dir, frame)
changed_frame = data.perturbed()
third = analysis.build_report(changed_frame).render(third_dir, changed_frame)
record(
"markdown_byte_identical",
"yes"
if (first_dir / "report.md").read_bytes() == (second_dir / "report.md").read_bytes()
else "no",
)
png_same = all(
p.read_bytes() == (second_dir / "figures" / p.name).read_bytes()
for p in sorted((first_dir / "figures").glob("*.png"))
)
record("figure_bytes_identical_same_machine", "yes" if png_same else "no")
record("changed_input_changes_document", "yes" if third != first else "no")
record("no_clock_reading_in_output", "no" if re.search(r"\b20\d\d-\d\d-\d\d\b", first) else "yes")
def west(text):
line = [ln for ln in text.splitlines() if "West change across the pricing change" in ln][0]
return float(re.search(r"(-?\d+(?:\.\d+)?)%", line).group(1))
record("west_change_original", f"{west(first):.1f}")
record("west_change_perturbed", f"{west(third):.1f}")
# -- ordering ----------------------------------------------------------
order = [
first.index("## Conclusion"),
first.index("## What we looked at and found nothing in"),
first.index("## Evidence"),
first.index("## Caveats"),
first.index("## Provenance"),
]
record("sections_in_reader_order", "yes" if order == sorted(order) else "no")
record(
"conclusion_before_first_figure",
"yes" if first.index("## Conclusion") < first.index("### Figure 1") else "no",
)
# -- orphans -----------------------------------------------------------
figure_dir = first_dir / "figures"
record("figures_written", str(len(list(figure_dir.glob("*.png")))))
record("no_orphans_in_report", "yes" if R.orphan_figures(first, figure_dir) == [] else "no")
shutil.copy(sorted(figure_dir.glob("*.png"))[0], figure_dir / "99-left-over.png")
record(
"orphan_is_detected",
"yes" if R.orphan_figures(first, figure_dir) == ["99-left-over.png"] else "no",
)
# -- dropped candidates never reach the document -----------------------
record(
"dropped_slugs_absent_from_report",
"yes" if all(c.slug not in first for c in dropped) else "no",
)
record(
"null_results_kept_as_one_line_each",
"yes" if all(c.dropped_because in first for c in dropped) else "no",
)
finally:
for directory in (first_dir, second_dir, third_dir):
shutil.rmtree(directory, ignore_errors=True)
# -- uncertainty -----------------------------------------------------------
built = analysis.build_report(frame)
record("every_estimate_has_uncertainty", "yes" if R.missing_uncertainty(built) == [] else "no")
estimates = [p.finding.estimate for p in built.panels]
record("estimates_with_interval", str(sum(1 for e in estimates if e.low is not None)))
record("estimates_with_explicit_note", str(sum(1 for e in estimates if e.no_interval_note)))
built.add_panel(analysis.bare_point_estimate_candidate(), frame)
record(
"bare_point_estimate_is_caught",
"yes" if R.missing_uncertainty(built) == ["bare-total"] else "no",
)
# -- accessibility ---------------------------------------------------------
clean = all(R.accessibility_problems(c.draw, frame) == [] for c in kept)
record("every_figure_passes_accessibility", "yes" if clean else "no")
bad = R.accessibility_problems(analysis.draw_inaccessible, frame)
record("inaccessible_chart_problem_count", str(len(bad)))
record(
"inaccessible_chart_names_both_colours",
"yes" if any("#ff0000" in p for p in bad) and any("#008000" in p for p in bad) else "no",
)
for key, value in results.items():
print(f"{key}={value}")
PY
)"
behaviour_status=$?
echo "${behaviour}"
echo
value_of() { echo "${behaviour}" | grep "^$1=" | cut -d= -f2-; }
check "the generator ran without error" "$( [ ${behaviour_status} -eq 0 ] && echo yes || echo no )"
check "refuses a figure with no stated question" "$( [ "$(value_of refuses_questionless)" = yes ] && echo yes || echo no )"
check "a refused figure leaves no half-added panel" "$( [ "$(value_of questionless_left_no_panel)" = yes ] && echo yes || echo no )"
check "accepts the identical figure once it has a question" "$( [ "$(value_of accepts_with_question)" = yes ] && echo yes || echo no )"
check "rejects the caption 'revenue by region' as a label" "$( [ "$(value_of rejects_label_caption)" = yes ] && echo yes || echo no )"
check "accepts a caption carrying a number" "$( [ "$(value_of accepts_claim_caption)" = yes ] && echo yes || echo no )"
check "12 candidate figures go in" "$( [ "$(value_of candidates_total)" = 12 ] && echo yes || echo no )"
check "5 survive the 'so what' filter and 7 do not" "$( [ "$(value_of candidates_kept)" = 5 ] && [ "$(value_of candidates_dropped)" = 7 ] && echo yes || echo no )"
check "two runs over the same input produce byte-identical Markdown" "$( [ "$(value_of markdown_byte_identical)" = yes ] && echo yes || echo no )"
check "figure PNG bytes match across two runs on this machine" "$( [ "$(value_of figure_bytes_identical_same_machine)" = yes ] && echo yes || echo no )"
check "changing one input value changes the document" "$( [ "$(value_of changed_input_changes_document)" = yes ] && echo yes || echo no )"
check "nothing in the output is a clock reading" "$( [ "$(value_of no_clock_reading_in_output)" = yes ] && echo yes || echo no )"
check "the West figure in the prose moved with the data" "$( [ "$(value_of west_change_original)" != "$(value_of west_change_perturbed)" ] && echo yes || echo no )"
check "conclusion, omissions, evidence, caveats, provenance are in reader order" "$( [ "$(value_of sections_in_reader_order)" = yes ] && echo yes || echo no )"
check "the conclusion is rendered before Figure 1" "$( [ "$(value_of conclusion_before_first_figure)" = yes ] && echo yes || echo no )"
check "five figures written, none of them orphaned" "$( [ "$(value_of figures_written)" = 5 ] && [ "$(value_of no_orphans_in_report)" = yes ] && echo yes || echo no )"
check "a deliberately orphaned figure is detected" "$( [ "$(value_of orphan_is_detected)" = yes ] && echo yes || echo no )"
check "no discarded slug reaches the rendered report" "$( [ "$(value_of dropped_slugs_absent_from_report)" = yes ] && echo yes || echo no )"
check "every discarded candidate survives as a one-line null result" "$( [ "$(value_of null_results_kept_as_one_line_each)" = yes ] && echo yes || echo no )"
check "every reported estimate carries an interval or an explicit note" "$( [ "$(value_of every_estimate_has_uncertainty)" = yes ] && echo yes || echo no )"
check "four estimates carry an interval and one carries an explicit note" "$( [ "$(value_of estimates_with_interval)" = 4 ] && [ "$(value_of estimates_with_explicit_note)" = 1 ] && echo yes || echo no )"
check "a bare point estimate is caught" "$( [ "$(value_of bare_point_estimate_is_caught)" = yes ] && echo yes || echo no )"
check "every figure passes the accessibility contract" "$( [ "$(value_of every_figure_passes_accessibility)" = yes ] && echo yes || echo no )"
check "the red-against-green chart fails with four named problems" "$( [ "$(value_of inaccessible_chart_problem_count)" = 4 ] && [ "$(value_of inaccessible_chart_names_both_colours)" = yes ] && echo yes || echo no )"
echo
# --------------------------------------------------------------------------
echo "3. 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 "4. 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 "5. Never run 'pytest examples starter' in one invocation -- both"
echo " directories define a module named test_report.py, and pytest"
echo " collects by dotted module name. Documented, and run as two commands."
# --------------------------------------------------------------------------
combined_output="$(cd "${lab_dir}" && "${pytest_bin}" examples starter -q 2>&1)"
combined_status=$?
check "'pytest examples starter' aborts 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 "6. 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 failure, then restore."
# --------------------------------------------------------------------------
scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/d133-scratch.XXXXXX")"
cleanup_scratch() { rm -rf "${scratch_dir}"; }
trap cleanup_scratch EXIT
for module in test_report.py analysis.py report.py data.py conftest.py; do
cp "${lab_dir}/examples/${module}" "${scratch_dir}/${module}"
done
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 8's exact survivor count on purpose.
sed -i.bak 's/assert len(kept) == 5/assert len(kept) == 99/' "${scratch_dir}/test_report.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 failure" "$( echo "${broken_output}" | grep -qiE 'failed|assert' && echo yes || echo no )"
mv "${scratch_dir}/test_report.py.bak" "${scratch_dir}/test_report.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 "7. Offline, and nothing left behind"
# --------------------------------------------------------------------------
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 '*.jpg' -o -iname '*.svg' -o -iname '*.pdf' \) -print 2>/dev/null || true)"
check "no image files anywhere inside the lab" "$( [ -z "${image_hits}" ] && echo yes || echo no )"
report_hits="$(find "${lab_dir}" -name '.venv' -prune -o -type f -name 'report.md' -print 2>/dev/null || true)"
check "no generated report.md left inside the lab" "$( [ -z "${report_hits}" ] && echo yes || echo no )"
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 )"
leftover_tmp="$(find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'd133-*' -print 2>/dev/null || true)"
check "no d133 temporary directory left in the system temp directory" "$( [ -z "${leftover_tmp}" ] && echo yes || echo no )"
echo
echo "-------------------------------------------------------------"
echo "${checks} checks, ${failures} failure(s)"
if [ "${failures}" -gt 0 ]; then
exit 1
fi
exit 0
Troubleshooting
Troubleshooting
pytest: command not found, or the harness exits before any check
You have not created the lab's virtual environment, or you are calling
bare pytest rather than the one in .venv.
cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
bash tests/run_tests.sh
The harness looks for .venv/bin/pytest first, then anything on your
PATH. To point it at an interpreter of your own:
PYTEST=/path/to/pytest bash tests/run_tests.sh
E ImportError while loading conftest or ModuleNotFoundError: No module named 'report'
You ran pytest from the wrong directory, or with an import mode that does
not put the test file's own directory on sys.path. Run from the lab
directory and name the directory, not the file:
.venv/bin/pytest starter # correct
.venv/bin/pytest starter/test_report.py # also fine
cd starter && pytest . # also fine
import file mismatch and a collection error
You ran pytest examples starter in one command. starter/test_report.py
and examples/test_report.py have the same module name, and pytest
collects test modules by dotted name, so the second import collides with
the first. Run them as two commands:
.venv/bin/pytest starter -q
.venv/bin/pytest examples -q
Section 5 of the harness runs the combined form deliberately and asserts it fails, so this is a documented behaviour rather than a surprise.
A window tries to open, or RuntimeError: main thread is not in main loop
Something imported matplotlib.pyplot before matplotlib.use("Agg")
ran. The backend can only be set before the first pyplot import.
conftest.py sets it on its second and third lines for exactly this
reason. If you added an import above it, move yours below. If you are
running the generator outside pytest, report.py sets Agg itself at
import — but only if report is the first thing that imports pyplot.
Belt and braces:
MPLBACKEND=Agg .venv/bin/pytest starter -q
version mismatch in section 1 of the harness
The harness compares every installed version against
requirements/requirements.txt and reports any difference. This is a
real check, not a formality: report.SAFE_PALETTE is
seaborn.color_palette("colorblind") frozen as it stands in seaborn
0.13.2, and exercise 9 compares against it exactly.
If you cannot install the pins, the nine exercises will very likely still
pass on any matplotlib 3.x, pandas 2.2+ and NumPy 2.x.
expected-output/FIELDS.md records exactly which captured numbers are
version-sensitive.
Exercise 6 fails on the figure bytes
Exercise 6 asserts that two renders of the same input produce identical
PNG bytes. That comparison is between two runs in the same session on
the same machine, and it should hold. If it does not, the usual cause is
that something non-deterministic crept into a draw function — a
random call without a seed, a dictionary iterated in a different order,
or a colour picked from a set.
What this assertion never claims is that your PNG bytes match the bytes on some other machine. They legitimately may not: matplotlib rasterises text through FreeType, and a different FreeType build or a different set of installed fonts produces different pixels from identical code. The byte-identity that the lesson leans on is the Markdown, which contains no rendered glyphs at all.
Exercise 3 passes but the numbers look identical
If the West's percentage is the same for monthly_sales() and
perturbed(), you rendered the same frame twice. build_report(frame)
and render(directory, frame) both take the frame, and they must be the
same one:
changed = data.perturbed()
markdown = analysis.build_report(changed).render(directory, changed) # both `changed`
Passing monthly_sales() to one and perturbed() to the other produces a
document whose figures and prose disagree — which is, with some irony,
precisely the failure the exercise exists to prevent.
ReportError when you did not expect one
That is usually the generator doing its job. The three messages:
| Message contains | What it means |
|---|---|
no stated question |
The candidate's question is None or blank |
has a question but no way to answer it |
draw or analyse is None |
label, not a claim |
The caption has no number and no comparative word |
The third is the one that surprises people. "revenue by region" is a
label. "revenue fell 12% in the West" is a claim. The check cannot tell
whether the claim is true — only that one was made.
The lab left something behind
It should not, and section 7 of the harness checks. If you find a stray directory, it is almost certainly from a manual render you did yourself rather than from the tests:
find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache out
Windows
Use WSL2 and follow the Linux instructions. Native Windows works for the
Python parts if you substitute .venv\Scripts\python.exe and
.venv\Scripts\pytest.exe, but tests/run_tests.sh is a bash script and
needs Git Bash or WSL; it will not run in cmd.exe or PowerShell.
Security notes
Security notes
What this lab does to your machine
- Opens one network connection, ever:
pip install -r requirements/requirements.txt, which downloads matplotlib, seaborn, pandas, NumPy and pytest from PyPI into this lab's own.venv. Everything after that runs completely offline. Section 7 of the harness asserts directly that no URL of any kind appears instarter/orexamples/. - Renders headless via matplotlib's
Aggbackend —matplotlib.use("Agg")runs in eachconftest.pybeforepyplotis imported anywhere, andreport.pysets it again at import for anyone who uses the generator outside pytest.MPLBACKEND=Aggis exported by the harness as a third belt. No window 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(created by you), transient__pycache__and.pytest_cachedirectories the harness removes both before and after every run, and temporary directories created withtempfile.TemporaryDirectoryandmktemp -dthat are deleted when the test or the harness section that made them finishes. - Never binds a port, never needs
sudo, never reads or writes a file outside this lab's directory and those temporary directories. - Needs no credential, API key, or account of any kind.
What the data in this lab is
data.monthly_sales() generates every row from a fixed
numpy.random.default_rng(133) call. Nothing is loaded from a file,
downloaded, or derived from any real organisation's or person's data. The
"pricing change", the missing partner rows and the single anomalous month
are all put there deliberately by data.py, and the module says so at the
top.
That matters for more than privacy. A generated report that argues about data whose truth you already know is the only way to check that the argument is honest — you can compare what the report claims against what you put in.
The part of this lab that is actually a security habit
A report generator writes files, and two of its behaviours are worth naming as controls rather than as features:
- Nothing is interpolated from user-controlled text into a shell or a
template engine. The generator builds Markdown by joining strings in
Python. It does not shell out, it does not
eval, and it does not render through a template language, so there is no injection surface in it at all. - Provenance is a hash of the input, not a note about the run. The
report records
sha256of the input frame's CSV serialisation, first twelve hex characters. That is what makes two runs byte-identical, and it is also what lets a reader ask "was this built from the data I think it was built from?" and get an answer. A report that records only a timestamp cannot answer that question.
What to be careful about if you point this at real data
The generator embeds computed numbers directly into prose. That is the point — but it means anything in the input reaches the output. Before you run it on a real dataset:
- Check what your captions and prose would print. A caption interpolating a "top customer by revenue" prints a customer name into a document you may be about to share.
- The
sha256fingerprint is of the data, so it changes when the data changes. It is not a secret, but it is a fingerprint: two people can tell whether they hold the same input without either showing it. - Figures are raster images with no metadata scrubbing applied. matplotlib
does not embed a filename or a user name in a PNG by default, but if you
add
savefig(..., metadata=...)you are responsible for what goes in.
Cleanup
find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv
Nothing else is created. Section 7 of the harness checks that claim
rather than asserting it: it looks for image files, for a generated
report.md, for __pycache__, for .pytest_cache, and for any
d133-* directory left in the system temporary directory, and fails if
it finds one.