cassionData Analysis

Lesson 2 of 8

Unit · What not to collect

Nineteen cells of one

Cross-tabulate area, category, age band and sex and this anonymous dataset produces 200 cells, 85 of them holding four cases or fewer and 19 holding exactly one. In a GBV dataset a cell of one is a person, and the table cannot leave the building.

PythonR120 minCore Humanitarian Standard (CHS)UNICEF indicator definitions

The dataset is anonymous and the table is not

Every direct identifier is gone. That protects against reading a row and knowing who it is. It does not protect against counting.

import pandas as pd

referrals = pd.read_csv("protection-referrals-2024.v1.csv")

cells = referrals.groupby(
    ["admin2", "case_category", "age_band", "sex"], dropna=False
).size()

print(f"{len(cells)} cells")
print(f"  holding 1 case:  {(cells == 1).sum()}")
print(f"  holding 4 or fewer: {(cells <= 4).sum()}")
library(dplyr)

referrals |>
  count(admin2, case_category, age_band, sex) |>
  summarise(cells = n(), of_one = sum(n == 1), of_four_or_fewer = sum(n <= 4))

200 cells, 85 of them with four cases or fewer, 19 with exactly one.

A cell of one says: in this district, there was one GBV case involving a girl aged 0–11 this year. Anyone who works in that district may know exactly who that is. The dataset never named her; the table did.

Why four and not one

The instinct is to suppress only the cells of one. That is not enough, for two reasons.

A cell of two identifies both people to each other, and to anyone who knows one of them.

Differencing recovers suppressed cells. If a row total is published and every cell in it but one is published, the missing one is arithmetic.

by_area = referrals.groupby(["admin2", "case_category"]).size().unstack()
print(by_area)
print("\nIf one cell is suppressed and the row total is printed,")
print("the suppressed cell is the total minus the others.")
referrals |> count(admin2, case_category) |>
  tidyr::pivot_wider(names_from = case_category, values_from = n)

So a suppression rule needs a threshold and a secondary suppression: suppress small cells, then suppress enough additional cells that the small ones cannot be recovered by subtraction. A threshold of five is the common floor in this sector; some agencies use ten.

A rule, written as code

THRESHOLD = 5

def suppress(counts, threshold=THRESHOLD):
    """Primary suppression, then a secondary pass so rows cannot be differenced."""
    table = counts.copy()
    small = table < threshold
    table = table.mask(small, other=pd.NA)

    # Secondary: if a row has exactly one suppressed cell, the row total gives
    # it away. Suppress the next smallest cell in that row as well.
    for index, row in table.iterrows():
        if row.isna().sum() == 1:
            remaining = counts.loc[index][~small.loc[index]]
            if len(remaining):
                table.loc[index, remaining.idxmin()] = pd.NA
    return table

print(suppress(by_area))
suppress <- function(x, threshold = 5) ifelse(x < threshold, NA, x)
# Plus the secondary pass: a row with exactly one NA is not suppressed at all.

Write the rule as a function and apply it to every table. A rule applied by eye is applied inconsistently, and the one table it is forgotten on is the one that gets forwarded.

What to do instead of publishing the cell

Suppression is the last resort. Three better answers come first, and each keeps the information the requester actually wanted.

Aggregate up. Drop one dimension. Area by category has no cell under five; area by category by age by sex has eighty-five.

coarse = referrals.groupby(["admin2", "case_category"]).size()
print(f"cells under 5: {(coarse < 5).sum()} of {len(coarse)}")
referrals |> count(admin2, case_category) |> summarise(small = sum(n < 5))

Report the rate, not the count, on a denominator large enough to carry it. “Completion is 31% for cases reporting a disability” is publishable; “3 of 11 cases in Mirebalais” is not.

Answer the question rather than supplying the table. A requester asking for area by age by sex usually wants to know whether services reach children. That is one number on a large denominator, and it is publishable.

The equity finding survives the rule

The worry is that suppression destroys the analysis. Test it.

def completion(frame):
    consenting = frame[frame["consent_to_refer"]]
    reached = consenting[consenting["referral_accepted"] &
                         consenting["days_to_first_service"].notna()]
    return len(reached), len(consenting), len(reached) / len(consenting)

disability = referrals["disability_reported"].isin([True, "true", "Yes"])
print("reported:    %d/%d = %.1f%%" % completion(referrals[disability]))
print("not reported: %d/%d = %.1f%%" % completion(referrals[~disability]))
referrals |>
  filter(consent_to_refer) |>
  summarise(reached = sum(referral_accepted & !is.na(days_to_first_service)),
            n = n(), .by = disability_reported) |>
  mutate(completion = reached / n)

26.7% against 46.2%, on denominators of 202 and 1,436. The nineteen-point equity gap — the most important finding in this dataset — is computed on denominators far above any suppression threshold and is entirely publishable.

The analyses that fail the disclosure test are almost never the ones that matter. A four-way disaggregation of 1,850 cases is rarely answering a question anybody asked; it is usually the result of putting every categorical variable into a groupby and seeing what comes out.

The request that has to be refused

Some do have to be refused, and it is worth rehearsing the sentence.

Request: case counts by commune, month and incident category.
Response: declined.

  Commune-by-month cells hold zero to three cases. At that resolution a
  count identifies individuals to anyone working in the area, and the
  dataset cannot be re-identified back but a person can be.

  What I can provide instead:
    - the same counts at district by quarter, no cell below five
    - the completion rate and time to service for each commune, on
      annual denominators
    - a written answer to the question the request is for, if you tell me
      what decision it feeds

  If the requirement is genuinely commune-level operational planning, the
  right route is a data sharing agreement with the case management agency,
  not an extract.

Offer three alternatives and ask what decision the request feeds. A refusal with no alternative gets escalated and overturned by someone who has not read this lesson; a refusal with three alternatives usually ends with one of them being accepted.

What comes next

The rules are established. The next unit is the analysis itself — starting with the gate at the front of the pathway that decides who is in the denominator at all, and it is not a data quality decision.

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.