Math, Statistics, and Data › Data Visualization › Day 129
Hands-on lab — Day 129: Statistical Plots with seaborn
- ← Back to the Day 129 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/math-statistics-and-data/day-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
- Lesson title: Statistical Plots with seaborn
- Day number: 129 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-129-statistical-plots-with-seaborn
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-129-statistical-plots-with-seabornwhen the site is running.
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
Axesyou own and return thatAxes) from figure-level functions (which create and own their ownFigureand return aFacetGrid), 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 astripplotof the same column. - Demonstrate that two
barplotcalls withoutseed=produce bootstrapped error bars of slightly different extent, and that fixingseed=makes two runs identical. - Compare
errorbar='sd'againsterrorbar=('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
huemapping by column name, and usemelt(Day 124) to produce the long form that works. - Use
col=to facet a plot into oneAxesper category, andcol_wrap=to reshape that grid without changing how manyAxesexist. - 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 matplotlibrcParamsand know how to capture and restore the prior state. - Overlay a
stripploton aboxplotin the sameAxesand 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
meltcall 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(), theFigure/Axes/Artisthierarchy, 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
python3on yourPATHto create the lab's virtual environment.
Supported operating systems
| System | Status |
|---|---|
| macOS (Apple Silicon or Intel) | Captured here — macOS 26.5.2, arm64 |
| Linux (any current distribution) | Expected identical, given the pinned versions below and the headless Agg backend |
| Windows | Use WSL and follow the Linux path. mktemp -d is used inside tests/run_tests.sh; native Windows was not tested and no output is claimed for it |
Hardware requirements
Anything. 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
bash tests/run_tests.shends with17 checks, 0 failure(s)and exits 0.sns.scatterplot(..., ax=ax)returns that sameax;sns.relplot(...)returns aseaborn.axisgrid.FacetGridwhose.figureowns its own.ax.- A
barplotofteam_scoresproduces bars of height79.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. - Six
barplotcalls with noseed=do not all produce identical bootstrapped error-bar extents; the same call withseed=42twice produces identical extents. errorbar='sd'anderrorbar=('ci', 95)produce different extents on the same data;'sd'alone is identical regardless of seed.sns.lineplotonwide_revenueasking forx="quarter"raisesValueError; the same call onlong_revenue(produced bymelt) succeeds with one legend entry per region.sns.catplot(..., col="region")on five regions produces exactly 5Axesarranged(1, 5); addingcol_wrap=3keeps 5Axesbut reshapes the grid to(2, 3).ax.set_ylabel(...)called after a seabornboxplotcall sticks and is readable back withax.get_ylabel().sns.set_theme()changes seven specificmatplotlib.rcParamskeys, includingaxes.facecolorto"#EAEAF2"andaxes.gridtoTrue; restoring the captured dictionary returns matplotlib to its prior state exactly.- A
boxplotfollowed by astripploton the sameAxescarries4box patches and4point 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 starteraborts withimport file mismatch— do not run both directories in one invocation; they share a module name.- A plot window tries to open — something imported
pyplotbeforematplotlib.use("Agg")ran; bothconftest.pyfiles set the backend first, and the harness also exportsMPLBACKEND=Agg. - Exercise 2's bar heights are not exactly
79.0, 70.0, 67.5, 57.5— recompute them fromteam_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
- Reproduce the barplot trap with more groups. Build a ten-group
version of
team_scoreswhere 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. - Compare
boxplotagainstviolinplot. Draw both onteam_scoresand describe, in one paragraph, what a violin plot shows about team B's distribution that a box plot's five-number summary does not. errorbar='pi'(a prediction interval) versus('ci', 95). Add a third comparison to exercise 4 usingerrorbar=('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.row=in addition tocol=. Extend exercise 6 with a second categorical column and facet on bothrow=andcol=at once; confirm the resultingAxescount is the product of the two categories' counts.- 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 additionalrcParamskeys 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.
Navigation
- 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
Aggbackend (matplotlib.use("Agg"), set beforepyplotis imported anywhere, andMPLBACKEND=Aggin the test harness) — no window ever opens, and no display server is needed, which matters on a CI runner or a machine with no screen. - Writes only inside its own
.venvdirectory (created by you, viapython3 -m venv .venv), transient__pycache__/.pytest_cachedirectories the harness removes both before and after every run, and one deliberately temporary directory (mktemp -d) that the harness's 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.py —
team_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.