cassionData Analysis

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.

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

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.

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.

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.