Lesson 2 of 8
Unit · Enrolment and its denominator
Behind, and getting further behind
Over-age enrolment runs 17.0% in grade 1 and 50.8% in grade 6. It rises because repetition creates it — 94.5% of the children who repeated in 2023 are over-age in 2024 — and because being over-age quadruples the chance of leaving.
The definition, and the range check that has to come first
A student is over-age for their grade when their age exceeds the official age, which in this system is the grade number plus five.
import pandas as pd
enrolment = pd.read_csv("school-enrolment-2024.v1.csv")
print(enrolment["age_years"].describe().round(1))
print(f"ages above 20: {(enrolment['age_years'] > 20).sum()}")
library(dplyr)
enrolment |> summarise(min = min(age_years), max = max(age_years),
impossible = sum(age_years > 20))
Twenty-one rows hold an age above 20 in a primary register. SCH04 records part of its intake in months, so a nine-year-old appears as 112. A mean would absorb them; a range check finds them, and the correction is a division rather than a deletion.
clean = enrolment[enrolment["age_years"] <= 20].copy()
clean["official_age"] = clean["grade"] + 5
clean["over_age"] = clean["age_years"] > clean["official_age"]
enrolment |> filter(age_years <= 20) |>
mutate(official_age = grade + 5, over_age = age_years > official_age)
Do the range check before the flag, not after. An age of 112 is over-age for every grade, so a flag computed on the raw column is technically correct about twenty-one children and wrong about what it means.
It compounds with grade
primary = clean[(clean["school_year"] == 2024) & clean["grade"].between(1, 6)]
by_grade = primary.groupby("grade").agg(
students=("student_id", "size"),
over_age=("over_age", "mean"),
)
print((by_grade * [1, 100]).round(1))
primary |> summarise(n = n(), over_age = mean(over_age), .by = grade)
| Grade | Official age | Students | Over-age |
|---|---|---|---|
| 1 | 6 | 53 | 17.0% |
| 2 | 7 | 153 | 25.5% |
| 3 | 8 | 247 | 29.6% |
| 4 | 9 | 258 | 36.8% |
| 5 | 10 | 209 | 47.8% |
| 6 | 11 | 132 | 50.8% |
Over-age triples between grade 1 and grade 6. Two mechanisms produce that shape and they are not the same problem.
Late entry puts a child above age at grade 1 and keeps them there. It shows up as the 17.0% floor, and the intervention is early registration.
Repetition creates over-age during schooling. It shows as the rise, and the intervention is entirely different.
previous = clean[clean["school_year"] == 2023].set_index("student_id")
current = clean[clean["school_year"] == 2024].copy()
current["last_year"] = current["student_id"].map(previous["end_of_year_status"])
print(current.groupby("last_year")["over_age"].agg(["mean", "size"]).round(3))
enrolment |>
filter(age_years <= 20) |>
select(student_id, school_year, grade, age_years, end_of_year_status) |>
tidyr::pivot_wider(names_from = school_year,
values_from = c(grade, age_years, end_of_year_status))
94.5% of the students who repeated in 2023 are over-age in 2024, against 30.6% of those who were promoted. Repetition does not correlate with over-age; it causes it, with a one-year lag, and the register lets you watch the mechanism rather than infer it.
Note that the comparison has to be across years. Within 2023 a repeater is not yet over-age — they are in their own grade at the normal age, and the extra year appears the following September.
Why it matters: the dropout link
year23 = clean[clean["school_year"] == 2023]
dropout = year23.groupby("over_age")["end_of_year_status"].apply(
lambda s: (s == "dropped-out").mean()
)
counts = year23.groupby("over_age").size()
print(pd.DataFrame({"dropout": (dropout * 100).round(1), "n": counts}))
enrolment |> filter(school_year == 2023) |>
summarise(dropout = mean(end_of_year_status == "dropped-out"),
n = n(), .by = over_age)
| Students | Dropped out | |
|---|---|---|
| Over-age | 478 | 19.5% |
| In-age | 814 | 4.8% |
Being behind quadruples the chance of leaving, and that is what closes the loop: a child repeats, becomes over-age, and is then far likelier to drop out than the repetition was intended to prevent.
So repetition is not a neutral intervention. It is offered as a second chance and it measurably raises the probability of leaving altogether. That finding is available from two columns of one register, and it is the strongest argument this dataset supports.
The disaggregation that does not work
Over-age is unusual among education indicators in that the obvious cuts show very little.
for column in ("sex", "disability_reported", "displacement_status"):
print(primary.groupby(column, dropna=False)["over_age"].agg(
["mean", "size"]).round(3), "\n")
primary |> summarise(over_age = mean(over_age), n = n(), .by = sex)
| Cut | Over-age | n |
|---|---|---|
| Boys | 39.2% | 510 |
| Girls | 33.8% | 542 |
| Disability reported | 33.7% | 89 |
| No disability reported | 36.7% | 963 |
Five points between boys and girls, on 510 and 542 students. That is around the edge of what sampling variation produces, so compute the interval before writing the sentence — the survey course’s machinery, on exactly the kind of gap that gets published as a finding and disappears in the next round.
Disability shows three points the other way on 89 students, which is noise on a denominator that small.
A disaggregation that mostly shows nothing is a result. Over-age here is a system-level phenomenon driven by repetition and late entry, not a group-level inequity — and saying so is more useful than hunting for a subgroup until one turns up.
Report the profile, not the headline
Over-age enrolment, primary, 2024
Overall 36.4% 383 of 1,052 primary enrolees
Grade 1 17.0% the late-entry floor
Grade 6 50.8% after five years of repetition
Repeated in 2023 94.5% over-age in 2024, against 30.6% promoted
Dropout, over-age 19.5% against 4.8% in-age (2023 cohort)
21 ages recorded in months at SCH04, corrected by division.
Sex difference is 39.2% (boys) against 33.8% (girls); report with an
interval or not at all.
The grade profile is the deliverable, because 36.4% overall could mean uniform late entry or accumulating repetition, and those need different programmes.
What comes next
Every child in this lesson is enrolled. Whether they are in the classroom is a different register and a different number — and the next lesson finds two of them, twenty-six points apart, in the same file.