cassionData Analysis

Back to the lessonLesson 1 of 8Compute it before you believe it

One score, two thresholds, eight times the answer

The same deck as the downloads, rendered as a page. Start the slideshow to present it full screen — arrow keys or a click advance one slide, Escape leaves.

Slides · PDFSlides · PowerPoint

  1. Slide 1 / 24

    What this lesson covers

    • The score, from its parts
    • Range-check first
    • A blank is not a zero
    • The two threshold sets
    • What the score is not
    • Report it with its rules
    • What comes next
    Speaker notes
    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.
  2. Slide 2 / 24

    The score, from its parts — In Python

    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,
    }
    Speaker notes
    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.
  3. Slide 3 / 24

    The score, from its parts — In R

    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)
  4. Slide 4 / 24

    The score, from its parts

    • Two things have to happen before the multiplication — and both are skipped routinely
    Speaker notes
    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.
  5. Slide 5 / 24

    Range-check first — In Python

    groups = list(WEIGHTS)
    print(survey[groups].max())
    print(f"values above 7: {(survey[groups] > 7).sum().sum()}")
  6. Slide 6 / 24

    Range-check first — In R

    survey |> summarise(across(all_of(names(weights)), max, .names = "{.col}"))
  7. Slide 7 / 24

    Range-check first

    • Twenty-three cells hold a value above seven days — against a seven-day recall
    Speaker notes
    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.
  8. Slide 8 / 24

    A blank is not a zero — In Python

    blanks = survey[groups].isna().sum()
    print(blanks[blanks > 0])
    print(f"households with any blank: {survey[groups].isna().any(axis=1).sum()}")
  9. Slide 9 / 24

    A blank is not a zero — In R

    survey |> summarise(across(all_of(names(weights)), ~ sum(is.na(.x))))
  10. Slide 10 / 24

    A blank is not a zero

    • 127 cells across dairy, fruit and meat are blank, in 123 households — Those are the three highest-weighted groups after…
    Speaker notes
    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.
  11. Slide 11 / 24

    A blank is not a zero — In Python

    # 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)}")
  12. Slide 12 / 24

    A blank is not a zero — In R

    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))
  13. Slide 13 / 24

    A blank is not a zero

    • 1,989 households of 2,112 have a complete consumption module — Report that number
    Speaker notes
    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.
  14. Slide 14 / 24

    The two threshold sets — In Python

    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())
  15. Slide 15 / 24

    The two threshold sets — In R

    classify <- function(x, poor, borderline) {
      cut(x, c(-1, poor, borderline, Inf), labels = c("poor", "borderline", "acceptable"))
    }
  16. Slide 16 / 24

    The two threshold sets

    ThresholdsPoorBorderlineAcceptable
    21 / 350.9%22.7%76.4%
    28 / 427.3%38.5%54.1%
  17. Slide 17 / 24

    The two threshold sets

    • The poor-consumption headline is eight times larger on one set than the other — and both are the published standard
    Speaker notes
    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.
  18. Slide 18 / 24

    The two threshold sets — In Python

    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")
  19. Slide 19 / 24

    The two threshold sets — In R

    survey |> summarise(oil = mean(fcs_oils_fats), sugar = mean(fcs_sugar))
  20. Slide 20 / 24

    The two threshold sets

    • Choose on the evidence, state the choice, and never change it between rounds — A programme that reports 0.9% one year…
    Speaker notes
    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.
  21. Slide 21 / 24

    What the score is not

    • It is not a measure of quantity — A household eating small portions of eight food groups scores well
    • It is not comparable across contexts with different diets — The weights are fixed globally and the food groups are not…
    • It is not an IPC phase — It is one outcome indicator feeding one of several evidence rows, and the last unit of this…
    Speaker notes
    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.
  22. Slide 22 / 24

    Report it with its rules — Example

    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.
    Speaker notes
    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.
  23. Slide 23 / 24

    What comes next

    • Food consumption is what a household ate.
    Speaker notes
    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.
  24. Slide 24 / 24

    Where this goes next

    Read the full lesson, with runnable code Back to the lesson