Math, Statistics, and Data › Data Visualization › Day 132
Hands-on lab — Day 132: Visual Storytelling and Chart Honesty
- ← Back to the Day 132 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-132-visual-storytelling-and-chart-honesty/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-132-visual-storytelling-and-chart-honesty
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import matplotlib, seaborn, pandas; print(matplotlib.__version__, seaborn.__version__, pandas.__version__)" Run
cd examples && ../.venv/bin/python3 01_lie_factor.py && cd ..
cd examples && ../.venv/bin/python3 02_bars_versus_lines.py && cd ..
cd examples && ../.venv/bin/python3 03_dual_axes.py && cd ..
cd examples && ../.venv/bin/python3 04_cherry_picked_window.py && cd ..
cd examples && ../.venv/bin/python3 05_binning_changes_the_conclusion.py && cd ..
cd examples && ../.venv/bin/python3 06_radius_versus_area.py && cd ..
cd examples && ../.venv/bin/python3 07_three_d_distortion.py && cd ..
cd examples && ../.venv/bin/python3 08_ordering_and_annotation.py && cd ..
cd examples && ../.venv/bin/python3 09_caption_contract.py && cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
.venv/bin/pytest starter -q -p no:cacheprovider Test
bash tests/run_tests.sh File tree
examples/01_lie_factor.py examples/02_bars_versus_lines.py examples/03_dual_axes.py examples/04_cherry_picked_window.py examples/05_binning_changes_the_conclusion.py examples/06_radius_versus_area.py examples/07_three_d_distortion.py examples/08_ordering_and_annotation.py examples/09_caption_contract.py examples/conftest.py examples/honesty.py examples/test_reference.py expected-output/01-lie-factor.txt expected-output/02-bars-versus-lines.txt expected-output/03-dual-axes.txt expected-output/04-cherry-picked-window.txt expected-output/05-binning-changes-the-conclusion.txt expected-output/06-radius-versus-area.txt expected-output/07-three-d-distortion.txt expected-output/08-ordering-and-annotation.txt expected-output/09-caption-contract.txt expected-output/FIELDS.md expected-output/pytest-examples.txt expected-output/pytest-starter.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/00_brief.md starter/conftest.py starter/honesty.py starter/test_starter.py tests/run_tests.sh troubleshooting.md
Lab README
Day 132 Lab — Charts That Cannot Lie To You
Lesson
- Lesson title: Visual Storytelling and Chart Honesty
- Day number: 132 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-132-visual-storytelling-and-chart-honesty
- 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-132-visual-storytelling-and-chart-honestywhen the site is running.
Purpose
Most misleading charts are made by honest people. The techniques in this lab are not tricks a villain reaches for — they are defaults, conveniences and reasonable-looking choices that happen to change the conclusion. So the goal here is not a gallery of villainy. It is an instrument: a way to check your own work.
You build that instrument in nine exercises. Each one takes a distortion
that reads as a judgment call and turns it into a number measured off the
chart's own rendered geometry. By the end you have review_chart — four
checks you can run on any figure you are about to publish, which pass an
honest chart, fail a truncated one, and pass a chart that breaks a rule
and says so.
The measuring stick throughout is Tufte's lie factor: the size of the
effect shown in the graphic divided by the size of the effect in the
data. Two bars, 100 and 102. On a zero baseline the lie factor is
1.0000. Move the axis floor to 99 and one bar is three times the height
of the other — a measured drawn ratio of 3.0000 — for a lie factor of
2.9412. Same two numbers, one line of code apart.
Learning objectives
By the end of this lab you can:
- Compute a lie factor from a chart's rendered geometry rather than from the numbers that were plotted.
- Explain why a truncated axis destroys a bar chart's encoding and leaves a line chart's intact, and prove both with a measurement.
- State precisely what a dual y-axis can and cannot do to an apparent relationship — including the part almost every warning about dual axes gets wrong.
- Show that a trend's sign is chosen by the window, and defend against it.
- Show that two citable bin rules can support opposite claims about the same sample.
- Explain why encoding by radius squares every ratio.
- Measure how far 3D perspective moves a comparison that a flat chart gets exactly right.
- Treat ordering, annotation and luminance contrast as measurable craft rather than decoration.
- Run a reusable review contract over your own charts before publishing.
Prerequisites
- Day 127 — chart choice and the perceptual ranking, including that radius encoding squares ratios and that a red/green palette collapses for colour-deficient readers.
- Day 128 — the matplotlib object model, and testing a chart by asserting on its artists rather than diffing pixels. Every measurement here is that pattern applied.
- Day 130 — bin width as a choice. This lab supplies the consequences.
- Day 131 — time series, including what a chosen window does to a trend.
- Day 116 — Simpson's paradox, referenced in the lesson, not re-derived here.
- Comfort with Python functions, NumPy arrays, and reading a stack trace.
Supported operating systems
macOS and Linux, exactly as written. Everything runs headless on
matplotlib's Agg backend — no display server, no GUI toolkit, no
window — so it works identically over SSH and in a container.
This lab was executed and captured on macOS 26.5.2 (Apple Silicon,
arm64) only. Linux uses identical commands. The Windows equivalents are
in troubleshooting.md, documented from the standard Python packaging
layout; they were not exercised on the authoring machine, and that is
stated rather than glossed.
Hardware requirements
Any machine that runs Python 3.14. The whole suite finishes in a few seconds and holds a couple of dozen small figures in memory, one at a time. No GPU, no special hardware, roughly 400 MB of disk for the virtual environment.
Required software
- 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, for the test harness
Exact pins are in requirements/requirements.txt, and
tests/run_tests.sh checks each installed version against that file so a
mismatch is reported rather than silently producing different numbers.
Free and open-source options
Every tool in this lab is free and open source, and there is no paid tier
of any of it to be nudged toward. matplotlib, seaborn, pandas, NumPy and
pytest are all permissively licensed and installed with one pip
command. Python itself is free.
The commercial BI tools discussed in the lesson — Tableau, Power BI — are not used here, are not installed, and produce no output anywhere in this lab. Everything said about them comes from their published documentation and is marked as such.
Installation
From this directory:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import matplotlib, seaborn, pandas; print(matplotlib.__version__, seaborn.__version__, pandas.__version__)"
That last command should print 3.11.1 0.13.2 3.0.5. The pip install
is the only step that touches the network; everything after it runs
offline.
File structure
day-132-visual-storytelling-and-chart-honesty/
├── README.md this file
├── metadata.yml lab metadata and the literal result of the real run
├── security.md what this lab touches, and what it does not
├── troubleshooting.md the failures you are most likely to hit
├── requirements/
│ ├── README.md why each pin is here
│ └── requirements.txt the exact versions this lab was run against
├── starter/
│ ├── 00_brief.md the nine exercises, in order
│ ├── honesty.py 14 functions to write; everything else works
│ ├── test_starter.py skips what you have not written yet
│ └── conftest.py keeps this directory's `honesty` module its own
├── examples/
│ ├── honesty.py the reference implementation
│ ├── 01_lie_factor.py … 09_caption_contract.py
│ ├── test_reference.py 42 tests over the finished module
│ └── conftest.py the matching import guard
├── expected-output/
│ ├── 01-lie-factor.txt … 09-caption-contract.txt
│ ├── pytest-examples.txt, pytest-starter.txt, test-run.txt
│ └── FIELDS.md which captured values are exact, and which are not
└── tests/
└── run_tests.sh 59 checks; exits 0 only if every one passes
How to run
Work through starter/00_brief.md, writing one function at a time in
starter/honesty.py and checking yourself:
cd starter
../.venv/bin/pytest . -q
cd ..
A fresh checkout reports 22 skipped. Each function you finish turns skips into passes.
When you want to see the finished version, run the nine demonstration scripts:
cd examples
../.venv/bin/python3 01_lie_factor.py
../.venv/bin/python3 02_bars_versus_lines.py
../.venv/bin/python3 03_dual_axes.py
../.venv/bin/python3 04_cherry_picked_window.py
../.venv/bin/python3 05_binning_changes_the_conclusion.py
../.venv/bin/python3 06_radius_versus_area.py
../.venv/bin/python3 07_three_d_distortion.py
../.venv/bin/python3 08_ordering_and_annotation.py
../.venv/bin/python3 09_caption_contract.py
cd ..
Then the two test suites and the full harness:
.venv/bin/pytest examples -q
.venv/bin/pytest starter -q
bash tests/run_tests.sh
Run pytest examples and pytest starter as two separate commands,
never as pytest examples starter. Both directories ship a module called
honesty, and combining them in one invocation is unreliable in both
directions — see troubleshooting.md.
What the commands do
| Command | What it does |
|---|---|
python3 -m venv .venv |
Creates the lab-local virtual environment |
.venv/bin/pip install -r requirements/requirements.txt |
Installs the five pinned packages; the only network step |
01_lie_factor.py |
Draws two bars twice and measures both lie factors off the rendered geometry |
02_bars_versus_lines.py |
Same numbers, same axis, two encodings; shows the bar's lie factor at 2.94 and the line's at exactly 1.00 |
03_dual_axes.py |
Proves scaling cannot change a drawn correlation, that inverting an axis negates it exactly, and that overlap is achievable for any correlation |
04_cherry_picked_window.py |
Fits three trends to three windows of one series and shows the sign flip, with real dates from pandas |
05_binning_changes_the_conclusion.py |
Bins one sample by two textbook rules and counts the humps each one draws |
06_radius_versus_area.py |
Measures the drawn area ratio under both encodings |
07_three_d_distortion.py |
Projects 3D bar corners through the Axes' own matrix and compares drawn areas against a flat control |
08_ordering_and_annotation.py |
Measures ordering, retrievable claim text, and luminance separation, using seaborn's colorblind palette |
09_caption_contract.py |
Runs the four-check review contract over four charts |
.venv/bin/pytest examples -q |
The 42-test reference suite |
.venv/bin/pytest starter -q |
Your progress: skips what is unwritten, fails what is wrong |
bash tests/run_tests.sh |
All 59 checks, including proving the harness can fail |
Expected output
Every file in expected-output/ was captured from a real run on
2026-08-20. The headline numbers:
| Measurement | Value |
|---|---|
| Lie factor, zero-baseline bar pair | 1.0000 |
Drawn height ratio, ylim=(99, 103) |
3.0000 |
| Lie factor, truncated bar pair | 2.9412 |
| Lie factor, same numbers as a line, any baseline | 1.0000 |
| Data correlation of the dual-axis pair | -0.001034 |
| Drawn correlation, under every scaling tried | -0.001034 |
| Drawn correlation of a strong pair with one axis inverted | -0.913234 |
| Tracking gap, curves parked apart | 0.4938 |
| Tracking gap, both axes widened 20× | 0.0147 |
| Tracking gap, a genuinely correlated pair, same widening | 0.0046 |
| Trend slope, first half / second half | -0.7305 / +0.7045 |
| Humps drawn, Sturges / Freedman-Diaconis | 1 / 2 |
| Drawn area ratio, radius encoding of a 4× difference | 16.00 |
| Drawn ratio of two 3D bars (data ratio 2) | 2.341 far, 4.204 near |
| Luminance gap, classic red vs classic green | 0.0996 |
| Luminance gap, deliberate emphasis | 0.5505 |
The last line of the harness:
59 checks, 0 failure(s).
expected-output/FIELDS.md separates the values that are exact on any
machine from the handful that depend on matplotlib's version, and
discloses the two datasets that were deliberately selected.
Validation steps
bash tests/run_tests.shprints59 checks, 0 failure(s).and exits 0..venv/bin/pytest examples -qreports42 passed..venv/bin/pytest starter -qreports22 skippedon an untouched checkout, and22 passedonce every exercise is written.- Section 6 of the harness proves the suite can fail: it replaces
review_chartwith a function that approves everything and confirms script 09 exits non-zero, then does the same with alie_factorstuck at 1.0 against script 01. Neither modifies a file on disk. - Section 7 confirms the lab left no image, no
__pycache__and no.pytest_cachebehind — this lab draws around fifty figures and saves none of them.
Tests
tests/run_tests.sh runs 59 checks in seven sections: installed versions
against the pins, all nine scripts exiting 0 with every internal
assertion holding, the twenty headline numbers re-measured live and
compared against their captured values, the reference suite, the starter
suite and its import guard, two deliberate self-sabotage runs proving the
harness can go red, and a cleanliness sweep.
Nothing in it compares rendered pixels to a stored reference image, and nothing asserts on a timing. Every assertion is on a shape, a value, or a piece of artist state — the pattern Day 128 established, and the reason this suite is portable.
Cleanup
find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv # optional: removes the lab virtual environment
git checkout -- starter/ # optional: reset your work
The lab writes nothing outside its own directory, and saves no images at all, so there is nothing else to remove.
Troubleshooting
See troubleshooting.md. The three most common: running the system
Python instead of .venv/bin/python3; expecting pytest starter to pass
rather than skip on a fresh checkout; and running pytest examples starter as one command, which you must never do.
Security notes
See security.md. In short: no network after the install, no files
written, no display server, no credentials, no sudo, and every dataset
generated from a seeded random number generator inside the lab.
Extension exercises
- Extend the contract. Add a fifth check to
review_chart: a bar chart whose y-axis floor is not zero should fail regardless of what the caption says, because no disclosure repairs a broken encoding. You will need to detect a bar chart —len(ax.patches) > 0with no lines is a start — and you will discover why that detection is harder than it looks. - A lie factor for a whole figure. The current measure takes two values. Generalise it to a set of bars: compute the drawn ratio of every pair against its data ratio and report the worst.
- Aggregation as an editorial choice. Build a small grouped dataset where the overall average points one way and every subgroup points the other, then write a check that flags a chart showing only the aggregate. Day 116 has the mechanism.
- Re-run exercise 7 across cameras. Sweep
focal_lengthfrom 1.0 down to 0.15 and plot the drawn ratio against it. The relationship tells you how much of a 3D chart's distortion is the projection and how much is the viewing angle. - Test your own charts. Take a figure you have already published,
run
review_chartover it, and fix what it finds. This is the only extension that changes anything outside this directory.
Navigation
- Previous day's lab:
../day-131-time-series-visualization/ - Next day's lab:
../day-133-building-an-eda-report/ - Week 19 index:
../README.md
Expected output
01-lie-factor.txt
Two bars, values 100 and 102. The data ratio is 102 / 100 = 1.02.
zero baseline (matplotlib's default for a bar chart)
drawn height ratio : 1.0200
data ratio : 1.0200
lie factor : 1.0000
y axis set to (99, 103) -- one line of code, no data changed
drawn height ratio : 3.0000
data ratio : 1.0200
lie factor : 2.9412
A two per cent difference, drawn as a bar three times as tall.
Tufte's threshold for a distortion is a lie factor outside
0.95 to 1.05. This one is 2.94.
01_lie_factor.py: every assertion held.
02-bars-versus-lines.txt
Values 100 and 102, y axis (99, 103), two encodings.
BAR -- encodes value as length from the baseline
shown length ratio : 3.0000 (true ratio 1.0200)
lie factor : 2.9412
LINE -- encodes change as vertical displacement
shown change : 2.0000 (true change 2.0000)
lie factor : 1.0000
same line on a zero baseline, lie factor: 1.0000
Cutting the axis destroys a bar's encoding, because the bar's
length IS the value. It leaves a line's encoding untouched,
because a labelled linear axis still converts displacement back
to the true change -- whatever the baseline is.
The rule that follows: a non-zero baseline is legitimate for a
line and never for a bar, and either way the baseline must be
visible and labelled.
02_bars_versus_lines.py: every assertion held.
03-dual-axes.txt
Two uncorrelated series, n=60. Data correlation r = -0.001034
Series A runs around 50; series B runs around 0.004. Nothing below
touches the data. Only the four numbers passed to set_ylim change.
scaling drawn-trace gap drawn-trace r
parked in separate halves 0.4938 -0.001034
each filling the frame 0.2261 -0.001034
each axis widened 20x 0.0147 -0.001034
right axis inverted (n/a) +0.001034
The visual impression moved from 'unrelated' (gap 0.49, two
curves in different halves of the plot) to 'these track each
other' (gap 0.0147, two curves lying on top of one another).
The only correlation actually present never moved at all.
Now the control that makes the last row mean something.
a genuinely correlated pair, data r = +0.913234
same 20x widening, gap = 0.0046
uncorrelated pair, gap = 0.0147
right axis inverted, drawn r = -0.913234
Both pairs draw as overlapping curves (0.0046 and 0.0147).
One has r = +0.913, the other r = -0.001. A reader who
concludes 'these move together' from the picture has learned
nothing about the data, because the picture is the same either way.
And the invariance, measured rather than argued:
500 random pairs of axis limits, worst |drawn r - data r| = 3.12e-15
03_dual_axes.py: every assertion held.
04-cherry-picked-window.txt
One weekly series, 48 points, pandas 3.0.5.
window from to slope per week
full series 2025-01-06 2025-12-01 -0.0131
first half only 2025-01-06 2025-06-16 -0.7305
second half only 2025-06-23 2025-12-01 +0.7045
The sign flips between the two halves, and the full series is
flat (-0.0131 per week). Three honest sentences:
declining at 0.73 a week -- true of the first half
growing at 0.70 a week -- true of the second half
essentially unchanged -- true of the whole thing
The defence is not a rule about slopes. It is a rule about the
picture: show the full series, and mark the window you are
talking about inside it.
04_cherry_picked_window.py: every assertion held.
05-binning-changes-the-conclusion.txt
A sample of 400 values, drawn from TWO normal components
centred at -0.85 and +0.85 with standard deviation 0.95. There are,
by construction, two modes in the process that generated it.
rule bins width humps drawn counts
sturges 10 0.670 1 1 9 31 59 62 82 73 50 24 9
fd 14 0.479 2 1 2 10 21 36 53 39 58 66 39 37 22 13 3
Two statements, each supported by a chart drawn from the same
400 numbers with a rule you could cite in a methods section:
Sturges (10 bins, width 0.670):
'the distribution is unimodal, centred near zero'
Freedman-Diaconis (14 bins, width 0.479):
'the distribution is bimodal, with two distinct groups'
The two rules differ by 4 bins. That is the whole distance
between the two conclusions, and it is the fragility -- not
either chart -- that the reader needs told.
The defence: when a conclusion depends on the bin width, say so,
and show the raw values as a rug or a strip plot underneath, so
the reader can see what the bins are hiding.
05_binning_changes_the_conclusion.py: every assertion held.
06-radius-versus-area.txt
Two bubbles for 25 and 100. Data ratio = 4.0.
encode by area drawn area ratio 4.00 lie factor 1.00
encode by radius drawn area ratio 16.00 lie factor 4.00
Stated precisely, because the sloppy version of this is easy to
get backwards:
the SHOWN AREA RATIO is the square of the data ratio
16.0 = 4.0 squared
so the LIE FACTOR equals the data ratio itself
4.0 = 16.0 / 4.0
Which means the distortion gets worse the bigger the real
difference is -- the chart exaggerates most exactly where the
reader is paying most attention.
matplotlib's scatter takes `s` as area in points squared, so the
correct encoding, s proportional to the value, is the one that
looks like it is doing less work.
06_radius_versus_area.py: every assertion held.
07-three-d-distortion.txt
Heights 1 and 2. Data ratio = 2.000.
rendering drawn ratio departure
flat 2D bars 2.000 0.0%
3D, taller bar at the far depth 2.341 17.1%
3D, taller bar at the near depth 4.204 110.2%
The flat chart is exact. Both 3D renderings overstate the taller
bar, and moving it from the far depth to the near one takes the
drawn ratio from 2.34 to 4.20 -- from a 17% overstatement to
a 110% one -- without touching a single number.
These figures depend on the camera: this run uses matplotlib's
perspective projection at focal_length=0.2 and the default view
angle. A different camera gives different numbers. What does not
change is the shape of the result: under perspective, the drawn
size of a bar depends on where it stands, so the one comparison
the chart exists to support is the one the third dimension
breaks.
07_three_d_distortion.py: every assertion held.
08-ordering-and-annotation.txt
Part 1 -- ordering. A five-bar chart, answering 'which is biggest?'
alphabetical order [41.0, 88.0, 37.0, 52.0, 63.0] -> 4 comparisons
sorted descending [88.0, 63.0, 52.0, 41.0, 37.0] -> 0 comparisons
This is an idealised model of reading effort, not a measurement
of human readers: sorted bars put the answer at a known end, so
position encodes rank and no comparison is needed. Its claim is
the ordering of the two numbers, not their exact size.
Part 2 -- annotation. The chart states its claim as retrievable text.
bars, left to right : ['south', 'central', 'west', 'north', 'east']
text on the Axes : 'south is 39% higher than the next region'
text on the Axes : 'value'
text on the Axes : 'south is 39% higher than the next region'
A caption that states a claim is what lets a reader disagree with
the chart. A chart with no claim cannot be argued with, which
reads as neutrality and is really just an unfinished argument.
Part 3 -- emphasis that survives losing colour.
pair colours luminance gap
highlight vs muted (this chart) #1d4ed8 / #cbd5e1 0.5505
seaborn 'colorblind' first two #0173b2 / #de8f05 0.1970
classic red vs classic green #d62728 / #2ca02c 0.0996
The classic red/green pair differs by 0.0996 in luminance --
the one channel every reader has. Told apart by hue alone, it is
the pair that vanishes for a red-green colour-deficient reader,
on a greyscale printer, and on a washed-out projector.
Seaborn's 'colorblind' palette opens at 0.1970, and deliberate
emphasis -- one dark bar against pale ones -- reaches 0.5505.
Luminance is ONE component of whether two colours can be told
apart, not the whole of it; a full colour-deficiency simulation
needs a colour-appearance model this lab does not implement.
The narrow claim it does support: two colours with nearly equal
luminance are separable by hue alone, and hue alone is exactly
what some of your readers do not have.
08_ordering_and_annotation.py: every assertion held.
09-caption-contract.txt
Four checks. A chart passes only if all four hold.
1. the caption states a claim a reader could disagree with
2. the y axis is labelled
3. a non-zero baseline is named in the caption
4. the baseline is zero, or its absence is disclosed
HONEST -- zero baseline
caption : 'Group B is 2% higher than group A.'
ylim : (np.float64(0.0), np.float64(107.1))
ylabel : 'value'
verdict : PASS
TRUNCATED -- baseline moved, nothing said
caption : 'Group B is 2% higher than group A.'
ylim : (np.float64(99.0), np.float64(103.0))
ylabel : 'value'
verdict : FAIL
- the y axis starts at 99 and the caption does not say so
- a non-zero baseline is used without disclosure
DISCLOSED -- a line on a non-zero baseline, declared
caption : 'Group B is 2% higher than group A. Note: the y axis starts at 99, not zero, so the change is legible; the baseline is labelled on the axis.'
ylim : (np.float64(99.0), np.float64(103.0))
ylabel : 'value'
verdict : PASS
BARE -- zero baseline, but no label and no claim
caption : 'Results.'
ylim : (np.float64(0.0), np.float64(107.1))
ylabel : ''
verdict : FAIL
- caption states no claim a reader could disagree with
- the y axis has no label
The interesting row is the third one. It uses a non-zero
baseline, which is the exact thing the second row failed for,
and it passes -- because it is a line rather than a bar, and
because the caption says what it did. The contract does not
forbid breaking the rule. It forbids breaking it in silence.
The fourth row is the other half of the lesson: a chart can be
perfectly accurate and still fail, because a chart with no
claim and no label has not communicated anything.
Check 1 is a keyword heuristic. It catches a MISSING claim; it
cannot judge a WRONG one. No automated check can, and a review
tool that pretended otherwise would be its own kind of lie.
09_caption_contract.py: every assertion held.
FIELDS.md
# What in these captures is exact, and what is not
Everything in `expected-output/` was captured from a real run on the
authoring machine on 2026-08-20: 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, through a lab-local `.venv` built by
the setup commands in `metadata.yml`. Nothing was typed by hand.
Nothing in this lab is randomly sampled at run time. Every generator
takes a fixed seed, so a second run on this machine reproduces these
files byte for byte. What follows is about running them *somewhere else*.
## Exact everywhere — identical on any machine, any platform
These come from arithmetic on fixed inputs and from matplotlib
transforms that are pure geometry. A different operating system,
processor or screen cannot change them.
| Value | Capture | Why it is exact |
| --- | --- | --- |
| Lie factor `1.0000`, zero-baseline bar pair | 01 | Matplotlib autoscales a bar chart from zero, so the drawn ratio is the data ratio |
| Drawn height ratio `3.0000` on `ylim=(99, 103)` | 01, 02 | `(102-99)/(100-99)` in axes fractions; a fixed transform of fixed numbers |
| Lie factor `2.9412` for the truncated pair | 01, 02 | `3.0 / 1.02` |
| Line lie factor `1.0000` on every baseline | 02 | A linear axis is affine; the recovered change is the true change |
| Data correlation `-0.001034` | 03 | NumPy's Philox/PCG64 generators are specified to be reproducible across platforms for a given seed |
| Drawn correlation equal to the data correlation | 03 | Correlation is invariant under affine transforms — this is algebra, not a measurement |
| Inverted-axis correlation `-0.913234` | 03 | An exact sign flip of `+0.913234` |
| Slopes `-0.7305` and `+0.7045` | 04 | Least squares on a fixed array |
| Mode counts `1` (Sturges) and `2` (Freedman-Diaconis) | 05 | Both rules are deterministic functions of the fixed sample |
| Bin counts `10` and `14` | 05 | As above |
| Drawn area ratio `4.00` (area) and `16.00` (radius) | 06 | `scatter`'s `s` is stored in points squared and read straight back |
| Flat 2D bar ratio `2.000` | 07 | The control case; a linear transform of `2.0` |
| Luminance gaps `0.0996` and `0.5505` | 08 | The WCAG relative-luminance formula on fixed hex colours |
| All four caption-contract verdicts | 09 | Pure string and limit logic |
| Test counts: 42 reference tests, 22 starter skips, 59 harness checks | test-run | Fixed by the files themselves |
## Version-specific — a different matplotlib may move these
| Value | Capture | What could change it |
| --- | --- | --- |
| Tracking gaps `0.4938`, `0.2261`, `0.0147`, `0.0046` | 03 | These depend on matplotlib's autoscale margins and on where `twinx` places the second Axes. The *ordering* — separated is large, widened is under 0.05, and both correlated and uncorrelated pairs reach the same small value — is what the lesson claims and is robust. The fourth decimal place is not. |
| 3D drawn ratios `2.341` and `4.204` | 07 | These depend on matplotlib's perspective projection at `focal_length=0.2`, its default `view_init` elevation and azimuth, and the axis limits set in `bar3d_projected_areas`. `Axes3D`'s projection internals have changed across matplotlib releases before. The reference test pins `2.341` to `±0.02`, which is a version claim, not a universal one. The claim that *survives* any camera is the one the script states: under perspective the drawn size of a bar depends on where it stands. |
| Luminance gap `0.1970` for seaborn's palette | 08 | Reads the first two colours of seaborn 0.13.2's `colorblind` palette. Those hex values are a library constant, so a future seaborn could change them. The assertion is only that this gap exceeds the red/green one, which is the claim being made. |
| Autoscaled top limit `107.1` | 09 | Matplotlib's default bar-chart margin. It appears in the printed `ylim` only, and no assertion depends on it. |
| `platform` line in the harness banner | test-run | Reports the machine it ran on. Expected to differ. |
| `pytest` timing lines (`2.12s` and similar) | pytest-*, test-run | Wall-clock. Never asserted on. |
## Deliberately selected, and therefore disclosed
Two of this lab's datasets were not the first thing tried. Both
selections are the same act the lab spends a day warning about, so both
are disclosed here and in the source docstrings.
- **`uncorrelated_pair`, seed 416.** Chosen by scanning seeds 1-599 for
the smallest absolute correlation, to get a clean demonstration series.
The claim being demonstrated — that scaling cannot change a drawn
correlation — holds for every seed; the seed only makes the printed
number tidy.
- **`bimodal_sample`, seed 21 at separation 0.85 and spread 0.95.**
Chosen by scanning a grid of separations, spreads, sample sizes and
seeds for a case where Sturges' rule and the Freedman-Diaconis rule
genuinely disagree. **Most parameter settings do not disagree.** The
claim is that the disagreement is *possible* with two citable rules,
not that it is typical. Any stronger reading of exercise 5 than that
is not supported by what was run here.
## What was not run
No BI tool — Tableau, Power BI, Looker Studio — was installed or executed
anywhere in this lab or its lesson. Everything said about their default
behaviour comes from their published documentation and is marked as such
in the lesson. No output from any of them is reproduced here.
pytest-examples.txt
.......................................... [100%]
42 passed in 2.44s
pytest-starter.txt
ssssssssssssssssssssss [100%]
22 skipped in 0.37s
test-run.txt
Day 132 — Visual Storytelling and Chart Honesty
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
platform macOS-26.5.2-arm64-arm-64bit-Mach-O
ok: installed matplotlib matches requirements.txt
ok: installed seaborn matches requirements.txt
ok: installed pandas matches requirements.txt
ok: installed numpy matches requirements.txt
ok: installed pytest matches requirements.txt
ok: matplotlib runs on the headless Agg backend
2. Every reference script runs and every assertion inside it holds
ok: 01_lie_factor.py exits 0
ok: 01_lie_factor.py reports every assertion held
ok: 02_bars_versus_lines.py exits 0
ok: 02_bars_versus_lines.py reports every assertion held
ok: 03_dual_axes.py exits 0
ok: 03_dual_axes.py reports every assertion held
ok: 04_cherry_picked_window.py exits 0
ok: 04_cherry_picked_window.py reports every assertion held
ok: 05_binning_changes_the_conclusion.py exits 0
ok: 05_binning_changes_the_conclusion.py reports every assertion held
ok: 06_radius_versus_area.py exits 0
ok: 06_radius_versus_area.py reports every assertion held
ok: 07_three_d_distortion.py exits 0
ok: 07_three_d_distortion.py reports every assertion held
ok: 08_ordering_and_annotation.py exits 0
ok: 08_ordering_and_annotation.py reports every assertion held
ok: 09_caption_contract.py exits 0
ok: 09_caption_contract.py reports every assertion held
3. The headline numbers, measured here and now
lf_zero 1.0000
lf_trunc 2.9412
shown_trunc 3.0000
lf_line 1.0000
data_r -0.001034
drawn_r_apart -0.001034
drawn_r_wide -0.001034
gap_apart 0.4938
gap_wide 0.0147
gap_strong 0.0046
strong_r 0.913234
strong_r_inverted -0.913234
slope_first -0.7305
slope_second 0.7045
modes_sturges 1
modes_fd 2
bubble_area_ratio 16.00
ratio_3d_far 2.341
ratio_3d_near 4.204
lum_red_green 0.0996
lum_emphasis 0.5505
ok: a zero-baseline bar pair has lie factor 1.0000
ok: the truncated bar pair draws a 3.0000 height ratio
ok: the truncated bar pair has lie factor 2.9412
ok: the same numbers as a line have lie factor 1.0000
ok: the demonstration pair's data correlation
ok: scaling apart leaves the drawn correlation unchanged
ok: scaling together leaves the drawn correlation unchanged
ok: inverting one axis negates a strong correlation exactly
ok: the separated scaling draws a 0.4938 tracking gap
ok: the widened scaling draws a 0.0147 tracking gap
ok: a strongly correlated pair draws the same small gap
ok: the first window's trend slope is negative
ok: the second window's trend slope is positive
ok: Sturges' rule draws one hump
ok: Freedman-Diaconis draws two humps
ok: radius encoding squares a data ratio of 4 into 16
ok: 3D, taller bar far, draws a 2.341 ratio (data ratio 2)
ok: 3D, taller bar near, draws a 4.204 ratio (data ratio 2)
ok: red and green differ by only 0.0996 in luminance
ok: deliberate emphasis reaches 0.5505 in luminance
4. The reference pytest suite: real geometry, real exceptions
.......................................... [100%]
42 passed in 1.80s
ok: pytest examples exits 0
ok: no test in the reference suite failed
ok: the reference suite ran at least 40 tests (ran 42)
5. The starter suite skips unattempted work instead of failing it
ssssssssssssssssssssss [100%]
22 skipped in 0.34s
ok: pytest starter exits 0 on an untouched checkout
ok: the starter suite reports no failures
ok: unwritten exercises are reported as skipped, not passed
ok: collecting both suites at once does not turn skips into passes
6. The harness can actually fail
ok: a review function that approves everything makes script 09 exit non-zero (1)
ok: the failing assertion is named in the output
ok: a lie_factor stuck at 1.0 makes script 01 exit non-zero (1)
7. Nothing was left behind
ok: no __pycache__ directory left by the lab's own code
ok: no .pytest_cache directory left under the lab
ok: no image file (.png/.svg/.pdf) left by the lab's own code
ok: no lab source calls plt.show()
ok: no lab source opens a network connection
59 checks, 0 failure(s).
Source files
examples/01_lie_factor.py (1676 bytes)
"""Exercise 1 — the lie factor, implemented and measured.
Two bars. The same two numbers, 100 and 102, drawn twice: once on the
baseline matplotlib chooses for you, once on a baseline an author typed.
The shown ratio is read off the *rendered* bar geometry, never off the
input numbers, which is the only way the measurement means anything.
"""
import honesty as H
VALUES = (100.0, 102.0)
def main():
print("Two bars, values 100 and 102. The data ratio is 102 / 100 = 1.02.")
print()
lf_honest, shown_honest, data_ratio = H.bar_pair_lie_factor(VALUES)
print(" zero baseline (matplotlib's default for a bar chart)")
print(f" drawn height ratio : {shown_honest:.4f}")
print(f" data ratio : {data_ratio:.4f}")
print(f" lie factor : {lf_honest:.4f}")
print()
lf_lie, shown_lie, _ = H.bar_pair_lie_factor(VALUES, ylim=(99, 103))
print(" y axis set to (99, 103) -- one line of code, no data changed")
print(f" drawn height ratio : {shown_lie:.4f}")
print(f" data ratio : {data_ratio:.4f}")
print(f" lie factor : {lf_lie:.4f}")
print()
assert abs(lf_honest - 1.0) < 1e-9, f"honest chart lie factor {lf_honest}"
assert abs(shown_lie - 3.0) < 1e-9, f"truncated shown ratio {shown_lie}"
assert lf_lie > 2.5, f"truncated lie factor {lf_lie}"
print(" A two per cent difference, drawn as a bar three times as tall.")
print(f" Tufte's threshold for a distortion is a lie factor outside")
print(f" 0.95 to 1.05. This one is {lf_lie:.2f}.")
print()
print("01_lie_factor.py: every assertion held.")
if __name__ == "__main__":
main()
examples/02_bars_versus_lines.py (2000 bytes)
"""Exercise 2 — truncation is fatal for bars and often fine for lines.
The same two numbers on the same non-zero baseline, drawn two ways. The
bar version's lie factor is nearly three. The line version's is exactly
one. The difference is not taste; it is what each mark encodes.
"""
import honesty as H
VALUES = (100.0, 102.0)
YLIM = (99, 103)
def main():
lf_bar, shown_bar, ratio = H.bar_pair_lie_factor(VALUES, ylim=YLIM)
lf_line, shown_change, true_change = H.line_pair_lie_factor(VALUES, ylim=YLIM)
lf_line_zero, _, _ = H.line_pair_lie_factor(VALUES)
print(f"Values {VALUES[0]:.0f} and {VALUES[1]:.0f}, y axis {YLIM}, two encodings.")
print()
print(" BAR -- encodes value as length from the baseline")
print(f" shown length ratio : {shown_bar:.4f} (true ratio {ratio:.4f})")
print(f" lie factor : {lf_bar:.4f}")
print()
print(" LINE -- encodes change as vertical displacement")
print(f" shown change : {shown_change:.4f} (true change {true_change:.4f})")
print(f" lie factor : {lf_line:.4f}")
print(f" same line on a zero baseline, lie factor: {lf_line_zero:.4f}")
print()
assert lf_bar > 2.5, f"bar lie factor {lf_bar}"
assert abs(lf_line - 1.0) < 1e-9, f"line lie factor {lf_line}"
assert abs(lf_line_zero - 1.0) < 1e-9, f"line lie factor on zero {lf_line_zero}"
print(" Cutting the axis destroys a bar's encoding, because the bar's")
print(" length IS the value. It leaves a line's encoding untouched,")
print(" because a labelled linear axis still converts displacement back")
print(" to the true change -- whatever the baseline is.")
print()
print(" The rule that follows: a non-zero baseline is legitimate for a")
print(" line and never for a bar, and either way the baseline must be")
print(" visible and labelled.")
print()
print("02_bars_versus_lines.py: every assertion held.")
if __name__ == "__main__":
main()
examples/03_dual_axes.py (4923 bytes)
"""Exercise 3 — what a dual y-axis really does, and what it cannot do.
The folk version of this warning says two independently scaled axes let
you make any two series look correlated. Measured, that turns out to be
false in one specific way and true in two others, and the correction is
worth more than the folk version.
* FALSE: the Pearson correlation of the two drawn traces cannot be
changed by scaling at all. Across 500 random pairs of axis limits it
stays equal to the data correlation to within 3e-15.
* TRUE: its SIGN is a free parameter, exactly. Inverting one axis
negates the drawn correlation with no change to the data.
* TRUE, and the one that actually fools people: how close the two
curves sit is entirely the author's choice, and a small gap is
achievable whether the data correlate or not. So overlap is evidence
of nothing.
"""
import matplotlib.pyplot as plt
import honesty as H
def measure(a, b, ylim_a, ylim_b, invert_b=False):
if invert_b:
ylim_b = (ylim_b[1], ylim_b[0])
fig, ax, ax2 = H.dual_axis_figure(a, b, ylim_a=ylim_a, ylim_b=ylim_b)
try:
fig.canvas.draw()
trace_a = H.drawn_trace(ax)
trace_b = H.drawn_trace(ax2)
return H.tracking_gap(trace_a, trace_b), H.pearson(trace_a, trace_b)
finally:
plt.close(fig)
def main():
a, b = H.uncorrelated_pair()
data_r = H.pearson(a, b)
print(f"Two uncorrelated series, n={len(a)}. Data correlation r = {data_r:+.6f}")
print("Series A runs around 50; series B runs around 0.004. Nothing below")
print("touches the data. Only the four numbers passed to set_ylim change.")
print()
gap_apart, r_apart = measure(
a, b, H.banded_limits(a, 0.55, 0.95), H.banded_limits(b, 0.05, 0.45)
)
gap_matched, r_matched = measure(a, b, H.matched_limits(a), H.matched_limits(b))
gap_wide, r_wide = measure(a, b, H.widened_limits(a), H.widened_limits(b))
_, r_inverted = measure(
a, b, H.matched_limits(a), H.matched_limits(b), invert_b=True
)
print(" scaling drawn-trace gap drawn-trace r")
print(f" parked in separate halves {gap_apart:.4f} {r_apart:+.6f}")
print(f" each filling the frame {gap_matched:.4f} {r_matched:+.6f}")
print(f" each axis widened 20x {gap_wide:.4f} {r_wide:+.6f}")
print(f" right axis inverted (n/a) {r_inverted:+.6f}")
print()
assert abs(r_apart - data_r) < 1e-12
assert abs(r_matched - data_r) < 1e-12
assert abs(r_wide - data_r) < 1e-12
assert abs(r_inverted + data_r) < 1e-12, "inverting an axis must negate r exactly"
assert gap_apart > 0.4, gap_apart
assert gap_wide < 0.05, gap_wide
print(" The visual impression moved from 'unrelated' (gap 0.49, two")
print(" curves in different halves of the plot) to 'these track each")
print(f" other' (gap {gap_wide:.4f}, two curves lying on top of one another).")
print(" The only correlation actually present never moved at all.")
print()
print(" Now the control that makes the last row mean something.")
c, d = H.correlated_pair()
strong_r = H.pearson(c, d)
gap_strong, r_strong = measure(c, d, H.widened_limits(c), H.widened_limits(d))
_, r_strong_inv = measure(
c, d, H.matched_limits(c), H.matched_limits(d), invert_b=True
)
print(f" a genuinely correlated pair, data r = {strong_r:+.6f}")
print(f" same 20x widening, gap = {gap_strong:.4f}")
print(f" uncorrelated pair, gap = {gap_wide:.4f}")
print(f" right axis inverted, drawn r = {r_strong_inv:+.6f}")
print()
assert strong_r > 0.85, strong_r
assert abs(r_strong - strong_r) < 1e-12
assert abs(r_strong_inv + strong_r) < 1e-12
assert gap_strong < 0.05 and gap_wide < 0.05, (gap_strong, gap_wide)
print(f" Both pairs draw as overlapping curves ({gap_strong:.4f} and {gap_wide:.4f}).")
print(f" One has r = {strong_r:+.3f}, the other r = {data_r:+.3f}. A reader who")
print(" concludes 'these move together' from the picture has learned")
print(" nothing about the data, because the picture is the same either way.")
print()
print(" And the invariance, measured rather than argued:")
worst = 0.0
import numpy as np
rng = np.random.default_rng(1132)
for _ in range(500):
factor_a = float(rng.uniform(0.5, 50.0))
factor_b = float(rng.uniform(0.5, 50.0))
_, r_random = measure(
a, b, H.widened_limits(a, factor_a), H.widened_limits(b, factor_b)
)
worst = max(worst, abs(r_random - data_r))
print(f" 500 random pairs of axis limits, worst |drawn r - data r| = {worst:.2e}")
assert worst < 1e-12, worst
print()
print("03_dual_axes.py: every assertion held.")
if __name__ == "__main__":
main()
examples/04_cherry_picked_window.py (2193 bytes)
"""Exercise 4 — the trend sign is chosen by the start date.
One series, three fitted slopes. The full series is flat. Its first half
falls hard, its second half rises hard. Any of the three is a true
statement about a window; only one of them is a true statement about the
series, and the chart is what decides which one the reader gets.
"""
import pandas as pd
import honesty as H
def main():
values = H.dipping_series()
dates = pd.date_range("2025-01-06", periods=len(values), freq="W-MON")
frame = pd.DataFrame({"date": dates, "value": values})
half = len(values) // 2
windows = {
"full series": frame,
"first half only": frame.iloc[:half],
"second half only": frame.iloc[half:],
}
print(f"One weekly series, {len(values)} points, pandas {pd.__version__}.")
print()
print(" window from to slope per week")
slopes = {}
for name, chunk in windows.items():
slope = H.trend_slope(chunk["value"].to_numpy())
slopes[name] = slope
start = chunk["date"].iloc[0].date()
end = chunk["date"].iloc[-1].date()
print(f" {name:<18} {start} {end} {slope:+.4f}")
print()
first = slopes["first half only"]
second = slopes["second half only"]
full = slopes["full series"]
assert first < 0 < second, (first, second)
assert abs(full) < 0.05, full
assert abs(first) > 0.5 and abs(second) > 0.5, (first, second)
print(" The sign flips between the two halves, and the full series is")
print(f" flat ({full:+.4f} per week). Three honest sentences:")
print(f" {'declining at %.2f a week' % abs(first):<32} -- true of the first half")
print(f" {'growing at %.2f a week' % second:<32} -- true of the second half")
print(f" {'essentially unchanged':<32} -- true of the whole thing")
print()
print(" The defence is not a rule about slopes. It is a rule about the")
print(" picture: show the full series, and mark the window you are")
print(" talking about inside it.")
print()
print("04_cherry_picked_window.py: every assertion held.")
if __name__ == "__main__":
main()
examples/05_binning_changes_the_conclusion.py (2536 bytes)
"""Exercise 5 — two textbook bin rules, two opposite conclusions.
Day 130 established that bin width is a choice. This is what the choice
costs. The sample really is drawn from two components. Sturges' rule
draws one hump; the Freedman-Diaconis rule draws two. Neither rule is
wrong, and neither chart is a lie -- but only one of them supports the
sentence the author wants to write.
"""
import numpy as np
import honesty as H
def main():
sample = H.bimodal_sample()
print(f"A sample of {len(sample)} values, drawn from TWO normal components")
print("centred at -0.85 and +0.85 with standard deviation 0.95. There are,")
print("by construction, two modes in the process that generated it.")
print()
results = {}
print(" rule bins width humps drawn counts")
for rule in ("sturges", "fd"):
edges = np.histogram_bin_edges(sample, bins=rule)
counts = H.histogram_counts(sample, bins=rule)
modes = H.count_modes(counts)
results[rule] = (len(counts), float(edges[1] - edges[0]), modes)
shown = " ".join(f"{int(c):>3d}" for c in counts)
print(f" {rule:<18} {len(counts):>3d} {edges[1] - edges[0]:.3f} {modes} {shown}")
print()
sturges_bins, sturges_width, sturges_modes = results["sturges"]
fd_bins, fd_width, fd_modes = results["fd"]
assert sturges_modes == 1, sturges_modes
assert fd_modes == 2, fd_modes
assert sturges_modes != fd_modes
print(" Two statements, each supported by a chart drawn from the same")
print(" 400 numbers with a rule you could cite in a methods section:")
print(f" Sturges ({sturges_bins} bins, width {sturges_width:.3f}):")
print(" 'the distribution is unimodal, centred near zero'")
print(f" Freedman-Diaconis ({fd_bins} bins, width {fd_width:.3f}):")
print(" 'the distribution is bimodal, with two distinct groups'")
print()
print(f" The two rules differ by {fd_bins - sturges_bins} bins. That is the whole distance")
print(" between the two conclusions, and it is the fragility -- not")
print(" either chart -- that the reader needs told.")
print()
print(" The defence: when a conclusion depends on the bin width, say so,")
print(" and show the raw values as a rug or a strip plot underneath, so")
print(" the reader can see what the bins are hiding.")
print()
print("05_binning_changes_the_conclusion.py: every assertion held.")
if __name__ == "__main__":
main()
examples/06_radius_versus_area.py (2318 bytes)
"""Exercise 6 — encoding by radius squares every ratio.
Day 127 named this; here it is as a number. Two bubbles, values 25 and
100, a data ratio of 4. Encode by area and the drawn areas are in the
ratio 4. Encode by radius -- which is what "make the circle four times as
big" means to most people -- and the drawn areas are in the ratio 16.
"""
import matplotlib.pyplot as plt
import honesty as H
VALUES = (25.0, 100.0)
def main():
data_ratio = VALUES[1] / VALUES[0]
print(f"Two bubbles for {VALUES[0]:.0f} and {VALUES[1]:.0f}. Data ratio = {data_ratio:.1f}.")
print()
measured = {}
for encoding in ("area", "radius"):
fig, ax = H.bubble_pair(VALUES, encode=encoding)
try:
fig.canvas.draw()
area_ratio = H.drawn_area_ratio(ax)
finally:
plt.close(fig)
factor = H.lie_factor(area_ratio, data_ratio)
measured[encoding] = (area_ratio, factor)
print(f" encode by {encoding:<7} drawn area ratio {area_ratio:>6.2f} lie factor {factor:>5.2f}")
print()
area_ratio_correct, factor_correct = measured["area"]
area_ratio_wrong, factor_wrong = measured["radius"]
assert abs(factor_correct - 1.0) < 1e-9, factor_correct
assert abs(area_ratio_wrong - data_ratio**2) < 1e-9, area_ratio_wrong
assert abs(factor_wrong - data_ratio) < 1e-9, factor_wrong
print(" Stated precisely, because the sloppy version of this is easy to")
print(" get backwards:")
print(f" the SHOWN AREA RATIO is the square of the data ratio")
print(f" {area_ratio_wrong:.1f} = {data_ratio:.1f} squared")
print(f" so the LIE FACTOR equals the data ratio itself")
print(f" {factor_wrong:.1f} = {area_ratio_wrong:.1f} / {data_ratio:.1f}")
print()
print(" Which means the distortion gets worse the bigger the real")
print(" difference is -- the chart exaggerates most exactly where the")
print(" reader is paying most attention.")
print()
print(" matplotlib's scatter takes `s` as area in points squared, so the")
print(" correct encoding, s proportional to the value, is the one that")
print(" looks like it is doing less work.")
print()
print("06_radius_versus_area.py: every assertion held.")
if __name__ == "__main__":
main()
examples/07_three_d_distortion.py (2800 bytes)
"""Exercise 7 — perspective breaks the comparison the chart exists for.
Two 3D bars, heights 1 and 2, a data ratio of 2. Every corner is pushed
through the Axes' own projection matrix and the drawn front-face areas
are compared. The flat 2D control gives exactly 2.000. The 3D versions do
not -- and how wrong they are depends on where the taller bar happens to
be standing, which is a fact about the camera, not about the data.
"""
import matplotlib.pyplot as plt
import honesty as H
HEIGHTS = (1.0, 2.0)
TOLERANCE = 0.10
def main():
data_ratio = HEIGHTS[1] / HEIGHTS[0]
fig, ax = H.bar_pair(HEIGHTS)
try:
fig.canvas.draw()
flat = H.drawn_bar_heights(ax)
finally:
plt.close(fig)
flat_ratio = flat[1] / flat[0]
far = H.bar3d_projected_areas(list(HEIGHTS), [0.0, 3.0])
near = H.bar3d_projected_areas(list(HEIGHTS), [3.0, 0.0])
far_ratio = far[1] / far[0]
near_ratio = near[1] / near[0]
print(f"Heights {HEIGHTS[0]:.0f} and {HEIGHTS[1]:.0f}. Data ratio = {data_ratio:.3f}.")
print()
print(" rendering drawn ratio departure")
print(f" flat 2D bars {flat_ratio:.3f} {abs(flat_ratio / data_ratio - 1) * 100:5.1f}%")
print(f" 3D, taller bar at the far depth {far_ratio:.3f} {abs(far_ratio / data_ratio - 1) * 100:5.1f}%")
print(f" 3D, taller bar at the near depth {near_ratio:.3f} {abs(near_ratio / data_ratio - 1) * 100:5.1f}%")
print()
assert abs(flat_ratio - data_ratio) < 1e-9, flat_ratio
assert abs(far_ratio / data_ratio - 1.0) > TOLERANCE, far_ratio
assert abs(near_ratio / data_ratio - 1.0) > TOLERANCE, near_ratio
assert near_ratio > far_ratio, (near_ratio, far_ratio)
print(" The flat chart is exact. Both 3D renderings overstate the taller")
print(f" bar, and moving it from the far depth to the near one takes the")
print(f" drawn ratio from {far_ratio:.2f} to {near_ratio:.2f} -- from a {abs(far_ratio / data_ratio - 1) * 100:.0f}% overstatement to")
print(f" a {abs(near_ratio / data_ratio - 1) * 100:.0f}% one -- without touching a single number.")
print()
print(" These figures depend on the camera: this run uses matplotlib's")
print(" perspective projection at focal_length=0.2 and the default view")
print(" angle. A different camera gives different numbers. What does not")
print(" change is the shape of the result: under perspective, the drawn")
print(" size of a bar depends on where it stands, so the one comparison")
print(" the chart exists to support is the one the third dimension")
print(" breaks.")
print()
print("07_three_d_distortion.py: every assertion held.")
if __name__ == "__main__":
main()
examples/08_ordering_and_annotation.py (4393 bytes)
"""Exercise 8 — the legitimate craft, made measurable.
Storytelling is not the opposite of honesty. An unordered, unlabelled,
uncommented chart is not neutral; it is unhelpful, and it quietly hands
the reader's conclusion over to whatever the default sort order happened
to be. This exercise measures three pieces of real craft: ordering,
annotation, and emphasis that survives a greyscale printer.
"""
import matplotlib.pyplot as plt
import seaborn as sns
import honesty as H
LABELS = ["north", "south", "east", "west", "central"]
VALUES = [41.0, 88.0, 37.0, 52.0, 63.0]
CLAIM = "south is 39% higher than the next region"
def main():
print("Part 1 -- ordering. A five-bar chart, answering 'which is biggest?'")
print()
unsorted_cost = H.comparisons_to_find_max(VALUES)
ordered = sorted(VALUES, reverse=True)
sorted_cost = H.comparisons_to_find_max(ordered)
print(f" alphabetical order {VALUES} -> {unsorted_cost} comparisons")
print(f" sorted descending {ordered} -> {sorted_cost} comparisons")
print()
assert unsorted_cost == len(VALUES) - 1, unsorted_cost
assert sorted_cost == 0, sorted_cost
assert sorted_cost < unsorted_cost
print(" This is an idealised model of reading effort, not a measurement")
print(" of human readers: sorted bars put the answer at a known end, so")
print(" position encodes rank and no comparison is needed. Its claim is")
print(" the ordering of the two numbers, not their exact size.")
print()
print("Part 2 -- annotation. The chart states its claim as retrievable text.")
print()
fig, ax = H.annotated_bar_chart(LABELS, VALUES, CLAIM)
try:
fig.canvas.draw()
text = H.axes_text(ax)
bar_order = [t.get_text() for t in ax.get_xticklabels()]
finally:
plt.close(fig)
print(f" bars, left to right : {bar_order}")
for item in text:
print(f" text on the Axes : {item!r}")
print()
assert CLAIM in text, text
assert text.count(CLAIM) >= 2, "the claim should be both the title and an annotation"
assert bar_order[0] == "south", bar_order
assert "value" in text, text
print(" A caption that states a claim is what lets a reader disagree with")
print(" the chart. A chart with no claim cannot be argued with, which")
print(" reads as neutrality and is really just an unfinished argument.")
print()
print("Part 3 -- emphasis that survives losing colour.")
print()
pairs = {
"highlight vs muted (this chart)": (H.HIGHLIGHT, H.MUTED),
"seaborn 'colorblind' first two": tuple(sns.color_palette("colorblind").as_hex()[:2]),
"classic red vs classic green": (H.CLASSIC_RED, H.CLASSIC_GREEN),
}
print(" pair colours luminance gap")
gaps = {}
for name, (first, second) in pairs.items():
gap = H.luminance_separation(first, second)
gaps[name] = gap
print(f" {name:<32} {first} / {second} {gap:.4f}")
print()
red_green = gaps["classic red vs classic green"]
emphasis = gaps["highlight vs muted (this chart)"]
palette = gaps["seaborn 'colorblind' first two"]
assert emphasis > 0.5, emphasis
assert palette > red_green, (palette, red_green)
assert emphasis > palette > red_green
print(f" The classic red/green pair differs by {red_green:.4f} in luminance --")
print(" the one channel every reader has. Told apart by hue alone, it is")
print(" the pair that vanishes for a red-green colour-deficient reader,")
print(" on a greyscale printer, and on a washed-out projector.")
print(f" Seaborn's 'colorblind' palette opens at {palette:.4f}, and deliberate")
print(f" emphasis -- one dark bar against pale ones -- reaches {emphasis:.4f}.")
print()
print(" Luminance is ONE component of whether two colours can be told")
print(" apart, not the whole of it; a full colour-deficiency simulation")
print(" needs a colour-appearance model this lab does not implement.")
print(" The narrow claim it does support: two colours with nearly equal")
print(" luminance are separable by hue alone, and hue alone is exactly")
print(" what some of your readers do not have.")
print()
print("08_ordering_and_annotation.py: every assertion held.")
if __name__ == "__main__":
main()
examples/09_caption_contract.py (3950 bytes)
"""Exercise 9 — a review contract you can run on your own charts.
Everything before this was diagnosis. This is the tool. Four checks, run
against a chart's own Axes and its caption, that catch the distortions
this lab measured -- and that pass a chart which breaks a rule and says
so, because breaking a rule deliberately and disclosing it is
professional work, and breaking it silently is not.
"""
import matplotlib.pyplot as plt
import honesty as H
VALUES = (100.0, 102.0)
def report(name, ax, caption):
passed, failures = H.review_chart(ax, caption)
print(f" {name}")
print(f" caption : {caption!r}")
print(f" ylim : {ax.get_ylim()}")
print(f" ylabel : {ax.get_ylabel()!r}")
print(f" verdict : {'PASS' if passed else 'FAIL'}")
for failure in failures:
print(f" - {failure}")
print()
return passed, failures
def main():
print("Four checks. A chart passes only if all four hold.")
print(" 1. the caption states a claim a reader could disagree with")
print(" 2. the y axis is labelled")
print(" 3. a non-zero baseline is named in the caption")
print(" 4. the baseline is zero, or its absence is disclosed")
print()
fig, ax = H.bar_pair(VALUES)
try:
fig.canvas.draw()
honest_pass, honest_failures = report(
"HONEST -- zero baseline",
ax,
"Group B is 2% higher than group A.",
)
finally:
plt.close(fig)
fig, ax = H.bar_pair(VALUES, ylim=(99, 103))
try:
fig.canvas.draw()
lying_pass, lying_failures = report(
"TRUNCATED -- baseline moved, nothing said",
ax,
"Group B is 2% higher than group A.",
)
finally:
plt.close(fig)
fig, ax = H.line_pair(VALUES, ylim=(99, 103))
ax.set_ylabel("value")
try:
fig.canvas.draw()
disclosed_pass, disclosed_failures = report(
"DISCLOSED -- a line on a non-zero baseline, declared",
ax,
"Group B is 2% higher than group A. Note: the y axis starts at "
"99, not zero, so the change is legible; the baseline is "
"labelled on the axis.",
)
finally:
plt.close(fig)
fig, ax = H.bar_pair(VALUES)
ax.set_ylabel("")
try:
fig.canvas.draw()
bare_pass, bare_failures = report(
"BARE -- zero baseline, but no label and no claim",
ax,
"Results.",
)
finally:
plt.close(fig)
assert honest_pass, honest_failures
assert not lying_pass, "the truncated chart must fail the contract"
assert any("does not say so" in f for f in lying_failures), lying_failures
assert any("without disclosure" in f for f in lying_failures), lying_failures
assert disclosed_pass, disclosed_failures
assert not bare_pass, "an unlabelled, claimless chart must fail"
assert len(bare_failures) == 2, bare_failures
print(" The interesting row is the third one. It uses a non-zero")
print(" baseline, which is the exact thing the second row failed for,")
print(" and it passes -- because it is a line rather than a bar, and")
print(" because the caption says what it did. The contract does not")
print(" forbid breaking the rule. It forbids breaking it in silence.")
print()
print(" The fourth row is the other half of the lesson: a chart can be")
print(" perfectly accurate and still fail, because a chart with no")
print(" claim and no label has not communicated anything.")
print()
print(" Check 1 is a keyword heuristic. It catches a MISSING claim; it")
print(" cannot judge a WRONG one. No automated check can, and a review")
print(" tool that pretended otherwise would be its own kind of lie.")
print()
print("09_caption_contract.py: every assertion held.")
if __name__ == "__main__":
main()
examples/conftest.py (1003 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain a module called `honesty`, and
pytest imports test files by putting their directory on `sys.path`.
Without this file, running `pytest` across both directories at once would
import whichever copy was seen first and reuse it for the other -- so the
starter tests would silently pass against the reference solution instead
of skipping. That is a wrong answer with a green tick on it, which is the
worst kind.
So: put this directory first on the import path, and drop any
already-imported module of that name that came from somewhere else.
"""
import sys
from pathlib import Path
HERE = str(Path(__file__).parent.resolve())
if HERE in sys.path:
sys.path.remove(HERE)
sys.path.insert(0, HERE)
for name in ("honesty",):
module = sys.modules.get(name)
origin = getattr(module, "__file__", "") or ""
if module is not None and not origin.startswith(HERE):
del sys.modules[name]
examples/honesty.py (22189 bytes)
"""Reference implementation — Day 132 — "Charts That Cannot Lie To You".
Every function here measures a chart by reading its own rendered geometry
back out of matplotlib's artists, never by trusting the numbers that were
passed in. That distinction is the whole lab: a chart's honesty is a
property of what got *drawn*, not of what got *plotted*.
matplotlib is forced onto the headless Agg backend before pyplot is
imported, so nothing here opens a window. Never call plt.show().
"""
from __future__ import annotations
import math
import matplotlib
matplotlib.use("Agg")
import matplotlib.colors as mcolors # noqa: E402
import matplotlib.pyplot as plt # noqa: E402 (must follow matplotlib.use)
import numpy as np # noqa: E402
# ===========================================================================
# Exercise 1 — the lie factor
# ===========================================================================
def lie_factor(shown_ratio, data_ratio):
"""Tufte's lie factor: the size of the effect shown in the graphic
divided by the size of the effect in the data.
A value of 1.0 means the graphic shows exactly the effect the data
contains. Tufte's own rule of thumb calls anything outside roughly
0.95 to 1.05 a distortion. The number is unitless and is a plain
ratio of two ratios, which is what makes "this chart is misleading"
into an arithmetic claim instead of an opinion.
"""
if data_ratio == 0:
raise ZeroDivisionError("data_ratio must be non-zero to form a lie factor")
return shown_ratio / data_ratio
def bar_pair(values, ylim=None, labels=("A", "B")):
"""Draw two bars for `values` and return (fig, ax).
`ylim` is passed straight to ax.set_ylim. Pass None to keep
matplotlib's autoscaled limits, which for a bar chart always include
zero -- matplotlib is honest by default here, and the distortion in
this lab is something an author has to reach out and add.
"""
fig, ax = plt.subplots(figsize=(4, 3))
ax.bar(list(labels), list(values), color="#1d4ed8")
if ylim is not None:
ax.set_ylim(*ylim)
ax.set_ylabel("value")
return fig, ax
def drawn_bar_heights(ax):
"""The heights of `ax`'s bars as the reader actually sees them, in
axes-fraction units (0.0 at the bottom of the plotting box, 1.0 at
the top), clipped to the visible box.
This reads the patches' real bounding boxes and pushes them through
the Axes' own data-to-axes transform, so it reports what got drawn.
A bar whose top is off the top of the axes contributes 1.0, and a
bar whose top is below the axes floor contributes 0.0 -- exactly what
a reader would see.
"""
to_axes = ax.transData + ax.transAxes.inverted()
heights = []
for patch in ax.patches:
bbox = patch.get_bbox().transformed(to_axes)
top = min(bbox.y1, 1.0)
bottom = max(bbox.y0, 0.0)
heights.append(max(top - bottom, 0.0))
return heights
def bar_pair_lie_factor(values, ylim=None):
"""Build a two-bar chart, measure the drawn bar heights, and return
(lie_factor, shown_ratio, data_ratio).
The shown ratio comes from the rendered geometry, not from `values`.
"""
fig, ax = bar_pair(values, ylim=ylim)
try:
fig.canvas.draw()
heights = drawn_bar_heights(ax)
if heights[0] == 0:
raise ZeroDivisionError("the first bar has zero drawn height")
shown = heights[1] / heights[0]
data = values[1] / values[0]
return lie_factor(shown, data), shown, data
finally:
plt.close(fig)
# ===========================================================================
# Exercise 2 — truncation for bars versus lines
# ===========================================================================
def line_pair(values, ylim=None, xs=(0, 1)):
"""Draw the same two numbers as a two-point line and return (fig, ax)."""
fig, ax = plt.subplots(figsize=(4, 3))
ax.plot(list(xs), list(values), marker="o", color="#1d4ed8")
if ylim is not None:
ax.set_ylim(*ylim)
ax.set_ylabel("value")
return fig, ax
def drawn_change(ax):
"""The change a line encodes, recovered from the drawn geometry.
A line encodes *change* as vertical displacement. A reader recovers
that change by measuring the displacement as a fraction of the
plotting box and multiplying by the labelled axis range -- which is
exactly what this function does. The answer is in data units.
"""
to_axes = ax.transData + ax.transAxes.inverted()
xy = ax.lines[0].get_xydata()
first = to_axes.transform((xy[0][0], xy[0][1]))[1]
last = to_axes.transform((xy[-1][0], xy[-1][1]))[1]
low, high = ax.get_ylim()
return (last - first) * (high - low)
def line_pair_lie_factor(values, ylim=None):
"""Build a two-point line chart on the given limits and return
(lie_factor, shown_change, data_change) for the change it encodes."""
fig, ax = line_pair(values, ylim=ylim)
try:
fig.canvas.draw()
shown = drawn_change(ax)
data = values[1] - values[0]
return lie_factor(shown, data), shown, data
finally:
plt.close(fig)
# ===========================================================================
# Exercise 3 — dual axes
# ===========================================================================
def pearson(a, b):
"""Pearson correlation coefficient, written out rather than imported,
so nothing about this measurement is hidden behind a library call."""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
a_c = a - a.mean()
b_c = b - b.mean()
denom = math.sqrt(float((a_c**2).sum()) * float((b_c**2).sum()))
if denom == 0:
raise ZeroDivisionError("a constant series has no correlation")
return float((a_c * b_c).sum() / denom)
def uncorrelated_pair(n=60, seed=416):
"""Two series with a near-zero sample correlation, drawn once from a
fixed seed so every run of this lab sees the same two series.
Seed 416 was not the first seed tried. It was chosen by scanning
seeds 1-599 for the one giving the smallest absolute correlation, to
get a clean demonstration series. That is a cherry-pick, and this
docstring is the disclosure -- which is the entire rule this lab
teaches, applied to the lab's own data.
"""
rng = np.random.default_rng(seed)
a = rng.normal(50.0, 8.0, n)
b = rng.normal(0.004, 0.0006, n)
return a, b
def dual_axis_figure(a, b, ylim_a=None, ylim_b=None):
"""Plot `a` on a left axis and `b` on a right twinx axis, each with
its own limits, and return (fig, ax_left, ax_right)."""
fig, ax = plt.subplots(figsize=(6, 3))
x = np.arange(len(a))
ax.plot(x, a, color="#1d4ed8", label="series A (left axis)")
ax2 = ax.twinx()
ax2.plot(x, b, color="#b91c1c", label="series B (right axis)")
if ylim_a is not None:
ax.set_ylim(*ylim_a)
if ylim_b is not None:
ax2.set_ylim(*ylim_b)
ax.set_ylabel("series A")
ax2.set_ylabel("series B")
return fig, ax, ax2
def drawn_trace(ax, line_index=0):
"""A drawn line's y-coordinates in axes-fraction units -- the shape
the reader's eye actually follows, independent of what the numbers on
the axis say."""
to_axes = ax.transData + ax.transAxes.inverted()
xy = ax.lines[line_index].get_xydata()
return np.array([to_axes.transform((px, py))[1] for px, py in xy])
def tracking_gap(trace_a, trace_b):
"""How far apart two drawn curves sit, as a root-mean-square vertical
distance in axes fractions. 0.0 means they lie exactly on top of one
another; a value near 0.5 means they occupy different halves of the
plot. This is the quantity a dual-axis chart actually manipulates."""
diff = np.asarray(trace_a) - np.asarray(trace_b)
return float(math.sqrt(float((diff**2).mean())))
def widened_limits(values, factor=20.0):
"""Limits `factor` times wider than the data, centred on it. Every
series flattens toward the middle of the plotting box, which is how
two unrelated curves get made to lie on top of each other."""
lo = float(np.min(values))
hi = float(np.max(values))
mid = (lo + hi) / 2.0
span = (hi - lo) * factor / 2.0
if span == 0:
span = 1.0
return mid - span, mid + span
def banded_limits(values, low_frac, high_frac):
"""Limits that place the data inside the vertical band running from
`low_frac` to `high_frac` of the plotting box, so a series can be
parked in the top half or the bottom half at will."""
lo = float(np.min(values))
hi = float(np.max(values))
span = hi - lo
if span == 0:
span = 1.0
unit = span / (high_frac - low_frac)
return lo - low_frac * unit, hi + (1.0 - high_frac) * unit
def correlated_pair(n=60, seed=7, rho=0.9):
"""A genuinely, strongly correlated pair -- the control that makes
the dual-axis result mean something. Without it, a small tracking gap
for uncorrelated data proves nothing; with it, the same small gap for
both proves that the gap carries no information at all."""
rng = np.random.default_rng(seed)
c = rng.normal(0.0, 1.0, n)
d = rho * c + math.sqrt(1.0 - rho**2) * rng.normal(0.0, 1.0, n)
return c, d
def matched_limits(values, pad=0.15):
"""Limits that centre a series in the plotting box with equal padding
above and below -- the scaling that makes any series fill the frame
the same way, and therefore the scaling that makes any two series
lie on top of each other."""
lo = float(np.min(values))
hi = float(np.max(values))
span = hi - lo
if span == 0:
span = 1.0
return lo - pad * span, hi + pad * span
# ===========================================================================
# Exercise 4 — cherry-picked windows
# ===========================================================================
def trend_slope(y):
"""The slope of the least-squares line through `y` against 0..n-1,
in units of y per step."""
y = np.asarray(y, dtype=float)
x = np.arange(len(y), dtype=float)
slope, _intercept = np.polyfit(x, y, 1)
return float(slope)
def dipping_series(n=48, seed=132):
"""A series that falls for its first stretch and rises for its
second, so the sign of its trend depends entirely on where a reader
is allowed to start looking. Noise is drawn from a fixed seed."""
rng = np.random.default_rng(seed)
x = np.arange(n, dtype=float)
shape = 0.03 * (x - n / 2.0) ** 2
return shape - shape.mean() + rng.normal(0.0, 1.2, n) + 100.0
# ===========================================================================
# Exercise 5 — binning
# ===========================================================================
def bimodal_sample(n=400, seed=21):
"""A sample drawn from two separated normal components, so it really
does have two modes -- and a histogram of it can still be made to
show one.
The components sit at -0.85 and +0.85 with a standard deviation of
0.95, so they overlap enough that the answer to "how many humps?"
depends on the bin width. That fragility is the finding, not a flaw
in the data.
These separations and this seed were chosen by scanning a grid of
separations, spreads, sample sizes and seeds for a case where the two
standard bin rules genuinely disagree -- Sturges strictly rising then
strictly falling, Freedman-Diaconis showing two humps with a valley
at least 15% below the lower peak. Most parameter settings do not
disagree. That search is a cherry-pick, and this docstring is the
disclosure. The claim being demonstrated is that the disagreement is
POSSIBLE with two citable rules, not that it is typical.
"""
rng = np.random.default_rng(seed)
left = rng.normal(-0.85, 0.95, n // 2)
right = rng.normal(0.85, 0.95, n - n // 2)
return np.concatenate([left, right])
def histogram_counts(sample, bins):
"""Draw a real histogram on a real Axes and read the bar heights back
off the drawn patches, so the counts under test are the counts the
reader sees."""
fig, ax = plt.subplots(figsize=(4, 3))
try:
ax.hist(sample, bins=bins, color="#1d4ed8")
fig.canvas.draw()
return [float(p.get_height()) for p in ax.patches]
finally:
plt.close(fig)
def count_modes(counts):
"""The number of local maxima in a sequence of bar heights: a bar
strictly taller than both of its neighbours, with the two end bars
compared against their single neighbour. This is how a reader counts
humps, and it is the whole conclusion a histogram is used to reach."""
counts = list(counts)
n = len(counts)
modes = 0
for i, height in enumerate(counts):
left = counts[i - 1] if i > 0 else -math.inf
right = counts[i + 1] if i < n - 1 else -math.inf
if height > left and height > right:
modes += 1
return modes
# ===========================================================================
# Exercise 6 — radius versus area
# ===========================================================================
def bubble_pair(values, encode="area"):
"""Draw two bubbles for `values` and return (fig, ax).
encode="area" -- marker area is proportional to the value, which is
the correct encoding.
encode="radius" -- marker *radius* is proportional to the value,
which is the convenient-looking mistake.
matplotlib's scatter takes `s` as marker area in points squared, so
the correct encoding is the one that looks like it is doing less.
"""
values = np.asarray(values, dtype=float)
if encode == "area":
sizes = values * 40.0
elif encode == "radius":
sizes = (values * 1.4) ** 2
else:
raise ValueError("encode must be 'area' or 'radius'")
fig, ax = plt.subplots(figsize=(4, 3))
ax.scatter([0, 1], [0, 0], s=sizes, color="#1d4ed8")
ax.set_xlim(-1, 2)
ax.set_ylim(-1, 1)
return fig, ax
def drawn_area_ratio(ax):
"""The ratio of the two drawn marker areas, read off the collection's
own sizes -- which matplotlib stores in points squared, i.e. area."""
sizes = ax.collections[0].get_sizes()
return float(sizes[1] / sizes[0])
# ===========================================================================
# Exercise 7 — 3D perspective
# ===========================================================================
def bar3d_projected_areas(heights, depths, focal_length=0.2):
"""Draw two 3D bars of the given heights at the given depths, and
return the drawn 2D area of each bar's front face in figure units.
Every corner is pushed through the Axes' own projection matrix, so
the areas are the areas matplotlib really draws, perspective and all.
"""
from mpl_toolkits.mplot3d import proj3d
fig = plt.figure(figsize=(5, 4))
ax = fig.add_subplot(projection="3d")
try:
ax.set_proj_type("persp", focal_length=focal_length)
width = 0.6
for i, (height, depth) in enumerate(zip(heights, depths)):
ax.bar3d(i * 2.0, depth, 0, width, width, height, color="#1d4ed8")
ax.set_xlim(-1, 4)
ax.set_ylim(min(depths) - 1, max(depths) + 1)
ax.set_zlim(0, max(heights) * 1.2)
fig.canvas.draw()
proj = ax.get_proj()
areas = []
for i, (height, depth) in enumerate(zip(heights, depths)):
x0 = i * 2.0
corners = [
(x0, depth, 0.0),
(x0 + width, depth, 0.0),
(x0 + width, depth, height),
(x0, depth, height),
]
flat = [proj3d.proj_transform(cx, cy, cz, proj)[:2] for cx, cy, cz in corners]
areas.append(_polygon_area(flat))
return areas
finally:
plt.close(fig)
def _polygon_area(points):
"""The area of a simple polygon by the shoelace formula."""
total = 0.0
n = len(points)
for i in range(n):
x1, y1 = points[i]
x2, y2 = points[(i + 1) % n]
total += x1 * y2 - x2 * y1
return abs(total) / 2.0
# ===========================================================================
# Exercise 8 — ordering and annotation
# ===========================================================================
def comparisons_to_find_max(values):
"""An idealised model of the reader effort a bar chart demands to
answer "which is biggest?".
When the bars are already in descending order, position encodes rank
and the answer is the first bar: zero comparisons. When they are not,
the reader must hold a running maximum and compare it against every
remaining bar: n - 1 comparisons.
This is a model of reading effort, not a measurement of human
behaviour. It is stated as a model everywhere it is used, and its
only claim is the ordering of the two numbers, not their exact size.
"""
values = list(values)
if len(values) < 2:
return 0
if all(values[i] >= values[i + 1] for i in range(len(values) - 1)):
return 0
return len(values) - 1
def annotated_bar_chart(labels, values, claim):
"""A bar chart carrying its claim as retrievable text: the claim goes
in the title and is also anchored to the winning bar with annotate,
so the chart states what it wants the reader to conclude."""
order = sorted(range(len(values)), key=lambda i: values[i], reverse=True)
labels = [labels[i] for i in order]
values = [values[i] for i in order]
fig, ax = plt.subplots(figsize=(5, 3))
colours = ["#1d4ed8"] + ["#cbd5e1"] * (len(values) - 1)
ax.bar(labels, values, color=colours)
ax.set_ylabel("value")
ax.set_title(claim)
ax.annotate(
claim,
xy=(0, values[0]),
xytext=(0.35, 0.85),
textcoords="axes fraction",
arrowprops={"arrowstyle": "->", "color": "#1a202c"},
)
return fig, ax
def axes_text(ax):
"""Every piece of text a reader can retrieve from an Axes: its title,
both axis labels, and every Text artist placed on it."""
found = [ax.get_title(), ax.get_xlabel(), ax.get_ylabel()]
found += [t.get_text() for t in ax.texts]
return [t for t in found if t]
# ===========================================================================
# Exercise 9 — the caption contract
# ===========================================================================
CLAIM_WORDS = (
"higher",
"lower",
"more",
"less",
"greater",
"smaller",
"rose",
"fell",
"grew",
"shrank",
"increase",
"decrease",
"than",
"no difference",
"unchanged",
"%",
)
DISCLOSURE_WORDS = (
"axis starts at",
"baseline",
"does not start at zero",
"log scale",
"logarithmic",
"clipped",
"truncated",
"excludes",
)
def review_chart(ax, caption):
"""Check one chart against the day's review contract and return
(passed, failures).
The four rules:
1. The caption states a claim, so a reader has something to disagree
with. Checked by looking for comparative or quantitative language;
this is a keyword heuristic, and it is honest about being one -- it
catches a missing claim, it cannot judge a wrong one.
2. The y axis is labelled, so the reader knows what is being measured.
3. The baseline the chart was drawn on is stated in the caption
whenever it is not zero.
4. Either the baseline is zero, or the caption discloses that it is
not. Breaking the rule is allowed; breaking it silently is not.
"""
text = (caption or "").lower()
low, _high = ax.get_ylim()
failures = []
if not text.strip():
failures.append("caption is empty: the chart states no claim")
elif not any(word in text for word in CLAIM_WORDS):
failures.append("caption states no claim a reader could disagree with")
if not ax.get_ylabel().strip():
failures.append("the y axis has no label")
if low != 0 and f"{low:g}" not in text:
failures.append(f"the y axis starts at {low:g} and the caption does not say so")
if low != 0 and not any(word in text for word in DISCLOSURE_WORDS):
failures.append("a non-zero baseline is used without disclosure")
return (not failures), failures
# ===========================================================================
# Exercise 8, second half — emphasis that survives a greyscale printer
# ===========================================================================
HIGHLIGHT = "#1d4ed8"
MUTED = "#cbd5e1"
CLASSIC_RED = "#d62728"
CLASSIC_GREEN = "#2ca02c"
def relative_luminance(colour):
"""The Rec. 709 relative luminance of a colour, as defined by WCAG:
each sRGB channel is linearised, then weighted 0.2126 / 0.7152 /
0.0722 and summed. The result runs from 0.0 (black) to 1.0 (white).
Luminance is the channel that survives every form of colour-vision
deficiency, every greyscale printer and every bad projector. It is
*one* component of whether two colours can be told apart, not the
whole of it -- a full colour-deficiency simulation needs a proper
colour-appearance model, which this lab does not implement and does
not pretend to. What this function supports is a narrow, checkable
claim: two colours with nearly equal luminance are distinguishable by
hue alone, and hue alone is exactly what some readers do not have.
"""
red, green, blue = mcolors.to_rgb(colour)
def linearise(channel):
if channel <= 0.04045:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
return (
0.2126 * linearise(red)
+ 0.7152 * linearise(green)
+ 0.0722 * linearise(blue)
)
def luminance_separation(colour_a, colour_b):
"""How far apart two colours sit on the one axis every reader has."""
return abs(relative_luminance(colour_a) - relative_luminance(colour_b))
examples/test_reference.py (14905 bytes)
"""Reference suite — Day 132. Every claim the lesson makes, asserted.
Nothing here compares rendered pixels to a stored reference image. Every
assertion reads a real artist's real geometry back out of matplotlib, or
computes a number from it, which is what makes the suite portable across
machines and matplotlib versions.
"""
import matplotlib.pyplot as plt
import numpy as np
import pytest
import honesty as H
# --------------------------------------------------------------------------
# Exercise 1 — the lie factor
# --------------------------------------------------------------------------
def test_lie_factor_is_a_ratio_of_ratios():
assert H.lie_factor(3.0, 1.02) == pytest.approx(2.9411764705882355)
assert H.lie_factor(1.02, 1.02) == 1.0
def test_lie_factor_rejects_a_zero_data_effect():
with pytest.raises(ZeroDivisionError):
H.lie_factor(1.0, 0.0)
def test_zero_baseline_bar_pair_has_lie_factor_one():
factor, shown, data = H.bar_pair_lie_factor((100.0, 102.0))
assert shown == pytest.approx(1.02)
assert data == pytest.approx(1.02)
assert factor == pytest.approx(1.0)
def test_truncated_bar_pair_shows_a_three_to_one_height_ratio():
factor, shown, data = H.bar_pair_lie_factor((100.0, 102.0), ylim=(99, 103))
assert shown == pytest.approx(3.0)
assert data == pytest.approx(1.02)
assert factor == pytest.approx(2.9411764705882355)
assert factor > 2.5
def test_shown_ratio_comes_from_geometry_not_from_the_inputs():
"""The same inputs give two different shown ratios, which is only
possible if the measurement really reads the drawn bars."""
_, shown_honest, _ = H.bar_pair_lie_factor((100.0, 102.0))
_, shown_lying, _ = H.bar_pair_lie_factor((100.0, 102.0), ylim=(99, 103))
assert shown_honest != shown_lying
def test_matplotlib_autoscales_a_bar_chart_to_include_zero():
fig, ax = H.bar_pair((100.0, 102.0))
try:
fig.canvas.draw()
assert ax.get_ylim()[0] == 0.0
finally:
plt.close(fig)
# --------------------------------------------------------------------------
# Exercise 2 — bars versus lines
# --------------------------------------------------------------------------
def test_a_line_on_a_truncated_axis_still_encodes_the_change_exactly():
factor, shown, true_change = H.line_pair_lie_factor((100.0, 102.0), ylim=(99, 103))
assert true_change == pytest.approx(2.0)
assert shown == pytest.approx(2.0)
assert factor == pytest.approx(1.0)
def test_the_line_lie_factor_is_one_on_every_baseline():
for limits in (None, (99, 103), (90, 110), (99.5, 102.5)):
factor, _, _ = H.line_pair_lie_factor((100.0, 102.0), ylim=limits)
assert factor == pytest.approx(1.0), limits
def test_the_bar_and_the_line_disagree_on_the_same_axis():
bar_factor, _, _ = H.bar_pair_lie_factor((100.0, 102.0), ylim=(99, 103))
line_factor, _, _ = H.line_pair_lie_factor((100.0, 102.0), ylim=(99, 103))
assert bar_factor > 2.5
assert line_factor == pytest.approx(1.0)
# --------------------------------------------------------------------------
# Exercise 3 — dual axes
# --------------------------------------------------------------------------
def _dual(a, b, ylim_a, ylim_b, invert_b=False):
if invert_b:
ylim_b = (ylim_b[1], ylim_b[0])
fig, ax, ax2 = H.dual_axis_figure(a, b, ylim_a=ylim_a, ylim_b=ylim_b)
try:
fig.canvas.draw()
trace_a = H.drawn_trace(ax)
trace_b = H.drawn_trace(ax2)
return H.tracking_gap(trace_a, trace_b), H.pearson(trace_a, trace_b)
finally:
plt.close(fig)
def test_the_demonstration_pair_really_is_uncorrelated():
a, b = H.uncorrelated_pair()
assert abs(H.pearson(a, b)) < 0.01
def test_pearson_matches_numpy_on_the_same_data():
a, b = H.uncorrelated_pair()
assert H.pearson(a, b) == pytest.approx(float(np.corrcoef(a, b)[0, 1]))
def test_axis_scaling_cannot_change_the_drawn_correlation():
a, b = H.uncorrelated_pair()
data_r = H.pearson(a, b)
rng = np.random.default_rng(1132)
for _ in range(40):
factor_a = float(rng.uniform(0.5, 50.0))
factor_b = float(rng.uniform(0.5, 50.0))
_, drawn_r = _dual(
a, b, H.widened_limits(a, factor_a), H.widened_limits(b, factor_b)
)
assert drawn_r == pytest.approx(data_r, abs=1e-12)
def test_inverting_one_axis_negates_the_drawn_correlation_exactly():
c, d = H.correlated_pair()
data_r = H.pearson(c, d)
assert data_r > 0.85
_, drawn_r = _dual(
c, d, H.matched_limits(c), H.matched_limits(d), invert_b=True
)
assert drawn_r == pytest.approx(-data_r, abs=1e-12)
def test_the_tracking_gap_is_a_free_parameter():
a, b = H.uncorrelated_pair()
gap_apart, _ = _dual(
a, b, H.banded_limits(a, 0.55, 0.95), H.banded_limits(b, 0.05, 0.45)
)
gap_wide, _ = _dual(a, b, H.widened_limits(a), H.widened_limits(b))
assert gap_apart > 0.4
assert gap_wide < 0.05
assert gap_apart > 10 * gap_wide
def test_overlap_carries_no_information_about_correlation():
"""The centrepiece. The same widening drives BOTH an uncorrelated pair
and a strongly correlated pair to overlapping curves, so a reader who
concludes 'these track' from the picture has learned nothing."""
a, b = H.uncorrelated_pair()
c, d = H.correlated_pair()
gap_uncorrelated, _ = _dual(a, b, H.widened_limits(a), H.widened_limits(b))
gap_correlated, _ = _dual(c, d, H.widened_limits(c), H.widened_limits(d))
assert abs(H.pearson(a, b)) < 0.01
assert H.pearson(c, d) > 0.85
assert gap_uncorrelated < 0.05
assert gap_correlated < 0.05
def test_twinx_really_produced_a_second_independent_axes():
a, b = H.uncorrelated_pair()
fig, ax, ax2 = H.dual_axis_figure(a, b, ylim_a=(0, 100), ylim_b=(0, 1))
try:
assert ax is not ax2
assert ax.get_ylim() == (0.0, 100.0)
assert ax2.get_ylim() == (0.0, 1.0)
assert len(ax.lines) == 1 and len(ax2.lines) == 1
finally:
plt.close(fig)
# --------------------------------------------------------------------------
# Exercise 4 — cherry-picked windows
# --------------------------------------------------------------------------
def test_trend_slope_recovers_a_known_slope():
y = 3.0 + 2.5 * np.arange(20)
assert H.trend_slope(y) == pytest.approx(2.5)
def test_the_trend_sign_flips_between_the_two_halves():
values = H.dipping_series()
half = len(values) // 2
first = H.trend_slope(values[:half])
second = H.trend_slope(values[half:])
assert first < 0 < second
assert abs(first) > 0.5 and abs(second) > 0.5
def test_the_full_series_is_flat():
values = H.dipping_series()
assert abs(H.trend_slope(values)) < 0.05
# --------------------------------------------------------------------------
# Exercise 5 — binning
# --------------------------------------------------------------------------
def test_count_modes_counts_strict_local_maxima():
assert H.count_modes([1, 5, 2]) == 1
assert H.count_modes([5, 1, 5]) == 2
assert H.count_modes([1, 2, 3, 4]) == 1
assert H.count_modes([3, 3, 3]) == 0
def test_two_textbook_bin_rules_give_opposite_answers():
sample = H.bimodal_sample()
sturges = H.histogram_counts(sample, bins="sturges")
freedman = H.histogram_counts(sample, bins="fd")
assert H.count_modes(sturges) == 1
assert H.count_modes(freedman) == 2
assert len(sturges) == 10
assert len(freedman) == 14
def test_the_sturges_histogram_really_reads_as_one_hump():
"""Not merely 'one strict local maximum' -- the counts rise to a peak
and fall away from it with no interior dip at all, so a reader looking
at the picture would call it unimodal too."""
counts = H.histogram_counts(H.bimodal_sample(), bins="sturges")
peak = int(np.argmax(counts))
assert all(counts[i] <= counts[i + 1] for i in range(peak))
assert all(counts[i] >= counts[i + 1] for i in range(peak, len(counts) - 1))
def test_histogram_counts_conserve_the_sample():
sample = H.bimodal_sample()
assert sum(H.histogram_counts(sample, bins="fd")) == len(sample)
# --------------------------------------------------------------------------
# Exercise 6 — radius versus area
# --------------------------------------------------------------------------
def _area_ratio(values, encode):
fig, ax = H.bubble_pair(values, encode=encode)
try:
fig.canvas.draw()
return H.drawn_area_ratio(ax)
finally:
plt.close(fig)
def test_encoding_by_area_is_faithful():
ratio = _area_ratio((25.0, 100.0), "area")
assert ratio == pytest.approx(4.0)
assert H.lie_factor(ratio, 4.0) == pytest.approx(1.0)
def test_encoding_by_radius_squares_the_shown_ratio():
data_ratio = 4.0
ratio = _area_ratio((25.0, 100.0), "radius")
assert ratio == pytest.approx(data_ratio**2)
assert H.lie_factor(ratio, data_ratio) == pytest.approx(data_ratio)
def test_the_radius_distortion_grows_with_the_real_difference():
for values in ((10.0, 20.0), (10.0, 50.0), (10.0, 100.0)):
data_ratio = values[1] / values[0]
ratio = _area_ratio(values, "radius")
assert H.lie_factor(ratio, data_ratio) == pytest.approx(data_ratio, rel=1e-6)
def test_bubble_pair_rejects_an_unknown_encoding():
with pytest.raises(ValueError):
H.bubble_pair((1.0, 2.0), encode="diameter")
# --------------------------------------------------------------------------
# Exercise 7 — 3D perspective
# --------------------------------------------------------------------------
def test_flat_bars_reproduce_the_data_ratio_exactly():
fig, ax = H.bar_pair((1.0, 2.0))
try:
fig.canvas.draw()
heights = H.drawn_bar_heights(ax)
finally:
plt.close(fig)
assert heights[1] / heights[0] == pytest.approx(2.0)
def test_perspective_departs_from_the_data_ratio_by_more_than_ten_percent():
areas = H.bar3d_projected_areas([1.0, 2.0], [0.0, 3.0])
ratio = areas[1] / areas[0]
assert abs(ratio / 2.0 - 1.0) > 0.10
assert ratio == pytest.approx(2.341, abs=0.02)
def test_moving_the_taller_bar_nearer_makes_the_distortion_much_worse():
far = H.bar3d_projected_areas([1.0, 2.0], [0.0, 3.0])
near = H.bar3d_projected_areas([1.0, 2.0], [3.0, 0.0])
assert near[1] / near[0] > far[1] / far[0]
assert near[1] / near[0] > 4.0
def test_polygon_area_matches_a_known_square():
assert H._polygon_area([(0, 0), (2, 0), (2, 3), (0, 3)]) == pytest.approx(6.0)
# --------------------------------------------------------------------------
# Exercise 8 — ordering, annotation, emphasis
# --------------------------------------------------------------------------
def test_sorting_removes_every_comparison_needed_to_find_the_maximum():
values = [41.0, 88.0, 37.0, 52.0, 63.0]
assert H.comparisons_to_find_max(values) == 4
assert H.comparisons_to_find_max(sorted(values, reverse=True)) == 0
def test_the_annotated_chart_sorts_its_bars_and_carries_its_claim():
claim = "south is 39% higher than the next region"
fig, ax = H.annotated_bar_chart(
["north", "south", "east", "west", "central"],
[41.0, 88.0, 37.0, 52.0, 63.0],
claim,
)
try:
fig.canvas.draw()
text = H.axes_text(ax)
labels = [t.get_text() for t in ax.get_xticklabels()]
finally:
plt.close(fig)
assert labels == ["south", "central", "west", "north", "east"]
assert claim in text
assert text.count(claim) == 2
assert "value" in text
def test_relative_luminance_matches_the_wcag_endpoints():
assert H.relative_luminance("#000000") == pytest.approx(0.0)
assert H.relative_luminance("#ffffff") == pytest.approx(1.0)
def test_red_and_green_collapse_where_deliberate_emphasis_does_not():
red_green = H.luminance_separation(H.CLASSIC_RED, H.CLASSIC_GREEN)
emphasis = H.luminance_separation(H.HIGHLIGHT, H.MUTED)
assert red_green < 0.11
assert emphasis > 0.5
assert emphasis > 5 * red_green
def test_seabornes_colorblind_palette_separates_better_than_red_green():
import seaborn as sns
first, second = sns.color_palette("colorblind").as_hex()[:2]
assert H.luminance_separation(first, second) > H.luminance_separation(
H.CLASSIC_RED, H.CLASSIC_GREEN
)
# --------------------------------------------------------------------------
# Exercise 9 — the caption contract
# --------------------------------------------------------------------------
def test_the_contract_passes_an_honest_chart():
fig, ax = H.bar_pair((100.0, 102.0))
try:
fig.canvas.draw()
passed, failures = H.review_chart(ax, "Group B is 2% higher than group A.")
finally:
plt.close(fig)
assert passed, failures
assert failures == []
def test_the_contract_fails_the_truncated_chart():
fig, ax = H.bar_pair((100.0, 102.0), ylim=(99, 103))
try:
fig.canvas.draw()
passed, failures = H.review_chart(ax, "Group B is 2% higher than group A.")
finally:
plt.close(fig)
assert not passed
assert any("does not say so" in f for f in failures)
assert any("without disclosure" in f for f in failures)
def test_the_contract_passes_a_disclosed_rule_break():
fig, ax = H.line_pair((100.0, 102.0), ylim=(99, 103))
try:
fig.canvas.draw()
passed, failures = H.review_chart(
ax,
"Group B is 2% higher than group A. Note: the y axis starts at "
"99, not zero.",
)
finally:
plt.close(fig)
assert passed, failures
def test_the_contract_fails_an_accurate_but_silent_chart():
fig, ax = H.bar_pair((100.0, 102.0))
ax.set_ylabel("")
try:
fig.canvas.draw()
passed, failures = H.review_chart(ax, "Results.")
finally:
plt.close(fig)
assert not passed
assert len(failures) == 2
assert any("no claim" in f for f in failures)
assert any("no label" in f for f in failures)
def test_an_empty_caption_is_reported_as_its_own_failure():
fig, ax = H.bar_pair((100.0, 102.0))
try:
fig.canvas.draw()
passed, failures = H.review_chart(ax, "")
finally:
plt.close(fig)
assert not passed
assert any("caption is empty" in f for f in failures)
# --------------------------------------------------------------------------
# The lab's own hygiene
# --------------------------------------------------------------------------
def test_no_figures_are_left_open_by_the_helper_functions():
plt.close("all")
H.bar_pair_lie_factor((100.0, 102.0))
H.line_pair_lie_factor((100.0, 102.0))
H.histogram_counts(H.bimodal_sample(), bins="fd")
H.bar3d_projected_areas([1.0, 2.0], [0.0, 3.0])
assert plt.get_fignums() == []
metadata.yml (6161 bytes)
lesson_id: D132
day: 132
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-132-visual-storytelling-and-chart-honesty
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/python3 -c "import matplotlib, seaborn, pandas; print(matplotlib.__version__, seaborn.__version__, pandas.__version__)"
run_commands:
- 'cd examples && ../.venv/bin/python3 01_lie_factor.py && cd ..'
- 'cd examples && ../.venv/bin/python3 02_bars_versus_lines.py && cd ..'
- 'cd examples && ../.venv/bin/python3 03_dual_axes.py && cd ..'
- 'cd examples && ../.venv/bin/python3 04_cherry_picked_window.py && cd ..'
- 'cd examples && ../.venv/bin/python3 05_binning_changes_the_conclusion.py && cd ..'
- 'cd examples && ../.venv/bin/python3 06_radius_versus_area.py && cd ..'
- 'cd examples && ../.venv/bin/python3 07_three_d_distortion.py && cd ..'
- 'cd examples && ../.venv/bin/python3 08_ordering_and_annotation.py && cd ..'
- 'cd examples && ../.venv/bin/python3 09_caption_contract.py && cd ..'
- .venv/bin/pytest examples -q -p no:cacheprovider
- .venv/bin/pytest starter -q -p no:cacheprovider
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
- rm -rf .pytest_cache
- 'rm -rf .venv # optional: removes the lab virtual environment'
- 'git checkout -- starter/ # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 45
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 -> 59 checks, 0 failure(s), exit 0; pytest
examples -> 42 passed; pytest starter -> 22 skipped on an untouched
checkout, and 22 passed against a fully solved copy of
starter/honesty.py (verified by temporarily copying the reference
examples/honesty.py into starter/, confirming all 22 tests passed, then
restoring the blank skeleton and confirming the skip count returned to
22; collecting both suites together reports 64 passed with the solved
copy in place and 42 passed plus 22 skipped without it, proving the two
conftest.py import guards work). All nine reference scripts exit 0 with
every internal assertion holding. Everything was run through a real
lab-local .venv created by the documented setup commands, not through an
authoring environment; pip install used the network exactly once, as
documented. Section 6 of the harness proves the suite can fail rather
than merely claiming it: it replaces review_chart in memory with a
function that approves every chart, confirms 09_caption_contract.py exits
1 with the named AssertionError "the truncated chart must fail the
contract", then replaces lie_factor with a function stuck at 1.0 and
confirms 01_lie_factor.py also exits 1. Neither self-test writes to disk.
Four honesty calls from this run. FIRST, and the largest: the brief for
this day asked the dual-axis exercise to show two uncorrelated series
made to appear correlated by choosing per-axis scalings, asserting a high
pixel-space correlation against a near-zero data correlation. That is not
possible, and the lab says so and proves it. Pearson correlation is
invariant under positive affine transforms of each variable, and a linear
dual axis is exactly such a transform, so the drawn traces' correlation
always equals the data's -- measured across 500 random pairs of axis
limits, the worst deviation was 3.12e-15. What the exercise demonstrates
instead is stronger and true: the SIGN is a free parameter (inverting one
axis takes a genuine r of +0.913234 to a drawn -0.913234, exactly), and
the visual impression is entirely the author's choice (the tracking gap
between the drawn curves runs from 0.4938 to 0.0147 on unchanged data)
-- and critically, the same widening drives a genuinely correlated pair
(r = +0.913) to a gap of 0.0046 and an uncorrelated pair (r = -0.001) to
0.0147, so overlapping curves are evidence of nothing. SECOND: the brief
asked exercise 6 to assert that a radius-encoded bubble pair's lie factor
equals the square of the data ratio. Measured, that is off by one power.
The SHOWN AREA RATIO is the square of the data ratio (16.00 for a data
ratio of 4), which makes the LIE FACTOR equal the data ratio itself
(4.00). The lab asserts both statements in the precise form and the
script spells out the distinction, because the sloppy version is easy to
invert. THIRD: two of this lab's datasets were deliberately selected --
the dual-axis seed was chosen by scanning seeds 1-599 for the smallest
absolute correlation, and the bimodal sample's separation, spread, size
and seed were chosen by scanning a grid for a case where Sturges' rule
and the Freedman-Diaconis rule genuinely disagree, which MOST parameter
settings do not. Both selections are disclosed in the source docstrings
and in expected-output/FIELDS.md, since they are the same act this lab
spends a day warning about. Exercise 5's claim is that the disagreement
is possible with two citable rules, not that it is typical. FOURTH: no BI
tool was installed or run. Tableau and Power BI are described in the
lesson from their published documentation only and no output from either
is reproduced anywhere. On the specific point the brief raised, the
documentation contradicts the assumption: Tableau's "Edit Axes" help
documents an "Include zero" check box and describes CLEARING it as the
action that narrows the axis to the data range, so a non-zero baseline is
the deviation rather than the default. What the Power BI documentation
does confirm is the dual-axis point -- adding a line value to a combo
chart creates a secondary Y-axis automatically -- along with a one-slider
"Invert range" control and a "Round range" toggle whose off state fits
the axis "more tightly to your data". The lesson states only what the
documentation states.
requirements/README.md (1201 bytes)
# Requirements
`requirements.txt` pins the exact versions this lab was executed against
on 2026-08-20. They are pins, not minimums: the captured numbers in
`expected-output/` came from these versions, and `tests/run_tests.sh`
checks each installed version against this file so a mismatch is reported
rather than silently producing different figures.
```
matplotlib==3.11.1
seaborn==0.13.2
pandas==3.0.5
numpy==2.5.2
pytest==9.1.1
```
Install them into a lab-local virtual environment:
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
```
That one `pip install` is the only step that touches the network.
Everything afterwards runs offline.
`seaborn` is used in exactly one place — reading the first two colours of
its `colorblind` palette in exercise 8 — and `pandas` in exactly one,
attaching real weekly dates to the cherry-picked-window series in
exercise 4. Both are genuinely executed here; neither is decorative.
matplotlib runs on the `Agg` backend, which needs no display server and
no GUI toolkit. Nothing in this lab opens a window and nothing calls
`plt.show()`, so it works identically over SSH, in a container and in
continuous integration.
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 (6123 bytes)
# Charts That Cannot Lie To You — the nine exercises
Fourteen functions in `honesty.py`, grouped into nine exercises. Each one
raises `NotImplementedError` until you write it. Check yourself from
inside `starter/`:
```bash
../.venv/bin/pytest . -q
```
A fresh checkout reports **22 skipped**. Every function you finish turns
skips into passes.
Everything that *draws* is already written for you — `bar_pair`,
`line_pair`, `dual_axis_figure`, `bubble_pair`, `annotated_bar_chart`,
`bar3d_projected_areas`, `histogram_counts`, and the data generators.
What you write is the **measurement**: the part that turns "this chart is
misleading" from an opinion into a number.
One rule runs through all nine: **measure the chart, not the inputs.**
Every function that reports what a reader sees must read it back out of
matplotlib's own artists. If you can compute the answer without touching
the figure, you have measured the wrong thing.
## Exercise 1 — the lie factor
Write `lie_factor(shown_ratio, data_ratio)` and `drawn_bar_heights(ax)`.
The lie factor is the size of the effect shown in the graphic divided by
the size of the effect in the data. `drawn_bar_heights` is what makes it
honest: it reads each bar's real bounding box and converts it to
axes-fraction units, clipped to the visible plotting box, so a bar whose
top is cut off contributes only what the reader can see.
Two bars, values `100` and `102`. On a zero baseline the drawn ratio is
`1.02` and the lie factor is `1.0`. On `ylim=(99, 103)` the drawn ratio
is `3.0` — one bar three times the height of the other — and the lie
factor is `2.94`. Same two numbers. One line of code between them.
## Exercise 2 — truncation for bars versus lines
Write `drawn_change(ax)`.
A bar encodes value as length from the baseline, so cutting the axis
breaks the encoding. A line encodes *change* as vertical displacement,
and a labelled linear axis converts that displacement back to the true
change whatever the baseline is. Your function does exactly what a
careful reader does: measure the displacement as a fraction of the
plotting box, then multiply by the labelled axis range.
The test runs it on three different `ylim` values and expects `2.0` every
time. That invariance is the nuance made measurable — and it is why "the
axis must start at zero" is right for bars and wrong for lines.
## Exercise 3 — dual axes
Write `pearson(a, b)`, `tracking_gap(trace_a, trace_b)` and
`widened_limits(values, factor)`.
This is the day's centrepiece, and the result is not the one most people
expect. Independently scaling two y-axes **cannot** change the Pearson
correlation of the two drawn traces — scaling is affine, and correlation
is invariant under affine transforms. Your test proves it: the drawn
correlation matches the data correlation to within `1e-12`.
What the scaling *does* control is how close the two curves sit, which is
what readers actually respond to. `tracking_gap` measures that as a
root-mean-square vertical distance in axes fractions. Widen both axes 20
times and two uncorrelated series lie on top of each other — and so does
a pair with `r = 0.91`. Same picture, opposite data. **Overlap is
evidence of nothing.**
## Exercise 4 — the cherry-picked window
Write `trend_slope(y)`.
One series, three windows, three true sentences: falling at 0.73 a week,
growing at 0.70 a week, and essentially unchanged. The test asserts the
sign flips between the halves while the full series is flat.
## Exercise 5 — binning
Write `count_modes(counts)`.
Count the strictly-local maxima in a set of drawn bar heights — how a
reader counts humps. Then run it on the same 400 values binned two ways.
Sturges' rule draws one hump; the Freedman-Diaconis rule draws two. Both
rules are citable. Only one supports the sentence you wanted to write.
## Exercise 6 — radius versus area
Write `drawn_area_ratio(ax)`.
matplotlib's `scatter` takes `s` as marker area in **points squared**, so
the correct encoding is the one that looks like it is doing less. Encode
by radius instead and the drawn area ratio becomes the square of the data
ratio — `16` for a data ratio of `4` — which makes the lie factor equal
to the data ratio itself. Get that statement the right way round; the
sloppy version is easy to invert.
## Exercise 7 — 3D perspective
Write `_polygon_area(points)` (the shoelace formula).
Two 3D bars, heights 1 and 2, every corner pushed through the Axes' own
projection matrix. Flat bars give exactly `2.000`. Under perspective the
drawn ratio is `2.34` with the taller bar at the far depth and `4.20`
with it at the near depth. Where a bar *stands* changes how big it looks,
which breaks the one comparison the chart exists to support.
## Exercise 8 — ordering, annotation, emphasis
Write `comparisons_to_find_max(values)`, `axes_text(ax)` and
`relative_luminance(colour)`.
The legitimate craft, made measurable. Sorted bars put the answer at a
known end. An annotated chart carries its claim as text you can retrieve
from the Axes. And emphasis has to survive losing colour: the classic
red/green pair differs by only `0.0996` in luminance — the one channel
every reader has — while one dark bar against pale ones reaches `0.5505`.
## Exercise 9 — the caption contract
Write `review_chart(ax, caption)`.
Four checks, returning `(passed, failures)`:
1. the caption states a claim a reader could disagree with
2. the y axis is labelled
3. a non-zero baseline is named in the caption
4. the baseline is zero, **or** its absence is disclosed
It must **pass** the honest chart, **fail** the truncated one, **pass** a
line on a non-zero baseline whose caption says so, and **fail** a
perfectly accurate chart that carries no claim and no label.
That third case is the point of the whole day. The contract does not
forbid breaking a rule. It forbids breaking it in silence.
Check 1 is a keyword heuristic, and the docstring says so. It catches a
*missing* claim; it cannot judge a *wrong* one. No automated check can,
and a review tool that pretended otherwise would be its own kind of lie.
starter/conftest.py (1003 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain a module called `honesty`, and
pytest imports test files by putting their directory on `sys.path`.
Without this file, running `pytest` across both directories at once would
import whichever copy was seen first and reuse it for the other -- so the
starter tests would silently pass against the reference solution instead
of skipping. That is a wrong answer with a green tick on it, which is the
worst kind.
So: put this directory first on the import path, and drop any
already-imported module of that name that came from somewhere else.
"""
import sys
from pathlib import Path
HERE = str(Path(__file__).parent.resolve())
if HERE in sys.path:
sys.path.remove(HERE)
sys.path.insert(0, HERE)
for name in ("honesty",):
module = sys.modules.get(name)
origin = getattr(module, "__file__", "") or ""
if module is not None and not origin.startswith(HERE):
del sys.modules[name]
starter/honesty.py (22650 bytes)
"""Starter — Day 132 — "Charts That Cannot Lie To You".
Fourteen functions to write, grouped into the nine exercises described in
`00_brief.md`. Each one currently raises NotImplementedError. Read the
docstring, write the body, and check yourself with:
../.venv/bin/pytest . -q (run from inside starter/)
An unattempted function skips its test rather than failing it. A wrong
answer fails and prints both your value and the expected one.
Everything NOT stubbed out below is working code you can lean on: the
functions that build the figures, generate the demonstration data and
read a drawn line's trace are already written. What you write is the
MEASUREMENT -- the part that turns "this chart is misleading" from an
opinion into a number.
Every measurement must come from the chart's own rendered geometry, never
from the numbers that were passed in. That is the whole point: a chart's
honesty is a property of what got *drawn*.
matplotlib is forced onto the headless Agg backend before pyplot is
imported, so nothing here opens a window. Never call plt.show().
"""
from __future__ import annotations
import math
import matplotlib
matplotlib.use("Agg")
import matplotlib.colors as mcolors # noqa: E402
import matplotlib.pyplot as plt # noqa: E402 (must follow matplotlib.use)
import numpy as np # noqa: E402
# ===========================================================================
# Exercise 1 — the lie factor
# ===========================================================================
def lie_factor(shown_ratio, data_ratio):
"""Tufte's lie factor: the size of the effect shown in the graphic
divided by the size of the effect in the data.
A value of 1.0 means the graphic shows exactly the effect the data
contains. Tufte's own rule of thumb calls anything outside roughly
0.95 to 1.05 a distortion. The number is unitless and is a plain
ratio of two ratios, which is what makes "this chart is misleading"
into an arithmetic claim instead of an opinion.
Write it: return shown_ratio / data_ratio, but raise
ZeroDivisionError with a clear message first if data_ratio == 0.
"""
raise NotImplementedError
def bar_pair(values, ylim=None, labels=("A", "B")):
"""Draw two bars for `values` and return (fig, ax).
`ylim` is passed straight to ax.set_ylim. Pass None to keep
matplotlib's autoscaled limits, which for a bar chart always include
zero -- matplotlib is honest by default here, and the distortion in
this lab is something an author has to reach out and add.
"""
fig, ax = plt.subplots(figsize=(4, 3))
ax.bar(list(labels), list(values), color="#1d4ed8")
if ylim is not None:
ax.set_ylim(*ylim)
ax.set_ylabel("value")
return fig, ax
def drawn_bar_heights(ax):
"""The heights of `ax`'s bars as the reader actually sees them, in
axes-fraction units (0.0 at the bottom of the plotting box, 1.0 at
the top), clipped to the visible box.
This reads the patches' real bounding boxes and pushes them through
the Axes' own data-to-axes transform, so it reports what got drawn.
A bar whose top is off the top of the axes contributes 1.0, and a
bar whose top is below the axes floor contributes 0.0 -- exactly what
a reader would see.
Write it: build the transform with
`to_axes = ax.transData + ax.transAxes.inverted()`, then for each
`patch` in `ax.patches` call `patch.get_bbox().transformed(to_axes)`
and take `min(bbox.y1, 1.0) - max(bbox.y0, 0.0)`, floored at 0.0.
"""
raise NotImplementedError
def bar_pair_lie_factor(values, ylim=None):
"""Build a two-bar chart, measure the drawn bar heights, and return
(lie_factor, shown_ratio, data_ratio).
The shown ratio comes from the rendered geometry, not from `values`.
"""
fig, ax = bar_pair(values, ylim=ylim)
try:
fig.canvas.draw()
heights = drawn_bar_heights(ax)
if heights[0] == 0:
raise ZeroDivisionError("the first bar has zero drawn height")
shown = heights[1] / heights[0]
data = values[1] / values[0]
return lie_factor(shown, data), shown, data
finally:
plt.close(fig)
# ===========================================================================
# Exercise 2 — truncation for bars versus lines
# ===========================================================================
def line_pair(values, ylim=None, xs=(0, 1)):
"""Draw the same two numbers as a two-point line and return (fig, ax)."""
fig, ax = plt.subplots(figsize=(4, 3))
ax.plot(list(xs), list(values), marker="o", color="#1d4ed8")
if ylim is not None:
ax.set_ylim(*ylim)
ax.set_ylabel("value")
return fig, ax
def drawn_change(ax):
"""The change a line encodes, recovered from the drawn geometry.
A line encodes *change* as vertical displacement. A reader recovers
that change by measuring the displacement as a fraction of the
plotting box and multiplying by the labelled axis range -- which is
exactly what this function does. The answer is in data units.
Write it: same `ax.transData + ax.transAxes.inverted()` transform.
Take `ax.lines[0].get_xydata()`, transform the first and last points,
subtract their y values to get the displacement as a fraction of the
box, then multiply by `ax.get_ylim()[1] - ax.get_ylim()[0]`.
"""
raise NotImplementedError
def line_pair_lie_factor(values, ylim=None):
"""Build a two-point line chart on the given limits and return
(lie_factor, shown_change, data_change) for the change it encodes."""
fig, ax = line_pair(values, ylim=ylim)
try:
fig.canvas.draw()
shown = drawn_change(ax)
data = values[1] - values[0]
return lie_factor(shown, data), shown, data
finally:
plt.close(fig)
# ===========================================================================
# Exercise 3 — dual axes
# ===========================================================================
def pearson(a, b):
"""Pearson correlation coefficient, written out rather than imported,
so nothing about this measurement is hidden behind a library call.
Write it by hand rather than calling np.corrcoef: centre both
arrays on their means, then divide the sum of their product by
`math.sqrt(sum(a_centred**2) * sum(b_centred**2))`. Raise
ZeroDivisionError if that denominator is zero.
"""
raise NotImplementedError
def uncorrelated_pair(n=60, seed=416):
"""Two series with a near-zero sample correlation, drawn once from a
fixed seed so every run of this lab sees the same two series.
Seed 416 was not the first seed tried. It was chosen by scanning
seeds 1-599 for the one giving the smallest absolute correlation, to
get a clean demonstration series. That is a cherry-pick, and this
docstring is the disclosure -- which is the entire rule this lab
teaches, applied to the lab's own data.
"""
rng = np.random.default_rng(seed)
a = rng.normal(50.0, 8.0, n)
b = rng.normal(0.004, 0.0006, n)
return a, b
def dual_axis_figure(a, b, ylim_a=None, ylim_b=None):
"""Plot `a` on a left axis and `b` on a right twinx axis, each with
its own limits, and return (fig, ax_left, ax_right)."""
fig, ax = plt.subplots(figsize=(6, 3))
x = np.arange(len(a))
ax.plot(x, a, color="#1d4ed8", label="series A (left axis)")
ax2 = ax.twinx()
ax2.plot(x, b, color="#b91c1c", label="series B (right axis)")
if ylim_a is not None:
ax.set_ylim(*ylim_a)
if ylim_b is not None:
ax2.set_ylim(*ylim_b)
ax.set_ylabel("series A")
ax2.set_ylabel("series B")
return fig, ax, ax2
def drawn_trace(ax, line_index=0):
"""A drawn line's y-coordinates in axes-fraction units -- the shape
the reader's eye actually follows, independent of what the numbers on
the axis say."""
to_axes = ax.transData + ax.transAxes.inverted()
xy = ax.lines[line_index].get_xydata()
return np.array([to_axes.transform((px, py))[1] for px, py in xy])
def tracking_gap(trace_a, trace_b):
"""How far apart two drawn curves sit, as a root-mean-square vertical
distance in axes fractions. 0.0 means they lie exactly on top of one
another; a value near 0.5 means they occupy different halves of the
plot. This is the quantity a dual-axis chart actually manipulates.
Write it: subtract the two arrays elementwise, square, take
the mean, take the square root -- a plain root-mean-square distance.
"""
raise NotImplementedError
def widened_limits(values, factor=20.0):
"""Limits `factor` times wider than the data, centred on it. Every
series flattens toward the middle of the plotting box, which is how
two unrelated curves get made to lie on top of each other.
Write it: take the data's min and max, find the midpoint, and
return `(mid - span, mid + span)` where `span` is
`(max - min) * factor / 2`. Guard against a zero span.
"""
raise NotImplementedError
def banded_limits(values, low_frac, high_frac):
"""Limits that place the data inside the vertical band running from
`low_frac` to `high_frac` of the plotting box, so a series can be
parked in the top half or the bottom half at will."""
lo = float(np.min(values))
hi = float(np.max(values))
span = hi - lo
if span == 0:
span = 1.0
unit = span / (high_frac - low_frac)
return lo - low_frac * unit, hi + (1.0 - high_frac) * unit
def correlated_pair(n=60, seed=7, rho=0.9):
"""A genuinely, strongly correlated pair -- the control that makes
the dual-axis result mean something. Without it, a small tracking gap
for uncorrelated data proves nothing; with it, the same small gap for
both proves that the gap carries no information at all."""
rng = np.random.default_rng(seed)
c = rng.normal(0.0, 1.0, n)
d = rho * c + math.sqrt(1.0 - rho**2) * rng.normal(0.0, 1.0, n)
return c, d
def matched_limits(values, pad=0.15):
"""Limits that centre a series in the plotting box with equal padding
above and below -- the scaling that makes any series fill the frame
the same way, and therefore the scaling that makes any two series
lie on top of each other."""
lo = float(np.min(values))
hi = float(np.max(values))
span = hi - lo
if span == 0:
span = 1.0
return lo - pad * span, hi + pad * span
# ===========================================================================
# Exercise 4 — cherry-picked windows
# ===========================================================================
def trend_slope(y):
"""The slope of the least-squares line through `y` against 0..n-1,
in units of y per step.
Write it: `np.polyfit(np.arange(len(y)), y, 1)` returns
`(slope, intercept)`. Return the slope as a float.
"""
raise NotImplementedError
def dipping_series(n=48, seed=132):
"""A series that falls for its first stretch and rises for its
second, so the sign of its trend depends entirely on where a reader
is allowed to start looking. Noise is drawn from a fixed seed."""
rng = np.random.default_rng(seed)
x = np.arange(n, dtype=float)
shape = 0.03 * (x - n / 2.0) ** 2
return shape - shape.mean() + rng.normal(0.0, 1.2, n) + 100.0
# ===========================================================================
# Exercise 5 — binning
# ===========================================================================
def bimodal_sample(n=400, seed=21):
"""A sample drawn from two separated normal components, so it really
does have two modes -- and a histogram of it can still be made to
show one.
The components sit at -0.85 and +0.85 with a standard deviation of
0.95, so they overlap enough that the answer to "how many humps?"
depends on the bin width. That fragility is the finding, not a flaw
in the data.
These separations and this seed were chosen by scanning a grid of
separations, spreads, sample sizes and seeds for a case where the two
standard bin rules genuinely disagree -- Sturges strictly rising then
strictly falling, Freedman-Diaconis showing two humps with a valley
at least 15% below the lower peak. Most parameter settings do not
disagree. That search is a cherry-pick, and this docstring is the
disclosure. The claim being demonstrated is that the disagreement is
POSSIBLE with two citable rules, not that it is typical.
"""
rng = np.random.default_rng(seed)
left = rng.normal(-0.85, 0.95, n // 2)
right = rng.normal(0.85, 0.95, n - n // 2)
return np.concatenate([left, right])
def histogram_counts(sample, bins):
"""Draw a real histogram on a real Axes and read the bar heights back
off the drawn patches, so the counts under test are the counts the
reader sees."""
fig, ax = plt.subplots(figsize=(4, 3))
try:
ax.hist(sample, bins=bins, color="#1d4ed8")
fig.canvas.draw()
return [float(p.get_height()) for p in ax.patches]
finally:
plt.close(fig)
def count_modes(counts):
"""The number of local maxima in a sequence of bar heights: a bar
strictly taller than both of its neighbours, with the two end bars
compared against their single neighbour. This is how a reader counts
humps, and it is the whole conclusion a histogram is used to reach.
Write it: walk the list and count entries strictly greater
than BOTH neighbours. Treat the missing neighbour of each end entry
as `-math.inf` so the ends can count as modes.
"""
raise NotImplementedError
# ===========================================================================
# Exercise 6 — radius versus area
# ===========================================================================
def bubble_pair(values, encode="area"):
"""Draw two bubbles for `values` and return (fig, ax).
encode="area" -- marker area is proportional to the value, which is
the correct encoding.
encode="radius" -- marker *radius* is proportional to the value,
which is the convenient-looking mistake.
matplotlib's scatter takes `s` as marker area in points squared, so
the correct encoding is the one that looks like it is doing less.
"""
values = np.asarray(values, dtype=float)
if encode == "area":
sizes = values * 40.0
elif encode == "radius":
sizes = (values * 1.4) ** 2
else:
raise ValueError("encode must be 'area' or 'radius'")
fig, ax = plt.subplots(figsize=(4, 3))
ax.scatter([0, 1], [0, 0], s=sizes, color="#1d4ed8")
ax.set_xlim(-1, 2)
ax.set_ylim(-1, 1)
return fig, ax
def drawn_area_ratio(ax):
"""The ratio of the two drawn marker areas, read off the collection's
own sizes -- which matplotlib stores in points squared, i.e. area.
Write it: `ax.collections[0].get_sizes()` returns the marker
sizes matplotlib will draw, already in points squared -- that is an
AREA. Return `sizes[1] / sizes[0]`.
"""
raise NotImplementedError
# ===========================================================================
# Exercise 7 — 3D perspective
# ===========================================================================
def bar3d_projected_areas(heights, depths, focal_length=0.2):
"""Draw two 3D bars of the given heights at the given depths, and
return the drawn 2D area of each bar's front face in figure units.
Every corner is pushed through the Axes' own projection matrix, so
the areas are the areas matplotlib really draws, perspective and all.
"""
from mpl_toolkits.mplot3d import proj3d
fig = plt.figure(figsize=(5, 4))
ax = fig.add_subplot(projection="3d")
try:
ax.set_proj_type("persp", focal_length=focal_length)
width = 0.6
for i, (height, depth) in enumerate(zip(heights, depths)):
ax.bar3d(i * 2.0, depth, 0, width, width, height, color="#1d4ed8")
ax.set_xlim(-1, 4)
ax.set_ylim(min(depths) - 1, max(depths) + 1)
ax.set_zlim(0, max(heights) * 1.2)
fig.canvas.draw()
proj = ax.get_proj()
areas = []
for i, (height, depth) in enumerate(zip(heights, depths)):
x0 = i * 2.0
corners = [
(x0, depth, 0.0),
(x0 + width, depth, 0.0),
(x0 + width, depth, height),
(x0, depth, height),
]
flat = [proj3d.proj_transform(cx, cy, cz, proj)[:2] for cx, cy, cz in corners]
areas.append(_polygon_area(flat))
return areas
finally:
plt.close(fig)
def _polygon_area(points):
"""The area of a simple polygon by the shoelace formula.
Write it with the shoelace formula: sum `x1*y2 - x2*y1` over
consecutive pairs, wrapping the last point back to the first, then
take `abs(total) / 2`.
"""
raise NotImplementedError
# ===========================================================================
# Exercise 8 — ordering and annotation
# ===========================================================================
def comparisons_to_find_max(values):
"""An idealised model of the reader effort a bar chart demands to
answer "which is biggest?".
When the bars are already in descending order, position encodes rank
and the answer is the first bar: zero comparisons. When they are not,
the reader must hold a running maximum and compare it against every
remaining bar: n - 1 comparisons.
This is a model of reading effort, not a measurement of human
behaviour. It is stated as a model everywhere it is used, and its
only claim is the ordering of the two numbers, not their exact size.
Write it: return 0 if the list is already in non-increasing
order (position then encodes rank), otherwise `len(values) - 1`.
Return 0 for a list of fewer than two entries.
"""
raise NotImplementedError
def annotated_bar_chart(labels, values, claim):
"""A bar chart carrying its claim as retrievable text: the claim goes
in the title and is also anchored to the winning bar with annotate,
so the chart states what it wants the reader to conclude."""
order = sorted(range(len(values)), key=lambda i: values[i], reverse=True)
labels = [labels[i] for i in order]
values = [values[i] for i in order]
fig, ax = plt.subplots(figsize=(5, 3))
colours = ["#1d4ed8"] + ["#cbd5e1"] * (len(values) - 1)
ax.bar(labels, values, color=colours)
ax.set_ylabel("value")
ax.set_title(claim)
ax.annotate(
claim,
xy=(0, values[0]),
xytext=(0.35, 0.85),
textcoords="axes fraction",
arrowprops={"arrowstyle": "->", "color": "#1a202c"},
)
return fig, ax
def axes_text(ax):
"""Every piece of text a reader can retrieve from an Axes: its title,
both axis labels, and every Text artist placed on it.
Write it: collect `ax.get_title()`, `ax.get_xlabel()`,
`ax.get_ylabel()` and `t.get_text() for t in ax.texts`, then drop the
empty strings.
"""
raise NotImplementedError
# ===========================================================================
# Exercise 9 — the caption contract
# ===========================================================================
CLAIM_WORDS = (
"higher",
"lower",
"more",
"less",
"greater",
"smaller",
"rose",
"fell",
"grew",
"shrank",
"increase",
"decrease",
"than",
"no difference",
"unchanged",
"%",
)
DISCLOSURE_WORDS = (
"axis starts at",
"baseline",
"does not start at zero",
"log scale",
"logarithmic",
"clipped",
"truncated",
"excludes",
)
def review_chart(ax, caption):
"""Check one chart against the day's review contract and return
(passed, failures).
The four rules:
1. The caption states a claim, so a reader has something to disagree
with. Checked by looking for comparative or quantitative language;
this is a keyword heuristic, and it is honest about being one -- it
catches a missing claim, it cannot judge a wrong one.
2. The y axis is labelled, so the reader knows what is being measured.
3. The baseline the chart was drawn on is stated in the caption
whenever it is not zero.
4. Either the baseline is zero, or the caption discloses that it is
not. Breaking the rule is allowed; breaking it silently is not.
Write it: lowercase the caption, read `low, _ = ax.get_ylim()`,
and append one message to `failures` per broken rule. Use the module
constants CLAIM_WORDS and DISCLOSURE_WORDS. The exact substrings the
tests look for are 'caption is empty', 'no claim', 'no label',
'does not say so' and 'without disclosure'. Return
`(not failures, failures)`.
"""
raise NotImplementedError
# ===========================================================================
# Exercise 8, second half — emphasis that survives a greyscale printer
# ===========================================================================
HIGHLIGHT = "#1d4ed8"
MUTED = "#cbd5e1"
CLASSIC_RED = "#d62728"
CLASSIC_GREEN = "#2ca02c"
def relative_luminance(colour):
"""The Rec. 709 relative luminance of a colour, as defined by WCAG:
each sRGB channel is linearised, then weighted 0.2126 / 0.7152 /
0.0722 and summed. The result runs from 0.0 (black) to 1.0 (white).
Luminance is the channel that survives every form of colour-vision
deficiency, every greyscale printer and every bad projector. It is
*one* component of whether two colours can be told apart, not the
whole of it -- a full colour-deficiency simulation needs a proper
colour-appearance model, which this lab does not implement and does
not pretend to. What this function supports is a narrow, checkable
claim: two colours with nearly equal luminance are distinguishable by
hue alone, and hue alone is exactly what some readers do not have.
Write it: `mcolors.to_rgb(colour)` gives three channels in
0..1. Linearise each with `c/12.92` if `c <= 0.04045` else
`((c + 0.055) / 1.055) ** 2.4`, then weight them 0.2126, 0.7152 and
0.0722 and sum.
"""
raise NotImplementedError
def luminance_separation(colour_a, colour_b):
"""How far apart two colours sit on the one axis every reader has."""
return abs(relative_luminance(colour_a) - relative_luminance(colour_b))
starter/test_starter.py (8188 bytes)
"""Starter suite — Day 132. Skips what you have not written yet.
Every test calls one of your functions inside a helper that turns a
NotImplementedError into a SKIP. So a fresh checkout reports skips, not
failures, and the count of skips is your progress bar. A wrong answer
still fails, and prints your value beside the expected one.
Run it from inside starter/:
../.venv/bin/pytest . -q
"""
import matplotlib.pyplot as plt
import numpy as np
import pytest
import honesty as H
def attempt(call, *args, **kwargs):
"""Run one of your functions, or skip if it is still a stub."""
try:
return call(*args, **kwargs)
except NotImplementedError:
pytest.skip(f"{call.__name__} is not written yet")
# --- Exercise 1 ------------------------------------------------------------
def test_ex1_lie_factor_arithmetic():
assert attempt(H.lie_factor, 3.0, 1.02) == pytest.approx(2.9411764705882355)
assert attempt(H.lie_factor, 1.02, 1.02) == 1.0
def test_ex1_drawn_bar_heights_read_the_zero_baseline_chart():
fig, ax = H.bar_pair((100.0, 102.0))
try:
fig.canvas.draw()
heights = attempt(H.drawn_bar_heights, ax)
finally:
plt.close(fig)
assert len(heights) == 2
assert heights[1] / heights[0] == pytest.approx(1.02)
def test_ex1_drawn_bar_heights_read_the_truncated_chart():
fig, ax = H.bar_pair((100.0, 102.0), ylim=(99, 103))
try:
fig.canvas.draw()
heights = attempt(H.drawn_bar_heights, ax)
finally:
plt.close(fig)
assert heights[1] / heights[0] == pytest.approx(3.0)
# --- Exercise 2 ------------------------------------------------------------
def test_ex2_a_line_encodes_the_change_whatever_the_baseline():
for limits in (None, (99, 103), (90, 110)):
fig, ax = H.line_pair((100.0, 102.0), ylim=limits)
try:
fig.canvas.draw()
change = attempt(H.drawn_change, ax)
finally:
plt.close(fig)
assert change == pytest.approx(2.0), f"ylim={limits}"
# --- Exercise 3 ------------------------------------------------------------
def test_ex3_pearson_matches_numpy():
a, b = H.uncorrelated_pair()
assert attempt(H.pearson, a, b) == pytest.approx(float(np.corrcoef(a, b)[0, 1]))
def test_ex3_tracking_gap_is_zero_for_identical_traces():
trace = np.array([0.1, 0.5, 0.9, 0.3])
assert attempt(H.tracking_gap, trace, trace) == pytest.approx(0.0)
assert attempt(H.tracking_gap, trace, trace + 0.2) == pytest.approx(0.2)
def test_ex3_widened_limits_widen_around_the_midpoint():
low, high = attempt(H.widened_limits, np.array([0.0, 10.0]), 4.0)
assert (low, high) == pytest.approx((-15.0, 25.0))
def test_ex3_scaling_cannot_change_the_drawn_correlation():
a, b = H.uncorrelated_pair()
data_r = attempt(H.pearson, a, b)
limits_a = attempt(H.widened_limits, a, 20.0)
limits_b = attempt(H.widened_limits, b, 20.0)
fig, ax, ax2 = H.dual_axis_figure(a, b, ylim_a=limits_a, ylim_b=limits_b)
try:
fig.canvas.draw()
drawn_r = attempt(H.pearson, H.drawn_trace(ax), H.drawn_trace(ax2))
gap = attempt(H.tracking_gap, H.drawn_trace(ax), H.drawn_trace(ax2))
finally:
plt.close(fig)
assert drawn_r == pytest.approx(data_r, abs=1e-12)
assert gap < 0.05
# --- Exercise 4 ------------------------------------------------------------
def test_ex4_trend_slope_recovers_a_known_slope():
assert attempt(H.trend_slope, 3.0 + 2.5 * np.arange(20)) == pytest.approx(2.5)
def test_ex4_the_trend_sign_flips_between_the_halves():
values = H.dipping_series()
half = len(values) // 2
first = attempt(H.trend_slope, values[:half])
second = attempt(H.trend_slope, values[half:])
full = attempt(H.trend_slope, values)
assert first < 0 < second
assert abs(full) < 0.05
# --- Exercise 5 ------------------------------------------------------------
def test_ex5_count_modes_counts_strict_local_maxima():
assert attempt(H.count_modes, [1, 5, 2]) == 1
assert attempt(H.count_modes, [5, 1, 5]) == 2
assert attempt(H.count_modes, [1, 2, 3, 4]) == 1
assert attempt(H.count_modes, [3, 3, 3]) == 0
def test_ex5_two_bin_rules_give_opposite_answers():
sample = H.bimodal_sample()
assert attempt(H.count_modes, H.histogram_counts(sample, bins="sturges")) == 1
assert attempt(H.count_modes, H.histogram_counts(sample, bins="fd")) == 2
# --- Exercise 6 ------------------------------------------------------------
def test_ex6_radius_encoding_squares_the_shown_ratio():
ratios = {}
for encoding in ("area", "radius"):
fig, ax = H.bubble_pair((25.0, 100.0), encode=encoding)
try:
fig.canvas.draw()
ratios[encoding] = attempt(H.drawn_area_ratio, ax)
finally:
plt.close(fig)
assert ratios["area"] == pytest.approx(4.0)
assert ratios["radius"] == pytest.approx(16.0)
assert attempt(H.lie_factor, ratios["radius"], 4.0) == pytest.approx(4.0)
# --- Exercise 7 ------------------------------------------------------------
def test_ex7_polygon_area_matches_a_known_rectangle():
assert attempt(H._polygon_area, [(0, 0), (2, 0), (2, 3), (0, 3)]) == pytest.approx(6.0)
def test_ex7_perspective_departs_from_the_data_ratio():
areas = attempt(H.bar3d_projected_areas, [1.0, 2.0], [0.0, 3.0])
ratio = areas[1] / areas[0]
assert abs(ratio / 2.0 - 1.0) > 0.10
# --- Exercise 8 ------------------------------------------------------------
def test_ex8_sorting_removes_the_comparisons():
values = [41.0, 88.0, 37.0, 52.0, 63.0]
assert attempt(H.comparisons_to_find_max, values) == 4
assert attempt(H.comparisons_to_find_max, sorted(values, reverse=True)) == 0
def test_ex8_the_chart_carries_its_claim_as_retrievable_text():
claim = "south is 39% higher than the next region"
fig, ax = H.annotated_bar_chart(
["north", "south", "east", "west", "central"],
[41.0, 88.0, 37.0, 52.0, 63.0],
claim,
)
try:
fig.canvas.draw()
text = attempt(H.axes_text, ax)
finally:
plt.close(fig)
assert claim in text
assert "" not in text
def test_ex8_relative_luminance_and_the_red_green_collapse():
assert attempt(H.relative_luminance, "#000000") == pytest.approx(0.0)
assert attempt(H.relative_luminance, "#ffffff") == pytest.approx(1.0)
red_green = attempt(H.luminance_separation, H.CLASSIC_RED, H.CLASSIC_GREEN)
emphasis = attempt(H.luminance_separation, H.HIGHLIGHT, H.MUTED)
assert red_green < 0.11
assert emphasis > 0.5
# --- Exercise 9 ------------------------------------------------------------
def test_ex9_the_contract_passes_an_honest_chart():
fig, ax = H.bar_pair((100.0, 102.0))
try:
fig.canvas.draw()
passed, failures = attempt(
H.review_chart, ax, "Group B is 2% higher than group A."
)
finally:
plt.close(fig)
assert passed, failures
def test_ex9_the_contract_fails_the_truncated_chart():
fig, ax = H.bar_pair((100.0, 102.0), ylim=(99, 103))
try:
fig.canvas.draw()
passed, failures = attempt(
H.review_chart, ax, "Group B is 2% higher than group A."
)
finally:
plt.close(fig)
assert not passed
assert any("does not say so" in f for f in failures)
assert any("without disclosure" in f for f in failures)
def test_ex9_the_contract_passes_a_disclosed_rule_break():
fig, ax = H.line_pair((100.0, 102.0), ylim=(99, 103))
try:
fig.canvas.draw()
passed, failures = attempt(
H.review_chart,
ax,
"Group B is 2% higher than group A. Note: the y axis starts at "
"99, not zero.",
)
finally:
plt.close(fig)
assert passed, failures
def test_ex9_an_accurate_but_silent_chart_still_fails():
fig, ax = H.bar_pair((100.0, 102.0))
ax.set_ylabel("")
try:
fig.canvas.draw()
passed, failures = attempt(H.review_chart, ax, "Results.")
finally:
plt.close(fig)
assert not passed
assert len(failures) == 2
tests/run_tests.sh (17994 bytes)
#!/usr/bin/env bash
# Tests for the Day 132 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and measuring
# real rendered geometry, never by diffing image bytes:
#
# * the lie factor -- 1.00 for a zero-baseline bar pair, 2.94 for the
# same two numbers on a truncated axis, with the shown ratio read off
# the drawn bars rather than off the inputs;
# * truncation is fatal for bars and neutral for lines -- the bar's lie
# factor exceeds 2.5 while the line's is exactly 1.0 on every
# baseline tried;
# * dual axes -- scaling CANNOT change the drawn correlation (invariant
# to 3e-15 over 500 random scalings), inverting one axis negates it
# exactly, and the tracking gap is a free parameter that reaches the
# same value for r = -0.001 and for r = +0.913;
# * a trend whose sign flips between two windows of one series;
# * two textbook bin rules that draw one hump and two humps;
# * radius encoding, whose shown area ratio is the square of the data
# ratio;
# * 3D perspective departing from the data ratio by 17% and by 110%
# depending only on where the taller bar stands;
# * ordering, annotation and luminance separation;
# * a caption contract that passes an honest chart, fails a truncated
# one, and passes a disclosed rule break;
# * nothing left behind on disk.
#
# Everything after the one-time install runs offline. Nothing binds a
# port, nothing writes outside the lab, 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)"
# Bytecode left by an EARLIER command is not this run's litter. The README
# documents `pytest starter -q`, and running it writes .pyc files that
# would then fail the cleanliness check at the end of this script --
# failing the reader for following the instructions. Clearing them here
# makes that final check measure what it claims to. `.venv` is untouched,
# because the packages' own bytecode is theirs, not ours.
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
check_eq() {
# check_eq <label> <expected> <actual>
if [ "$2" = "$3" ]; then
check "$1" "yes"
else
check "$1 (expected [$2], got [$3])" "no"
fi
}
# Resolve pytest: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping checks.
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
for module in matplotlib seaborn pandas numpy; do
if ! "${python_bin}" -c "import ${module}" >/dev/null 2>&1; then
echo "FAIL: ${module} 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
done
echo "Day 132 — Visual Storytelling and Chart Honesty"
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"):
print(f"{name:<11} {version(name)}")
print(f"platform {platform.platform()}")
PY
)"
echo "${versions}" | sed 's/^/ /'
for package in matplotlib seaborn pandas numpy pytest; do
pinned="$(grep -E "^${package}==" "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed="$("${python_bin}" -c "from importlib.metadata import version; print(version('${package}'))")"
check_eq "installed ${package} matches requirements.txt" "${pinned}" "${installed}"
done
backend="$("${python_bin}" -c "import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot as plt; print(plt.get_backend())")"
check_eq "matplotlib runs on the headless Agg backend" "agg" "$(echo "${backend}" | tr '[:upper:]' '[:lower:]')"
# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------
for script in 01_lie_factor 02_bars_versus_lines 03_dual_axes \
04_cherry_picked_window 05_binning_changes_the_conclusion \
06_radius_versus_area 07_three_d_distortion \
08_ordering_and_annotation 09_caption_contract; do
out="$(cd "${lab_dir}/examples" && "${python_bin}" "${script}.py" 2>&1)"
status=$?
if [ "${status}" -ne 0 ]; then
check "${script}.py exits 0" "no"
echo "${out}" | tail -5 | sed 's/^/ /'
else
check "${script}.py exits 0" "yes"
fi
case "${out}" in
*"${script}.py: every assertion held."*)
check "${script}.py reports every assertion held" "yes" ;;
*) check "${script}.py reports every assertion held" "no" ;;
esac
done
# --------------------------------------------------------------------------
echo
echo "3. The headline numbers, measured here and now"
# --------------------------------------------------------------------------
measured="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY'
import matplotlib.pyplot as plt
import honesty as H
lf_zero, _, _ = H.bar_pair_lie_factor((100.0, 102.0))
lf_trunc, shown, _ = H.bar_pair_lie_factor((100.0, 102.0), ylim=(99, 103))
lf_line, _, _ = H.line_pair_lie_factor((100.0, 102.0), ylim=(99, 103))
print(f"lf_zero {lf_zero:.4f}")
print(f"lf_trunc {lf_trunc:.4f}")
print(f"shown_trunc {shown:.4f}")
print(f"lf_line {lf_line:.4f}")
def dual(a, b, la, lb, invert=False):
if invert:
lb = (lb[1], lb[0])
fig, ax, ax2 = H.dual_axis_figure(a, b, ylim_a=la, ylim_b=lb)
try:
fig.canvas.draw()
ta, tb = H.drawn_trace(ax), H.drawn_trace(ax2)
return H.tracking_gap(ta, tb), H.pearson(ta, tb)
finally:
plt.close(fig)
a, b = H.uncorrelated_pair()
c, d = H.correlated_pair()
gap_apart, r_apart = dual(a, b, H.banded_limits(a, .55, .95), H.banded_limits(b, .05, .45))
gap_wide, r_wide = dual(a, b, H.widened_limits(a), H.widened_limits(b))
gap_strong, _ = dual(c, d, H.widened_limits(c), H.widened_limits(d))
_, r_inv = dual(c, d, H.matched_limits(c), H.matched_limits(d), invert=True)
print(f"data_r {H.pearson(a, b):.6f}")
print(f"drawn_r_apart {r_apart:.6f}")
print(f"drawn_r_wide {r_wide:.6f}")
print(f"gap_apart {gap_apart:.4f}")
print(f"gap_wide {gap_wide:.4f}")
print(f"gap_strong {gap_strong:.4f}")
print(f"strong_r {H.pearson(c, d):.6f}")
print(f"strong_r_inverted {r_inv:.6f}")
values = H.dipping_series()
half = len(values) // 2
print(f"slope_first {H.trend_slope(values[:half]):.4f}")
print(f"slope_second {H.trend_slope(values[half:]):.4f}")
sample = H.bimodal_sample()
print(f"modes_sturges {H.count_modes(H.histogram_counts(sample, bins='sturges'))}")
print(f"modes_fd {H.count_modes(H.histogram_counts(sample, bins='fd'))}")
fig, ax = H.bubble_pair((25.0, 100.0), encode="radius")
fig.canvas.draw()
print(f"bubble_area_ratio {H.drawn_area_ratio(ax):.2f}")
plt.close(fig)
far = H.bar3d_projected_areas([1.0, 2.0], [0.0, 3.0])
near = H.bar3d_projected_areas([1.0, 2.0], [3.0, 0.0])
print(f"ratio_3d_far {far[1] / far[0]:.3f}")
print(f"ratio_3d_near {near[1] / near[0]:.3f}")
print(f"lum_red_green {H.luminance_separation(H.CLASSIC_RED, H.CLASSIC_GREEN):.4f}")
print(f"lum_emphasis {H.luminance_separation(H.HIGHLIGHT, H.MUTED):.4f}")
PY
)"
echo "${measured}" | sed 's/^/ /'
value_of() { printf '%s\n' "${measured}" | grep "^$1 " | cut -d' ' -f2; }
check_eq "a zero-baseline bar pair has lie factor 1.0000" "1.0000" "$(value_of lf_zero)"
check_eq "the truncated bar pair draws a 3.0000 height ratio" "3.0000" "$(value_of shown_trunc)"
check_eq "the truncated bar pair has lie factor 2.9412" "2.9412" "$(value_of lf_trunc)"
check_eq "the same numbers as a line have lie factor 1.0000" "1.0000" "$(value_of lf_line)"
check_eq "the demonstration pair's data correlation" "-0.001034" "$(value_of data_r)"
check_eq "scaling apart leaves the drawn correlation unchanged" "-0.001034" "$(value_of drawn_r_apart)"
check_eq "scaling together leaves the drawn correlation unchanged" "-0.001034" "$(value_of drawn_r_wide)"
check_eq "inverting one axis negates a strong correlation exactly" "-0.913234" "$(value_of strong_r_inverted)"
check_eq "the separated scaling draws a 0.4938 tracking gap" "0.4938" "$(value_of gap_apart)"
check_eq "the widened scaling draws a 0.0147 tracking gap" "0.0147" "$(value_of gap_wide)"
check_eq "a strongly correlated pair draws the same small gap" "0.0046" "$(value_of gap_strong)"
check_eq "the first window's trend slope is negative" "-0.7305" "$(value_of slope_first)"
check_eq "the second window's trend slope is positive" "0.7045" "$(value_of slope_second)"
check_eq "Sturges' rule draws one hump" "1" "$(value_of modes_sturges)"
check_eq "Freedman-Diaconis draws two humps" "2" "$(value_of modes_fd)"
check_eq "radius encoding squares a data ratio of 4 into 16" "16.00" "$(value_of bubble_area_ratio)"
check_eq "3D, taller bar far, draws a 2.341 ratio (data ratio 2)" "2.341" "$(value_of ratio_3d_far)"
check_eq "3D, taller bar near, draws a 4.204 ratio (data ratio 2)" "4.204" "$(value_of ratio_3d_near)"
check_eq "red and green differ by only 0.0996 in luminance" "0.0996" "$(value_of lum_red_green)"
check_eq "deliberate emphasis reaches 0.5505 in luminance" "0.5505" "$(value_of lum_emphasis)"
# --------------------------------------------------------------------------
echo
echo "4. The reference pytest suite: real geometry, real exceptions"
# --------------------------------------------------------------------------
ref_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
ref_status=$?
echo "${ref_out}" | tail -3 | sed 's/^/ /'
if [ "${ref_status}" -eq 0 ]; then
check "pytest examples exits 0" "yes"
else
check "pytest examples exits 0" "no"
fi
case "${ref_out}" in
*" failed"*) check "no test in the reference suite failed" "no" ;;
*) check "no test in the reference suite failed" "yes" ;;
esac
ref_passed="$(printf '%s\n' "${ref_out}" | grep -o '[0-9][0-9]* passed' | head -1 | cut -d' ' -f1)"
if [ "${ref_passed:-0}" -ge 40 ]; then
check "the reference suite ran at least 40 tests (ran ${ref_passed})" "yes"
else
check "the reference suite ran at least 40 tests (ran ${ref_passed:-0})" "no"
fi
# --------------------------------------------------------------------------
echo
echo "5. The starter suite skips unattempted work instead of failing it"
# --------------------------------------------------------------------------
start_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
start_status=$?
echo "${start_out}" | tail -3 | sed 's/^/ /'
if [ "${start_status}" -eq 0 ]; then
check "pytest starter exits 0 on an untouched checkout" "yes"
else
check "pytest starter exits 0 on an untouched checkout" "no"
fi
case "${start_out}" in
*" failed"*) check "the starter suite reports no failures" "no" ;;
*) check "the starter suite reports no failures" "yes" ;;
esac
case "${start_out}" in
*skipped*) check "unwritten exercises are reported as skipped, not passed" "yes" ;;
*) check "unwritten exercises are reported as skipped, not passed" "no" ;;
esac
# The import guard. Both directories contain a module called `honesty`,
# and pytest imports test files by putting their directory on sys.path --
# so collecting both suites at once would otherwise let the starter tests
# import the REFERENCE solution and report unwritten exercises as passing.
# Each directory's conftest.py prevents that. This check proves it still
# does: across both suites, the skip count must be unchanged.
both_out="$(cd "${lab_dir}" && "${pytest_bin}" -q -p no:cacheprovider 2>&1)"
start_skipped="$(printf '%s\n' "${start_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
both_skipped="$(printf '%s\n' "${both_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
check_eq "collecting both suites at once does not turn skips into passes" \
"${start_skipped:-none}" "${both_skipped:-none}"
# --------------------------------------------------------------------------
echo
echo "6. The harness can actually fail"
# --------------------------------------------------------------------------
# A green suite proves nothing until you have watched it go red. This
# section re-runs the caption contract with the zero-baseline rule
# deliberately removed, and asserts the re-run reports the failure and
# exits non-zero. Nothing on disk is modified: the reference function is
# replaced in memory for the duration of one subprocess.
if [ -z "${D132_SELF_TEST:-}" ]; then
self_out="$(cd "${lab_dir}/examples" && D132_SELF_TEST=1 "${python_bin}" -c "
import honesty as H
def _toothless(ax, caption):
# a review tool that approves of everything -- the exact failure mode
# a checklist is supposed to prevent
return True, []
H.review_chart = _toothless
exec(open('09_caption_contract.py').read())
" 2>&1)"
self_status=$?
if [ "${self_status}" -ne 0 ]; then
check "a review function that approves everything makes script 09 exit non-zero (${self_status})" "yes"
else
check "a review function that approves everything makes script 09 exit non-zero" "no"
fi
case "${self_out}" in
*"AssertionError"*"the truncated chart must fail the contract"*)
check "the failing assertion is named in the output" "yes" ;;
*) check "the failing assertion is named in the output" "no" ;;
esac
# And the same for a measurement, not just a check: a lie_factor that
# ignores the drawn geometry and reports 1.0 for everything must be
# caught by script 01.
self2_out="$(cd "${lab_dir}/examples" && D132_SELF_TEST=1 "${python_bin}" -c "
import honesty as H
H.lie_factor = lambda shown, data: 1.0
exec(open('01_lie_factor.py').read())
" 2>&1)"
self2_status=$?
if [ "${self2_status}" -ne 0 ]; then
check "a lie_factor stuck at 1.0 makes script 01 exit non-zero (${self2_status})" "yes"
else
check "a lie_factor stuck at 1.0 makes script 01 exit non-zero" "no"
fi
else
echo " (self-test run: section 6 does not recurse)"
fi
# --------------------------------------------------------------------------
echo
echo "7. Nothing was left behind"
# --------------------------------------------------------------------------
# `.venv` is pruned from every search below. The virtual environment ships
# matplotlib's, pandas' and pytest's own precompiled bytecode -- hundreds
# of __pycache__ directories that came with the packages and have nothing
# to do with whether THIS lab tidied up after itself.
if find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -print -quit 2>/dev/null | grep -q .; then
check "no __pycache__ directory left by the lab's own code" "no"
else
check "no __pycache__ directory left by the lab's own code" "yes"
fi
if find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -print -quit 2>/dev/null | grep -q .; then
check "no .pytest_cache directory left under the lab" "no"
else
check "no .pytest_cache directory left under the lab" "yes"
fi
# This lab draws around fifty figures. Not one of them may reach the disk.
if find "${lab_dir}" -name '.venv' -prune -o -type f \( -name '*.png' -o -name '*.svg' -o -name '*.pdf' \) -print -quit 2>/dev/null | grep -q .; then
check "no image file (.png/.svg/.pdf) left by the lab's own code" "no"
find "${lab_dir}" -name '.venv' -prune -o -type f \( -name '*.png' -o -name '*.svg' -o -name '*.pdf' \) -print 2>/dev/null | sed 's/^/ /'
else
check "no image file (.png/.svg/.pdf) left by the lab's own code" "yes"
fi
# Match a real CALL at the start of a statement, not the "never call
# plt.show()" warnings the lab's own docstrings carry -- an earlier
# version of this check matched those and failed the lab for documenting
# the rule it follows.
if grep -rnE '^[[:space:]]*plt\.show\(' "${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
check "no lab source calls plt.show()" "no"
else
check "no lab source calls plt.show()" "yes"
fi
if grep -rqE 'urlopen|requests\.|socket\.|http://|https://' \
"${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
check "no lab source opens a network connection" "no"
else
check "no lab source opens a network connection" "yes"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting
ModuleNotFoundError: No module named 'matplotlib'
You are running the system Python rather than the lab's virtual
environment. Every command in this lab is prefixed with .venv/bin/
for exactly this reason:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest examples -q
If you would rather use a Python you already have, point the harness at its pytest instead:
PYTEST=/path/to/pytest bash tests/run_tests.sh
The harness checks each installed version against
requirements/requirements.txt and reports a mismatch rather than
producing different numbers in silence.
pytest starter reports 22 skipped and nothing else
That is correct on an untouched checkout. Every function in
starter/honesty.py still raises NotImplementedError, and the starter
suite turns that into a skip rather than a failure so the skip count is
your progress bar. Write a function, re-run, and watch one or more skips
become passes.
Never run pytest examples starter as one command
Run them as two:
.venv/bin/pytest examples -q
.venv/bin/pytest starter -q
Both directories ship a module called honesty, and pytest imports test
files by putting their directory on sys.path. Combining the two
directories in one invocation is unreliable in both directions across
labs in this course: it can silently let one suite import the other
directory's module — which would report your unwritten exercises as
passing — or it can abort collection outright with an import file
mismatch. Each directory's conftest.py guards against the first case,
and tests/run_tests.sh verifies the guard still works by checking that
the skip count is unchanged when both suites are collected together. Two
separate runs are well defined and need no guard at all.
A test fails with a number close to but not equal to the expected one
Check which number. expected-output/FIELDS.md splits every captured
value into three groups, and the group tells you what to do:
- Exact everywhere — arithmetic and affine geometry. A mismatch here is a real bug in your implementation, not an environment difference.
- Version-specific — the tracking gaps in exercise 3 and the 3D
drawn ratios in exercise 7. These depend on matplotlib's autoscale
margins and on
Axes3D's projection internals, which have moved between releases. If you are not on matplotlib 3.11.1, expect the fourth decimal to differ; the ordering of the values is what the lesson claims. - Deliberately selected — the two seeds, both disclosed.
RuntimeWarning: More than 20 figures have been opened
Something is creating figures without closing them. Every helper in this
lab wraps its drawing in try: … finally: plt.close(fig) for this
reason. If you add a figure of your own, close it the same way. Day 128
covers the figure lifecycle in full.
The 3D exercise gives a different ratio on your machine
Expected, and covered above. The projected areas depend on the camera:
this lab uses matplotlib's perspective projection at focal_length=0.2
with the default view angle. Change the focal length or the elevation and
the numbers move. What does not move is the finding — under perspective,
the drawn size of a bar depends on where it stands, so the comparison the
chart exists to support is the one the third dimension breaks. If you
want to see that directly, call bar3d_projected_areas with the depths
swapped and watch the ratio go from 2.34 to 4.20.
Windows
The commands above assume macOS or Linux paths. On Windows, the virtual
environment puts its executables in .venv\Scripts\ rather than
.venv/bin/:
py -m venv .venv
.venv\Scripts\pip install -r requirements\requirements.txt
.venv\Scripts\pytest examples -q
tests/run_tests.sh is a bash script. Run it from Git Bash or WSL. It
was executed and captured on macOS only; the Windows path above is
documented from the standard Python packaging layout and was not
exercised on the authoring machine.
Security notes
Security notes
What this lab does
It draws charts in memory and measures them. That is the whole surface area.
- No network. After the single
pip installthat creates the virtual environment, nothing in this lab opens a socket. The test harness greps every source file underexamples/andstarter/forurlopen,requests.,socket.,http://andhttps://and fails if any appears. - No files written. This lab draws roughly fifty figures and saves
none of them. Nothing calls
savefig. The harness fails if any.png,.svgor.pdfis found anywhere under the lab directory after a run, and also fails on a leftover__pycache__or.pytest_cache. - No display server, no window.
matplotlib.use("Agg")is called beforepyplotis imported in every file, andplt.show()is never called. The harness greps for a realplt.show(call at the start of a statement and fails if it finds one. - No credentials, no keys, no external services.
requires_api_keyisfalseinmetadata.ymland there is nothing to authenticate to. - No
sudo, ever. Every command in this lab runs as your normal user, and everything it creates lives inside the lab directory.
The data
Every dataset here is generated from a seeded NumPy random generator inside the lab. There is no input file, no download, and no personal or proprietary data of any kind. The bar labels ("north", "south", …) and the values attached to them are invented for the exercise and mean nothing.
Cleaning up
.venv is the only large thing this lab creates, and removing it is one
command:
rm -rf .venv
The cleanup_commands in metadata.yml list that alongside clearing
bytecode caches and resetting starter/ to its blank skeleton.
A note that belongs on this page more than most
This lab is a tutorial in making misleading charts. It is worth being
explicit that the intent runs the other way: the point of building the
distortions is to be able to measure them, and the exercise that
matters most is the last one, which is a review tool you can run on your
own work. Nothing in examples/ should be lifted into a real report. The
one function that should be is review_chart.