---
title: "Computing FCS, HHS and rCSI from raw components"
subtitle: "Food security survey, 2024 · R"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
---

## What this produces

The same three composite indicators as the Python example, built with dplyr —
including the exclusion rule for incomplete Household Hunger Scale responses,
which is the step most implementations get wrong.

Every dataset on this platform is synthetic. No real household is described.

## Setup

```{r}
#| message: false
library(readr)
library(dplyr)
library(tidyr)

URL <- paste0(
  "https://data-analysis.cassion.dev/datasets/files/",
  "food-security-survey-2024.v1.csv"
)

fs <- read_csv(URL, col_types = cols(
  household_id = col_character(),
  .default     = col_guess()
))

glimpse(fs)
```

## Range-check before you score

Twenty-three records hold a consumption value above seven days, impossible
against a seven-day recall.

```{r}
components <- c(
  "fcs_cereals_tubers", "fcs_pulses", "fcs_vegetables", "fcs_fruit",
  "fcs_meat_fish_eggs", "fcs_dairy", "fcs_oils_fats", "fcs_sugar"
)

fs |>
  summarise(across(all_of(components), list(min = ~min(.x, na.rm = TRUE),
                                            max = ~max(.x, na.rm = TRUE)))) |>
  pivot_longer(everything())
```

```{r}
# Out of range is not a measurement. NA rather than clipping to 7 — clipping
# invents a value the enumerator never recorded.
fs <- fs |>
  mutate(across(all_of(components), ~ if_else(.x >= 0 & .x <= 7, .x, NA_real_)))

cat("blank or out-of-range cells:", sum(is.na(fs[components])), "\n")
```

## The blank that is not a zero

`rowSums(..., na.rm = TRUE)` is the trap. It treats a blank as zero days, scores
the household as eating less than it did, and gives no warning of any kind. The
components most often blank carry the heaviest weights — dairy and meat are 4
each.

Matrix multiplication is used here rather than `rowSums` precisely because it
propagates `NA` by default: an incomplete household gets no score, which is the
correct outcome.

```{r}
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
)

observed <- as.matrix(fs[, names(weights)])
zeroed   <- observed
zeroed[is.na(zeroed)] <- 0

fs <- fs |>
  mutate(
    fcs_complete    = if_all(all_of(components), ~ !is.na(.x)),
    fcs             = as.vector(observed %*% weights),
    fcs_zero_filled = as.vector(zeroed   %*% weights)
  )

fs |>
  summarise(
    incomplete           = sum(!fcs_complete),
    mean_complete        = round(mean(fcs[fcs_complete]), 1),
    mean_zero_filled     = round(mean(fcs_zero_filled[!fcs_complete]), 1)
  )
```

The zero-filled households average about six points below those that answered
fully. That gap is the blanks being counted as days of not eating, not a finding
about their diet.

```{r}
share <- function(scores, poor, borderline) {
  s <- scores[!is.na(scores)]
  tibble(
    households   = length(s),
    poor_pct     = round(100 * mean(s <= poor), 2),
    borderline_pct = round(100 * mean(s > poor & s <= borderline), 2)
  )
}

bind_rows(
  share(fs$fcs, 21, 35)             |> mutate(treatment = "exclude incomplete", thresholds = "21/35"),
  share(fs$fcs_zero_filled, 21, 35) |> mutate(treatment = "zero-fill and keep", thresholds = "21/35"),
  share(fs$fcs, 28, 42)             |> mutate(treatment = "exclude incomplete", thresholds = "28/42"),
  share(fs$fcs_zero_filled, 28, 42) |> mutate(treatment = "zero-fill and keep", thresholds = "28/42")
) |>
  select(thresholds, treatment, households, poor_pct, borderline_pct)
```

At 21/35 the distortion is small. At 28/42 it is not: thirty-two of the
incomplete households are classified as having poor food consumption on scores
that are artificially low, and every one of them would be counted in a caseload.

## Both threshold sets, side by side

```{r}
consumption_group <- function(score, poor, borderline) {
  case_when(
    is.na(score)       ~ NA_character_,
    score <= poor      ~ "poor",
    score <= borderline ~ "borderline",
    TRUE               ~ "acceptable"
  )
}

valid <- fs |> filter(fcs_complete)

bind_rows(
  valid |> count(group = consumption_group(fcs, 21, 35)) |>
    mutate(thresholds = "21/35", pct = round(100 * n / sum(n), 1)),
  valid |> count(group = consumption_group(fcs, 28, 42)) |>
    mutate(thresholds = "28/42", pct = round(100 * n / sum(n), 1))
) |>
  select(thresholds, group, n, pct) |>
  arrange(thresholds, group)
```

Choosing the 28/42 set is a judgement about the food system — it is used where
oil and sugar are consumed near-universally — and it moves the headline from
about 1% poor to about 7%. State which set you used, in the same sentence as the
number.

## The Household Hunger Scale, with its exclusion rule

```{r}
hhs_items <- c(
  "hhs_no_food_in_house", "hhs_sleep_hungry",
  "hhs_day_and_night_without_eating"
)

fs <- fs |>
  mutate(
    hhs_complete = if_all(all_of(hhs_items), ~ !is.na(.x)),
    hhs = if_else(
      hhs_complete,
      rowSums(pick(all_of(hhs_items)), na.rm = FALSE),
      NA_real_
    ),
    hhs_category = cut(
      hhs, c(-1, 1, 3, 6),
      labels = c("little to none", "moderate", "severe")
    )
  )

cat("partial responses excluded:", sum(!fs$hhs_complete), "\n")

fs |>
  filter(hhs_complete) |>
  count(hhs_category) |>
  mutate(pct = round(100 * n / sum(n), 1))
```

`na.rm = FALSE` is doing the work, and the `if_else` on `hhs_complete` makes the
exclusion explicit rather than implicit. Set `na.rm = TRUE` and a household that
answered two of three questions scores as though it answered zero to the third —
which is to say, as food secure.

## The reduced Coping Strategies Index

```{r}
rcsi_weights <- c(
  rcsi_less_preferred_food = 1, rcsi_borrowed_food = 2,
  rcsi_limit_portion_size = 1, rcsi_restrict_adult_consumption = 3,
  rcsi_reduce_meal_numbers = 1
)

fs <- fs |>
  mutate(
    rcsi = as.vector(as.matrix(pick(all_of(names(rcsi_weights)))) %*% rcsi_weights)
  )

summary(fs$rcsi)
```

## They are not proxies for one another

```{r}
valid <- fs |> filter(fcs_complete)
cor(valid$fcs, valid$rcsi) |> round(3)
```

About -0.45. A household can eat monotonously without yet resorting to coping
strategies, and another can be coping heavily while eating a varied diet on
borrowed food. Reporting one as a stand-in for the other loses real information.

## What this does and does not produce

Food consumption evidence used **in** an IPC analysis — not an IPC phase. A phase
is assigned by a technical working group convening several outcome indicators
against contributing factors, and printing "Phase 3" out of an FCS distribution
skips the entire process the classification exists to represent.
