cassionData Analysis

Lesson 8 of 8

Unit · Reshaping and repeating

A function another officer can run

Turning a script into functions with arguments and stopifnot(), the tidy evaluation you need to pass a column name, and the command-line script that runs on next quarter's export unedited.

R90 min

The handover test

An analysis is finished when someone else can run it on next quarter’s export without editing the code. Not “without much editing” — without editing.

That standard rules out the three habits every R script accumulates: a path written for one machine, a threshold changed by hand between runs, and a step that only works if you already ran the lines above it in the right order.

From script to function

The cleaning from the earlier lessons, as one function:

# R/clean_register.R

clean_register <- function(muac, gam_mm = 125, sam_mm = 115) {
  stopifnot(is.data.frame(muac))
  required <- c("child_id", "commune", "muac_mm", "oedema")
  missing <- setdiff(required, names(muac))
  if (length(missing) > 0) {
    stop("Input is missing columns: ", paste(missing, collapse = ", "))
  }

  muac |>
    dplyr::mutate(
      # A MUAC below 40 is centimetres in a millimetre field. Applied only
      # below a threshold no plausible measurement reaches.
      muac_mm = dplyr::if_else(muac_mm < 40, muac_mm * 10L, muac_mm),
      muac_mm = dplyr::if_else(dplyr::between(muac_mm, 80L, 220L), muac_mm, NA_integer_),
      oedema  = dplyr::recode(
        tolower(trimws(oedema)),
        "true" = TRUE, "y" = TRUE, "yes" = TRUE,
        "false" = FALSE, "n" = FALSE, "no" = FALSE,
        .default = NA
      ),
      assessed = !is.na(muac_mm) | !is.na(oedema),
      gam = assessed & (muac_mm < gam_mm | oedema),
      sam = assessed & (muac_mm < sam_mm | oedema)
    )
}

Four things about that shape are deliberate.

It takes a data frame and returns one. No file reading, no writing, no <<-. That is what makes it testable and what lets a notebook and a script share it.

The thresholds are arguments with defaults. A threshold that is only ever changed by editing a line will eventually be changed and not changed back. One that is an argument is visible in the call that produced the output.

It validates its input. stop() with a message naming the missing columns beats a NULL propagating into a mutate and surfacing as an unrelated error two functions later.

Nothing is grouped on the way out. A grouped frame escaping a function is the bug from the grouping lesson, arriving from a new direction.

stopifnot() for what cannot happen

stopifnot(
  "every row needs an identifier" = !any(is.na(muac$child_id)),
  "MUAC must be plausible after cleaning" =
    all(is.na(muac$muac_mm) | dplyr::between(muac$muac_mm, 80, 220))
)

Named expressions became the error message, which is the difference between a useful failure and Error: ... is not TRUE.

Use stop() for “this input is not what I was promised” and stopifnot() for “this cannot happen unless the code is wrong”. The distinction matters to whoever reads the failure at seven in the morning.

Passing a column name: tidy evaluation

This is the one piece of R that surprises people coming from Python, and it has exactly one thing to learn.

# Does not work.
rate_by <- function(df, group_col) {
  df |> dplyr::summarise(n = dplyr::n(), .by = group_col)
}

rate_by(muac, commune)
#> Error: object 'commune' not found

dplyr verbs evaluate their arguments inside the data frame. Your function’s group_col is a variable in the function, not a column, so dplyr looks for a column called group_col and fails.

The fix is one operator:

rate_by <- function(df, group_col) {
  df |> dplyr::summarise(n = dplyr::n(), .by = {{ group_col }})
}

rate_by(muac, commune)

{{ }} — “embrace” — means take what the caller wrote and evaluate it in the data. It is the answer for almost every function you will write around dplyr.

For a column name arriving as a string, which is what a command-line argument gives you:

rate_by_name <- function(df, group_col) {
  df |> dplyr::summarise(n = dplyr::n(), .by = dplyr::all_of(group_col))
}

rate_by_name(muac, "commune")

all_of() errors if the column does not exist; any_of() silently skips it. For a script whose output is reported, all_of() is the one you want.

To name an output column from an argument:

count_as <- function(df, group_col, out_name) {
  df |> dplyr::summarise("{out_name}" := dplyr::n(), .by = {{ group_col }})
}

The "{...}" := form is glue syntax inside a tidyverse verb. It is worth recognising; you will need it rarely.

The indicator function

indicator_table <- function(muac, gam_mm = 125, sam_mm = 115) {
  clean_register(muac, gam_mm, sam_mm) |>
    dplyr::summarise(
      screened  = dplyr::n(),
      assessed  = sum(assessed, na.rm = TRUE),
      gam_cases = sum(gam, na.rm = TRUE),
      sam_cases = sum(sam, na.rm = TRUE),
      .by = commune
    ) |>
    dplyr::mutate(
      assessment_rate = round(assessed / screened, 3),
      gam_rate = round(gam_cases / assessed, 3),
      sam_rate = round(sam_cases / assessed, 3)
    ) |>
    dplyr::arrange(dplyr::desc(gam_rate))
}

Note what it does not do: it does not drop communes with a low assessment rate. Dropping one would change the district denominator and nothing in the output would say so. Flagging is the caller’s job, and the caller can see the rate.

Testing the part that computes

Once the computation is a function taking a frame, a test is three lines:

# tests/testthat/test-indicator-table.R
test_that("the denominator excludes unmeasured children", {
  muac <- tibble::tibble(
    child_id = c("a", "b", "c"),
    commune  = "X",
    muac_mm  = c(110L, 130L, NA_integer_),
    oedema   = c("false", "false", NA_character_)
  )

  out <- indicator_table(muac)

  expect_equal(out$screened, 3)
  expect_equal(out$assessed, 2)     # the unmeasured child is not a denominator
  expect_equal(out$gam_rate, 0.5)
})

That test encodes the decision from the grouping lesson — which children are in the denominator — in a form that fails if someone changes it. Worth writing for every indicator whose definition you had to argue about.

usethis::use_testthat() sets the directory up; devtools::test() runs it.

The command-line script

#!/usr/bin/env Rscript
# scripts/indicator_table.R

suppressPackageStartupMessages({
  library(optparse)
  library(readr)
})

source(here::here("R", "clean_register.R"))
source(here::here("R", "indicator_table.R"))

option_list <- list(
  make_option("--input", type = "character", help = "Screening register CSV."),
  make_option("--output", type = "character", help = "Directory for the tables."),
  make_option("--gam-threshold", type = "integer", default = 125L,
              help = "MUAC cut-off in mm for GAM [default %default].")
)

opt <- parse_args(OptionParser(option_list = option_list))
if (is.null(opt$input) || is.null(opt$output)) {
  stop("--input and --output are required.")
}

muac <- read_register(opt$input)
message("Read ", nrow(muac), " rows from ", opt$input)

table <- indicator_table(muac, gam_mm = opt$`gam-threshold`)

thin <- table$commune[table$assessment_rate < 0.8]
if (length(thin) > 0) {
  warning("Assessment rate below floor: ", paste(thin, collapse = ", "))
}

dir.create(opt$output, recursive = TRUE, showWarnings = FALSE)
destination <- file.path(opt$output, "gam_by_commune.csv")
write_csv(table, destination)
message("Wrote ", destination, " (", nrow(table), " communes)")
Rscript scripts/indicator_table.R \
  --input data/raw/muac-screening-artibonite-2024.v1.csv \
  --output outputs/tables

message() rather than print(): messages go to standard error, so the script’s narration does not end up mixed into piped output. warning() rather than message() for the thin communes, because it is a condition a caller might want to escalate with options(warn = 2).

Recording what produced the output

run_metadata <- function(opt) {
  list(
    generated_at  = format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z"),
    input         = opt$input,
    gam_threshold = opt$`gam-threshold`,
    r_version     = as.character(getRversion()),
    dplyr         = as.character(packageVersion("dplyr"))
  )
}

jsonlite::write_json(run_metadata(opt), file.path(opt$output, "run.json"),
                     auto_unbox = TRUE, pretty = TRUE)

Six months later, “which threshold produced this table” has an answer that does not depend on anyone’s memory.

Then the real test, from the first lesson:

rm -rf outputs/
Rscript scripts/indicator_table.R --input data/raw/muac.csv --output outputs/tables
git status

If that does not restore the outputs exactly, something is not reproducible.

Where this course ends

You can build a project that reopens a year later with its package versions recorded, read any export without corrupting it, keep the labels a survey file carries, put categories in the order a report needs, aggregate to a numerator and a denominator that came from one call, reshape a flattened repeat group, and hand the result to someone else as a script rather than as a favour.

What this course deliberately did not teach is what to compute — which indicator, which denominator, which threshold, and how to defend it. That is Data Analysis Foundations, and the sector courses under Sector Analysis on the programme roadmap take it further into the classifications your cluster holds you to.

If your team works in Python rather than R, Python for Programme Data covers the same ground in that language — and the table of reversals in lesson 6 is the fastest way to move between them.

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.