cassionData Analysis

Lesson 8 of 8

Unit · Producing and handing over

A script another officer can run

Arguments instead of edits, logging instead of print, failing loudly instead of producing a wrong number — turning the analysis into something that runs next quarter without you in the room.

Python90 min

The handover test

An analysis is finished when someone else can run it on next quarter’s export without editing the code. Not “without much editing” — without editing.

That standard rules out the three habits every notebook accumulates: a path written for one machine, a value changed by hand between runs, and a step that only works if you already know which cell to run first.

Arguments, not edits

# scripts/indicator_table.py
import argparse
from pathlib import Path

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Compute GAM and SAM rates by commune from a screening register."
    )
    parser.add_argument(
        "--input", type=Path, required=True,
        help="Path to the screening register CSV.",
    )
    parser.add_argument(
        "--output", type=Path, required=True,
        help="Directory to write tables into. Created if missing.",
    )
    parser.add_argument(
        "--gam-threshold", type=int, default=125,
        help="MUAC cut-off in mm for global acute malnutrition (default: 125).",
    )
    parser.add_argument(
        "--min-assessment-rate", type=float, default=0.8,
        help="Communes below this assessment rate are flagged, not dropped.",
    )
    return parser.parse_args()
uv run python scripts/indicator_table.py \
    --input data/raw/muac-screening-artibonite-2024.v1.csv \
    --output outputs/tables

type=Path converts the string for you, and required=True means a forgotten argument produces a clear message rather than a KeyError two hundred lines in.

Put the thresholds in arguments and the defaults in the code. A threshold that is only ever changed by editing a line will eventually be changed and not changed back. One that is an argument with a documented default is visible in the command that produced the output, which is what you want when someone asks how last quarter’s figure was computed.

--help is generated from the same declarations, and it is the documentation people actually read:

uv run python scripts/indicator_table.py --help

Fail loudly, early

A script that produces a wrong number silently is worse than one that crashes. Check what you assume, at the point you assume it.

def read_register(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise SystemExit(f"Input file not found: {path}")

    muac = pd.read_csv(
        path,
        dtype={"child_id": "string", "commune": "string"},
        na_values={"muac_mm": ["-99"]},
    )

    expected = {"child_id", "commune", "screening_date", "muac_mm", "oedema"}
    missing = expected - set(muac.columns)
    if missing:
        raise SystemExit(f"Input is missing columns: {sorted(missing)}")

    if muac["child_id"].isna().any():
        raise SystemExit("Some rows have no child_id; refusing to continue.")

    return muac

SystemExit with a message exits with a non-zero status and prints one readable line, rather than a traceback the reader has to interpret. Use it for “this input is not what I was promised”; use assert for “this cannot happen unless the code is wrong”.

Turn the pandas warning that means a silent failure into an error:

import warnings
import pandas as pd

warnings.simplefilter("error", pd.errors.ChainedAssignmentError)

Chained assignment never modifies the frame, and by default it only warns. In a script whose output someone will report, stopping is the right response.

Logging, not print

print goes to standard output alongside the results, has no severity, and cannot be turned down.

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)-8s %(message)s",
    datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)

log.info("Read %d rows from %s", len(muac), args.input)
log.warning("%d communes below the assessment-rate floor", len(flagged))
log.info("Wrote %s", output_path)
09:14:02 INFO     Read 4218 rows from data/raw/muac-...v1.csv
09:14:02 WARNING  2 communes below the assessment-rate floor
09:14:02 INFO     Wrote outputs/tables/gam_by_commune.csv

Three things this buys that print does not: a timestamp, a severity someone can filter on, and the ability to add --verbose without touching every call.

Use the %s form rather than an f-string — the formatting is skipped entirely when the level is disabled.

Log the numbers that would let someone reconstruct the run: rows read, rows excluded and why, the denominator, the output path. A log that says “done” tells nobody anything.

The shape of the script

# scripts/indicator_table.py
"""Compute GAM and SAM rates by commune from a MUAC screening register."""

import argparse
import logging
from pathlib import Path

import pandas as pd

log = logging.getLogger(__name__)

GAM_DEFAULT, SAM_DEFAULT = 125, 115


def read_register(path: Path) -> pd.DataFrame:
    ...


def indicator_table(muac: pd.DataFrame, gam_mm: int, sam_mm: int) -> pd.DataFrame:
    measured = muac["muac_mm"].notna() | muac["oedema"].notna()
    gam = measured & ((muac["muac_mm"] < gam_mm) | (muac["oedema"] == True))
    sam = measured & ((muac["muac_mm"] < sam_mm) | (muac["oedema"] == True))

    table = (
        muac.assign(measured=measured, gam=gam, sam=sam)
        .groupby("commune", dropna=False, observed=True)
        .agg(
            screened=("child_id", "size"),
            assessed=("measured", "sum"),
            gam_cases=("gam", "sum"),
            sam_cases=("sam", "sum"),
        )
    )
    table["assessment_rate"] = (table["assessed"] / table["screened"]).round(3)
    table["gam_rate"] = (table["gam_cases"] / table["assessed"]).round(3)
    table["sam_rate"] = (table["sam_cases"] / table["assessed"]).round(3)
    return table.reset_index()


def main() -> None:
    args = parse_args()
    logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")

    muac = read_register(args.input)
    log.info("Read %d rows", len(muac))

    table = indicator_table(muac, args.gam_threshold, SAM_DEFAULT)

    thin = table.loc[table["assessment_rate"] < args.min_assessment_rate, "commune"]
    if len(thin):
        log.warning("Assessment rate below floor: %s", ", ".join(thin))

    args.output.mkdir(parents=True, exist_ok=True)
    destination = args.output / "gam_by_commune.csv"
    table.to_csv(destination, index=False)
    log.info("Wrote %s (%d communes)", destination, len(table))


if __name__ == "__main__":
    main()

Four things about that structure are deliberate.

Functions take arguments and return values. indicator_table does not read a file, does not know where output goes, and does not touch a global. That is what makes it testable and what lets the notebook import it.

main() is the only place that does input and output. Reading, writing and logging live there; everything else is computation.

The if __name__ == "__main__" guard means importing this module from a notebook runs nothing. Without it, from indicator_table import indicator_table executes the whole script.

Thin data is flagged, not dropped. A commune below the assessment-rate floor stays in the table with a warning beside it. Dropping it would change the district denominator and nothing in the output would say so.

Testing the part that computes

Once the computation is a function taking a frame, testing it is three lines:

# tests/test_indicator_table.py
import pandas as pd
from indicator_table import indicator_table


def test_denominator_excludes_unmeasured_children():
    muac = pd.DataFrame({
        "child_id": ["a", "b", "c"],
        "commune": ["X", "X", "X"],
        "muac_mm": [110, 130, None],
        "oedema": [False, False, None],
    })

    table = indicator_table(muac, gam_mm=125, sam_mm=115)

    assert table.loc[0, "screened"] == 3
    assert table.loc[0, "assessed"] == 2      # the unmeasured child is not a denominator
    assert table.loc[0, "gam_rate"] == 0.5

That test encodes the decision from lesson 6 — which children are in the denominator — in a form that fails if someone changes it. It is worth writing for every indicator whose definition you had to argue about.

Making the run reproducible

Record what produced the output, beside the output:

import json
import subprocess
from datetime import datetime, timezone


def run_metadata(args) -> dict:
    return {
        "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "input": str(args.input),
        "gam_threshold": args.gam_threshold,
        "pandas": pd.__version__,
        "commit": subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True, text=True,
        ).stdout.strip() or "not a git repository",
    }


(args.output / "run.json").write_text(json.dumps(run_metadata(args), indent=2))

Six months later, “which threshold produced this table” has an answer that does not depend on anyone’s memory.

Then the check from lesson 2, which is the real test:

rm -rf outputs/
uv run python scripts/indicator_table.py --input data/raw/muac.csv --output outputs/tables
git status

If that does not restore the outputs exactly, something is not reproducible.

Handing it over

The README from lesson 2 needs one more line now — the command with real arguments:

## Run

    uv sync
    uv run python scripts/indicator_table.py \
        --input data/raw/muac-screening-artibonite-2024.v1.csv \
        --output outputs/tables

Thresholds default to MUAC < 125 mm (GAM) and < 115 mm (SAM).
Pass --gam-threshold to change it; the value used is recorded in
outputs/tables/run.json.

Two commands, and the thresholds visible without opening the code. That is the whole handover.

Where this course ends

You can build an environment that reinstalls offline, read any export without corrupting it, turn its codes into values, aggregate to a numerator and a denominator that came from the same operation, get the date arithmetic right, and hand the result to someone else as a script rather than as a favour.

What this course deliberately did not teach is what to compute — which indicator, which denominator, which threshold, and how to defend it. That is Data Analysis Foundations, and the sector courses in Sector Analysis on the programme roadmap take it further into the classifications your cluster holds you to.

If your team works in R rather than Python, R for Programme Data covers the same ground in that language.

Teach this lesson

The lesson as a slide deck, with the prose kept in the speaker notes rather than on the slide. Generated from this page, so it cannot fall out of step with it.

Start the slideshowRead the slides

The PDF needs no software and projects from any machine. The PowerPoint file is there to be edited — add your organisation's branding, cut a section for a shorter session, or merge two lessons into a workshop.