cassionData Analysis

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.

PythonR120 minCore Humanitarian Standard (CHS)UNICEF indicator definitionsSphere Standards

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.

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.