Back to the lesson·Lesson 6 of 8·Making the pieces line up
The rows that were never written
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
- A missing row has no value to be missing
- Build the grid
- Where all 1,755 went
- Absent is not absent
- A complete grid does not prove everyone reported
- Period keys that sort
- Aligning a daily register to a monthly aggregate
- What comes next
Speaker notes
A missing row has no value to be missing. Build the grid the data should have filled, find where 1,755 attendance rows went, and learn why a complete grid still does not prove everyone reported.A missing row has no value to be missing
- Every check in the cleaning course looked at values: blanks, sentinels, implausible numbers.
Speaker notes
Every check in the cleaning course looked at values: blanks, sentinels, implausible numbers. All of them share an assumption — that the row is there. The failure this lesson deals with is different. A school that was closed has no attendance rows. A facility that did not report has no row in the extract. A month with no distribution has nothing at all. There is no blank cell to count, noNAto detect, and every summary you compute is over the periods that happened to be recorded. The fix is always the same shape: construct the grid of periods that should exist, join the data onto it, and let the holes appear as rows.Build the grid — In Python
students = roster["student_id"].drop_duplicates() school_days = attendance["attendance_date"].drop_duplicates() grid = pd.MultiIndex.from_product( [students, school_days], names=["student_id", "attendance_date"] ).to_frame(index=False) complete = grid.merge(attendance, on=["student_id", "attendance_date"], how="left") print(f"grid {len(grid):,}, actual {len(attendance):,}, " f"missing {len(grid) - len(attendance):,}")Build the grid — In R
grid <- tidyr::expand_grid( student_id = unique(roster$student_id), attendance_date = unique(attendance$attendance_date) ) complete <- grid |> left_join(attendance, by = c("student_id", "attendance_date")) cat(sprintf("grid %d, actual %d, missing %d\n", nrow(grid), nrow(attendance), nrow(grid) - nrow(attendance)))Build the grid — In R
complete <- attendance |> tidyr::complete(student_id, attendance_date)Speaker notes
1,200 students by 60 school days is a grid of 72,000. The file holds 70,245. 1,755 rows were never written. dplyr has a shorter route when the frame is already the data:complete()fills the cross-product of the values it finds, which is right when every combination should exist and wrong when a student joined mid-term — it will not invent days for a student the file never mentions before March. Build the grid explicitly when the universe is defined outside the data, which is the usual case.Where all 1,755 went — In Python
missing = complete[complete["present"].isna()] by_school = ( missing.merge(roster[["student_id", "school_id"]], on="student_id") .groupby("school_id") .agg(missing_rows=("present", "size"), days=("attendance_date", "nunique")) ) print(by_school)Where all 1,755 went — In R
complete |> filter(is.na(present)) |> left_join(select(roster, student_id, school_id), by = "student_id") |> summarise(missing_rows = n(), days = n_distinct(attendance_date), .by = school_id)Where all 1,755 went
school_id missing rows days SCH07 915 15 SCH18 840 15 Speaker notes
Two schools, fifteen days each, and 61 × 15 + 56 × 15 = 1,755. The grid accounts for every single missing row, and it does so in a form that names the cause: two schools were shut for the same fifteen days in March. That is the payoff. Before the grid, the missing rows were invisible. After it, they are two schools and a date range — a strike, a flood, an exam period, something a colleague can confirm in one phone call.Absent is not absent — In Python
naive = complete["present"].fillna(False) print("attendance rate treating gaps as absences:", naive.mean())Speaker notes
Now the decision, and it is the whole reason the lesson exists.Absent is not absent — In R
complete |> summarise(rate = mean(coalesce(present, FALSE)))Absent is not absent — In Python
open_days = attendance.merge(roster[["student_id", "school_id"]], on="student_id") school_calendar = open_days[["school_id", "attendance_date"]].drop_duplicates() grid = ( roster[["student_id", "school_id"]] .merge(school_calendar, on="school_id") ) print(len(grid), "student-days the schools were actually open")Speaker notes
Filling the gaps with "absent" makes SCH07 and SCH18 look like a dropout emergency, because a quarter of their term is now recorded as every child missing every day. The correct handling is the opposite: those days were not school days for those schools, and they belong in neither numerator nor denominator.Absent is not absent — In R
school_calendar <- attendance |> left_join(select(roster, student_id, school_id), by = "student_id") |> distinct(school_id, attendance_date) grid <- roster |> select(student_id, school_id) |> left_join(school_calendar, by = "school_id", relationship = "many-to-many")Absent is not absent
- Build the grid per school, not per district — The universe of periods is a property of the reporting unit, and assuming…
Speaker notes
Build the grid per school, not per district. The universe of periods is a property of the reporting unit, and assuming a single shared calendar is how a closure becomes an absence.Absent is not absent
Deciding whether a gap is a zero, a missing value or a period that should not exist is not a technical question. It is the analysis, and it belongs in the log with its reason.
A complete grid does not prove everyone reported — In Python
expected = ( vax["facility_id"].nunique() * vax["period"].nunique() * vax["antigen"].nunique() ) print(expected, "expected rows;", len(vax), "actual")Speaker notes
The vaccination extract is the counter-example, and it is important because it looks like the good case.A complete grid does not prove everyone reported — In R
c(expected = n_distinct(vax$facility_id) * n_distinct(vax$period) * n_distinct(vax$antigen), actual = nrow(vax))A complete grid does not prove everyone reported — In Python
coverage = ( vax[vax["report_submitted"]] .groupby(["period", "antigen"]) .agg(doses=("doses_administered", "sum"), target=("target_population", "sum")) ) reporting = vax.groupby(["period", "antigen"])["report_submitted"].mean()Speaker notes
38 × 12 × 6 = 2,736, and the file has 2,736 rows. The grid is perfect. And 642 of those rows carryreport_submittedoffalseand zero doses. So the grid check passes and the data is still full of holes — they are just holes with a row around them. Two separate checks, and you need both: is every expected row present, and does every present row contain a report.A complete grid does not prove everyone reported — In R
vax |> summarise( reporting_rate = mean(report_submitted), doses = sum(doses_administered[report_submitted]), target = sum(target_population[report_submitted]), .by = c(period, antigen) )Period keys that sort — In Python
vax["period"] = pd.to_datetime(vax["period"]) vax["month"] = vax["period"].dt.strftime("%Y-%m") # 2024-08, sorts correctlySpeaker notes
A small mechanical point that causes disproportionate trouble.Period keys that sort
- The month must carry its year — A twelve-month grid keyed on
monthalone silently merges August 2023 and August 2024…
Speaker notes
Use ISO period keys —2024-08,2024-Q3,2024-W32— everywhere a period is a key.Aug,Augustand08/2024all sort wrongly, and08/2024is ambiguous between two conventions that are both in daily use in this sector. The month must carry its year. A twelve-month grid keyed onmonthalone silently merges August 2023 and August 2024 the first time two years of data are in the same folder.- The month must carry its year — A twelve-month grid keyed on
Aligning a daily register to a monthly aggregate — In Python
monthly = ( attendance.assign(month=attendance["attendance_date"].dt.strftime("%Y-%m")) .merge(roster[["student_id", "school_id"]], on="student_id") .groupby(["school_id", "month"]) .agg(marks=("present", "size"), present=("present", "sum")) .reset_index() )Speaker notes
The last shape in this lesson, and the one the next lesson builds on. To compare a line-level register with a monthly return, aggregate the register to the aggregate's grain — never the other way.Aligning a daily register to a monthly aggregate — In R
monthly <- attendance |> mutate(month = format(attendance_date, "%Y-%m")) |> left_join(select(roster, student_id, school_id), by = "student_id") |> summarise(marks = n(), present = sum(present %in% TRUE), .by = c(school_id, month))Aligning a daily register to a monthly aggregate — In Python
last = monthly["month"].max() print(f"excluding partial period {last}") monthly = monthly[monthly["month"] < last]Speaker notes
Two things to notice, because both are choices. The month a record belongs to comes from the event date, not from when the form was submitted — a late submission still belongs to the month it describes. And the last month in the file is very often partial, so a trend chart that includes it always appears to be falling.Aligning a daily register to a monthly aggregate — In R
monthly <- monthly |> filter(month < max(month))Speaker notes
Drop it or mark it, but never plot it silently next to complete months.What comes next
- You now have a register aggregated to the same grain as the monthly return.
Speaker notes
You now have a register aggregated to the same grain as the monthly return. The next lesson puts the two side by side, works out why they disagree, and turns the difference into a table you can publish rather than an argument you have to win.