cassionData Analysis

Back to the lessonLesson 6 of 8The verbs

Where most indicator bugs are born

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 / 26

    What this lesson covers

    • An indicator is two numbers
    • n() and the two ways to count
    • The grouping survives, and it is the commonest bug
    • A missing key becomes its own group
    • Unused factor levels: count() drops, table() keeps
    • Grouped mutate(): a group statistic on every row
    • count() for when that is all you want
    • Two keys, and the table a report wants
    • Writing the result out
    • The three reversals, in one place
    • What comes next
    Speaker notes
    group_by() and summarise() — the grouping that survives the call, the missing key that becomes its own group, and the three defaults that are the exact opposite of pandas'.
  2. Slide 2 / 26

    An indicator is two numbers — In R

    library(dplyr)
    
    by_commune <- muac |>
      mutate(
        assessed = !is.na(muac_mm) | !is.na(oedema),
        gam = assessed & (muac_mm < 125 | oedema == "true")
      ) |>
      group_by(commune) |>
      summarise(
        screened  = n(),
        assessed  = sum(assessed, na.rm = TRUE),
        gam_cases = sum(gam, na.rm = TRUE),
        .groups = "drop"
      ) |>
      mutate(gam_rate = round(gam_cases / assessed, 3))
    Speaker notes
    Almost every figure this sector reports is a numerator over a denominator, and almost every argument about a figure is an argument about the denominator. The practical consequence: compute both in the same call. Three columns and every one is load-bearing. screened is how many children came; assessed is how many produced a usable measurement and is the denominator; gam_cases is the numerator. Two separate calculations drift — one gets a filter the other does not — and the ratio becomes a number nobody can reconstruct.
  3. Slide 3 / 26

    n() and the two ways to count — In R

    summarise(muac, rows = n(), measured = sum(!is.na(muac_mm)))
  4. Slide 4 / 26

    n() and the two ways to count — In R

    sum(c(TRUE, NA))
    #> [1] NA
    
    sum(c(TRUE, NA), na.rm = TRUE)
    #> [1] 1
    Speaker notes
    n() counts rows in the group, including those with missing values. Summing a logical counts the TRUEs. That distinction is the difference between the two denominators above, and it is worth saying out loud every time you use one. sum() on a logical with NA in it returns NA, which is R being loud again: Reach for na.rm = TRUE deliberately, and know that it removes the row from the count rather than counting it as FALSE. Those are different claims.
  5. Slide 5 / 26

    The grouping survives, and it is the commonest bug — In R

    out <- muac |>
      mutate(month = format(screening_date, "%Y-%m")) |>
      group_by(commune, month) |>
      summarise(n = n())
    Speaker notes
    This is the one to internalise. summarise() removes the last grouping variable and leaves the rest:
  6. Slide 6 / 26

    The grouping survives, and it is the commonest bug — Example

    `summarise()` has regrouped the output.
    ℹ Summaries were computed grouped by commune and month.
    ℹ Output is grouped by commune.
    ℹ Use `summarise(.groups = "drop_last")` to silence this message.
  7. Slide 7 / 26

    The grouping survives, and it is the commonest bug — In R

    class(out)[1]
    #> [1] "grouped_df"
    
    dplyr::group_vars(out)
    #> [1] "commune"
  8. Slide 8 / 26

    The grouping survives, and it is the commonest bug — In R

    out |> mutate(share = n / sum(n))     # share within the commune, not the district
    out |> arrange(desc(n))               # sorts within each commune
    out |> slice_max(n, n = 5)            # top 5 per commune, not overall
    Speaker notes
    The result is still grouped by commune. Every verb after this silently operates per commune:
  9. Slide 9 / 26

    The grouping survives, and it is the commonest bug

    • Say what you want, every time
    Speaker notes
    Each of those produces a plausible number that answers a different question from the one you asked. Nothing errors. Say what you want, every time:
  10. Slide 10 / 26

    The grouping survives, and it is the commonest bug — In R

    summarise(..., .groups = "drop")        # ungrouped result — the usual answer
    summarise(..., .groups = "drop_last")   # keep all but the last (the default)
    summarise(..., .groups = "keep")        # keep all groupings
  11. Slide 11 / 26

    The grouping survives, and it is the commonest bug — In R

    muac |>
      summarise(n = n(), .by = c(commune, month))
    Speaker notes
    Or avoid the state entirely with per-operation grouping, which dplyr gained in 1.1: .by groups for that call only and returns an ungrouped result. Prefer it in new code: there is no leftover state, so there is no bug of this shape.
  12. Slide 12 / 26

    A missing key becomes its own group — In R

    d <- tibble::tibble(g = c("a", NA, "a"), v = c(1, 2, 3))
    
    d |> group_by(g) |> summarise(s = sum(v), .groups = "drop")
    Speaker notes
    Here R and pandas take opposite bets.
  13. Slide 13 / 26

    A missing key becomes its own group — Example

    # A tibble: 2 × 2
      g         s
      <chr> <dbl>
    1 a         4
    2 <NA>      2
  14. Slide 14 / 26

    A missing key becomes its own group — In R

    d |> filter(!is.na(g)) |> group_by(g) |> summarise(s = sum(v), .groups = "drop")
    Speaker notes
    The NA group is kept. pandas drops it by default, so the same operation in the two languages gives you a different set of rows and a different total. R's default is the safer one, and in this sector the missing key is usually the interesting one: a facility with no district recorded, a household with no site code, a child with no commune. But it means a table can arrive at a report with an <NA> row nobody meant to publish.
  15. Slide 15 / 26

    A missing key becomes its own group — In R

    stopifnot(!any(is.na(muac$commune)))
    Speaker notes
    Excluding it is fine. Excluding it without noticing is how the parts stop summing to the whole. Assert instead, where the key should be complete:
  16. Slide 16 / 26

    Unused factor levels: count() drops, table() keeps — In R

    d <- tibble::tibble(k = factor(c("a", "b", "a"), levels = c("a", "b", "c")))
    
    nrow(count(d, k))                #> 2   — the empty level is gone
    nrow(count(d, k, .drop = FALSE)) #> 3   — kept
    length(table(d$k))               #> 3   — kept
    Speaker notes
    The second reversal, and R is not consistent with itself here — which is why the lesson on factors covers it and this one repeats it.
  17. Slide 17 / 26

    Unused factor levels: count() drops, table() keeps

    • Pass .drop explicitly — in anything that produces a reported table
    Speaker notes
    Both are right for different questions, and it is the same question the Python course raises about observed=. Reporting on facilities that submitted data wants the empty level gone; reporting coverage against a list of facilities that should have submitted wants it there, because a facility with zero rows is the finding. Pass .drop explicitly in anything that produces a reported table.
  18. Slide 18 / 26

    Grouped mutate(): a group statistic on every row — In R

    muac |>
      group_by(commune) |>
      mutate(
        commune_mean = mean(muac_mm, na.rm = TRUE),
        vs_commune   = muac_mm - commune_mean
      ) |>
      ungroup()
    Speaker notes
    summarise() collapses; mutate() on a grouped frame returns a value per original row. This is how you compare a row against its own group without a join:
  19. Slide 19 / 26

    Grouped mutate(): a group statistic on every row

    • Note the ungroup() — A grouped frame that escapes into the rest of a script is the same bug as the one above,…
    Speaker notes
    That is the check the SMART survey analysis project uses to find a team measuring half a kilogram light. Note the ungroup(). A grouped frame that escapes into the rest of a script is the same bug as the one above, arriving from a different direction.
  20. Slide 20 / 26

    count() for when that is all you want — In R

    muac |> count(commune, outcome, sort = TRUE)
    muac |> count(commune, wt = muac_mm)          # sum a column instead of counting rows
    Speaker notes
    count() groups, counts and ungroups in one call, so it cannot leave state behind. Where a count is all you need, it is the better verb.
  21. Slide 21 / 26

    Two keys, and the table a report wants — In R

    by_commune_month <- muac |>
      mutate(month = format(screening_date, "%Y-%m")) |>
      summarise(cases = sum(gam, na.rm = TRUE), .by = c(commune, month))
    
    wide <- by_commune_month |>
      tidyr::pivot_wider(names_from = month, values_from = cases, values_fill = 0)
    Speaker notes
    values_fill = 0 is safe here because a commune-month with no cases genuinely had zero. It would be wrong for a rate, where the absence means "not computed", not "zero percent" — a distinction worth checking every time you reach for it.
  22. Slide 22 / 26

    Writing the result out — In R

    readr::write_csv(by_commune, here::here("outputs", "tables", "gam_by_commune.csv"))
  23. Slide 23 / 26

    Writing the result out — In R

    definitions <- tibble::tibble(
      indicator   = "GAM",
      numerator   = "MUAC < 125 mm or bilateral pitting oedema",
      denominator = "children with a MUAC measurement or a recorded oedema assessment"
    )
    
    readr::write_csv(definitions, here::here("outputs", "tables", "definitions.csv"))
    Speaker notes
    For a table someone will read rather than compute on, write the definition with it: A number and its definition travel together, or the monthly argument about the denominator starts again.
  24. Slide 24 / 26

    The three reversals, in one place

    pandasR / dplyr
    Missing value in mean()skipped silentlyreturns NA until you say na.rm
    Missing group keydroppedkept as its own group
    Unused categorydropped by observed=Truekept by table(), dropped by count()
    Speaker notes
    None is better. Each is a different bet about what silence should mean, and knowing which bet you are inside is most of the job.
  25. Slide 25 / 26

    What comes next

    • The table aggregates correctly and both halves of every rate come from one call.
    Speaker notes
    The table aggregates correctly and both halves of every rate come from one call. The next unit reshapes: pivot_longer() on the flattened repeat group a CommCare or Kobo export arrives as, and then the function that makes the whole cleaning run again next quarter.
  26. Slide 26 / 26

    Where this goes next

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