Math, Statistics, and Datapandas and Data Wrangling › Day 123

Hands-on lab — Day 123: Groupby and Aggregation

Commands

Setup

cd labs/sections/math-statistics-and-data/day-123-groupby-and-aggregation
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__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_groupby.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_groupby.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 123 lab — Groups That Reconcile

Lesson

  • Lesson title: Groupby and Aggregation
  • Day number: 123 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-123-groupby-and-aggregation
  • 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-123-groupby-and-aggregation when the site is running.

Purpose

Nine numbered exercises, each proving one real pandas 3.0.5 groupby behaviour by running code and reading real values. The throughline is split, apply, combine — and the day's discipline is that the pieces must add back up to the whole. groupby drops rows whose key is missing by default, silently, and exercise 1 makes that failure concrete before anything else: the sum of per-group totals comes back less than the overall total, and the gap equals exactly the missing-key rows' total. Every later exercise adds one more piece of the split-apply-combine model — count versus size, .agg()'s four forms, agg versus transform versus apply, GroupBy.filter, multi-key grouping, observed=, performance, and a weighted mean checked two ways.

Learning objectives

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

  • State that groupby(...).sum() silently excludes rows with a missing key by default, and use dropna=False when the parts must sum back to the whole.
  • Explain the difference between size() (rows) and count() (non-missing values per column), and predict exactly where they disagree.
  • Write .agg() four ways — a single function, a list of functions, a per-column dict, and named aggregation — and say which produces a flat column index and which produces a MultiIndex.
  • Explain and demonstrate the shape difference between agg (one row per group) and transform (the input's shape), and use transform to attach a group statistic back to every row.
  • Use GroupBy.filter to keep or drop whole groups by a predicate, and say how that differs from Day 122's row-level filtering.
  • Build a multi-key groupby, read its MultiIndex, and produce the same values in a flat frame with as_index=False.
  • Explain what observed= controls for a categorical groupby, and measure how many rows observed=False manufactures that were never actually in the data.
  • Measure a built-in aggregation against the equivalent .apply(lambda ...) and report the gap as a ratio, never a millisecond figure.
  • Compute a weighted mean per group with apply and again without it, and check that the two agree.

Prerequisites

  • Day 120 — Series and DataFrames, index alignment, and Copy-on-Write. This lab's tables are ordinary DataFrames built the way that day taught.
  • Day 121 — loading and inspecting data, the habit of checking a frame before trusting it.
  • Day 122 — boolean masks, .query(), and the partition invariant (a filter that silently drops rows so the halves no longer sum to the whole) that this lab's exercise 1 directly extends to groupby.
  • Week 13 (SQL) — this lab's Tools section in the lesson compares groupby against SQL GROUP BY; you do not need SQL to run anything here.
  • 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
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. The largest structure built in this lab is a 200,000-row, two-column DataFrame of random floats for exercise 8's timing comparison, which needs a few megabytes and finishes in well under a second. No GPU, no network beyond the one-time install, no meaningful disk use.

Required software

Tool Minimum Used here Why
python3 3.11 3.14.0 Runs everything; standard library venv builds the lab's environment
pandas 3.0.5 exactly 3.0.5 Every groupby, .agg, .transform and .filter call
pyarrow 25.0.1 25.0.1 pandas 3.0's default backend, installed for parity with Days 120-122
numpy 2.5.2 2.5.2 np.average for exercise 9; np.random.default_rng for exercise 8
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.

  • pandas (BSD 3-Clause), NumPy (BSD 3-Clause) and pytest (MIT) are fully open source with no paid tier.
  • PyArrow (Apache 2.0) is the Arrow project's Python bindings, also fully open source.
  • polars (MIT), described from its documentation in the lesson's Tools section rather than run here, offers group_by as a free alternative with a lazy query-planning model.
  • Plain SQLite (public domain), covered in Week 13, is the better tool when the "table" in question does not fit in memory or needs concurrent writers — the lesson's Tools section says exactly when to prefer it.

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-123-groupby-and-aggregation
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__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-123-groupby-and-aggregation/
├── 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           pandas==3.0.5, pyarrow==25.0.1, numpy==2.5.2, pytest==9.1.1
├── starter/                      YOUR work happens here
│   ├── 00_brief.md                exercise-by-exercise instructions
│   ├── data.py                    the five tables every exercise uses
│   ├── conftest.py                fixtures wrapping data.py
│   └── test_groupby.py            nine exercises, each a pytest.skip to replace
├── examples/                     the reference. Read AFTER you have tried
│   ├── data.py
│   ├── conftest.py
│   └── test_groupby.py            the fully worked, 20-assertion answer key
├── tests/
│   └── run_tests.sh               13 checks of real behaviour
└── expected-output/               captured from a real run on 2026-08-19
    ├── FIELDS.md                   what must match and what may differ
    ├── 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
##    20 skipped, 0 failed.
.venv/bin/pytest starter -v

## 3. Your work: open starter/test_groupby.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_groupby.py; pytest imports test modules by their dotted name, and the second one collected can shadow the first. Run them as two separate commands, always, as shown above.

What the commands do

.venv/bin/pytest examples runs the fully worked reference suite: 20 tests across the nine exercises, each asserting a real value computed from one of the five tables in data.py.

.venv/bin/pytest starter runs your own suite. On an untouched checkout, every one of the 20 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 20 are written and passing, the exercise is done.

bash tests/run_tests.sh confirms the installed pandas matches requirements.txt exactly, runs pytest examples and requires 20 passed, runs pytest starter and requires 20 skipped on the checked-in state, then solves every exercise in a scratch copy made with mktemp -d (never touching the real starter/test_groupby.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 — proving the suite can genuinely fail rather than merely claiming to. 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:

13 checks, 0 failure(s)

and exits 0. pytest examples ends with:

20 passed in 0.06s

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

20 skipped in 0.01s

The reconciliation this whole lab is built on, exactly as captured:

grouped_total (dropna=True)  = 1945.0
overall_total                = 2115.0
gap                           = 170.0
missing-key rows' amount total = 170.0   # matches the gap exactly

The full capture of both suites is in expected-output/, and expected-output/FIELDS.md says which values are specific to pandas 3.0.5 or to this machine, and which would not differ on any correctly installed copy of this exact version.

Validation steps

  1. bash tests/run_tests.sh ends with 13 checks, 0 failure(s) and exits 0.
  2. Grouping orders by region (default dropna=True) and summing amount gives a total of 1945.0, versus an overall total of 2115.0 — a gap of exactly 170.0, which equals the amount total of the two rows whose region is missing.
  3. With dropna=False, the grouped sum equals the overall total exactly: 2115.0 == 2115.0.
  4. size() and count() disagree by exactly 2 in total, matching orders['amount'].isna().sum().
  5. Named aggregation (agg(total=(...), avg=(...), n=(...))) produces flat column names ['total', 'avg', 'n'], never a MultiIndex.
  6. agg on sales returns shape (4,); transform on the same call returns shape (12,), matching sales.shape[0].
  7. GroupBy.filter on orders with a >= 3 size predicate drops West entirely and keeps exactly 9 rows.
  8. Grouping cat_sales by two categorical keys gives 20 rows with observed=False and 9 with observed=True.
  9. Built-in .agg('mean') beats the equivalent .apply(lambda g: g.mean()) by at least 3x on a 200,000-row frame (this run measured roughly 10-15x — a ratio, not a promise).
  10. A weighted mean computed with apply and again without it agree exactly: North 17.5, South 13.0, East 60.0.

Tests

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

13 checks, exit 0 when they all pass and non-zero otherwise. They are value checks, not file-existence checks: the reference suite's 20 assertions are exercised through pytest, the exercise suite is confirmed all-skip on the checked-in state, and a scratch copy proves the suite can genuinely fail and then recover.

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 lives in a mktemp -d directory removed by a trap — 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 reports fewer failures than expected — do not run both directories in one invocation; they share a module name.
  • Exercise 1's grouped sum does not equal the overall total — that is the point of exercise 1; group with dropna=False if you want the parts to reconcile.
  • TypeError: agg function failed [how->mean,dtype->object] — you called .agg('mean') on the whole grouped frame instead of one numeric column first.
  • Exercise 4's z-score does not average to zero — check you used transform, not agg, for both the group mean and the group standard deviation.

Security notes

security.md has the full account. In short: this lab opens the network exactly once, to install its four pinned packages, and everything else runs offline, writes only inside its own .venv, needs no credential, and touches no real data — every table is a small invented literal except one seeded random column generated purely to make exercise 8's timing comparison meaningful at scale.

Extension exercises

  1. Reproduce exercise 1 with a real fairness report in mind. Replace region with a demographic segment column, introduce missing values in the same pattern, and write one paragraph on what a report that used dropna=True (the default) without checking the reconciliation invariant would have silently hidden.
  2. Measure the observed= memory cost at three categorical widths. Repeat exercise 7 with 5, 20 and 50 categories per key (keeping the actual data the same size) and record how many rows observed=False manufactures at each width. Is the growth linear or combinatorial?
  3. Rewrite exercise 9's weighted mean as a single .agg() call with no apply and no intermediate .assign() column. pandas.NamedAgg composition alone cannot express a ratio of two sums directly — write down exactly why, and what the smallest change to the approach would be if pandas ever added that capability.
  4. Compare .filter() against a two-step .size() plus .merge() equivalent that achieves the same row selection as exercise 5's GroupBy.filter, and time both on large from data.py. Report which is faster and by how much, as a ratio.
  5. Find the point where .apply stops losing. Shrink build_large's row count in a scratch copy of data.py until the measured ratio in exercise 8 drops below 2x, and report the row count where that happens on your machine. What does that tell you about where .apply's Python-level overhead actually comes from?
  • Previous day: Day 122 — Selecting and Filtering (labs/sections/math-statistics-and-data/day-122-selecting-and-filtering/).
  • Next day: Day 124 (labs/sections/math-statistics-and-data/), continuing Week 18.
  • Week 18 project: the week's project directory (labs/sections/math-statistics-and-data/projects/week-18/), "Messy Dataset Rescue" — building directly on the groupby fundamentals from this lab.

Expected output

FIELDS.md

# What in this directory is version-specific to pandas 3.0.5

Captured on macOS, Python 3.14.0, pandas 3.0.5, pyarrow 25.0.1, NumPy
2.5.2, pytest 9.1.1, on 2026-08-19.

## Will differ on another machine, and that is fine

- **Exercise 8's timing numbers** (`apply_seconds`, `builtin_seconds`, the
  measured ratio). This machine measured roughly 10-15x; the lab asserts
  only `ratio >= 3.0`, a conservative floor, never a millisecond figure.
  A different machine, a busier machine, or a different pandas build's
  internal C paths can all shift the exact ratio while leaving the
  asserted floor easily clear.
- **pytest's run duration line** (`20 passed in 0.06s` — the count is
  fixed, the seconds are not).
- **`platform darwin`** in `examples-run.txt` and `starter-run.txt` — a
  Linux or Windows run reports its own platform string there instead;
  nothing about the test results depends on it.

## Would differ on an earlier pandas major version, and that is the point

- **Named aggregation's output columns being flat** (exercise 3d,
  `test_3_agg_named_aggregation_gives_flat_columns`). Named aggregation
  itself (the `agg(name=(column, func))` syntax) was added in pandas
  0.25 and has produced flat columns since; nothing here is 3.0-specific,
  but it is included because almost every list/dict `.agg()` call
  produces a `pandas.MultiIndex` instead, which is the contrast the test
  exists to make concrete.
- **`include_groups=False`** in exercise 9's `apply` call
  (`weighted.groupby("region").apply(weighted_mean, include_groups=False)`).
  This keyword was added in pandas 2.2 to silence a deprecation warning
  about the grouping columns being included in the group passed to
  `apply`; on pandas older than 2.2 the keyword does not exist and must
  be omitted, and the applied function then receives the `region` column
  too (harmless here, since `weighted_mean` never reads it, but worth
  knowing about before copying this pattern elsewhere).
- **`observed=` defaulting to `False`** for a categorical `groupby` is
  pandas' long-standing behaviour through 2.x; pandas 2.1 announced this
  default would change to `True` in a future major version, and as
  of 3.0.5 it has **not** changed — `observed=False` is still the default
  measured here. Exercise 7 states this explicitly rather than assuming
  either default; do not rely on the default silently flipping on a
  version newer than the one pinned in `requirements/requirements.txt`.

## Will not differ, because they are exact arithmetic on fixed literals

Every other captured value — the reconciliation gap (170.0), the
`size`/`count` disagreement (2), every `.agg()` result in exercise 3, the
`.filter()` survivor count (9), the `MultiIndex` values in exercise 6, the
`observed=` row counts (20 and 9), and the weighted means in exercise 9
(17.5, 13.0, 60.0) — are exact arithmetic over the fixed literal tables in
`data.py`, and will reproduce identically on any machine running pandas
3.0.5 with the same input.

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-123-groupby-and-aggregation/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-123-groupby-and-aggregation
collecting ... collected 20 items

examples/test_groupby.py::test_1_dropna_true_undercounts_by_exactly_the_missing_rows PASSED [  5%]
examples/test_groupby.py::test_1_dropna_false_reconciles_exactly PASSED  [ 10%]
examples/test_groupby.py::test_2_size_and_count_disagree_where_amount_is_missing PASSED [ 15%]
examples/test_groupby.py::test_3_agg_single_function PASSED              [ 20%]
examples/test_groupby.py::test_3_agg_list_of_functions PASSED            [ 25%]
examples/test_groupby.py::test_3_agg_per_column_dict PASSED              [ 30%]
examples/test_groupby.py::test_3_agg_named_aggregation_gives_flat_columns PASSED [ 35%]
examples/test_groupby.py::test_4_agg_returns_one_row_per_group PASSED    [ 40%]
examples/test_groupby.py::test_4_transform_returns_the_input_shape PASSED [ 45%]
examples/test_groupby.py::test_4_transform_attaches_a_group_mean_to_every_row PASSED [ 50%]
examples/test_groupby.py::test_4_within_group_zscore_has_zero_mean_per_group PASSED [ 55%]
examples/test_groupby.py::test_5_filter_drops_whole_groups_below_the_threshold PASSED [ 60%]
examples/test_groupby.py::test_6_multi_key_grouping_produces_a_multiindex PASSED [ 65%]
examples/test_groupby.py::test_6_as_index_false_gives_the_same_values_flat PASSED [ 70%]
examples/test_groupby.py::test_7_observed_false_manufactures_unseen_combinations PASSED [ 75%]
examples/test_groupby.py::test_7_observed_true_keeps_only_combinations_actually_seen PASSED [ 80%]
examples/test_groupby.py::test_8_builtin_agg_beats_apply_by_a_wide_margin PASSED [ 85%]
examples/test_groupby.py::test_8_sort_false_does_not_change_the_values PASSED [ 90%]
examples/test_groupby.py::test_9_weighted_mean_via_apply PASSED          [ 95%]
examples/test_groupby.py::test_9_weighted_mean_without_apply_agrees PASSED [100%]

============================== 20 passed in 0.06s ==============================

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-123-groupby-and-aggregation/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-123-groupby-and-aggregation
collecting ... collected 20 items

starter/test_groupby.py::test_1_dropna_true_undercounts_by_exactly_the_missing_rows SKIPPED [  5%]
starter/test_groupby.py::test_1_dropna_false_reconciles_exactly SKIPPED  [ 10%]
starter/test_groupby.py::test_2_size_and_count_disagree_where_amount_is_missing SKIPPED [ 15%]
starter/test_groupby.py::test_3_agg_single_function SKIPPED (exercis...) [ 20%]
starter/test_groupby.py::test_3_agg_list_of_functions SKIPPED (exerc...) [ 25%]
starter/test_groupby.py::test_3_agg_per_column_dict SKIPPED (exercis...) [ 30%]
starter/test_groupby.py::test_3_agg_named_aggregation_gives_flat_columns SKIPPED [ 35%]
starter/test_groupby.py::test_4_agg_returns_one_row_per_group SKIPPED    [ 40%]
starter/test_groupby.py::test_4_transform_returns_the_input_shape SKIPPED [ 45%]
starter/test_groupby.py::test_4_transform_attaches_a_group_mean_to_every_row SKIPPED [ 50%]
starter/test_groupby.py::test_4_within_group_zscore_has_zero_mean_per_group SKIPPED [ 55%]
starter/test_groupby.py::test_5_filter_drops_whole_groups_below_the_threshold SKIPPED [ 60%]
starter/test_groupby.py::test_6_multi_key_grouping_produces_a_multiindex SKIPPED [ 65%]
starter/test_groupby.py::test_6_as_index_false_gives_the_same_values_flat SKIPPED [ 70%]
starter/test_groupby.py::test_7_observed_false_manufactures_unseen_combinations SKIPPED [ 75%]
starter/test_groupby.py::test_7_observed_true_keeps_only_combinations_actually_seen SKIPPED [ 80%]
starter/test_groupby.py::test_8_builtin_agg_beats_apply_by_a_wide_margin SKIPPED [ 85%]
starter/test_groupby.py::test_8_sort_false_does_not_change_the_values SKIPPED [ 90%]
starter/test_groupby.py::test_9_weighted_mean_via_apply SKIPPED (exe...) [ 95%]
starter/test_groupby.py::test_9_weighted_mean_without_apply_agrees SKIPPED [100%]

============================= 20 skipped in 0.02s ==============================

test-run.txt

Day 123 — Groups That Reconcile

1. The tools and the versions this lab was written against
python   3.14.0
pandas   3.0.5
pyarrow  25.0.1
numpy    2.5.2
pytest   9.1.1

  ok: installed pandas matches requirements.txt exactly

2. Reference suite -- examples/ must pass in full
....................                                                     [100%]
20 passed in 0.06s
  ok: examples/ exits 0
  ok: examples/ reports 20 passed, 0 failed

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

4. Never run 'pytest examples starter' in one invocation -- same
   module name in both directories means the second collected can
   shadow the first. Documented and checked separately, 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 20 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 20 passed again

6. Nothing in examples/ or starter/ opens a network connection
  ok: no URLs inside examples/ or starter/

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

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

Source files

examples/conftest.py (683 bytes)
"""Shared fixtures. pytest finds this file by itself -- nothing imports it.

Each fixture returns a FRESH copy of its table, so one test's mutation
(there should not be any, but fixtures should not have to trust that) can
never leak into the next test.
"""

import pytest

from data import (
    build_cat_sales,
    build_large,
    build_orders,
    build_sales,
    build_weighted,
)


@pytest.fixture
def orders():
    return build_orders()


@pytest.fixture
def sales():
    return build_sales()


@pytest.fixture
def cat_sales():
    return build_cat_sales()


@pytest.fixture
def weighted():
    return build_weighted()


@pytest.fixture
def large():
    return build_large()
examples/data.py (5122 bytes)
"""The three tables every exercise in this lab is built from.

Nothing here is randomised except the two performance-comparison tables,
which are seeded so a re-run always produces the same row counts (the
*timing* still varies machine to machine, which is why exercise 8 asserts a
ratio rather than a millisecond figure).

`orders` carries the day's opening failure on purpose: two rows have no
`region` at all, and two more have no `amount`. `sales` and `cat_sales` are
deliberately clean, so exercises 3, 4, 6 and 7 are not fighting missing data
while they teach a different point.
"""

from __future__ import annotations

import numpy as np
import pandas as pd

# --------------------------------------------------------------------------
# `orders` -- exercises 1, 2 and 5. Twelve rows. Two have no region at all
# (order_id 5 and 8); two have no amount at all (order_id 2 and 11).
# --------------------------------------------------------------------------


def build_orders() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "order_id": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
            "region": [
                "North",
                "South",
                "North",
                "East",
                None,
                "South",
                "North",
                None,
                "East",
                "South",
                "West",
                "East",
            ],
            "rep": [
                "Ann",
                "Bo",
                "Ann",
                "Cy",
                "Bo",
                "Ann",
                "Cy",
                "Bo",
                "Ann",
                "Cy",
                "Deb",
                "Deb",
            ],
            "amount": [
                100.0,
                np.nan,
                150.0,
                300.0,
                80.0,
                200.0,
                120.0,
                90.0,
                400.0,
                175.0,
                np.nan,
                500.0,
            ],
        }
    )


# --------------------------------------------------------------------------
# `sales` -- exercises 3, 4 and 6. Twelve rows, four regions of three rows
# each, no missing values anywhere.
# --------------------------------------------------------------------------


def build_sales() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "order_id": [101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112],
            "region": [
                "North",
                "North",
                "North",
                "South",
                "South",
                "South",
                "East",
                "East",
                "East",
                "West",
                "West",
                "West",
            ],
            "rep": ["Ann", "Bo", "Ann", "Bo", "Cy", "Bo", "Cy", "Ann", "Cy", "Ann", "Bo", "Cy"],
            "amount": [
                120.0,
                80.0,
                160.0,
                200.0,
                150.0,
                250.0,
                300.0,
                420.0,
                360.0,
                60.0,
                90.0,
                45.0,
            ],
        }
    )


# --------------------------------------------------------------------------
# `cat_sales` -- exercise 7. Same rows as `sales`, but `region` and `rep`
# are declared as categoricals with categories that are never observed in
# the data ("Central" and "Deb"), so grouping by both keys can either
# manufacture every combination or only the ones actually seen.
# --------------------------------------------------------------------------


def build_cat_sales() -> pd.DataFrame:
    df = build_sales()
    df["region"] = pd.Categorical(df["region"], categories=["North", "South", "East", "West", "Central"])
    df["rep"] = pd.Categorical(df["rep"], categories=["Ann", "Bo", "Cy", "Deb"])
    return df


# --------------------------------------------------------------------------
# `weighted` -- exercise 9. Three groups of unequal size and unequal
# weights, so a weighted mean genuinely differs from a plain mean.
# --------------------------------------------------------------------------


def build_weighted() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "region": ["North", "North", "South", "South", "South", "East", "East"],
            "value": [10.0, 20.0, 5.0, 15.0, 25.0, 100.0, 50.0],
            "weight": [1.0, 3.0, 2.0, 2.0, 1.0, 1.0, 4.0],
        }
    )


# --------------------------------------------------------------------------
# `build_large` -- exercise 8. A frame big enough that the gap between a
# built-in aggregation and a Python-level `.apply` is not measurement noise.
# --------------------------------------------------------------------------


def build_large(n: int = 200_000, n_keys: int = 2000, seed: int = 42) -> pd.DataFrame:
    rng = np.random.default_rng(seed)
    return pd.DataFrame(
        {
            "key": rng.integers(0, n_keys, size=n),
            "value": rng.normal(size=n),
        }
    )
examples/test_groupby.py (12175 bytes)
"""The worked reference suite for Day 123 -- "Groups That Reconcile".

Nine exercises, each proving one real pandas 3.0.5 behaviour by running code
and reading real 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 time

import numpy as np
import pandas as pd
import pytest

# --------------------------------------------------------------------------
# Exercise 1 -- the reconciliation invariant
#
# groupby drops rows whose key is missing, by default, silently. Prove the
# gap exists, prove it equals exactly the missing-key rows' total, and prove
# dropna=False makes the parts sum back to the whole.
# --------------------------------------------------------------------------


def test_1_dropna_true_undercounts_by_exactly_the_missing_rows(orders):
    grouped_total = orders.groupby("region")["amount"].sum().sum()
    overall_total = orders["amount"].sum()
    gap = overall_total - grouped_total

    assert grouped_total == 1945.0
    assert overall_total == 2115.0
    assert gap == 170.0

    missing_key_total = orders.loc[orders["region"].isna(), "amount"].sum()
    assert missing_key_total == 170.0
    assert gap == missing_key_total, "the gap must equal exactly the missing-key rows' total"

    missing_key_count = orders["region"].isna().sum()
    assert missing_key_count == 2


def test_1_dropna_false_reconciles_exactly(orders):
    grouped = orders.groupby("region", dropna=False)["amount"].sum()

    # The NaN group is real and carries the missing rows' total.
    assert grouped.loc[np.nan] == 170.0

    # Now the parts add back up to the whole, to the last cent.
    assert grouped.sum() == orders["amount"].sum() == 2115.0


# --------------------------------------------------------------------------
# Exercise 2 -- count() versus size(). size() counts ROWS; count() counts
# NON-MISSING VALUES per column. They disagree exactly where data is
# missing, and confusing them misstates every denominator downstream.
# --------------------------------------------------------------------------


def test_2_size_and_count_disagree_where_amount_is_missing(orders):
    grouped = orders.groupby("region", dropna=False)
    size = grouped.size()
    count = grouped["amount"].count()

    assert size.loc["South"] == 3
    assert count.loc["South"] == 2  # one of South's three amounts is NaN

    assert size.loc["West"] == 1
    assert count.loc["West"] == 0  # West's only amount is NaN

    diff = size - count
    assert diff.sum() == 2
    assert diff.sum() == orders["amount"].isna().sum(), (
        "the total gap between size and count must equal the missing-amount count exactly"
    )


# --------------------------------------------------------------------------
# Exercise 3 -- .agg() four ways: one function, a list, a per-column dict,
# and named aggregation, which is the readable modern form and produces
# flat column names instead of a MultiIndex.
# --------------------------------------------------------------------------


def test_3_agg_single_function(sales):
    result = sales.groupby("region")["amount"].agg("sum")
    assert result.loc["East"] == 1080.0
    assert result.loc["North"] == 360.0
    assert result.loc["South"] == 600.0
    assert result.loc["West"] == 195.0


def test_3_agg_list_of_functions(sales):
    result = sales.groupby("region")["amount"].agg(["sum", "mean", "count"])
    assert list(result.columns) == ["sum", "mean", "count"]
    assert result.loc["East", "sum"] == 1080.0
    assert result.loc["East", "mean"] == pytest.approx(360.0)
    assert result.loc["East", "count"] == 3


def test_3_agg_per_column_dict(sales):
    result = sales.groupby("region").agg({"amount": "sum", "order_id": "count"})
    assert result.loc["North", "amount"] == 360.0
    assert result.loc["North", "order_id"] == 3


def test_3_agg_named_aggregation_gives_flat_columns(sales):
    result = sales.groupby("region").agg(
        total=("amount", "sum"), avg=("amount", "mean"), n=("order_id", "count")
    )
    # Flat column names, not a MultiIndex -- that is the whole point of
    # named aggregation over the list/dict forms above.
    assert list(result.columns) == ["total", "avg", "n"]
    assert not isinstance(result.columns, pd.MultiIndex)
    assert result.loc["West", "total"] == 195.0
    assert result.loc["West", "avg"] == pytest.approx(65.0)
    assert result.loc["West", "n"] == 3


# --------------------------------------------------------------------------
# Exercise 4 -- agg reduces each group to one row; transform returns the
# input's shape, which is how a group statistic gets attached back to
# every row (here: a within-group z-score whose group mean is 0).
# --------------------------------------------------------------------------


def test_4_agg_returns_one_row_per_group(sales):
    result = sales.groupby("region")["amount"].agg("mean")
    assert result.shape == (4,)  # four regions


def test_4_transform_returns_the_input_shape(sales):
    result = sales.groupby("region")["amount"].transform("mean")
    assert result.shape == (12,)  # twelve rows, same as sales itself
    assert result.shape[0] == sales.shape[0]


def test_4_transform_attaches_a_group_mean_to_every_row(sales):
    group_mean = sales.groupby("region")["amount"].transform("mean")
    # North's three amounts are 120, 80, 160 -- mean 120.
    north_rows = sales["region"] == "North"
    assert (group_mean[north_rows] == 120.0).all()


def test_4_within_group_zscore_has_zero_mean_per_group(sales):
    group_mean = sales.groupby("region")["amount"].transform("mean")
    group_std = sales.groupby("region")["amount"].transform("std")
    zscore = (sales["amount"] - group_mean) / group_std

    per_group_zscore_mean = zscore.groupby(sales["region"]).mean()
    for region, value in per_group_zscore_mean.items():
        assert value == pytest.approx(0.0, abs=1e-9), f"{region}'s z-scores must average to 0"


# --------------------------------------------------------------------------
# Exercise 5 -- GroupBy.filter keeps or discards WHOLE GROUPS by a
# predicate. Distinct from Day 122's row-level filtering, despite the
# shared word.
# --------------------------------------------------------------------------


def test_5_filter_drops_whole_groups_below_the_threshold(orders):
    sizes = orders.groupby("region").size()
    assert sizes.to_dict() == {"East": 3, "North": 3, "South": 3, "West": 1}

    survivors = orders.groupby("region").filter(lambda g: len(g) >= 3)

    # West (size 1) is dropped whole; nothing partial survives from it.
    assert "West" not in survivors["region"].unique()
    assert set(survivors["region"].unique()) == {"East", "North", "South"}

    # The row count matches the sum of the surviving groups' own sizes.
    assert survivors.shape[0] == sizes[sizes >= 3].sum() == 9


# --------------------------------------------------------------------------
# Exercise 6 -- multi-key grouping produces a MultiIndex; as_index=False
# gives a flat frame with the same values.
# --------------------------------------------------------------------------


def test_6_multi_key_grouping_produces_a_multiindex(sales):
    result = sales.groupby(["region", "rep"])["amount"].sum()
    assert isinstance(result.index, pd.MultiIndex)
    assert result.index.names == ["region", "rep"]
    assert result.loc[("East", "Ann")] == 420.0
    assert result.loc[("West", "Cy")] == 45.0


def test_6_as_index_false_gives_the_same_values_flat(sales):
    indexed = sales.groupby(["region", "rep"])["amount"].sum()
    flat = sales.groupby(["region", "rep"], as_index=False)["amount"].sum()

    assert not isinstance(flat.index, pd.MultiIndex)
    assert list(flat.columns) == ["region", "rep", "amount"]

    east_ann = flat.loc[(flat["region"] == "East") & (flat["rep"] == "Ann"), "amount"].iloc[0]
    assert east_ann == indexed.loc[("East", "Ann")] == 420.0


# --------------------------------------------------------------------------
# Exercise 7 -- observed=. Grouping a categorical produces rows for
# unobserved combinations unless observed=True. With two categorical keys
# this explodes combinatorially.
# --------------------------------------------------------------------------


def test_7_observed_false_manufactures_unseen_combinations(cat_sales):
    # 5 region categories x 4 rep categories = 20 possible combinations,
    # only 9 of which are ever actually seen in the 12 rows of data.
    result = cat_sales.groupby(["region", "rep"], observed=False).size()
    assert len(result) == 20


def test_7_observed_true_keeps_only_combinations_actually_seen(cat_sales):
    result = cat_sales.groupby(["region", "rep"], observed=True).size()
    assert len(result) == 9
    assert result.loc[("North", "Ann")] == 2


# --------------------------------------------------------------------------
# Exercise 8 -- performance. Report the SHAPE of the gap between a
# built-in aggregation and the equivalent .apply(lambda ...), not a
# millisecond figure -- this is one machine, one day.
# --------------------------------------------------------------------------


def test_8_builtin_agg_beats_apply_by_a_wide_margin(large):
    start = time.perf_counter()
    builtin_result = large.groupby("key")["value"].agg("mean")
    builtin_seconds = time.perf_counter() - start

    start = time.perf_counter()
    apply_result = large.groupby("key")["value"].apply(lambda g: g.mean())
    apply_seconds = time.perf_counter() - start

    # Both paths must agree on the actual numbers -- speed is not the
    # only thing being asserted here.
    assert np.allclose(builtin_result.sort_index().to_numpy(), apply_result.sort_index().to_numpy())

    # The margin is asserted as a conservative ratio, never a timing.
    # This machine measured roughly 10-15x; 3x is asserted as the floor
    # so the check does not flake on a slower or busier machine.
    ratio = apply_seconds / builtin_seconds
    assert ratio >= 3.0, f"expected .apply to be at least 3x slower, measured {ratio:.1f}x"


def test_8_sort_false_does_not_change_the_values(large):
    sorted_result = large.groupby("key", sort=True)["value"].sum()
    unsorted_result = large.groupby("key", sort=False)["value"].sum()
    assert sorted_result.sort_index().equals(unsorted_result.sort_index())
    # sort=False is not guaranteed to change ORDER on every input, but it
    # must never change the VALUES -- that is the only thing asserted here.


# --------------------------------------------------------------------------
# Exercise 9 -- a weighted mean per group, computed with apply and again
# without it, asserting the two agree.
# --------------------------------------------------------------------------


def test_9_weighted_mean_via_apply(weighted):
    def weighted_mean(group: pd.DataFrame) -> float:
        return float(np.average(group["value"], weights=group["weight"]))

    via_apply = weighted.groupby("region").apply(weighted_mean, include_groups=False)

    assert via_apply.loc["North"] == pytest.approx(17.5)  # (10*1 + 20*3) / 4
    assert via_apply.loc["South"] == pytest.approx(13.0)  # (5*2+15*2+25*1) / 5
    assert via_apply.loc["East"] == pytest.approx(60.0)  # (100*1 + 50*4) / 5


def test_9_weighted_mean_without_apply_agrees(weighted):
    def weighted_mean(group: pd.DataFrame) -> float:
        return float(np.average(group["value"], weights=group["weight"]))

    via_apply = weighted.groupby("region").apply(weighted_mean, include_groups=False)

    # The vectorised route: build a value*weight column, sum both pieces
    # per group with .agg, then divide -- no apply anywhere.
    weighted_products = weighted.assign(value_weight=weighted["value"] * weighted["weight"])
    sums = weighted_products.groupby("region").agg(
        sum_value_weight=("value_weight", "sum"), sum_weight=("weight", "sum")
    )
    without_apply = sums["sum_value_weight"] / sums["sum_weight"]

    assert without_apply.sort_index().equals(via_apply.sort_index())
metadata.yml (2787 bytes)
lesson_id: D123
day: 123
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-123-groupby-and-aggregation
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python3 -c "import pandas; print(pandas.__version__)"
run_commands:
  - .venv/bin/pytest examples
  - .venv/bin/pytest starter
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
  - rm -rf .pytest_cache
  - 'rm -rf .venv  # optional: removes the lab virtual environment'
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 35
last_executed: '2026-08-19'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, pandas 3.0.5, pyarrow 25.0.1, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 13 checks, 0 failure(s), exit 0. pytest examples -> 20 passed. pytest starter -> 20 skipped (untouched checkout). Section 5 of the harness solves every exercise in a scratch copy (20 passed), deliberately breaks the exercise-1 gap assertion (170.0 -> 999.0), confirms the run exits non-zero with a printed FAIL, restores the file, and confirms 20 passed again -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Separately, the coordinator broke the same assertion directly in examples/test_groupby.py (not a scratch copy) and re-ran the full harness: 1 failed, 19 passed from pytest, and 6 of the harness''s own 13 checks failed with overall exit 1; restoring the line returned the harness to 13 checks, 0 failure(s), exit 0. Everything was run through a real lab-local .venv created by the documented setup commands. Two honesty notes from this run. FIRST: exercise 8''s performance ratio was measured at approximately 10-15x (built-in .agg(''mean'') versus .apply(lambda g: g.mean()) on a 200,000-row, 2,000-key frame) in prototyping before this lab was authored; the lab asserts only a conservative floor of 3.0x so the check does not flake on a slower or busier machine, and no millisecond figure is asserted anywhere. SECOND: pandas 3.0.5 has NOT changed groupby''s observed= default for categorical keys to True, despite pandas 2.1 having announced that change as coming in a future major version -- observed=False remains the default measured here, and exercise 7 states this explicitly rather than assuming the deprecation had already landed. matplotlib, scipy and polars are not installed in this environment; polars'' group_by is described from its public documentation in the lesson''s Tools section as a design contrast and no output attributed to it is reproduced anywhere.'
requirements/README.md (1892 bytes)
# What is installed, why, and what it costs

Four 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 |
| --- | --- | --- | --- |
| `pandas` | 3.0.5 | BSD 3-Clause | Every `groupby`, `.agg`, `.transform` and `.filter` call in this lab. |
| `pyarrow` | 25.0.1 | Apache 2.0 | pandas 3.0's default backend for string and several nullable dtypes; installed for parity with Days 120-122 even though this lab's tables use plain numeric and object columns throughout. |
| `numpy` | 2.5.2 | BSD 3-Clause | `np.nan`, `np.average` for exercise 9's weighted mean, and `np.random.default_rng` for exercise 8's synthetic 200,000-row table. |
| `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.

## What is deliberately *not* installed

**matplotlib**, **scipy** and **polars** are not installed in this
environment. The lesson's Tools section describes polars' `group_by`
from its public documentation as a design contrast to pandas — no output
from polars, scipy or matplotlib is reproduced anywhere in this lab or
its lesson; every place they are mentioned says so plainly.

## If you cannot install anything at all

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

Nine exercises, in order. Work top to bottom in `test_groupby.py`. Every
table comes from a fixture defined in `conftest.py` (`orders`, `sales`,
`cat_sales`, `weighted`, `large`) — 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 `20 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 exact values everywhere except exercise 8's timing ratio, which is
inherently one machine on one day — `pytest.approx` is for that ratio and
for the z-scores in exercise 4, nowhere else.

---

## Exercise 1 — the reconciliation invariant (`orders`)

`orders` has two rows with no `region` at all. `groupby('region')` drops
them by default, silently. In `test_1_dropna_true_undercounts_by_exactly_the_missing_rows`:

- Group by `region`, sum `amount`, sum the per-group sums. That total is
  **less** than `orders['amount'].sum()`.
- Assert the exact gap, and assert the gap equals exactly the sum of
  `amount` on the rows where `region` is missing.

In `test_1_dropna_false_reconciles_exactly`: repeat with `dropna=False`.
Now there is a real `NaN` group carrying the missing rows' total, and the
grouped sum equals the overall total exactly. **This is the habit the
whole day is built on: after any groupby aggregation, check that the parts
reconcile with the whole.**

## Exercise 2 — `count()` versus `size()` (`orders`)

`orders` also has two rows with no `amount`. `size()` counts **rows**;
`count()` counts **non-missing values per column**. Group with
`dropna=False` so the region-missing rows stay in the picture too. Assert
that `size` and `count` disagree on the groups that contain a missing
`amount`, and that `(size - count).sum()` equals
`orders['amount'].isna().sum()` exactly.

## Exercise 3 — `.agg()` four ways (`sales`)

`sales` is clean: four regions, three rows each. Write four tests:

1. A single function: `.agg('sum')`.
2. A list: `.agg(['sum', 'mean', 'count'])` — assert the column names.
3. A per-column dict: `.agg({'amount': 'sum', 'order_id': 'count'})`.
4. Named aggregation: `.agg(total=('amount', 'sum'), avg=('amount', 'mean'), n=('order_id', 'count'))`
   — assert the result columns are **flat**, not a `pandas.MultiIndex`.
   This is the readable modern form.

## Exercise 4 — shapes: `agg` versus `transform` (`sales`)

- `agg` reduces each group to one row: assert the shape has one entry per
  group (4,).
- `transform` returns the **input's shape**: assert its shape matches
  `sales.shape[0]` (12), not the number of groups.
- Use `transform` to attach North's group mean (120.0) to every North row.
- Build a within-group z-score: `(amount - group_mean) / group_std`, both
  computed with `transform`. Assert each region's z-scores average to 0
  (`pytest.approx`, `abs=1e-9`).

## Exercise 5 — `GroupBy.filter` (`orders`)

`filter` keeps or drops **whole groups**, unlike Day 122's row-level
`.query()`/boolean-mask filtering — same word, different operation, worth
naming as such. `orders` grouped by `region` (default `dropna=True`) has
sizes East=3, North=3, South=3, West=1. Filter to groups of size `>= 3`.
Assert West is entirely absent from the survivors, and that the survivors'
row count equals the sum of the surviving groups' own sizes (9).

## Exercise 6 — multi-key grouping (`sales`)

Group `sales` by `['region', 'rep']`. Assert the result's index is a
`pandas.MultiIndex` with `.names == ['region', 'rep']`, and check one
value: `('East', 'Ann')` is 420.0. Repeat with `as_index=False` and assert
the same value comes back in a flat frame instead.

## Exercise 7 — `observed=` (`cat_sales`)

`cat_sales` declares `region` (5 categories) and `rep` (4 categories) as
pandas categoricals, two of which — `Central` and `Deb` — never actually
appear in the 12 rows of data. Group by both keys with `observed=False`:
assert 20 rows (5 x 4, every possible combination, most never observed).
Repeat with `observed=True`: assert 9 rows (only the combinations that are
actually present), and check `('North', 'Ann')` is 2.

## Exercise 8 — performance (`large`, 200,000 rows)

Time `large.groupby('key')['value'].agg('mean')` against
`large.groupby('key')['value'].apply(lambda g: g.mean())`. First assert
both give the same numbers (`np.allclose` after sorting both by index).
Then assert `apply_seconds / builtin_seconds >= 3.0` — a conservative
floor; this machine measured roughly 10-15x, but a slower or busier
machine should still clear 3x easily. Never assert a millisecond figure.

Second test: assert `groupby(sort=True)` and `groupby(sort=False)` give
the same **values** (once both are sorted by index for comparison) — only
the ordering of unsorted work should ever differ, never the numbers.

## Exercise 9 — a weighted mean per group (`weighted`)

Write a `weighted_mean(group)` function using
`np.average(group['value'], weights=group['weight'])`. Apply it per
`region` with `include_groups=False`. Assert North is `approx(17.5)`,
South is `approx(13.0)`, East is `approx(60.0)`.

Then compute the same weighted means **without** `apply`: build a
`value * weight` column, sum both that column and `weight` per group with
`.agg`, then divide. Assert the two routes agree exactly. This is the
honest case where `apply` is the readable choice, and you can still check
it against a faster vectorised route.

---

Prove your suite is not vacuous once you are green: re-break one assertion
on purpose (flip a comparison, change an expected number), confirm the run
exits non-zero with a printed `FAIL`, then restore it and confirm green
again.
starter/conftest.py (683 bytes)
"""Shared fixtures. pytest finds this file by itself -- nothing imports it.

Each fixture returns a FRESH copy of its table, so one test's mutation
(there should not be any, but fixtures should not have to trust that) can
never leak into the next test.
"""

import pytest

from data import (
    build_cat_sales,
    build_large,
    build_orders,
    build_sales,
    build_weighted,
)


@pytest.fixture
def orders():
    return build_orders()


@pytest.fixture
def sales():
    return build_sales()


@pytest.fixture
def cat_sales():
    return build_cat_sales()


@pytest.fixture
def weighted():
    return build_weighted()


@pytest.fixture
def large():
    return build_large()
starter/data.py (5122 bytes)
"""The three tables every exercise in this lab is built from.

Nothing here is randomised except the two performance-comparison tables,
which are seeded so a re-run always produces the same row counts (the
*timing* still varies machine to machine, which is why exercise 8 asserts a
ratio rather than a millisecond figure).

`orders` carries the day's opening failure on purpose: two rows have no
`region` at all, and two more have no `amount`. `sales` and `cat_sales` are
deliberately clean, so exercises 3, 4, 6 and 7 are not fighting missing data
while they teach a different point.
"""

from __future__ import annotations

import numpy as np
import pandas as pd

# --------------------------------------------------------------------------
# `orders` -- exercises 1, 2 and 5. Twelve rows. Two have no region at all
# (order_id 5 and 8); two have no amount at all (order_id 2 and 11).
# --------------------------------------------------------------------------


def build_orders() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "order_id": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
            "region": [
                "North",
                "South",
                "North",
                "East",
                None,
                "South",
                "North",
                None,
                "East",
                "South",
                "West",
                "East",
            ],
            "rep": [
                "Ann",
                "Bo",
                "Ann",
                "Cy",
                "Bo",
                "Ann",
                "Cy",
                "Bo",
                "Ann",
                "Cy",
                "Deb",
                "Deb",
            ],
            "amount": [
                100.0,
                np.nan,
                150.0,
                300.0,
                80.0,
                200.0,
                120.0,
                90.0,
                400.0,
                175.0,
                np.nan,
                500.0,
            ],
        }
    )


# --------------------------------------------------------------------------
# `sales` -- exercises 3, 4 and 6. Twelve rows, four regions of three rows
# each, no missing values anywhere.
# --------------------------------------------------------------------------


def build_sales() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "order_id": [101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112],
            "region": [
                "North",
                "North",
                "North",
                "South",
                "South",
                "South",
                "East",
                "East",
                "East",
                "West",
                "West",
                "West",
            ],
            "rep": ["Ann", "Bo", "Ann", "Bo", "Cy", "Bo", "Cy", "Ann", "Cy", "Ann", "Bo", "Cy"],
            "amount": [
                120.0,
                80.0,
                160.0,
                200.0,
                150.0,
                250.0,
                300.0,
                420.0,
                360.0,
                60.0,
                90.0,
                45.0,
            ],
        }
    )


# --------------------------------------------------------------------------
# `cat_sales` -- exercise 7. Same rows as `sales`, but `region` and `rep`
# are declared as categoricals with categories that are never observed in
# the data ("Central" and "Deb"), so grouping by both keys can either
# manufacture every combination or only the ones actually seen.
# --------------------------------------------------------------------------


def build_cat_sales() -> pd.DataFrame:
    df = build_sales()
    df["region"] = pd.Categorical(df["region"], categories=["North", "South", "East", "West", "Central"])
    df["rep"] = pd.Categorical(df["rep"], categories=["Ann", "Bo", "Cy", "Deb"])
    return df


# --------------------------------------------------------------------------
# `weighted` -- exercise 9. Three groups of unequal size and unequal
# weights, so a weighted mean genuinely differs from a plain mean.
# --------------------------------------------------------------------------


def build_weighted() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "region": ["North", "North", "South", "South", "South", "East", "East"],
            "value": [10.0, 20.0, 5.0, 15.0, 25.0, 100.0, 50.0],
            "weight": [1.0, 3.0, 2.0, 2.0, 1.0, 1.0, 4.0],
        }
    )


# --------------------------------------------------------------------------
# `build_large` -- exercise 8. A frame big enough that the gap between a
# built-in aggregation and a Python-level `.apply` is not measurement noise.
# --------------------------------------------------------------------------


def build_large(n: int = 200_000, n_keys: int = 2000, seed: int = 42) -> pd.DataFrame:
    rng = np.random.default_rng(seed)
    return pd.DataFrame(
        {
            "key": rng.integers(0, n_keys, size=n),
            "value": rng.normal(size=n),
        }
    )
starter/test_groupby.py (8565 bytes)
"""YOUR test suite for Day 123 -- "Groups That Reconcile". Nine exercises.

Run it from the lab directory, not from here:

    pytest starter -v

Every exercise below ends in a `pytest.skip(...)` line. pytest reports a
skip as `s` in the dot line and moves on, so an unfinished suite still
exits 0. Replace each skip with real assertions -- deleting the skip line
is part of the exercise. `starter/00_brief.md` explains each exercise in
full; the fixtures you need (`orders`, `sales`, `cat_sales`, `weighted`,
`large`) come from `conftest.py` and are described there too.

Assert exact values everywhere except exercise 8's timing ratio, which is
inherently one machine on one day.
"""

import time

import numpy as np
import pandas as pd
import pytest

# --------------------------------------------------------------------------
# EXERCISE 1 -- the reconciliation invariant. groupby drops rows whose key
# is missing, by default, silently. See starter/00_brief.md exercise 1.
#
# Check with:   pytest starter -v -k test_1
# --------------------------------------------------------------------------


def test_1_dropna_true_undercounts_by_exactly_the_missing_rows(orders):
    pytest.skip(
        "exercise 1a: assert grouped_total (1945.0), overall_total (2115.0), "
        "the gap (170.0), and that the gap equals the missing-key rows' amount total exactly"
    )


def test_1_dropna_false_reconciles_exactly(orders):
    pytest.skip(
        "exercise 1b: with dropna=False, assert the NaN group's total is 170.0 "
        "and that the grouped sum equals orders['amount'].sum() exactly"
    )


# --------------------------------------------------------------------------
# EXERCISE 2 -- count() versus size(). size() counts rows; count() counts
# non-missing values per column.
#
# Check with:   pytest starter -v -k test_2
# --------------------------------------------------------------------------


def test_2_size_and_count_disagree_where_amount_is_missing(orders):
    pytest.skip(
        "exercise 2: group orders by region with dropna=False; assert size and "
        "count disagree on South and West, and that (size - count).sum() equals "
        "orders['amount'].isna().sum() exactly"
    )


# --------------------------------------------------------------------------
# EXERCISE 3 -- .agg() four ways: single function, list, per-column dict,
# and named aggregation.
#
# Check with:   pytest starter -v -k test_3
# --------------------------------------------------------------------------


def test_3_agg_single_function(sales):
    pytest.skip("exercise 3a: sales.groupby('region')['amount'].agg('sum'); assert East is 1080.0")


def test_3_agg_list_of_functions(sales):
    pytest.skip(
        "exercise 3b: .agg(['sum', 'mean', 'count']); assert the column names "
        "and East's three values"
    )


def test_3_agg_per_column_dict(sales):
    pytest.skip(
        "exercise 3c: .agg({'amount': 'sum', 'order_id': 'count'}); assert "
        "North's amount sum and order_id count"
    )


def test_3_agg_named_aggregation_gives_flat_columns(sales):
    pytest.skip(
        "exercise 3d: .agg(total=('amount','sum'), avg=('amount','mean'), "
        "n=('order_id','count')); assert the result columns are flat, NOT a "
        "MultiIndex, and check West's three values"
    )


# --------------------------------------------------------------------------
# EXERCISE 4 -- agg reduces each group to one row; transform returns the
# input's shape. Use transform to build a within-group z-score.
#
# Check with:   pytest starter -v -k test_4
# --------------------------------------------------------------------------


def test_4_agg_returns_one_row_per_group(sales):
    pytest.skip("exercise 4a: assert sales.groupby('region')['amount'].agg('mean').shape == (4,)")


def test_4_transform_returns_the_input_shape(sales):
    pytest.skip(
        "exercise 4b: assert sales.groupby('region')['amount'].transform('mean').shape "
        "matches sales.shape[0], not the number of groups"
    )


def test_4_transform_attaches_a_group_mean_to_every_row(sales):
    pytest.skip(
        "exercise 4c: assert every North row's transformed group mean equals 120.0"
    )


def test_4_within_group_zscore_has_zero_mean_per_group(sales):
    pytest.skip(
        "exercise 4d: build (amount - group_mean) / group_std using transform for "
        "both, then assert each region's z-scores average to 0 within pytest.approx"
    )


# --------------------------------------------------------------------------
# EXERCISE 5 -- GroupBy.filter keeps or drops WHOLE GROUPS by a predicate.
# Distinct from Day 122's row-level filtering.
#
# Check with:   pytest starter -v -k test_5
# --------------------------------------------------------------------------


def test_5_filter_drops_whole_groups_below_the_threshold(orders):
    pytest.skip(
        "exercise 5: filter orders.groupby('region') to groups of size >= 3; "
        "assert West is entirely absent from the survivors and that the survivors' "
        "row count equals the sum of the surviving groups' own sizes (9)"
    )


# --------------------------------------------------------------------------
# EXERCISE 6 -- multi-key grouping produces a MultiIndex; as_index=False
# gives a flat frame with the same values.
#
# Check with:   pytest starter -v -k test_6
# --------------------------------------------------------------------------


def test_6_multi_key_grouping_produces_a_multiindex(sales):
    pytest.skip(
        "exercise 6a: group sales by ['region', 'rep'], assert the result index "
        "is a pandas.MultiIndex with names ['region', 'rep'], and check "
        "('East', 'Ann') is 420.0"
    )


def test_6_as_index_false_gives_the_same_values_flat(sales):
    pytest.skip(
        "exercise 6b: repeat with as_index=False; assert the result is NOT a "
        "MultiIndex and that ('East', 'Ann')'s value still matches exercise 6a"
    )


# --------------------------------------------------------------------------
# EXERCISE 7 -- observed=. Grouping a categorical produces rows for every
# possible combination unless observed=True.
#
# Check with:   pytest starter -v -k test_7
# --------------------------------------------------------------------------


def test_7_observed_false_manufactures_unseen_combinations(cat_sales):
    pytest.skip(
        "exercise 7a: group cat_sales by ['region', 'rep'] with observed=False; "
        "assert the result has 20 rows (5 region categories x 4 rep categories)"
    )


def test_7_observed_true_keeps_only_combinations_actually_seen(cat_sales):
    pytest.skip(
        "exercise 7b: repeat with observed=True; assert the result has 9 rows "
        "and that ('North', 'Ann') is 2"
    )


# --------------------------------------------------------------------------
# EXERCISE 8 -- performance. Report the SHAPE of the gap, never a
# millisecond figure.
#
# Check with:   pytest starter -v -k test_8
# --------------------------------------------------------------------------


def test_8_builtin_agg_beats_apply_by_a_wide_margin(large):
    pytest.skip(
        "exercise 8a: time large.groupby('key')['value'].agg('mean') against "
        "large.groupby('key')['value'].apply(lambda g: g.mean()); assert both give "
        "the same numbers, then assert apply_seconds / builtin_seconds >= 3.0"
    )


def test_8_sort_false_does_not_change_the_values(large):
    pytest.skip(
        "exercise 8b: assert groupby(sort=True) and groupby(sort=False) give the "
        "same values once both are sorted for comparison"
    )


# --------------------------------------------------------------------------
# EXERCISE 9 -- a weighted mean per group, computed with apply and again
# without it, asserting the two agree.
#
# Check with:   pytest starter -v -k test_9
# --------------------------------------------------------------------------


def test_9_weighted_mean_via_apply(weighted):
    pytest.skip(
        "exercise 9a: write a weighted_mean(group) function using "
        "np.average(group['value'], weights=group['weight']); apply it per region "
        "with include_groups=False; assert North is approx(17.5), South approx(13.0), "
        "East approx(60.0)"
    )


def test_9_weighted_mean_without_apply_agrees(weighted):
    pytest.skip(
        "exercise 9b: compute the same weighted means WITHOUT apply -- build a "
        "value*weight column, sum both columns per group with .agg, then divide -- "
        "and assert the result equals exercise 9a's result exactly"
    )
tests/run_tests.sh (10303 bytes)
#!/usr/bin/env bash
# Tests for the Day 123 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and reading real
# values, never by reading source:
#
#   * groupby drops rows with a missing key by default, silently, and the
#     resulting gap equals exactly the missing-key rows' total; dropna=False
#     makes the parts sum back to the whole;
#   * size() and count() disagree exactly where data is missing;
#   * .agg() four ways -- single function, list, per-column dict, and named
#     aggregation -- produce the documented shapes and values, and named
#     aggregation's columns are flat, never a MultiIndex;
#   * agg returns one row per group; transform returns the input's shape,
#     and a transform-built within-group z-score averages to zero per group;
#   * GroupBy.filter keeps or drops WHOLE groups by a size predicate;
#   * multi-key grouping produces a MultiIndex, and as_index=False gives the
#     same values flat;
#   * observed=False manufactures every possible categorical combination
#     (20, on this lab's two keys); observed=True keeps only the 9 seen;
#   * a built-in aggregation beats the equivalent .apply by a wide,
#     machine-independent margin, asserted as a ratio floor, never a timing;
#   * a weighted mean computed with apply and again without it agree;
#   * 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. Nothing binds a port,
# nothing writes outside the lab, nothing needs a key. Deterministic,
# non-interactive, exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

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

# Bytecode left by an EARLIER command is not this run's litter. The README
# documents `pytest starter -q` and `pytest examples -q` separately, and
# running either writes .pyc files that would then fail the cleanliness
# check at the end of this script -- failing the reader for following the
# instructions. Clearing them here makes that final check measure what it
# claims to: what THIS run left behind. `.venv` is untouched, because the
# packages' own bytecode is theirs, not ours.
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true

failures=0
checks=0

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

check_eq() {
  # check_eq <label> <expected> <actual>
  if [ "$2" = "$3" ]; then
    check "$1" "yes"
  else
    check "$1 (expected [$2], got [$3])" "no"
  fi
}

# Resolve pytest: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping checks.
resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

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

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

if ! "${python_bin}" -c "import pandas" >/dev/null 2>&1; then
  echo "FAIL: pandas 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 123 — Groups That Reconcile"
echo

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

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

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

pandas_version="$("${python_bin}" -c "import pandas; print(pandas.__version__)" 2>/dev/null || echo "")"
pinned_pandas="$(grep -m1 '^pandas==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
check_eq "installed pandas matches requirements.txt exactly" "${pinned_pandas}" "${pandas_version}"
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 20 passed, 0 failed" "$( echo "${examples_passed_line}" | grep -qE '^20 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 20 skipped, 0 failed" "$( echo "${starter_output}" | grep -qE '^20 skipped' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "4. Never run 'pytest examples starter' in one invocation -- same"
echo "   module name in both directories means the second collected can"
echo "   shadow the first. Documented and checked separately, 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}/d123-scratch.XXXXXX")"
cleanup_scratch() { rm -rf "${scratch_dir}"; }
trap cleanup_scratch EXIT

cp "${lab_dir}/examples/test_groupby.py" "${scratch_dir}/test_groupby.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 20 passed" "$( echo "${solved_output}" | grep -qE '^20 passed' && echo yes || echo no )"

# Break test_1's exact gap assertion on purpose: 170.0 -> 999.0.
sed -i.bak 's/assert gap == 170\.0/assert gap == 999.0/' "${scratch_dir}/test_groupby.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_groupby.py.bak" "${scratch_dir}/test_groupby.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 20 passed again" "$( echo "${restored_output}" | grep -qE '^20 passed' && echo yes || echo no )"

cleanup_scratch
trap - EXIT
echo

# --------------------------------------------------------------------------
echo "6. Nothing in examples/ or starter/ opens a network connection"
# --------------------------------------------------------------------------

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 )"
echo

# --------------------------------------------------------------------------
echo "7. 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 'pandas'

The lab's dependencies live in its own .venv, not on your system Python.

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

Or point the test suite at a Python that already has pandas 3.0.5 installed: PYTHON=/path/to/python3 bash tests/run_tests.sh.

pytest examples starter reports fewer failures than you expected, or none

Do not pass both directories to one pytest invocation. starter/ and examples/ both define a module named test_groupby.py, and pytest imports test modules by their dotted name; the second one collected shadows the first, so pytest examples starter can silently run only one directory's tests under the other's name. Run them as two separate commands, always:

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

Exercise 1's grouped sum does not equal orders['amount'].sum()

That is the point of exercise 1, not a bug in your code — groupby excludes rows whose key is missing by default. Group with dropna=False if you want the parts to add back up to the whole; the gap under the default (dropna=True) should equal exactly the total of the rows whose region is missing.

Exercise 2's size and count are equal on every group

You are counting the wrong thing, or grouping the wrong column. size() counts rows and is the same number no matter which column you ask about; count() is a per-column method (grouped['amount'].count(), not grouped.count() alone) that counts only non-missing values in that one column. Confirm you introduced the missing amount values by inspecting orders['amount'].isna() directly before grouping anything.

TypeError: agg function failed [how->mean,dtype->object]

You called .agg('mean') (or .transform('mean')) on a GroupBy built from the whole DataFrame rather than from one numeric column or a numeric subset — sales.groupby('region').agg('mean') tries to average every column, including the non-numeric rep. Select the column first: sales.groupby('region')['amount'].agg('mean').

Exercise 4's z-score does not average to (approximately) zero

Check that you used transform, not agg, for both the group mean and the group standard deviation — agg returns one row per group, and subtracting a 4-row Series from a 12-row Series aligns by label rather than raising the error you might expect, producing mostly NaN. transform is the one that returns a value for every original row.

Exercise 6's as_index=False result has different values than the MultiIndex version

It should not — if it does, you likely grouped a different key order or column selection between the two calls. Compare exactly the same groupby(["region", "rep"])["amount"].sum() call, once with as_index=False and once without, before comparing values.

Exercise 7's observed=False count is not 20

Confirm both region and rep are genuinely pandas.Categorical with the categories declared in data.py (5 and 4 respectively, including one unused category each) — grouping a plain object/str column never manufactures unobserved rows regardless of observed=, because there is no fixed category list to draw the unseen combinations from.

Exercise 8's ratio assertion fails on a fast or heavily loaded machine

The 3.0x floor is deliberately conservative — this machine measured roughly 10-15x. If your machine is unusually fast, lightly loaded, or the run happens to catch a slow build of apply's Python-level call overhead, the ratio should still clear 3x; if it does not even after a couple of re-runs, increase n in build_large() (in data.py) so the built-in path's per-call savings dominate more clearly, and note the change rather than lowering the assertion.

pip install fails or hangs

You are offline, or a corporate proxy is blocking PyPI. This is the only network-dependent step in the entire lab. Retry on a connection that can reach pypi.org, or ask whoever manages your network for a mirror.

Security notes

Security notes

What this lab does to your machine

  • Opens one network connection, ever: pip install -r requirements/requirements.txt, to download pandas, pyarrow, NumPy and pytest from PyPI into this lab's own .venv. Every script and test after that runs completely offline.
  • Writes only inside its own .venv directory (created by you, via python3 -m venv .venv) and transient __pycache__ / .pytest_cache directories that the test harness removes both before and after every run.
  • 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 in data.pyorders, sales, cat_sales and weighted are each a dozen or fewer rows invented for the exercises — except large, a synthetic column of 200,000 rows generated with a fixed seed (np.random.default_rng(42)) purely to make the built-in-versus-.apply timing comparison in exercise 8 meaningful at scale. 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

groupby drops rows whose key is missing by default, silently — no exception, no warning, a plausible-looking result. Applied to a fairness or coverage report (accuracy per demographic segment, revenue per region), that default quietly removes exactly the rows whose segment label was never recorded, which are rarely the rows a report can afford to drop without saying so. This lab's exercise 1 is not a hypothetical: it is the concrete mechanism by which an aggregate report can look complete while a whole category of it is missing, undetected, unless someone checks that the parts reconcile with the whole.

observed= in exercise 7 has a related, quieter cost: grouping several categorical keys without observed=True can manufacture a combinatorial number of empty rows for combinations that were never seen. On a wide categorical dataset this is a genuine memory hazard, not merely a cosmetic one — the lesson's Implications section measures it.