cassionData Analysis

Lesson 1 of 8

Unit · Compute it before you believe it

One score, two thresholds, eight times the answer

On the 21/35 thresholds 0.9% of these households have poor food consumption. On the 28/42 thresholds 7.3% do. Both are correct applications of a published standard, and a report that does not say which it used is not.

PythonR135 minIntegrated Food Security Phase Classification (IPC)Sphere Standards

The score, from its parts

The Food Consumption Score asks how many of the last seven days a household ate from each of eight food groups, and weights them by nutritional density.

import pandas as pd

survey = pd.read_csv("food-security-survey-2024.v1.csv")

WEIGHTS = {
    "fcs_cereals_tubers": 2.0,
    "fcs_pulses": 3.0,
    "fcs_vegetables": 1.0,
    "fcs_fruit": 1.0,
    "fcs_meat_fish_eggs": 4.0,
    "fcs_dairy": 4.0,
    "fcs_oils_fats": 0.5,
    "fcs_sugar": 0.5,
}
library(dplyr)

weights <- c(fcs_cereals_tubers = 2, fcs_pulses = 3, fcs_vegetables = 1,
             fcs_fruit = 1, fcs_meat_fish_eggs = 4, fcs_dairy = 4,
             fcs_oils_fats = 0.5, fcs_sugar = 0.5)

Meat and dairy carry four points a day and sugar carries half, because the score is a proxy for dietary quality rather than for quantity. A household eating cereals and oil every day scores 17.5; one eating meat twice a week scores 8 for those two days alone.

Two things have to happen before the multiplication, and both are skipped routinely.

Range-check first

groups = list(WEIGHTS)
print(survey[groups].max())
print(f"values above 7: {(survey[groups] > 7).sum().sum()}")
survey |> summarise(across(all_of(names(weights)), max, .names = "{.col}"))

Twenty-three cells hold a value above seven days, against a seven-day recall. They are impossible, and a score computed without checking inherits them: the highest FCS in this file is 114.5 uncorrected against 105.5 with the values capped.

The correction is a judgement. Capping at seven assumes a keying error in the last digit; blanking assumes the answer is unknown. Say which you did, and notice that capping is the more conservative choice here because it keeps the household in the denominator.

A blank is not a zero

blanks = survey[groups].isna().sum()
print(blanks[blanks > 0])
print(f"households with any blank: {survey[groups].isna().any(axis=1).sum()}")
survey |> summarise(across(all_of(names(weights)), ~ sum(is.na(.x))))

127 cells across dairy, fruit and meat are blank, in 123 households. Those are the three highest-weighted groups after pulses, which is not a coincidence — they are the questions an enumerator skips when the answer is obviously none and the form does not force a response.

Treating a blank as zero days scores a household as eating less than it did. sum() in pandas does exactly that by default, and sum(na.rm = TRUE) in R does it on request.

# The silent one: NaN treated as zero.
naive = sum(survey[group].fillna(0) * weight for group, weight in WEIGHTS.items())

# The honest one: a household missing any group has no score.
capped = survey[groups].clip(upper=7)
fcs = sum(capped[group] * weight for group, weight in WEIGHTS.items())
fcs = fcs.where(capped.notna().all(axis=1))

print(f"analysable: {fcs.notna().sum()} of {len(survey)}")
survey |>
  mutate(across(all_of(names(weights)), ~ pmin(.x, 7))) |>
  rowwise() |>
  mutate(fcs = if (anyNA(c_across(all_of(names(weights))))) NA_real_
               else sum(c_across(all_of(names(weights))) * weights))

1,989 households of 2,112 have a complete consumption module. Report that number. A prevalence computed on 1,989 and printed beside a demographic figure computed on 2,112 invites a subtraction that does not mean anything.

The two threshold sets

for poor, borderline in [(21, 35), (28, 42)]:
    bands = pd.cut(fcs, [-1, poor, borderline, 200],
                   labels=["poor", "borderline", "acceptable"])
    print(f"{poor}/{borderline}: ",
          (bands.value_counts(normalize=True) * 100).round(1).to_dict())
classify <- function(x, poor, borderline) {
  cut(x, c(-1, poor, borderline, Inf), labels = c("poor", "borderline", "acceptable"))
}
Thresholds Poor Borderline Acceptable
21 / 35 0.9% 22.7% 76.4%
28 / 42 7.3% 38.5% 54.1%

The poor-consumption headline is eight times larger on one set than the other, and both are the published standard. The 28/42 set exists for contexts where oil and sugar are consumed near-universally: those two groups add 7 points to almost every household, which shifts the whole distribution right and makes the lower cut-offs too generous.

Median FCS here is 43.5, and oil is eaten a mean 4.7 days a week and sugar 3.8 — frequent enough that the two groups add about 4 points to a typical household before any nutrient-dense food is counted.

staples = survey[["fcs_oils_fats", "fcs_sugar"]].mean()
print(f"oil eaten {staples['fcs_oils_fats']:.1f} days a week on average")
print(f"sugar eaten {staples['fcs_sugar']:.1f} days")
print("→ 28/42 is the defensible set here; say so in the methodology note")
survey |> summarise(oil = mean(fcs_oils_fats), sugar = mean(fcs_sugar))

Choose on the evidence, state the choice, and never change it between rounds. A programme that reports 0.9% one year and 7.3% the next because someone switched threshold sets has reported a fourfold deterioration that did not happen.

What the score is not

Three things the FCS is regularly asked to do and cannot.

It is not a measure of quantity. A household eating small portions of eight food groups scores well. The score is about dietary diversity and frequency, and Sphere’s kilocalorie standards are a different instrument.

It is not comparable across contexts with different diets. The weights are fixed globally and the food groups are not eaten in the same proportions everywhere, which is exactly why two threshold sets exist.

It is not an IPC phase. It is one outcome indicator feeding one of several evidence rows, and the last unit of this course is about the difference.

Report it with its rules

Food consumption, lean season 2024

  Analysable                  1,989 of 2,112 households (94.2%)
  Poor consumption               7.3%   146 households
  Borderline                    38.5%   766
  Acceptable                    54.1%  1,077

  FCS computed on the 28/42 threshold set: oil is eaten a mean 4.7 days
  a week and sugar 3.8, so the standard 21/35 cut-offs would classify
  0.9% as poor and understate the caseload.
  23 impossible values capped at 7 days; 123 households excluded for an
  incomplete consumption module.

The thresholds named, the exclusions counted, and the reason for the choice in one sentence. That paragraph is what makes the 7.3% auditable, and it is three lines longer than the version most reports carry.

What comes next

Food consumption is what a household ate. The next lesson is what it went without and what it did to avoid going without — two more instruments on the same households, each with an exclusion rule of its own.

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.