Math, Statistics, and DataData Visualization › Day 129

Hands-on lab — Day 129: Statistical Plots with seaborn

Commands

Setup

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

Run

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

Test

bash tests/run_tests.sh

File tree

examples/conftest.py
examples/data.py
examples/test_seaborn.py
expected-output/examples-run.txt
expected-output/FIELDS.md
expected-output/starter-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/00_brief.md
starter/conftest.py
starter/data.py
starter/test_seaborn.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 129 lab — Plots That Say What They Computed

Lesson

Purpose

Nine numbered exercises, sixteen tests, each proving one real seaborn 0.13.2 / matplotlib 3.11.1 behaviour by drawing a real plot, headless via the Agg backend, and reading real return types, artist state, or numeric values. The through-line is that seaborn does statistics for you before it draws — a barplot is a chart of a computed estimator with a bootstrapped interval, not a chart of your raw data. Exercise 2 makes that concrete immediately: the four bar heights equal the four group means, none of which is a value any of that group's own observations actually holds, and a stripplot of the same column recovers every one of the sixteen raw points a bar chart aggregated away. Every later exercise adds one more piece of the API this fact depends on: axes-level versus figure-level functions and their different return types, the bootstrap's own randomness and how to fix it with seed=, errorbar= alternatives, long-versus-wide data, faceting, the matplotlib escape hatch, theme side effects, and the honest overlay of raw points on an aggregated chart.

Learning objectives

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

  • Distinguish axes-level seaborn functions (which draw into an Axes you own and return that Axes) from figure-level functions (which create and own their own Figure and return a FacetGrid), and read the return type to tell which one you called.
  • Demonstrate that a barplot's bar heights equal the group means, that none of those means is a value present in that group's own raw data, and recover every raw observation instead with a stripplot of the same column.
  • Demonstrate that two barplot calls without seed= produce bootstrapped error bars of slightly different extent, and that fixing seed= makes two runs identical.
  • Compare errorbar='sd' against errorbar=('ci', 95) on the same data and state which one is a closed-form statistic (does not depend on the seed) and which is a resampling procedure (does).
  • Explain why a wide DataFrame fails when asked for a hue mapping by column name, and use melt (Day 124) to produce the long form that works.
  • Use col= to facet a plot into one Axes per category, and col_wrap= to reshape that grid without changing how many Axes exist.
  • Use the Day 128 matplotlib object API (ax.set_ylabel, ax.set_ylim, and similar) as the escape hatch after a seaborn call has already drawn.
  • Demonstrate that sns.set_theme() mutates global matplotlib rcParams and know how to capture and restore the prior state.
  • Overlay a stripplot on a boxplot in the same Axes and confirm both kinds of artist (box patches and point collections) are present — the honest form for a small sample.

Prerequisites

  • Day 124 — merging and reshaping; this lab's exercise 5 uses the exact melt call that day taught to turn a wide table long.
  • Day 117 — sampling and the Central Limit Theorem, specifically the bootstrap resampling technique that reappears unannounced in this lab's exercise 3 as barplot's default error bar.
  • Day 127 — why to visualize and how to choose a chart type; this lab assumes you already know when a bar, box or scatter chart is the right starting point and focuses instead on what seaborn actually draws.
  • Day 128 — matplotlib's object model (fig, ax = plt.subplots(), the Figure/Axes/Artist hierarchy, and testing by asserting on artists), which this lab's escape-hatch exercise (7) and every artist assertion in exercises 2 and 9 depend on directly.
  • A working python3 on your PATH to create the lab's virtual environment.

Supported operating systems

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

Hardware requirements

Anything. Every table in this lab is a small hand-built literal, at most sixteen rows. No GPU, no display, no meaningful disk use, and no network beyond the one-time install.

Required software

Tool Minimum Used here Why
python3 3.11 3.14.0 Runs everything; standard library venv builds the lab's environment
seaborn 0.13.2 exactly 0.13.2 Every plotting call in this lab
matplotlib 3.11.1 exactly 3.11.1 seaborn's drawing engine; every assertion reads its Axes/Figure/Patch/Line2D objects directly
pandas 3.0.5 exactly 3.0.5 team_scores, wide_revenue, long_revenue, and the exercise-5 melt call
numpy 2.5.2 2.5.2 Transitive dependency of pandas and matplotlib
pytest 9.1.1 9.1.1 The test harness every exercise is written against
bash 3.2 3.2.57 The outer test harness

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

Free and open-source options

Everything here is free.

  • seaborn (BSD 3-Clause), matplotlib (PSF-derived, BSD-style), pandas (BSD 3-Clause), NumPy (BSD 3-Clause) and pytest (MIT) are fully open source with no paid tier.
  • plotnine (MIT/BSD, described from documentation only, not run here) offers a ggplot2-style grammar-of-graphics alternative, also free and open source.
  • Vega-Lite / Altair (BSD 3-Clause, described from documentation only, not run here) is a free, declarative, JSON-based alternative that renders interactively in a browser or notebook.

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

Installation

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

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

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

File structure

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

How to run

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

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

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

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

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

What the commands do

.venv/bin/pytest examples runs the fully worked reference suite: 16 tests across the nine exercises, each asserting a real value read off a real seaborn/matplotlib object built from one of the two tables in data.py.

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

bash tests/run_tests.sh confirms the installed packages match requirements.txt exactly, runs pytest examples and requires 16 passed, runs pytest starter and requires 16 skipped on the checked-in state, then solves every exercise in a scratch copy made with mktemp -d (never touching the real starter/test_seaborn.py), confirms that copy passes in full, deliberately breaks one assertion inside it, confirms the run now exits non-zero with a failure reported, restores the line, and confirms it passes again. It then draws one real barplot, saves it headless to a temporary PNG file, confirms the file exists, and removes it — proving both that headless rendering genuinely works and that this lab leaves no image file behind. It finishes by checking no file in examples/ or starter/ contains a URL, and that nothing is left on disk.

Expected output

The harness ends with a real captured line:

17 checks, 0 failure(s)

and exits 0. pytest examples ends with:

16 passed in 0.83s

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

16 skipped in 0.03s

Exercise 2's barplot trap, exactly as captured — the four bar heights are the four group means, and team B's mean is lower than team A's despite three of team B's four raw scores beating every one of team A's:

team
A    79.0
B    70.0
C    67.5
D    57.5
Name: score, dtype: float64

The full capture of both suites is in expected-output/, and expected-output/FIELDS.md says which values are exact everywhere, which are specific to this seaborn/matplotlib pin, and which (exercise 3's unseeded bootstrap extents) are expected to differ between runs by design.

Validation steps

  1. bash tests/run_tests.sh ends with 17 checks, 0 failure(s) and exits 0.
  2. sns.scatterplot(..., ax=ax) returns that same ax; sns.relplot(...) returns a seaborn.axisgrid.FacetGrid whose .figure owns its own .ax.
  3. A barplot of team_scores produces bars of height 79.0, 70.0, 67.5, 57.5 — the four group means — and none of those four numbers is one of that group's own raw scores.
  4. Six barplot calls with no seed= do not all produce identical bootstrapped error-bar extents; the same call with seed=42 twice produces identical extents.
  5. errorbar='sd' and errorbar=('ci', 95) produce different extents on the same data; 'sd' alone is identical regardless of seed.
  6. sns.lineplot on wide_revenue asking for x="quarter" raises ValueError; the same call on long_revenue (produced by melt) succeeds with one legend entry per region.
  7. sns.catplot(..., col="region") on five regions produces exactly 5 Axes arranged (1, 5); adding col_wrap=3 keeps 5 Axes but reshapes the grid to (2, 3).
  8. ax.set_ylabel(...) called after a seaborn boxplot call sticks and is readable back with ax.get_ylabel().
  9. sns.set_theme() changes seven specific matplotlib.rcParams keys, including axes.facecolor to "#EAEAF2" and axes.grid to True; restoring the captured dictionary returns matplotlib to its prior state exactly.
  10. A boxplot followed by a stripplot on the same Axes carries 4 box patches and 4 point collections, together showing all 16 raw observations.

Tests

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

17 checks, exit 0 when they all pass and non-zero otherwise. They are value checks, not file-existence checks: the reference suite's 16 tests are exercised through pytest, the exercise suite is confirmed all-skip on the checked-in state, a scratch copy proves the suite can genuinely fail and then recover, and one real headless savefig proves the Agg backend and the lab's own cleanup both work.

Override, if your tools are somewhere unusual:

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

Cleanup

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

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

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

To reset your own work and start the exercises again:

git checkout -- starter/

Troubleshooting

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

  • pytest examples starter aborts with import file mismatch — do not run both directories in one invocation; they share a module name.
  • A plot window tries to open — something imported pyplot before matplotlib.use("Agg") ran; both conftest.py files set the backend first, and the harness also exports MPLBACKEND=Agg.
  • Exercise 2's bar heights are not exactly 79.0, 70.0, 67.5, 57.5 — recompute them from team_scores.groupby("team")["score"].mean() rather than hardcoding a number.
  • Exercise 3's two "unseeded" runs come out identical — can happen by chance on a tiny sample; the seeded half must always agree.

Security notes

security.md has the full account. In short: this lab opens the network exactly once, to install its five pinned packages, renders entirely headless via matplotlib's Agg backend, writes only inside its own .venv and one deliberately temporary directory it cleans up itself, and touches no real data — every table is a small invented literal built by hand in data.py.

Extension exercises

  1. Reproduce the barplot trap with more groups. Build a ten-group version of team_scores where every group's mean is close together but one group has an extreme outlier; confirm the bar chart alone cannot distinguish "consistently middling" from "excellent except for one bad day", and that a stripplot or swarmplot immediately can.
  2. Compare boxplot against violinplot. Draw both on team_scores and describe, in one paragraph, what a violin plot shows about team B's distribution that a box plot's five-number summary does not.
  3. errorbar='pi' (a prediction interval) versus ('ci', 95). Add a third comparison to exercise 4 using errorbar=('pi', 95) and report how its extent differs from both 'sd' and the confidence interval, and explain in one sentence what a prediction interval claims that a confidence interval does not.
  4. row= in addition to col=. Extend exercise 6 with a second categorical column and facet on both row= and col= at once; confirm the resulting Axes count is the product of the two categories' counts.
  5. A theme that persists across a whole notebook session. Call sns.set_theme(context="talk", palette="colorblind") instead of the bare default, draw a plot, and write down which additional rcParams keys changed compared to this lab's exercise 8 -- and why forgetting to reset a theme before a screenshot for a report is a common way a chart's colours stop matching a team's usual style guide.
  • Previous day: Day 128 — Matplotlib Fundamentals (labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals/).
  • Next day: Day 130 — Distributions and Relationships (labs/sections/math-statistics-and-data/), continuing Week 19.
  • Week 19 project: the week's project directory (labs/sections/math-statistics-and-data/projects/week-19/), building directly on this week's charting fundamentals.

Expected output

FIELDS.md

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

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

## Exact everywhere on this exact pin set

- `examples-run.txt` ends with `16 passed`, `starter-run.txt` ends with
  `16 skipped` — both counts are structural (16 test functions in each
  file) and do not depend on the machine.
- The four group means in exercise 2 — `79.0`, `70.0`, `67.5`, `57.5` —
  are arithmetic on a fixed, hand-written literal table (`team_scores` in
  `data.py`) and are exact on any correctly installed copy of pandas.
- `errorbar='sd'` in exercise 4 is a closed-form statistic (mean +/- one
  sample standard deviation), not a resampling procedure, so its extent
  is identical on any machine and does not depend on any seed.
- The `ValueError` in exercise 5 (asking a wide frame for columns it does
  not have) and the grid-shape numbers in exercise 6 (`(1, 5)` without
  `col_wrap`, `(2, 3)` with `col_wrap=3`) are structural and exact
  everywhere.
- The seven `rcParams` keys reported as changed by `sns.set_theme()` in
  exercise 8, and the exact values `axes.facecolor == "#EAEAF2"` and
  `axes.grid == True`, are seaborn's own fixed default theme and are
  exact on 0.13.2 specifically.
- `ax.patches` count (4, one box per team) and `ax.collections` count (4,
  one stripplot point cloud per team) in exercise 9 are structural.
- `17 checks, 0 failure(s)` and exit 0 from `tests/run_tests.sh`.

## Version-specific, checked directly rather than assumed

- The `MatplotlibDeprecationWarning: vert: bool was deprecated...`
  warning in both `examples-run.txt` and `test-run.txt` comes from
  seaborn 0.13.2's internal `ax.bxp(**boxplot_kws)` call still passing
  the now-deprecated `vert` keyword on matplotlib 3.11. It is a warning,
  not a failure — every test still passes — and is expected to disappear
  once seaborn ships a release built against matplotlib's newer
  `orientation=` argument. It is captured here rather than filtered out,
  because pretending the run was silent would not be honest.
- `sns.set_theme()`'s specific palette (`#EAEAF2`) and which of the seven
  watched `rcParams` keys change are properties of seaborn 0.13.2's
  default theme; an older or newer seaborn release could change the
  palette or the specific key set without changing the underlying claim
  (that `set_theme()` mutates global state and it is reversible).

## Sampled / random by construction, and exactly what "sampled" means here

- **Exercise 3 is the one place this lab expects runs to disagree.**
  `_barplot_errorbar_extents(team_scores)` with no `seed=` argument draws
  from `numpy`'s global random state via seaborn's bootstrap, so the
  precise upper/lower extents printed in any single capture are specific
  to that run's random draws. With only four observations per group, a
  bootstrap resamples from a small, discrete space, so any single PAIR
  of unseeded runs can coincidentally land on identical extents by
  chance — the reference test therefore draws six independent unseeded
  runs and asserts that not all six are identical, which is reliable in
  practice even though any two of the six, taken alone, occasionally
  agree. The *seeded* half of the same exercise (`seed=42` used twice) is
  exact and reproducible on any machine with the same seaborn/NumPy pin,
  because seaborn's `seed=` argument drives the same NumPy `Generator`
  deterministically.
- The specific bootstrap-CI extents shown for `errorbar=('ci', 95)` in
  exercise 4 are seeded (`seed=42`) and therefore exact and reproducible
  on this pin set, but would differ under a different NumPy version's
  random-number implementation even with the same seed value — NumPy
  does not guarantee bit-identical `Generator` output across major
  versions. This lab pins NumPy exactly for that reason.

## Machine-dependent

- Wall-clock timings inside pytest's own summary lines (`in 0.83s`,
  `in 0.03s`) will differ on any other machine and are not asserted on
  anywhere in this lab.
- The `.venv` path embedded in pytest's own `rootdir:` and platform
  banner lines has been sanitized to `<repo>` in these captures; on a
  fresh checkout it will show that checkout's own absolute path instead.

examples-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-129-statistical-plots-with-seaborn/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-129-statistical-plots-with-seaborn
collecting ... collected 16 items

examples/test_seaborn.py::test_1_axes_level_function_returns_the_axes_you_gave_it PASSED [  6%]
examples/test_seaborn.py::test_1_figure_level_function_returns_a_facetgrid_owning_its_own_figure PASSED [ 12%]
examples/test_seaborn.py::test_2_bar_heights_are_group_means_not_raw_values PASSED [ 18%]
examples/test_seaborn.py::test_2_team_b_bar_is_lower_than_team_a_despite_having_the_best_scores PASSED [ 25%]
examples/test_seaborn.py::test_2_stripplot_of_the_same_column_shows_all_four_raw_points_per_group PASSED [ 31%]
examples/test_seaborn.py::test_3_unseeded_bootstrap_intervals_differ_between_two_runs PASSED [ 37%]
examples/test_seaborn.py::test_3_seeded_bootstrap_intervals_are_identical_between_two_runs PASSED [ 43%]
examples/test_seaborn.py::test_4_sd_and_ci95_error_bars_have_different_extents PASSED [ 50%]
examples/test_seaborn.py::test_4_sd_error_bar_does_not_depend_on_the_seed PASSED [ 56%]
examples/test_seaborn.py::test_5_wide_frame_raises_when_asked_for_columns_it_does_not_have PASSED [ 62%]
examples/test_seaborn.py::test_5_melted_long_form_has_the_columns_hue_needs_and_plots_successfully PASSED [ 68%]
examples/test_seaborn.py::test_6_col_produces_exactly_one_axes_per_category PASSED [ 75%]
examples/test_seaborn.py::test_6_col_wrap_changes_the_grid_shape_not_the_axes_count PASSED [ 81%]
examples/test_seaborn.py::test_7_a_label_set_after_a_seaborn_call_is_present_on_the_axes PASSED [ 87%]
examples/test_seaborn.py::test_8_set_theme_changes_specific_rcparams_and_is_reversible PASSED [ 93%]
examples/test_seaborn.py::test_9_boxplot_with_stripplot_overlaid_carries_both_kinds_of_artist PASSED [100%]

=============================== warnings summary ===============================
examples/test_seaborn.py::test_7_a_label_set_after_a_seaborn_call_is_present_on_the_axes
examples/test_seaborn.py::test_9_boxplot_with_stripplot_overlaid_carries_both_kinds_of_artist
  <repo>/labs/sections/math-statistics-and-data/day-129-statistical-plots-with-seaborn/.venv/lib/python3.14/site-packages/seaborn/categorical.py:700: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.
    artists = ax.bxp(**boxplot_kws)

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================== 16 passed, 2 warnings in 0.72s ========================

starter-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-129-statistical-plots-with-seaborn/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-129-statistical-plots-with-seaborn
collecting ... collected 16 items

starter/test_seaborn.py::test_1_axes_level_function_returns_the_axes_you_gave_it SKIPPED [  6%]
starter/test_seaborn.py::test_1_figure_level_function_returns_a_facetgrid_owning_its_own_figure SKIPPED [ 12%]
starter/test_seaborn.py::test_2_bar_heights_are_group_means_not_raw_values SKIPPED [ 18%]
starter/test_seaborn.py::test_2_team_b_bar_is_lower_than_team_a_despite_having_the_best_scores SKIPPED [ 25%]
starter/test_seaborn.py::test_2_stripplot_of_the_same_column_shows_all_four_raw_points_per_group SKIPPED [ 31%]
starter/test_seaborn.py::test_3_unseeded_bootstrap_intervals_differ_between_two_runs SKIPPED [ 37%]
starter/test_seaborn.py::test_3_seeded_bootstrap_intervals_are_identical_between_two_runs SKIPPED [ 43%]
starter/test_seaborn.py::test_4_sd_and_ci95_error_bars_have_different_extents SKIPPED [ 50%]
starter/test_seaborn.py::test_4_sd_error_bar_does_not_depend_on_the_seed SKIPPED [ 56%]
starter/test_seaborn.py::test_5_wide_frame_raises_when_asked_for_columns_it_does_not_have SKIPPED [ 62%]
starter/test_seaborn.py::test_5_melted_long_form_has_the_columns_hue_needs_and_plots_successfully SKIPPED [ 68%]
starter/test_seaborn.py::test_6_col_produces_exactly_one_axes_per_category SKIPPED [ 75%]
starter/test_seaborn.py::test_6_col_wrap_changes_the_grid_shape_not_the_axes_count SKIPPED [ 81%]
starter/test_seaborn.py::test_7_a_label_set_after_a_seaborn_call_is_present_on_the_axes SKIPPED [ 87%]
starter/test_seaborn.py::test_8_set_theme_changes_specific_rcparams_and_is_reversible SKIPPED [ 93%]
starter/test_seaborn.py::test_9_boxplot_with_stripplot_overlaid_carries_both_kinds_of_artist SKIPPED [100%]

============================= 16 skipped in 0.04s ==============================

test-run.txt

Day 129 — Plots That Say What They Computed

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

  ok: installed packages match requirements.txt exactly

2. Reference suite -- examples/ must pass in full
  <repo>/labs/sections/math-statistics-and-data/day-129-statistical-plots-with-seaborn/.venv/lib/python3.14/site-packages/seaborn/categorical.py:700: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.
    artists = ax.bxp(**boxplot_kws)

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
16 passed, 2 warnings in 0.69s
  ok: examples/ exits 0
  ok: examples/ reports 16 passed, 0 failed

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

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

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

6. A real headless savefig, into a temporary directory, cleaned up
  ok: headless savefig exits 0
  ok: the saved PNG file actually exists and is non-empty
  ok: the temporary savefig directory is fully removed

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

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

-------------------------------------------------------------
17 checks, 0 failure(s)
exit=0

Source files

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

pytest finds this file by itself -- nothing imports it. `matplotlib.use`
must run before `pyplot` is imported anywhere, which is why it happens
here, first, before the `data` import below pulls in pandas (and,
transitively, nothing that touches matplotlib yet). Every fixture returns
a FRESH copy of its table so one test's accidental mutation can never
leak into the next test, and every test that opens a Figure is
responsible for `plt.close()`-ing it -- `_close_all_figures` below is a
backstop, not a substitute.
"""

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import pytest

from data import build_long_revenue, build_team_scores, build_wide_revenue


@pytest.fixture
def team_scores():
    return build_team_scores()


@pytest.fixture
def wide_revenue():
    return build_wide_revenue()


@pytest.fixture
def long_revenue():
    return build_long_revenue()


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

Nothing here is randomised or loaded from a file -- every table is a small
literal so a reader can check every asserted number by eye against the
source. Two tables carry the whole lab:

`team_scores` -- exercises 2, 3, 4, 7 and 9. Four teams, four observations
each. Team B's four scores are 90, 88, 92, 10 -- three of the four highest
scores in the entire dataset, dragged down by one bad observation to a
group mean (70.0) lower than team A's (79.0), whose four scores are all
unremarkable and close together. This is the lab's through-line: a bar
chart of the mean tells you B did worse than A. A strip chart of the same
four points each shows the opposite story.

`wide_revenue` / its melted form `long_revenue` -- exercises 1, 5 and 6.
One row per region in wide form (a `q1`..`q4` column each); one row per
region-quarter observation in long form, produced with the exact `melt`
call Day 124 taught.
"""

from __future__ import annotations

import pandas as pd

# --------------------------------------------------------------------------
# `team_scores` -- the barplot trap. Four teams, four observations each.
#
# Team A: 78, 82, 80, 76 -- mean 79.0, all four values close together.
# Team B: 90, 88, 92, 10 -- mean 70.0, but three of four are the highest
#          individual scores in the whole table; one outlier (10) drags
#          the mean below team A's despite B's typical performance being
#          the best in the dataset.
# Team C: 65, 70, 68, 67 -- mean 67.5.
# Team D: 55, 60, 58, 57 -- mean 57.5.
# --------------------------------------------------------------------------


def build_team_scores() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "team": ["A", "A", "A", "A", "B", "B", "B", "B", "C", "C", "C", "C", "D", "D", "D", "D"],
            "score": [78, 82, 80, 76, 90, 88, 92, 10, 65, 70, 68, 67, 55, 60, 58, 57],
        }
    )


# --------------------------------------------------------------------------
# `wide_revenue` -- exercises 1, 5 and 6. One row per region, one column
# per quarter. `melt` (Day 124) turns this into `long_revenue`: one row
# per (region, quarter) observation, which is what lets `hue="region"`
# and `col="region"` work at all.
# --------------------------------------------------------------------------


def build_wide_revenue() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "region": ["North", "South", "East", "West", "Central"],
            "q1": [120, 95, 110, 130, 88],
            "q2": [125, 98, 108, 128, 90],
            "q3": [130, 101, 115, 135, 95],
            "q4": [128, 105, 118, 140, 97],
        }
    )


def build_long_revenue() -> pd.DataFrame:
    return build_wide_revenue().melt(id_vars="region", var_name="quarter", value_name="revenue")
examples/test_seaborn.py (12803 bytes)
"""The worked reference suite for Day 129 -- "Plots That Say What They
Computed".

Nine exercises, each proving one real seaborn 0.13.2 / matplotlib 3.11.1
behaviour by drawing a real plot and reading real return types, artist
state, or numeric values -- never by reading source. Run it:

    pytest examples

Every table these tests use comes from `data.py`, imported through the
fixtures in `conftest.py`. Read `starter/00_brief.md` for the exercise-by-
exercise explanation; this file is the answer key.
"""

import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
import pytest
import seaborn as sns

# --------------------------------------------------------------------------
# Exercise 1 -- axes-level versus figure-level. `scatterplot` draws into
# an Axes you already own and returns that Axes; `relplot` creates and
# owns its own Figure and returns a FacetGrid wrapping it.
# --------------------------------------------------------------------------


def test_1_axes_level_function_returns_the_axes_you_gave_it(team_scores):
    fig, ax = plt.subplots()
    result = sns.scatterplot(data=team_scores, x="team", y="score", ax=ax)
    assert isinstance(result, matplotlib.axes.Axes)
    assert result is ax  # drew into the Axes it was handed, nothing new


def test_1_figure_level_function_returns_a_facetgrid_owning_its_own_figure(team_scores):
    grid = sns.relplot(data=team_scores, x="team", y="score")
    assert isinstance(grid, sns.axisgrid.FacetGrid)
    assert isinstance(grid.figure, matplotlib.figure.Figure)
    # relplot never took an ax= argument here -- the Figure it owns is
    # its own, not one the caller created and handed in.
    assert grid.ax is not None
    assert grid.ax.figure is grid.figure


# --------------------------------------------------------------------------
# Exercise 2 -- the barplot trap. team_scores' four groups: A and B have
# almost the same shape of data (four values), but B's are 90, 88, 92, 10
# -- three of the four best scores in the whole table, with one outlier.
# The bar heights are the group MEANS, not any raw value; a stripplot of
# the same column exposes the four real points per group.
# --------------------------------------------------------------------------


def test_2_bar_heights_are_group_means_not_raw_values(team_scores):
    fig, ax = plt.subplots()
    sns.barplot(data=team_scores, x="team", y="score", ax=ax)
    heights = {round(float(bar.get_height()), 4) for bar in ax.patches}

    expected_means = team_scores.groupby("team")["score"].mean()
    assert set(round(float(m), 4) for m in expected_means) == heights
    assert heights == {79.0, 70.0, 67.5, 57.5}

    # For every team, its own bar height is a computed mean, not one of
    # its own four raw observations -- the chart draws a statistic, not
    # a value anyone actually recorded.
    for team, group in team_scores.groupby("team"):
        mean = round(float(group["score"].mean()), 4)
        raw_values_for_team = set(group["score"])
        assert mean not in raw_values_for_team


def test_2_team_b_bar_is_lower_than_team_a_despite_having_the_best_scores(team_scores):
    means = team_scores.groupby("team")["score"].mean()
    assert means["B"] < means["A"]

    # But three of team B's four raw scores exceed every one of team A's.
    b_scores = team_scores.loc[team_scores["team"] == "B", "score"]
    a_scores = team_scores.loc[team_scores["team"] == "A", "score"]
    b_above_all_of_a = (b_scores.values[:, None] > a_scores.values[None, :]).all(axis=1)
    assert b_above_all_of_a.sum() == 3  # 90, 88 and 92 all beat every A score; only 10 does not


def test_2_stripplot_of_the_same_column_shows_all_four_raw_points_per_group(team_scores):
    fig, ax = plt.subplots()
    sns.stripplot(data=team_scores, x="team", y="score", ax=ax)
    # One PathCollection per category; each collection's offsets are the
    # real per-point (x, y) locations stripplot drew -- there is no
    # aggregation step to hide anything behind.
    assert len(ax.collections) == 4
    all_y = sorted(float(y) for coll in ax.collections for (_, y) in coll.get_offsets())
    assert all_y == sorted(float(v) for v in team_scores["score"])  # every raw value present, none averaged


# --------------------------------------------------------------------------
# Exercise 3 -- bootstrap randomness. The default error bar is a
# bootstrapped 95% confidence interval, and a bootstrap is a random
# resampling procedure: two unseeded calls give slightly different
# extents; fixing seed= makes them identical.
# --------------------------------------------------------------------------


def _barplot_errorbar_extents(team_scores, **kwargs):
    fig, ax = plt.subplots()
    sns.barplot(data=team_scores, x="team", y="score", ax=ax, **kwargs)
    return [(float(min(line.get_ydata())), float(max(line.get_ydata()))) for line in ax.lines]


def test_3_unseeded_bootstrap_intervals_differ_between_two_runs(team_scores):
    # A bootstrap over a tiny sample (4 observations per group) draws
    # from a small, discrete space of possible resamples, so any SINGLE
    # pair of unseeded runs can occasionally land on the same extent by
    # chance -- that is a real property of resampling a small sample,
    # not a flaw in the claim. Six independent draws makes the claim
    # itself robust: the probability that all six happen to coincide is
    # negligible, while any two of them differing is still exactly the
    # fact this exercise is about.
    runs = [_barplot_errorbar_extents(team_scores) for _ in range(6)]
    distinct_runs = {tuple(run) for run in runs}
    assert len(distinct_runs) > 1


def test_3_seeded_bootstrap_intervals_are_identical_between_two_runs(team_scores):
    run_1 = _barplot_errorbar_extents(team_scores, seed=42)
    run_2 = _barplot_errorbar_extents(team_scores, seed=42)
    assert run_1 == run_2


# --------------------------------------------------------------------------
# Exercise 4 -- errorbar= options. 'sd' draws +/- one standard deviation;
# ('ci', 95) draws a bootstrapped 95% confidence interval. On the same
# data these are different statistics and must produce different extents.
# --------------------------------------------------------------------------


def test_4_sd_and_ci95_error_bars_have_different_extents(team_scores):
    sd_extents = _barplot_errorbar_extents(team_scores, errorbar="sd", seed=42)
    ci_extents = _barplot_errorbar_extents(team_scores, errorbar=("ci", 95), seed=42)
    assert sd_extents != ci_extents

    # Team A specifically: 'sd' is a fixed, deterministic computation
    # (does not depend on the seed at all) while ('ci', 95) is bootstrapped.
    sd_width = sd_extents[0][1] - sd_extents[0][0]
    ci_width = ci_extents[0][1] - ci_extents[0][0]
    assert round(sd_width, 2) != round(ci_width, 2)


def test_4_sd_error_bar_does_not_depend_on_the_seed(team_scores):
    # 'sd' is a closed-form statistic, not a resampling procedure, so it
    # is identical regardless of seed -- unlike the bootstrapped options.
    run_1 = _barplot_errorbar_extents(team_scores, errorbar="sd", seed=1)
    run_2 = _barplot_errorbar_extents(team_scores, errorbar="sd", seed=2)
    assert run_1 == run_2


# --------------------------------------------------------------------------
# Exercise 5 -- long versus wide. seaborn's semantic mappings read column
# NAMES; a wide frame has no "quarter" or "revenue" column to name, so
# asking for one raises. The long form melt (Day 124) produces has both.
# --------------------------------------------------------------------------


def test_5_wide_frame_raises_when_asked_for_columns_it_does_not_have(wide_revenue):
    with pytest.raises(ValueError, match="quarter"):
        sns.lineplot(data=wide_revenue, x="quarter", y="revenue", hue="region")


def test_5_melted_long_form_has_the_columns_hue_needs_and_plots_successfully(wide_revenue, long_revenue):
    # The exact melt call Day 124 taught: one id column, the rest become
    # a variable/value pair.
    reconstructed = wide_revenue.melt(id_vars="region", var_name="quarter", value_name="revenue")
    pd.testing.assert_frame_equal(reconstructed, long_revenue)

    assert list(long_revenue.columns) == ["region", "quarter", "revenue"]
    assert long_revenue.shape == (20, 3)  # 5 regions * 4 quarters

    ax = sns.lineplot(data=long_revenue, x="quarter", y="revenue", hue="region")
    assert isinstance(ax, matplotlib.axes.Axes)
    _, legend_labels = ax.get_legend_handles_labels()
    assert set(legend_labels) == set(long_revenue["region"].unique())  # one legend entry per region


# --------------------------------------------------------------------------
# Exercise 6 -- faceting. col= produces exactly one Axes per category;
# col_wrap reshapes the grid without changing how many Axes exist.
# --------------------------------------------------------------------------


def test_6_col_produces_exactly_one_axes_per_category(long_revenue):
    n_regions = long_revenue["region"].nunique()
    assert n_regions == 5

    grid = sns.catplot(data=long_revenue, x="quarter", y="revenue", col="region", kind="bar")
    assert len(grid.axes.flat) == n_regions
    assert grid._nrow == 1
    assert grid._ncol == n_regions


def test_6_col_wrap_changes_the_grid_shape_not_the_axes_count(long_revenue):
    n_regions = long_revenue["region"].nunique()

    wrapped = sns.catplot(data=long_revenue, x="quarter", y="revenue", col="region", kind="bar", col_wrap=3)
    assert len(wrapped.axes.flat) == n_regions  # still 5 Axes total
    assert wrapped._ncol == 3  # but now arranged 3 wide
    assert wrapped._nrow == 2  # and 2 rows tall (ceil(5 / 3))


# --------------------------------------------------------------------------
# Exercise 7 -- the escape hatch. seaborn draws with matplotlib underneath,
# so a label set with the Day 128 object API after a seaborn call sticks.
# --------------------------------------------------------------------------


def test_7_a_label_set_after_a_seaborn_call_is_present_on_the_axes(team_scores):
    fig, ax = plt.subplots()
    sns.boxplot(data=team_scores, x="team", y="score", ax=ax)
    assert ax.get_ylabel() == "score"  # seaborn's own default label, from the column name

    ax.set_ylabel("Score (0-100 scale)")
    ax.set_ylim(0, 100)
    assert ax.get_ylabel() == "Score (0-100 scale)"
    assert ax.get_ylim() == (0.0, 100.0)


# --------------------------------------------------------------------------
# Exercise 8 -- theme side effects. set_theme() mutates matplotlib's
# global rcParams; every plot drawn afterwards, seaborn or not, inherits
# the change until it is explicitly reset.
# --------------------------------------------------------------------------


def test_8_set_theme_changes_specific_rcparams_and_is_reversible():
    watched_keys = [
        "axes.facecolor",
        "axes.grid",
        "axes.edgecolor",
        "grid.color",
        "axes.axisbelow",
        "xtick.bottom",
        "ytick.left",
    ]
    before = {key: matplotlib.rcParams[key] for key in watched_keys}

    sns.set_theme()
    after = {key: matplotlib.rcParams[key] for key in watched_keys}
    changed_keys = {key for key in watched_keys if before[key] != after[key]}

    # Every one of these seven keys changed on this run; report exactly
    # which if that ever narrows on a different seaborn version.
    assert changed_keys == set(watched_keys)
    assert after["axes.facecolor"] == "#EAEAF2"
    assert after["axes.grid"] is True

    matplotlib.rcParams.update(before)
    restored = {key: matplotlib.rcParams[key] for key in watched_keys}
    assert restored == before  # matplotlib is back exactly where it started


# --------------------------------------------------------------------------
# Exercise 9 -- overlay. A boxplot's box artists are matplotlib patches;
# a stripplot's points are a separate PathCollection per category. Both
# can share one Axes, which is the honest form for a small sample.
# --------------------------------------------------------------------------


def test_9_boxplot_with_stripplot_overlaid_carries_both_kinds_of_artist(team_scores):
    fig, ax = plt.subplots()
    sns.boxplot(data=team_scores, x="team", y="score", ax=ax)
    assert len(ax.patches) == 4  # one box per team
    assert len(ax.collections) == 0  # nothing point-based yet

    sns.stripplot(data=team_scores, x="team", y="score", ax=ax, color="black")
    assert len(ax.patches) == 4  # the box patches are still there, untouched
    assert len(ax.collections) == 4  # one point collection per team, added on top

    n_points_drawn = sum(len(coll.get_offsets()) for coll in ax.collections)
    assert n_points_drawn == len(team_scores)  # every one of the 16 raw points is visible somewhere
metadata.yml (3391 bytes)
lesson_id: D129
day: 129
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-129-statistical-plots-with-seaborn
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python3 -c "import seaborn; print(seaborn.__version__)"
run_commands:
  - .venv/bin/pytest examples
  - .venv/bin/pytest starter
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
  - rm -rf .pytest_cache
  - 'rm -rf .venv  # optional: removes the lab virtual environment'
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 45
last_executed: '2026-08-20'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, seaborn 0.13.2, matplotlib 3.11.1, pandas 3.0.5, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 17 checks, 0 failure(s), exit 0. pytest examples -> 16 passed (2 warnings). pytest starter -> 16 skipped (untouched checkout). Section 5 of the harness solves every exercise in a scratch copy (16 passed), deliberately breaks exercise 2''s exact bar-heights assertion ({79.0, 70.0, 67.5, 57.5} -> {1.0, 2.0, 3.0, 4.0}), confirms the run exits non-zero with a printed FAIL, restores the file, and confirms 16 passed again -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Section 4 confirms directly that `pytest examples starter` in one invocation aborts collection with `import file mismatch` (both directories define a module named test_seaborn.py) rather than silently letting one shadow the other. Section 6 draws one real barplot, saves it headless via the Agg backend to a temporary directory, confirms the PNG file exists and is non-empty, then removes the temporary directory and confirms no image file (png/svg/jpg/pdf) is left anywhere inside the lab. Everything was run through a real lab-local .venv created by the documented setup commands. Two honesty notes from this run. FIRST: seaborn 0.13.2''s internal boxplot call passes the matplotlib-deprecated `vert` keyword to `ax.bxp()`, so both `pytest examples` and the harness print a `MatplotlibDeprecationWarning` on matplotlib 3.11.1; every test still passes, and the warning is captured verbatim in expected-output rather than filtered out. SECOND: exercise 3''s unseeded bootstrap runs are, by construction, not reproducible byte-for-byte between invocations, and with only four observations per group a bootstrap resamples from a small enough discrete space that any single PAIR of unseeded runs can occasionally coincide by chance -- an early version of this test compared exactly two runs and was observed to fail intermittently for exactly that reason during authoring, so the reference test now draws six independent unseeded runs and asserts they are not all identical, which is reliable in practice; expected-output/FIELDS.md documents this explicitly. The seeded half of the same exercise (seed=42 used twice) is exact and reproducible on this pin set. plotnine, Altair and Vega-Lite are not installed in this environment; the lesson''s Tools section describes them from public documentation only, and no output attributed to any of them is reproduced anywhere in this lab or its lesson.'
requirements/README.md (2322 bytes)
# What is installed, why, and what it costs

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

| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `seaborn` | 0.13.2 | BSD 3-Clause | Every plotting call in this lab — `barplot`, `stripplot`, `boxplot`, `lineplot`, `catplot`, `relplot`, `set_theme`. |
| `matplotlib` | 3.11.1 | PSF-derived (BSD-style) | seaborn's drawing engine; every assertion reads matplotlib `Axes`, `Figure`, `Patch` and `Line2D` objects directly. |
| `pandas` | 3.0.5 | BSD 3-Clause | `team_scores`, `wide_revenue` and `long_revenue`; the `melt` call in exercise 5, continuing directly from Day 124. |
| `numpy` | 2.5.2 | BSD 3-Clause | Pulled in transitively by pandas and matplotlib; not called directly by this lab's own code. |
| `pytest` | 9.1.1 | MIT | The test harness every exercise is written against. |

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

## The one time the network is needed

```bash
.venv/bin/pip install -r requirements/requirements.txt
```

That is the only command in the lab that opens a connection. Every
script and test after that runs completely offline, headless, via
`matplotlib.use("Agg")`.

## What is deliberately *not* pinned here

`pyarrow` is not installed for this lab. Day 124's merge/reshape lab
pinned it for parity with the pandas-dtype days it built on; this lab's
three tables are plain `int64`/`object` columns with no Arrow-backed
dtype anywhere, and a `pip install --dry-run` of `seaborn==0.13.2`
against this lab's other pins does not pull `pyarrow` in as a
dependency, so it is left out rather than added for no reason.

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

## If you cannot install anything at all

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

Nine exercises, sixteen tests, in order. Work top to bottom in
`test_seaborn.py`. Every table comes from a fixture defined in
`conftest.py` (`team_scores`, `wide_revenue`, `long_revenue`) — read
`data.py` once to see exactly what each one contains before you start.

Check yourself at any point:

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

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

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

Assert on returned objects and artist state, not on what a plot *looks*
like. seaborn draws with matplotlib underneath, so every fact this lab
cares about — a return type, a bar's height, an error bar's extent, how
many `Axes` a facet grid produced, which `rcParams` changed — is readable
straight off the objects seaborn hands back, with no image comparison
anywhere.

---

## Exercise 1 — return types (`team_scores`)

`sns.scatterplot(data=..., x=..., y=..., ax=ax)` draws into the `Axes`
you hand it and returns that same `Axes` — assert `result is ax`.
`sns.relplot(data=..., x=..., y=...)`, called with **no** `ax=`, creates
its own `Figure` and returns a `seaborn.axisgrid.FacetGrid` wrapping it.
Assert the return type of each, and that the `FacetGrid`'s `.figure` is a
real `matplotlib.figure.Figure` that its own `.ax` belongs to.

## Exercise 2 — the barplot trap (`team_scores`)

Draw `sns.barplot(data=team_scores, x="team", y="score", ax=ax)`. Read
each bar's height from `ax.patches` (`bar.get_height()`) and compare it
to `team_scores.groupby("team")["score"].mean()` — they must match
exactly. Then, for each team, assert that team's bar height is **not**
one of that team's own four raw scores. Separately: team A's mean is
*higher* than team B's, even though three of team B's four raw scores
beat every one of team A's — compute that comparison directly from the
raw values, not from the bar heights. Finally, draw
`sns.stripplot(data=team_scores, x="team", y="score", ax=ax)` and read
every point back out of `ax.collections[i].get_offsets()`; assert the
full set of y-values recovered equals the full set of raw scores — the
strip chart hides nothing the bar chart hid.

## Exercise 3 — bootstrap randomness (`team_scores`)

`sns.barplot`'s default error bar is a **bootstrapped** 95% confidence
interval — a random resampling procedure. Draw the same barplot **six**
times with no `seed=` argument, read each run's line extents from
`ax.lines`, and assert that not all six runs are identical (collect them
into a set and assert its length is more than 1). Six draws, not two —
with only four observations per group, a bootstrap resamples from a
small, discrete space, and any single pair of runs can coincidentally
land on the same extent by chance; six independent draws makes the claim
itself reliable to test. Then draw it twice more with the *same*
explicit `seed=` value both times and assert those two extents match
exactly. This is the day's sharpest measured fact: the same call, the
same data, different pictures — unless you pin the seed.

## Exercise 4 — `errorbar=` options (`team_scores`)

Compare `errorbar='sd'` (one standard deviation, a closed-form
statistic) against `errorbar=('ci', 95)` (a bootstrapped interval) on the
same data, same seed. Assert their extents differ. Then draw `'sd'` with
two *different* seed values and assert the extents are identical either
way — `'sd'` does not resample, so a seed cannot change it.

## Exercise 5 — long versus wide (`wide_revenue`, `long_revenue`)

`wide_revenue` has one row per region and a `q1`..`q4` column each.
Asking `sns.lineplot` for `x="quarter", y="revenue", hue="region"` on
that frame must raise `ValueError` — those column names do not exist in
wide form. Melt `wide_revenue` yourself with the Day 124 call
(`id_vars="region", var_name="quarter", value_name="revenue"`), compare
your result to the `long_revenue` fixture with
`pandas.testing.assert_frame_equal`, then draw the same `lineplot` call
against the long form and confirm it succeeds, with one legend entry per
region (`ax.get_legend_handles_labels()`).

## Exercise 6 — faceting (`long_revenue`)

Call `sns.catplot(data=long_revenue, x="quarter", y="revenue",
col="region", kind="bar")`. Assert the number of `Axes` in the returned
`FacetGrid` (`grid.axes.flat`) equals `long_revenue["region"].nunique()`.
Repeat with `col_wrap=3` and assert the *Axes count is unchanged* but the
grid's `(_nrow, _ncol)` shape is now `(2, 3)` instead of `(1, 5)`.

## Exercise 7 — the escape hatch (`team_scores`)

Draw a boxplot into an `ax` you own. Assert `ax.get_ylabel()` is
seaborn's own default (the column name, `"score"`). Then call
`ax.set_ylabel(...)` and `ax.set_ylim(...)` — the Day 128 object API,
used *after* seaborn has already drawn — and assert both stick.

## Exercise 8 — theme side effects

Capture `matplotlib.rcParams[key]` for a handful of keys (`axes.facecolor`,
`axes.grid`, `axes.edgecolor`, `grid.color`, `axes.axisbelow`,
`xtick.bottom`, `ytick.left`) before calling `sns.set_theme()`. Assert
every one of those keys changed. Then restore them with
`matplotlib.rcParams.update(before)` and assert the restored dictionary
equals the captured one exactly — proof that the side effect is real and
reversible.

## Exercise 9 — overlay (`team_scores`)

Draw a boxplot into an `ax`, then a stripplot into the *same* `ax`.
Assert `len(ax.patches) == 4` (the four boxes) both before and after the
stripplot call, and `len(ax.collections)` goes from `0` to `4` (one point
collection per team) once the stripplot is added. Sum every collection's
point count and assert it equals `len(team_scores)` — all sixteen raw
points are visible, on top of the aggregated boxes.
starter/conftest.py (1034 bytes)
"""Shared fixtures and headless matplotlib setup.

pytest finds this file by itself -- nothing imports it. `matplotlib.use`
must run before `pyplot` is imported anywhere, which is why it happens
here, first, before the `data` import below pulls in pandas (and,
transitively, nothing that touches matplotlib yet). Every fixture returns
a FRESH copy of its table so one test's accidental mutation can never
leak into the next test, and every test that opens a Figure is
responsible for `plt.close()`-ing it -- `_close_all_figures` below is a
backstop, not a substitute.
"""

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import pytest

from data import build_long_revenue, build_team_scores, build_wide_revenue


@pytest.fixture
def team_scores():
    return build_team_scores()


@pytest.fixture
def wide_revenue():
    return build_wide_revenue()


@pytest.fixture
def long_revenue():
    return build_long_revenue()


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

Nothing here is randomised or loaded from a file -- every table is a small
literal so a reader can check every asserted number by eye against the
source. Two tables carry the whole lab:

`team_scores` -- exercises 2, 3, 4, 7 and 9. Four teams, four observations
each. Team B's four scores are 90, 88, 92, 10 -- three of the four highest
scores in the entire dataset, dragged down by one bad observation to a
group mean (70.0) lower than team A's (79.0), whose four scores are all
unremarkable and close together. This is the lab's through-line: a bar
chart of the mean tells you B did worse than A. A strip chart of the same
four points each shows the opposite story.

`wide_revenue` / its melted form `long_revenue` -- exercises 1, 5 and 6.
One row per region in wide form (a `q1`..`q4` column each); one row per
region-quarter observation in long form, produced with the exact `melt`
call Day 124 taught.
"""

from __future__ import annotations

import pandas as pd

# --------------------------------------------------------------------------
# `team_scores` -- the barplot trap. Four teams, four observations each.
#
# Team A: 78, 82, 80, 76 -- mean 79.0, all four values close together.
# Team B: 90, 88, 92, 10 -- mean 70.0, but three of four are the highest
#          individual scores in the whole table; one outlier (10) drags
#          the mean below team A's despite B's typical performance being
#          the best in the dataset.
# Team C: 65, 70, 68, 67 -- mean 67.5.
# Team D: 55, 60, 58, 57 -- mean 57.5.
# --------------------------------------------------------------------------


def build_team_scores() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "team": ["A", "A", "A", "A", "B", "B", "B", "B", "C", "C", "C", "C", "D", "D", "D", "D"],
            "score": [78, 82, 80, 76, 90, 88, 92, 10, 65, 70, 68, 67, 55, 60, 58, 57],
        }
    )


# --------------------------------------------------------------------------
# `wide_revenue` -- exercises 1, 5 and 6. One row per region, one column
# per quarter. `melt` (Day 124) turns this into `long_revenue`: one row
# per (region, quarter) observation, which is what lets `hue="region"`
# and `col="region"` work at all.
# --------------------------------------------------------------------------


def build_wide_revenue() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "region": ["North", "South", "East", "West", "Central"],
            "q1": [120, 95, 110, 130, 88],
            "q2": [125, 98, 108, 128, 90],
            "q3": [130, 101, 115, 135, 95],
            "q4": [128, 105, 118, 140, 97],
        }
    )


def build_long_revenue() -> pd.DataFrame:
    return build_wide_revenue().melt(id_vars="region", var_name="quarter", value_name="revenue")
starter/test_seaborn.py (5490 bytes)
"""Your exercises for Day 129 -- "Plots That Say What They Computed".

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

Check yourself at any point:

    pytest starter -v

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

import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
import pytest
import seaborn as sns

# --------------------------------------------------------------------------
# Exercise 1 -- axes-level versus figure-level.
# --------------------------------------------------------------------------


def test_1_axes_level_function_returns_the_axes_you_gave_it(team_scores):
    pytest.skip("Call sns.scatterplot(..., ax=ax) and assert the return value is that same ax")


def test_1_figure_level_function_returns_a_facetgrid_owning_its_own_figure(team_scores):
    pytest.skip("Call sns.relplot(...) and assert it returns a seaborn.axisgrid.FacetGrid owning its own Figure")


# --------------------------------------------------------------------------
# Exercise 2 -- the barplot trap.
# --------------------------------------------------------------------------


def test_2_bar_heights_are_group_means_not_raw_values(team_scores):
    pytest.skip("Draw a barplot; assert each bar's height equals that team's mean, and is absent from that team's own raw values")


def test_2_team_b_bar_is_lower_than_team_a_despite_having_the_best_scores(team_scores):
    pytest.skip("Compare team A's and team B's means, then compare their raw scores directly")


def test_2_stripplot_of_the_same_column_shows_all_four_raw_points_per_group(team_scores):
    pytest.skip("Draw a stripplot; assert every raw score in team_scores appears in some collection's offsets")


# --------------------------------------------------------------------------
# Exercise 3 -- bootstrap randomness.
# --------------------------------------------------------------------------


def test_3_unseeded_bootstrap_intervals_differ_between_two_runs(team_scores):
    pytest.skip("Draw the same barplot six times without seed=; assert not all six sets of error-bar extents are identical")


def test_3_seeded_bootstrap_intervals_are_identical_between_two_runs(team_scores):
    pytest.skip("Draw the same barplot twice with the same seed=; assert the two sets of error-bar extents match exactly")


# --------------------------------------------------------------------------
# Exercise 4 -- errorbar= options.
# --------------------------------------------------------------------------


def test_4_sd_and_ci95_error_bars_have_different_extents(team_scores):
    pytest.skip("Compare errorbar='sd' against errorbar=('ci', 95) on the same data; assert the extents differ")


def test_4_sd_error_bar_does_not_depend_on_the_seed(team_scores):
    pytest.skip("Draw errorbar='sd' with two different seed values; assert the extents are identical either way")


# --------------------------------------------------------------------------
# Exercise 5 -- long versus wide.
# --------------------------------------------------------------------------


def test_5_wide_frame_raises_when_asked_for_columns_it_does_not_have(wide_revenue):
    pytest.skip("Call sns.lineplot on wide_revenue asking for x='quarter'; assert it raises ValueError")


def test_5_melted_long_form_has_the_columns_hue_needs_and_plots_successfully(wide_revenue, long_revenue):
    pytest.skip("melt wide_revenue yourself and compare it to long_revenue; then plot the long form with hue='region'")


# --------------------------------------------------------------------------
# Exercise 6 -- faceting.
# --------------------------------------------------------------------------


def test_6_col_produces_exactly_one_axes_per_category(long_revenue):
    pytest.skip("Call sns.catplot(..., col='region', kind='bar'); assert the number of Axes equals the region count")


def test_6_col_wrap_changes_the_grid_shape_not_the_axes_count(long_revenue):
    pytest.skip("Repeat with col_wrap=3; assert the same Axes count but a different (nrow, ncol) grid shape")


# --------------------------------------------------------------------------
# Exercise 7 -- the escape hatch.
# --------------------------------------------------------------------------


def test_7_a_label_set_after_a_seaborn_call_is_present_on_the_axes(team_scores):
    pytest.skip("Draw a boxplot into ax, then ax.set_ylabel(...) afterwards; assert the new label sticks")


# --------------------------------------------------------------------------
# Exercise 8 -- theme side effects.
# --------------------------------------------------------------------------


def test_8_set_theme_changes_specific_rcparams_and_is_reversible():
    pytest.skip("Capture matplotlib.rcParams before sns.set_theme(); assert specific keys changed; restore and assert equality")


# --------------------------------------------------------------------------
# Exercise 9 -- overlay.
# --------------------------------------------------------------------------


def test_9_boxplot_with_stripplot_overlaid_carries_both_kinds_of_artist(team_scores):
    pytest.skip("Draw a boxplot then a stripplot into the same ax; assert both ax.patches and ax.collections are populated")
tests/run_tests.sh (11567 bytes)
#!/usr/bin/env bash
# Tests for the Day 129 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running real seaborn/matplotlib
# code and reading real return types, artist state, and numeric values --
# never by reading source:
#
#   * an axes-level function (scatterplot) returns the Axes it was handed;
#     a figure-level function (relplot) returns a FacetGrid owning its own
#     Figure;
#   * a barplot's bar heights equal the group means and are absent from
#     each group's own raw values -- and a stripplot of the same column
#     recovers every raw point;
#   * two unseeded barplot calls produce different bootstrap error-bar
#     extents; the same call with a fixed seed= is identical between runs;
#   * errorbar='sd' and errorbar=('ci', 95) produce different extents on
#     the same data, and 'sd' does not depend on the seed at all;
#   * a wide frame raises ValueError when asked for columns it does not
#     have; its melted long form (Day 124's exact call) plots successfully;
#   * col= produces exactly one Axes per category; col_wrap reshapes the
#     grid without changing how many Axes exist;
#   * a label set with ax.set_ylabel AFTER a seaborn call sticks;
#   * sns.set_theme() changes specific matplotlib rcParams, and restoring
#     them returns matplotlib to its prior state exactly;
#   * a boxplot with a stripplot overlaid on the same Axes carries both
#     box patches and point collections;
#   * a real headless savefig to a temporary directory produces a file,
#     and this lab leaves no image files behind once cleaned up;
#   * the reference suite (`examples/`) passes in full;
#   * the exercise suite (`starter/`) is all-skip on an untouched checkout,
#     and the harness proves it can genuinely FAIL by solving every
#     exercise in a scratch copy, breaking one assertion on purpose,
#     confirming a non-zero exit and a printed FAIL, then restoring it;
#   * nothing is left behind on disk.
#
# Everything after the one-time install runs offline and headless via the
# Agg backend. Nothing binds a port, nothing needs a key. Deterministic,
# non-interactive, exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1
export MPLBACKEND=Agg

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

find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true

failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  echo "  Install the lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  echo "  Or point this suite at an existing pytest:" >&2
  echo "    PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
  exit 1
}

python_bin="$(dirname "${pytest_bin}")/python3"
if [ ! -x "${python_bin}" ]; then
  python_bin="$(command -v python3 || true)"
fi
if [ -z "${python_bin}" ]; then
  echo "FAIL: python3 not found on PATH." >&2
  exit 1
fi

if ! "${python_bin}" -c "import seaborn" >/dev/null 2>&1; then
  echo "FAIL: seaborn is not importable from ${python_bin}." >&2
  echo "  Install the lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

echo "Day 129 — Plots That Say What They Computed"
echo

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

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

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

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

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

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

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

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

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

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

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

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

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

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

# Break exercise 2's exact bar-heights assertion on purpose.
sed -i.bak 's/assert heights == {79.0, 70.0, 67.5, 57.5}/assert heights == {1.0, 2.0, 3.0, 4.0}/' "${scratch_dir}/test_seaborn.py"

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

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

cleanup_scratch
trap - EXIT
echo

# --------------------------------------------------------------------------
echo "6. A real headless savefig, into a temporary directory, cleaned up"
# --------------------------------------------------------------------------

savefig_dir="$(mktemp -d "${TMPDIR:-/tmp}/d129-savefig.XXXXXX")"
savefig_path="${savefig_dir}/team_scores.png"

"${python_bin}" - "${savefig_path}" <<'PY'
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import seaborn as sns

sys.path.insert(0, "examples")
from data import build_team_scores

fig, ax = plt.subplots()
sns.barplot(data=build_team_scores(), x="team", y="score", ax=ax)
fig.savefig(sys.argv[1])
plt.close(fig)
PY
savefig_status=$?
check "headless savefig exits 0" "$( [ ${savefig_status} -eq 0 ] && echo yes || echo no )"
check "the saved PNG file actually exists and is non-empty" "$( [ -s "${savefig_path}" ] && echo yes || echo no )"

rm -rf "${savefig_dir}"
check "the temporary savefig directory is fully removed" "$( [ ! -e "${savefig_dir}" ] && echo yes || echo no )"
echo

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

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

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

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

find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true

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

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

Troubleshooting

Troubleshooting

Grouped by the message you actually see.

ModuleNotFoundError: No module named 'seaborn'

Your .venv was never created or activated. Run:

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

Or point the harness at an existing install:

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

A plot window tries to open, or the run hangs

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

pytest examples starter aborts with import file mismatch

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

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

Exercise 2's bar heights are not exactly 79.0, 70.0, 67.5, 57.5

Recompute them from team_scores.groupby("team")["score"].mean() rather than hardcoding the numbers — if your team_scores fixture has been edited, the means will legitimately differ from the ones in this README.

Exercise 3's two "unseeded" runs come out identical

This can genuinely happen by chance on a tiny sample, though it did not happen in this lab's own capture (expected-output/examples-run.txt). If it does, re-run once more before concluding something is wrong; the seeded half of the same exercise (seed=42 twice) must always be identical, with no exceptions — if that half also disagrees, seaborn is not receiving your seed= argument at all, which usually means an older seaborn is installed. Check with .venv/bin/python3 -c "import seaborn; print(seaborn.__version__)".

MatplotlibDeprecationWarning: vert: bool was deprecated

Expected on this pin set. seaborn 0.13.2's internal boxplot call still passes the now-deprecated vert keyword to matplotlib's ax.bxp() on matplotlib 3.11.1. It is a warning, not a failure, and every test still passes; it is documented in expected-output/FIELDS.md rather than hidden.

The savefig check in tests/run_tests.sh fails

Confirm the same .venv used for the rest of the harness has Pillow available implicitly through matplotlib's own PNG writer (matplotlib ships its own PNG backend and needs no separate image library for this). If fig.savefig(...) raises, run the same three lines from section 6 of tests/run_tests.sh directly in your shell to see the real traceback.

Image files left behind after a manual experiment

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

Security notes

Security notes

What this lab does to your machine

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

What the data in this lab is

Every table is a small literal built by hand in data.pyteam_scores (sixteen rows across four teams), wide_revenue (five regions, four quarters) and its melted form long_revenue — invented for the exercises. Nothing here is real personal, financial or otherwise sensitive data, and nothing is downloaded from any external dataset.

The design point this day is actually about

seaborn draws a computed statistic, not your raw data — a barplot's bar height is a group mean, and its error bar is, by default, a random bootstrap resampling of your own data. That randomness is exactly why this lab's exercise 3 asserts that two unseeded runs differ: nothing is broken, seaborn's default confidence interval is genuinely a different number each time it is drawn, unless you fix seed=. The practical consequence for anyone reading a barplot in a report, including one a model produced: the chart states a claim about sampling variability whether or not the person who made it meant to make one, and knowing which statistic is on the page — mean, standard deviation, a bootstrapped interval — is part of reading the page honestly.