cassionData Analysis

Lesson 6 of 8

Unit · The cohort

A survival rate you should not publish

Chain one year's promotion rates and 19.3% of children reach the final grade. Count repeaters as retained and it is 40.3%. Both come from the same file, and neither is a completion rate.

PythonR105 minSustainable Development Goals (SDG)UNICEF indicator definitionsMultiple Indicator Cluster Survey (MICS)

The calculation everyone reaches for

You have promotion rates by grade for one year. Chain them and you have survival to the final grade — the SDG-style completion measure — without waiting six years.

import pandas as pd

enrolment = pd.read_csv("school-enrolment-2024.v1.csv")
clean = enrolment[enrolment["age_years"] <= 20]
cohort = clean[clean["school_year"] == 2023]

survival, cumulative = {}, 1.0
for grade in range(1, 7):
    grade_rows = cohort[cohort["grade"] == grade]
    promoted = grade_rows["end_of_year_status"].isin(
        ["promoted", "completed-final-grade"]).mean()
    survival[grade] = cumulative
    cumulative *= promoted
    print(f"grade {grade}: promotion {promoted:.1%}, "
          f"survival to here {survival[grade]:.1%}")

print(f"\nsurvival to grade 6 entry: {cumulative:.1%}")
library(dplyr)

cohort |>
  filter(between(grade, 1, 6)) |>
  summarise(promotion = mean(end_of_year_status %in%
              c("promoted", "completed-final-grade")), .by = grade) |>
  arrange(grade) |>
  mutate(survival = cumprod(lag(promotion, default = 1)))
Grade Promotion Cumulative survival
1 89.3% 100.0%
2 83.0% 89.3%
3 71.7% 74.1%
4 76.2% 53.1%
5 70.9% 40.5%
6 67.3% 28.7%
— — 19.3%

19.3% of children entering grade 1 reach the end of primary school. It is a striking number, it is what the method produces, and it should not leave your screen.

Why it is wrong

This is a reconstructed cohort: it assumes that this year’s grade-5 promotion rate is what today’s grade-1 children will face in four years. Four things break that, and here they break it in the same direction.

Repeaters are counted as failures. A child who repeats grade 2 is not out of school — they are in grade 2 again. The chain treats non-promotion as exit, so 10.1% repetition compounds six times into a huge apparent loss.

promotion_or_repeat = cohort[cohort["grade"].between(1, 6)].groupby("grade")[
    "end_of_year_status"].apply(
    lambda s: s.isin(["promoted", "completed-final-grade", "repeated"]).mean())

still_enrolled = 1.0
for grade, rate in promotion_or_repeat.items():
    still_enrolled *= rate
print(f"survival counting repeaters as retained: {still_enrolled:.1%}")
# Retention, not promotion, is what a survival rate needs.

Transfers are counted as failures too. 2.9% left for another school and the chain reads them as leaving education.

A single year is assumed to be six. The grade-6 promotion rate observed in 2023 belongs to children who entered in 2018, under different conditions.

The grade sizes are not a cohort. Grade 1 has 149 students and grade 6 has 110, and some of that is genuine attrition while some is a changing population — a larger birth cohort arriving at grade 1 makes the pyramid look like dropout.

What to compute instead

Retention, not promotion, if you must use a single year. Count children who are still in school — promoted or repeating — as retained.

def chained(statuses):
    rate, product = {}, 1.0
    for grade in range(1, 7):
        rows = cohort[cohort["grade"] == grade]["end_of_year_status"]
        product *= rows.isin(statuses).mean()
        rate[grade] = product
    return product

promotion_only = chained(["promoted", "completed-final-grade"])
retained = chained(["promoted", "completed-final-grade", "repeated"])
print(f"chained promotion: {promotion_only:.1%}")
print(f"chained retention: {retained:.1%}")
# Same chain, different numerator. The gap is repetition compounding.

19.3% against 40.3%. More than twenty points of the apparent loss was repetition being read as exit, and the retention figure is the one closer to something meaningful — though still not a completion rate.

Follow real students where you can. This register has two years and the same identifiers, so a one-year transition is directly observable rather than assumed.

years = clean.pivot_table(index="student_id", columns="school_year",
                          values="grade", aggfunc="first")
both = years.dropna()
print(f"students observed in both years: {len(both)}")
print(f"  advanced a grade: {(both[2024] > both[2023]).mean():.1%}")
print(f"  same grade:       {(both[2024] == both[2023]).mean():.1%}")

1,103 students appear in both years: 88.5% advanced a grade and 11.5% are in the same grade twice. That is a measured transition, not an assumed one, and it is the number to put in a report.

enrolment |>
  select(student_id, school_year, grade) |>
  tidyr::pivot_wider(names_from = school_year, values_from = grade) |>
  filter(!is.na(`2023`), !is.na(`2024`))

A one-year transition observed on real students beats a six-year chain assembled from one year’s rates, even though it answers a smaller question. The smaller question is one you can defend.

Who leaves

The disaggregation that does work, unlike the over-age cuts in lesson 2.

year23 = cohort.assign(
    over_age=cohort["age_years"] > cohort["grade"] + 5,
    dropped=cohort["end_of_year_status"].eq("dropped-out"),
)
for column in ("over_age", "disability_reported", "displacement_status", "sex"):
    table = year23.groupby(column, dropna=False)["dropped"].agg(["mean", "size"])
    print((table * [100, 1]).round(1), "\n")
cohort |>
  mutate(dropped = end_of_year_status == "dropped-out") |>
  summarise(dropout = mean(dropped), n = n(), .by = displacement_status)
Cut Dropout n
Over-age 19.5% 478
In-age 4.8% 814
Returnee 19.7% 71
Disability reported 17.9% 112
Internally displaced 14.7% 163
Host community 11.6% 95
Resident 8.6% 940
Boys 10.4% 634
Girls 10.0% 658

Over-age is the strongest predictor and the largest group. Disability and displacement roughly double the rate on smaller denominators.

Sex shows nothing — 10.4% against 10.0% on 634 and 658 students. That is a result worth stating, because a gender gap in dropout is the finding an education programme most expects to see and this register does not contain one.

The missing data is not missing at random

print(year23["displacement_status"].isna().sum(), "students with no status")
print(year23.loc[year23["displacement_status"].isna(), "dropped"].mean())
cohort |> filter(is.na(displacement_status)) |> nrow()

Displacement status is blank for 42 students across the register, 23 of them in the 2023 cohort. The blanks are drawn entirely from displaced and returnee households — it is the field an enumerator skips when a family has just arrived.

Be precise about what that does and does not bias. The blanks are removed roughly at random within those two categories, so:

  • the dropout rate for internally displaced and returnee students is unbiased — 14.7% and 19.7% are the right numbers for those groups;
  • their counts and population share are understated by about a tenth. 234 students are recorded as displaced or returnee in 2023 and the true figure is nearer 260.

So “displaced children drop out at 14.7%” is defensible and “displaced children are 18% of enrolment” is not. A missing-value pattern can bias a count without biasing a rate, and which one your sentence relies on decides whether the pattern matters.

The observed dropout among the blanks themselves is 8.7% on 23 students, which is too few to read anything into and should not be reported as a category.

Report it as a risk profile

Dropout, 2023 cohort, 1,122 students

  Overall                      10.2%

  Over-age for grade           19.5%   478 students
  In-age                        4.8%   814

  Returnee                     19.7%    71 students
  Disability reported          17.9%   112
  Internally displaced         14.7%   163
  Resident                      8.6%   940

  No meaningful difference by sex: 10.4% boys, 10.0% girls.

  42 students have no displacement status, all drawn from displaced and
  returnee households. The rates above are unbiased for those groups; their
  counts are understated by about a tenth.

  Not reported: survival to the final grade. A reconstructed cohort from a
  single year gives 19.3% on promotion and 40.3% on retention, neither of
  which is a completion rate. A true cohort needs six years of the register
  or a household survey. The measured one-year transition is 88.5%.

“Not reported, and why” is the most useful line in that block. Somebody will ask for a completion rate, and the answer is a real one: not from this, from a household survey, and here is what this can tell you instead.

What comes next

Enrolment, attendance and retention describe whether children are in school. None of them says whether they are learning, and the last unit is the instrument that does — where two rounds turn out not to be comparable.

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.