Lesson 5 of 8
Unit · The cohort
Three outcomes that have to sum to one
70.4% promoted, 9.8% repeated, 10.2% dropped out, 5.7% finished and 2.9% transferred. Every one of those needs the same denominator, and the transfers are the reason it is not the enrolment count.
The year, as a set of exits
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]
outcomes = cohort["end_of_year_status"].value_counts(normalize=True)
print((outcomes * 100).round(1))
print(f"\ncohort: {len(cohort)} students")
library(dplyr)
enrolment |>
filter(age_years <= 20, school_year == 2023) |>
count(end_of_year_status) |>
mutate(share = n / sum(n))
| Outcome | Students | Share |
|---|---|---|
| Promoted | 910 | 70.4% |
| Dropped out | 132 | 10.2% |
| Repeated | 127 | 9.8% |
| Completed the final grade | 74 | 5.7% |
| Transferred out | 37 | 2.9% |
| Still enrolled at the cut-off | 12 | 0.9% |
The three headline rates are promotion, repetition and dropout, and they only mean anything if they share a denominator. That sounds obvious and it is the step most often skipped.
The denominator is not the enrolment count
Two categories complicate it, and they complicate it in opposite directions.
Transfers are not dropouts. A child who moved to another school is still in school. Counting them as dropouts overstates dropout by nearly three points and — worse — attributes a system success to a school failure.
Transfers are also not successes. They cannot be counted as promoted, because this register does not know whether they enrolled anywhere.
def rates(frame, exclude_transfers):
base = frame[frame["end_of_year_status"] != "transferred-out"] \
if exclude_transfers else frame
return {
"n": len(base),
"promotion": (base["end_of_year_status"]
.isin(["promoted", "completed-final-grade"]).mean()),
"repetition": base["end_of_year_status"].eq("repeated").mean(),
"dropout": base["end_of_year_status"].eq("dropped-out").mean(),
}
for exclude in (False, True):
result = rates(cohort, exclude)
label = "excluding transfers" if exclude else "all students"
print(f"{label:20} n={result['n']} "
f"promotion {result['promotion']:.1%} "
f"repetition {result['repetition']:.1%} "
f"dropout {result['dropout']:.1%}")
cohort |>
filter(end_of_year_status != "transferred-out") |>
summarise(n = n(),
promotion = mean(end_of_year_status %in% c("promoted", "completed-final-grade")),
repetition = mean(end_of_year_status == "repeated"),
dropout = mean(end_of_year_status == "dropped-out"))
This is the CMAM cure-rate denominator from earlier in this module, in a different sector. The convention there was to exclude transfers because the programme cannot claim their outcome; the convention here is the same, for the same reason. Say which you used, and the three rates will sum to something you can defend.
Repetition is a decision, not an event
by_district = clean.groupby("admin2")["repeating"].agg(["mean", "size"])
print((by_district * [100, 1]).round(1))
enrolment |> filter(age_years <= 20) |>
summarise(repeating = mean(repeating), n = n(), .by = admin2)
| District | Repetition rate | n |
|---|---|---|
| Sud | 12.3% | 583 |
| Artibonite | 12.1% | 618 |
| Nord-Ouest | 12.0% | 625 |
| Centre | 7.4% | 624 |
Centre reports repetition nearly five points below every other district. That is either the best-performing district in the region or a recording habit, and the register alone cannot tell you which.
It is a recording habit: Centre flags a share of its genuine repeaters as new entrants. The way to suspect it without being told is that repetition should track over-age, and in Centre it does not — 8.7% repetition against 11.8% elsewhere, while Centre’s over-age share is 40.6% against 35.7%. The district reporting the least repetition has the most over-age children, which is the wrong way round for any explanation except the coding.
centre = clean[(clean["admin2"] == "Centre") & (clean["school_year"] == 2023)]
elsewhere = clean[(clean["admin2"] != "Centre") & (clean["school_year"] == 2023)]
for name, frame in [("Centre", centre), ("elsewhere", elsewhere)]:
over = frame["age_years"] > frame["grade"] + 5
print(f"{name}: repetition {frame['repeating'].mean():.1%}, "
f"over-age {over.mean():.1%}")
# Repetition should predict over-age. Where it does not, suspect the coding.
A rate that is out of line with the indicator it mechanically causes is a question about the office before it is a finding about the schools. That is the same reasoning as the protection course’s closure-reason catch-all, and it is worth having as a reflex.
Dropout by grade, and the grade-6 spike
by_grade = cohort[cohort["grade"].between(1, 6)].groupby("grade").agg(
n=("student_id", "size"),
dropout=("end_of_year_status", lambda s: s.eq("dropped-out").mean()),
repetition=("end_of_year_status", lambda s: s.eq("repeated").mean()),
)
print((by_grade * [1, 100, 100]).round(1))
cohort |> filter(between(grade, 1, 6)) |>
summarise(n = n(),
dropout = mean(end_of_year_status == "dropped-out"),
repetition = mean(end_of_year_status == "repeated"), .by = grade)
| Grade | n | Dropout | Repetition |
|---|---|---|---|
| 1 | 149 | 8.1% | 2.0% |
| 2 | 271 | 8.5% | 7.0% |
| 3 | 332 | 13.0% | 6.6% |
| 4 | 231 | 8.7% | 10.4% |
| 5 | 165 | 8.5% | 19.4% |
| 6 | 110 | 18.2% | 12.7% |
Two things in that table need reading carefully.
Repetition rises with grade and dropout does not. Grade 5 holds back 19.4% of its students while its dropout is 8.5%; grade 6 does the opposite. The two are alternative exits from the same decision point, and reading either alone gets the shape of the problem wrong.
Grade 6 dropout is 18.2% on 110 students. That is the largest rate in the table and the smallest denominator, so before treating it as the priority, put an interval on it — the survey course’s machinery, on a proportion that would move several points if four children had done something else.
Report the exits, then the rates
End of school year 2023, 1,292 students
Promoted 70.4% 910
Dropped out 10.2% 132
Repeated 9.8% 127
Completed the final grade 5.7% 74
Transferred out 2.9% 37 excluded from the rates below
Still enrolled at cut-off 0.9% 12
Rates on 1,255 students, transfers excluded:
Promotion (incl. completion) 78.4%
Repetition 10.1%
Dropout 10.5%
Centre reports repetition at 7.4% against 12.0-12.3% elsewhere while
holding the highest over-age share of the four districts. Treat as a
recording difference pending verification, not as performance.
What comes next
These are one year’s exits. Turning them into “how many children finish primary school” needs a cohort, and the next lesson builds one — along with the reason the number it produces is almost certainly wrong.