Lesson 6 of 8
Unit · The caseload
The cases that have not closed are the long ones
Median time to closure is four months on the 636 cases that closed. 472 more are still open, 140 of them already past four months, and child protection is 58% censored against general protection's 32% — so the same statistic means different things in different columns.
The obvious calculation is biased
import pandas as pd
cases = pd.read_csv("protection-case-management-2024.v1.csv")
opened = cases["opened_month"].str[5:].astype(int)
closed = cases["closed_month"].str[5:].astype("Int64")
duration = closed - opened
print(f"closed cases: {duration.notna().sum()}")
print(f"median months to closure: {duration.median()}")
print(f"still open: {duration.isna().sum()} ({duration.isna().mean():.1%})")
library(dplyr)
cases |>
mutate(opened = as.integer(substr(opened_month, 6, 7)),
closed = as.integer(substr(closed_month, 6, 7)),
duration = closed - opened) |>
summarise(closed = sum(!is.na(duration)),
median = median(duration, na.rm = TRUE),
open = sum(is.na(duration)))
Four months, on 636 of 1,108 cases. The other 472 are still open at the December cut-off, and they are not missing — they are censored.
The bias has a direction and you can see it directly.
still_open = 12 - opened[duration.isna()] + 1
print(f"open cases already past 4 months: {(still_open > 4).sum()}")
print(f"longest open: {still_open.max()} months and counting")
cases |> filter(is.na(closed_month)) |>
mutate(so_far = 12 - as.integer(substr(opened_month, 6, 7)) + 1) |>
summarise(past_four = sum(so_far > 4), longest = max(so_far))
140 of the open cases have already been open longer than the median closed case, and one has been open eleven months. Every one of them will close at a duration above four months, or never. Dropping them makes the answer smaller than the truth, systematically.
Fix the impossible durations first
impossible = duration.notna() & (duration <= 0)
print(f"cases closing on or before the month they opened: {impossible.sum()}")
print(cases.loc[impossible, ["opened_month", "closed_month"]])
cases |> filter(!is.na(closed_month),
closed_month <= opened_month) |> nrow()
Seven cases close in a month earlier than they opened. The contradiction is
invisible in either column alone and produces negative durations that a median()
absorbs without complaint.
Decide and record: exclude them, or treat the closing month as a keying error and set it to the opening month. Seven cases will not move the median; the discipline of finding and declaring them is what will not be there next time if you skip it.
Three honest ways to report it
Report the median with the censoring stated. The cheapest correct option.
Median time to closure: 4 months (636 closed cases).
472 cases (42.6%) were still open at the cut-off and are excluded; 140 of
them have already been open longer than 4 months, so the true median is
higher.
Report a completion-by-month curve. What share of cases opened in month m had closed within k months, computed only on cases with at least k months of follow-up. This is the cohort approach and it uses the censored cases correctly for as long as they were observed.
def closed_within(k):
eligible = cases[opened <= 12 - k]
dur = (eligible["closed_month"].str[5:].astype("Int64")
- eligible["opened_month"].str[5:].astype(int))
return (dur <= k).sum() / len(eligible), len(eligible)
for k in (3, 6, 9):
share, n = closed_within(k)
print(f"closed within {k} months: {share:.1%} (n={n})")
# For each k, restrict to cases with k months of possible follow-up.
Report time to closure only for a cohort with full follow-up. Cases opened in January have eleven months of observation; cases opened in November have one. Restricting to the early cohort answers a narrower question honestly.
All three are defensible and the first is not enough on its own. A median with no censoring statement beside it is the version that gets quoted.
Censoring is not evenly spread
by_category = pd.DataFrame({
"closed": duration.notna().groupby(cases["case_category"]).sum(),
"open": duration.isna().groupby(cases["case_category"]).sum(),
})
by_category["censored"] = by_category["open"] / by_category.sum(axis=1)
print(by_category.round(3))
cases |> summarise(closed = sum(!is.na(closed_month)),
open = sum(is.na(closed_month)), .by = case_category) |>
mutate(censored = open / (open + closed))
| Category | Closed | Open | Censored | Median of the closed |
|---|---|---|---|---|
| Child protection | 131 | 179 | 58% | 5 months |
| GBV | 255 | 173 | 40% | 4 months |
| General protection | 250 | 120 | 32% | 4 months |
Child protection is 58% censored and general protection 32%. So the two medians in that table are computed on very different fractions of their cohorts, and the gap between five months and four months is an understatement of the real gap.
Uneven censoring makes a comparison of medians misleading in a specific direction: the group with more censoring is the one whose median is most understated, which is the group already looking worse.
The closure reason is a judgement
print(cases["closure_reason"].value_counts(normalize=True).round(3))
cases |> filter(!is.na(closure_reason)) |> count(closure_reason) |>
mutate(share = n / sum(n))
| Reason | Share of closures |
|---|---|
| Case plan objectives met | 29.1% |
| Lost contact | 27.2% |
| Survivor withdrew | 13.1% |
| Closed administratively | 11.3% |
| Relocated | 9.7% |
| Transferred to another agency | 9.6% |
Only 29.1% of closures are a case plan completed. That is the headline, and it is the number a supervision conversation starts from.
But look at how the reasons distribute by area before believing any of it.
by_area = pd.crosstab(cases["admin2"], cases["closure_reason"], normalize="index")
print((by_area[["closed-administratively", "lost-contact"]] * 100).round(1))
cases |> filter(!is.na(closure_reason)) |>
count(admin2, closure_reason) |> mutate(share = n / sum(n), .by = admin2)
Hinche files 36.2% of its closures as closed-administratively against 3.8% to
9.8% everywhere else, and its lost-contact rate reads 20.2% against up to 38.9%.
The catch-all is absorbing the reason a supervisor needs.
A closure reason is a caseworker’s judgement, not an observation. So a distribution that differs sharply between offices is a question about the offices before it is a question about the cases — and the fix is a coding conversation, not a statistical adjustment.
Report it whole
Case closure, 1,108 cases
Closed by the cut-off 636 57.4%
Still open 472 42.6% censored, not missing
Median months to closure 4 of closed cases only
open cases already past 4 months 140 so the true median is higher
Censoring by category: child protection 58%, GBV 40%, general 32%.
Medians are not comparable across those columns.
Closure reasons (of 636 closures)
Case plan objectives met 29.1%
Lost contact 27.2%
Survivor withdrew 13.1%
Closed administratively 11.3% 36.2% in Hinche, 3.8-9.8% elsewhere
Relocated 9.7%
Transferred 9.6%
7 cases record a closing month before their opening month and are excluded.
What comes next
Everything in this unit is about cases already in the system. The last unit asks what the number of cases means at all — and why an area whose case count doubled is usually the area where something went right.