Lesson 5 of 8
Unit · The caseload
Thirty-three cases each
Guidance puts an active GBV caseload near 25 cases per worker. One area here averages 33.2 and peaks at 51. It also holds 1.78 case plan reviews per case against 2.45, and loses contact with 38.9% of the cases it closes. Three tables, one cause.
Caseload is a stock, not a flow
A case count is a flow: how many cases opened this month. A caseload is a stock: how many are open right now, and it is the number a caseworker experiences.
Computing it means expanding each case across the months it was open.
import pandas as pd
cases = pd.read_csv("protection-case-management-2024.v1.csv")
CUTOFF = 12
def months_open(row):
opened = int(row["opened_month"][5:7])
closed = int(row["closed_month"][5:7]) if pd.notna(row["closed_month"]) else CUTOFF
return range(opened, max(closed, opened) + 1)
active = [
{"caseworker_id": row["caseworker_id"], "admin2": row["admin2"], "month": month}
for _, row in cases.iterrows()
for month in months_open(row)
]
active = pd.DataFrame(active)
caseload = active.groupby(["caseworker_id", "month"]).size().rename("open_cases")
print(caseload.describe().round(1))
library(dplyr)
cases |>
mutate(opened = as.integer(substr(opened_month, 6, 7)),
closed = coalesce(as.integer(substr(closed_month, 6, 7)), 12L)) |>
rowwise() |>
mutate(month = list(seq(opened, max(closed, opened)))) |>
tidyr::unnest(month) |>
count(caseworker_id, month, name = "open_cases")
This is the person-time expansion from module 4’s first course, applied to a caseworker rather than to a patient. A case open from March to August contributes to six monthly caseloads, and a register read one row at a time never shows it.
The number the guidance is written about
by_area = caseload.reset_index().merge(
cases[["caseworker_id", "admin2"]].drop_duplicates("caseworker_id"),
on="caseworker_id",
)
summary = by_area.groupby("admin2")["open_cases"].agg(["mean", "max"]).round(1)
print(summary.sort_values("mean", ascending=False))
# Mean and peak monthly caseload per area.
| Area | Mean caseload | Peak |
|---|---|---|
| Port-de-Paix | 33.2 | 51 |
| Gonaives | 28.2 | 41 |
| Saint-Marc | 24.7 | 43 |
| Hinche | 20.7 | 44 |
| Mirebalais | 18.7 | 32 |
| Saint-Louis-du-Nord | 14.8 | 26 |
GBV case management guidance puts an active caseload at around 25. Port-de-Paix sits above it all year and peaks at twice it; Saint-Louis-du-Nord sits well below.
Report the mean and the peak. A worker at 51 open cases in one month has, that month, roughly four working hours per case including travel and documentation, and an annual mean of 33 conceals it.
What an overloaded caseload does
The reason caseload has a threshold is that things fail when it is exceeded, and this register lets you watch two of them fail together.
quality = cases.groupby("admin2").agg(
cases=("case_id", "size"),
reviews_per_case=("case_plan_reviews", "mean"),
)
closed = cases[cases["closure_reason"].notna()]
quality["lost_contact"] = closed.groupby("admin2")["closure_reason"].apply(
lambda s: (s == "lost-contact").mean()
)
print(quality.round(2).sort_values("reviews_per_case"))
cases |>
summarise(n = n(), reviews = mean(case_plan_reviews),
lost = mean(closure_reason == "lost-contact", na.rm = TRUE),
.by = admin2)
| Area | Caseload | Reviews per case | Lost contact |
|---|---|---|---|
| Saint-Louis-du-Nord | 14.8 | 1.74 | 26.0% |
| Port-de-Paix | 33.2 | 1.78 | 38.9% |
| Mirebalais | 18.7 | 2.10 | 30.8% |
| Gonaives | 28.2 | 2.31 | 24.7% |
| Saint-Marc | 24.7 | 2.38 | 22.1% |
| Hinche | 20.7 | 2.45 | 20.2% |
Port-de-Paix carries the highest caseload, holds among the fewest case plan reviews, and loses contact with the largest share of the cases it closes. Three different tables produced by one cause, and the caseload table is the one that names the cause.
Note that Saint-Louis-du-Nord also holds few reviews on a low caseload — so reviews per case is not a clean function of caseload, and the honest reading is that Port-de-Paix has a workload problem while Saint-Louis-du-Nord may have a different one. A pattern that fits three areas and not the fourth is still a pattern, and saying which area does not fit is part of reporting it.
The establishment error
areas_per_worker = cases.groupby("caseworker_id")["admin2"].nunique()
print(areas_per_worker[areas_per_worker > 1])
cases |> summarise(areas = n_distinct(admin2), .by = caseworker_id) |>
filter(areas > 1)
One caseworker identifier appears under two areas, because a worker who transferred was re-registered rather than moved.
A caseload computed per worker is right. A caseload computed per worker per area is wrong, because it splits one person’s real workload across two rows and makes both look manageable. Decide which the identifier means before you group by it, and if the register cannot tell you, that is a question for the office rather than an assumption for the analyst.
What caseload is not
It is not a productivity measure. A worker with 40 open cases is not working harder than one with 15; they are more likely to be failing 40 people slowly. Using caseload to rank workers inverts what the indicator is for.
It is not comparable across case types. A child protection case with a case
plan involving a school and a guardian is not equivalent to a one-off legal
referral, and this register’s case_category is what lets you weight them.
mix = cases.groupby(["admin2", "case_category"]).size().unstack(fill_value=0)
print((mix.div(mix.sum(axis=1), axis=0) * 100).round(1))
cases |> count(admin2, case_category) |>
mutate(share = n / sum(n), .by = admin2)
Check the case mix before comparing caseloads. An area with more child protection cases is carrying more work per case, and a raw comparison penalises it.
Report it as a supervision table
Caseload, 2024, 1,108 cases across 17 caseworkers
Area Mean Peak Reviews/case Lost contact
Port-de-Paix 33.2 51 1.78 38.9%
Gonaives 28.2 41 2.31 24.7%
Saint-Marc 24.7 43 2.38 22.1%
Hinche 20.7 44 2.45 20.2%
Mirebalais 18.7 32 2.10 30.8%
Saint-Louis-du-Nord 14.8 26 1.74 26.0%
Guidance: active caseload around 25 cases per worker.
One caseworker identifier appears in two areas after a transfer; caseload
is computed per worker, not per worker per area.
Port-de-Paix is above the guideline all year and shows the pattern that
follows: fewest case plan reviews and most cases closed for lost contact.
Put the guideline in the table. A caseload of 33 means nothing to a reader who does not know what 25 is, and the whole point of the row is the comparison.
What comes next
Cases close, and how long they take is the other number a supervisor is judged on. The next lesson is that measurement, and the 42.6% of cases that have not closed yet and would bias it if they were ignored.