Back to the lesson·Lesson 1 of 8·Before you change anything
The profile you run before you touch it
The same deck as the downloads, rendered as a page. Start the slideshow to present it full screen — arrow keys or a click advance one slide, Escape leaves.
What this lesson covers
- The file is evidence until you edit it
- Read it as text first
- What a profile has to answer
- Shape, against an expectation you write down
- The column inventory
- Sentinels are not missing values, yet
- Is the key a key?
- Ranges, before you trust a summary
- Save the profile next to the data
- Comparing this export against the last one
- What comes next
Speaker notes
A fresh export is evidence until you edit it. Read it as raw text first, inventory every column, prove or disprove the key, and save the result as the record of what arrived.The file is evidence until you edit it
- Six months from now someone will ask whether the August figure was always like that.
Speaker notes
Six months from now someone will ask whether the August figure was always like that. There are two ways that conversation goes. Either you open a file saved on the day the export arrived and read the answer off it, or you reconstruct it from memory and a script that has been edited eleven times since. So the first thing you do with a new export is not clean it. It is profile it and write the profile down, before a single value changes. The profile is cheap — twenty lines — and it is the only artefact that can ever prove what the file looked like on arrival. This lesson builds that profile. The Foundations course taught the five checks that find defects; this one turns them into something you keep.Read it as text first
- Read once with everything as a string — That read is for looking, not for computing
Speaker notes
Every reader guesses.read_csvlooks at the first few thousand rows and decides each column is a number, a date or a string, and every one of those guesses can destroy information you will never get back — a leading zero on a facility code, a sentinel-99averaged into a mean, a date read as month-first. Read once with everything as a string. That read is for looking, not for computing.Read it as text first — In Python
import pandas as pd PATH = "muac-screening-artibonite-2024.v1.csv" raw = pd.read_csv(PATH, dtype="string", keep_default_na=False) print(raw.shape) print(raw.dtypes)Read it as text first — In R
library(readr) library(dplyr) PATH <- "muac-screening-artibonite-2024.v1.csv" raw <- read_csv(PATH, col_types = cols(.default = col_character())) dim(raw)Speaker notes
Two arguments are doing real work in the Python call.dtype="string"stops the type inference;keep_default_na=Falsestops pandas turning the literal stringsNA,N/A,nullandnaninto missing values before you have seen them. In this sector that matters more than it sounds — a column where an enumerator typedNAfor "not applicable" is not the same column as one where the field was skipped, and pandas conflates them by default. The R read is deliberatelycol_character()for everything, which is also whatproblems()needs to be useful: read strictly later and any value that would not convert is reported rather than silently madeNA.What a profile has to answer
- How much arrived? Rows and columns, against what you expected.
- What is each column really? Its raw values, not the type a reader guessed.
- Where are the holes? Per column, and per site — never one global figure.
- Is the key a key? Not "does the column exist" but "is it unique".
- What is out of range? Against the sector's limits, not against the data's own.
- What codes are in use? Every distinct value of every categorical column.
Speaker notes
Six questions, and a profile that does not answer all six is not one:Shape, against an expectation you write down — In Python
EXPECTED_COLUMNS = [ "child_id", "commune", "screening_date", "age_months", "sex", "muac_mm", "oedema", "outcome", ] print(f"{len(raw)} rows, {raw.shape[1]} columns") print("missing columns:", set(EXPECTED_COLUMNS) - set(raw.columns)) print("unexpected columns:", set(raw.columns) - set(EXPECTED_COLUMNS))Shape, against an expectation you write down — In R
EXPECTED_COLUMNS <- c( "child_id", "commune", "screening_date", "age_months", "sex", "muac_mm", "oedema", "outcome" ) cat(nrow(raw), "rows,", ncol(raw), "columns\n") setdiff(EXPECTED_COLUMNS, names(raw)) setdiff(names(raw), EXPECTED_COLUMNS)Speaker notes
This register has 4,218 rows and eight columns. The useful part is not the count; it is that you wrote down what you expected. A column that quietly disappeared between one export and the next is the most common breaking change a form platform ships, and it is invisible unless something is comparing against a list.The column inventory — In Python
def inventory(df): rows = [] for column in df.columns: values = df[column] counts = values.value_counts(dropna=False) rows.append({ "column": column, "blank": int((values == "").sum()), "distinct": int(values.nunique(dropna=False)), "top": counts.index[0] if len(counts) else None, "top_n": int(counts.iloc[0]) if len(counts) else 0, }) return pd.DataFrame(rows) print(inventory(raw).to_string(index=False))Speaker notes
For every column: how many rows are blank, how many distinct values, and the handful of most frequent ones. This one table replaces most of what people do by scrolling.The column inventory — In R
inventory <- function(df) { purrr::map_dfr(names(df), function(column) { values <- df[[column]] counts <- sort(table(values, useNA = "ifany"), decreasing = TRUE) tibble::tibble( column = column, blank = sum(values == "" | is.na(values)), distinct = dplyr::n_distinct(values), top = names(counts)[1], top_n = as.integer(counts[1]) ) }) } print(inventory(raw), n = Inf)The column inventory
Column Blank Distinct Note age_months 226 55 5.4% blank — lesson 2 asks where oedema 44 4 four distinct values in a boolean column Speaker notes
Run it on this register and two rows are worth stopping at.The column inventory
- Four distinct values in a boolean column — is the finding
Speaker notes
Four distinct values in a boolean column is the finding. The register holdstrue,false, an empty string, andN— twenty-five rows where an enumerator usedY/Nconventions in a column the form expectedtrue/falsein. Cast that column to a boolean and theNrows become missing, silently, and you have lost twenty-five recorded negatives by treating them as unrecorded. That is the whole argument for reading as text first. After the cast there is nothing to find.Sentinels are not missing values, yet — In Python
print(raw["muac_mm"].value_counts().head()) print("rows coded -99:", int((raw["muac_mm"] == "-99").sum()))Speaker notes
muac_mmlooks complete: no blanks at all. It is not.Sentinels are not missing values, yet — In R
raw |> count(muac_mm, sort = TRUE) |> head() sum(raw$muac_mm == "-99")Speaker notes
Seventy-two rows carry-99, the register's code for "not measured". Read the column as a number without declaring that code and the mean drops by about two millimetres and the caseload is understated, because you have averaged seventy-two children in at minus ninety-nine. Sentinel codes are documented in the data dictionary and nowhere else. Check the dictionary before the first numeric read, every time —-99,-1,999,9999and88are all in live use in this sector, and none of them look wrong in a summary.Is the key a key? — In Python
duplicated_ids = raw["child_id"].duplicated(keep=False) print("rows sharing a child_id:", int(duplicated_ids.sum())) print("distinct ids involved:", raw.loc[duplicated_ids, "child_id"].nunique())Speaker notes
The column calledchild_idis an identifier. Whether it is unique is a separate question, and the answer here is no.Is the key a key? — In R
raw |> group_by(child_id) |> filter(n() > 1) |> ungroup() |> summarise(rows = n(), ids = n_distinct(child_id))Speaker notes
Twenty-four rows across twelve identifiers. Every join you write from here on assumes something about this column, and the assumption is currently false. Unit 2 is about that; the profile's job is only to surface it on day one rather than in the middle of a join that quietly doubles a caseload.Ranges, before you trust a summary — In Python
muac = pd.to_numeric(raw["muac_mm"], errors="coerce") muac = muac.where(muac != -99) print(muac.describe()) print(muac.nsmallest(10).tolist()) print(muac.nlargest(10).tolist())Speaker notes
A five-number summary hides exactly the values you are looking for, because one implausible row barely moves a quartile. Look at the extremes directly.Ranges, before you trust a summary — In R
muac <- suppressWarnings(as.numeric(raw$muac_mm)) muac[muac == -99] <- NA summary(muac) head(sort(muac), 10) head(sort(muac, decreasing = TRUE), 10)Ranges, before you trust a summary
- Always look at the ten smallest and ten largest values of any measurement column — It costs one line and it is the…
Speaker notes
The ten smallest values run 13, 14, 14, 15, 16, 17, 18 and then jump to 92. The first seven are centimetres that were never converted — a MUAC of 13.4 cm typed as 13. The mean barely notices them. The tail names them immediately. Always look at the ten smallest and ten largest values of any measurement column. It costs one line and it is the single highest-yield check in this lesson.Save the profile next to the data — In Python (cont.)
from pathlib import Path import json profile = { "file": PATH, "rows": len(raw), "columns": list(raw.columns), "blank_by_column": {c: int((raw[c] == "").sum()) for c in raw.columns}, "distinct_by_column": {c: int(raw[c].nunique()) for c in raw.columns}, "duplicate_key_rows": int(raw["child_id"].duplicated(keep=False).sum()), "categorical_values": { c: sorted(raw[c].unique().tolist()) for c in ["commune", "sex", "oedema", "outcome"] }, }Speaker notes
The profile is only worth writing if it survives the session.Save the profile next to the data — In Python (cont.)
Path("outputs/profiles").mkdir(parents=True, exist_ok=True) Path("outputs/profiles/muac-2024-q4.json").write_text(json.dumps(profile, indent=2))Save the profile next to the data — In R (cont.)
profile <- list( file = PATH, rows = nrow(raw), columns = names(raw), blank_by_column = sapply(raw, function(x) sum(x == "" | is.na(x))), distinct_by_column = sapply(raw, dplyr::n_distinct), duplicate_key_rows = sum(duplicated(raw$child_id) | duplicated(raw$child_id, fromLast = TRUE)), categorical_values = lapply( raw[c("commune", "sex", "oedema", "outcome")], function(x) sort(unique(x)) ) ) dir.create(here::here("outputs", "profiles"), recursive = TRUE, showWarnings = FALSE) jsonlite::write_json( profile,Save the profile next to the data — In R (cont.)
here::here("outputs", "profiles", "muac-2024-q4.json"), pretty = TRUE, auto_unbox = TRUE )Speaker notes
JSON rather than a printed table, because the point of the next section is to compare two of them.Comparing this export against the last one — In Python
previous = json.loads(Path("outputs/profiles/muac-2024-q3.json").read_text()) for column, values in profile["categorical_values"].items(): was, now = set(previous["categorical_values"][column]), set(values) if was != now: print(f"{column}: new {sorted(now - was)}, gone {sorted(was - now)}") growth = (profile["rows"] - previous["rows"]) / previous["rows"] print(f"row count moved {growth:.1%}")Speaker notes
Most exports are the same export again, one quarter later. The interesting question is therefore never "what is in this file" but "what changed".Comparing this export against the last one — In R
previous <- jsonlite::read_json( here::here("outputs", "profiles", "muac-2024-q3.json"), simplifyVector = TRUE ) for (column in names(profile$categorical_values)) { was <- previous$categorical_values[[column]] now <- profile$categorical_values[[column]] if (!setequal(was, now)) { cat(column, ": new", setdiff(now, was), "| gone", setdiff(was, now), "\n") } } sprintf("row count moved %.1f%%", 100 * (profile$rows - previous$rows) / previous$rows)Comparing this export against the last one
A profile you did not save is a profile you did not run. The value is entirely in being able to open it later.
Speaker notes
A new value in a categorical column is the single most common way an analysis starts producing wrong answers without failing. A form gets a new response option, a thirteenth commune is added, an antigen is renamed — and everycase_whenwritten before that day now sends the new value to itselsebranch. Comparing profiles catches it on arrival, which is the only moment it is cheap.What comes next
- You now know this register is missing 5.4% of its ages.
Speaker notes
You now know this register is missing 5.4% of its ages. That figure on its own is almost meaningless — it matters enormously whether those 226 rows are scattered across twelve communes or concentrated in one. The next lesson breaks missingness down until it either stops looking like an accident or proves it is one, and puts a number on what dropping those rows would do to the ranking you publish.