Math, Statistics, and DataData Visualization › Day 132

Hands-on lab — 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

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

  1. bash tests/run_tests.sh prints 59 checks, 0 failure(s). and exits 0.
  2. .venv/bin/pytest examples -q reports 42 passed.
  3. .venv/bin/pytest starter -q reports 22 skipped on an untouched checkout, and 22 passed once every exercise is written.
  4. Section 6 of the harness proves the suite can fail: it replaces review_chart with a function that approves everything and confirms script 09 exits non-zero, then does the same with a lie_factor stuck at 1.0 against script 01. Neither modifies a file on disk.
  5. Section 7 confirms the lab left no image, no __pycache__ and no .pytest_cache behind — 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

  1. 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) > 0 with no lines is a start — and you will discover why that detection is harder than it looks.
  2. 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.
  3. 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.
  4. Re-run exercise 7 across cameras. Sweep focal_length from 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.
  5. Test your own charts. Take a figure you have already published, run review_chart over it, and fix what it finds. This is the only extension that changes anything outside this directory.
  • 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 install that creates the virtual environment, nothing in this lab opens a socket. The test harness greps every source file under examples/ and starter/ for urlopen, requests., socket., http:// and https:// 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, .svg or .pdf is 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 before pyplot is imported in every file, and plt.show() is never called. The harness greps for a real plt.show( call at the start of a statement and fails if it finds one.
  • No credentials, no keys, no external services. requires_api_key is false in metadata.yml and 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.