cassionData Analysis

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.

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

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.

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.