cassionData Analysis

Lesson 2 of 8

Unit · A project that reopens

Reading an export with readr and readxl

Column specifications instead of guesses, problems() as a habit rather than a rescue, and the Excel traps — multiple sheets, a header that is not row one, merged cells and serial dates.

R85 min

readr guesses, and tells you what it guessed

library(readr)
library(here)

PATH <- here("data", "raw", "muac-screening-artibonite-2024.v1.csv")
muac <- read_csv(PATH)

read_csv() prints the specification it inferred. That message is not noise to suppress — it is the only moment the reader tells you what it decided, and on this register it decides eight things:

child_id       character
commune        character
screening_date date
age_months     numeric
sex            character
muac_mm        numeric
oedema         character
outcome        character

Most of that is right. Two parts are worth taking control of.

The guess uses the first 1,000 rows

guess_max defaults to 1,000. A column that is empty for the first thousand rows and numeric afterwards is guessed as logical — because NA is logical — and every later value becomes NA.

This is the readr equivalent of pandas’ mixed-dtype warning, and it fails more quietly: you get a column full of missing values and a problems() table you did not look at.

# Deliberate: read everything as text, look, then convert.
peek <- read_csv(PATH, col_types = cols(.default = col_character()), n_max = 200)
print(peek)

Two hundred rows is enough to see the shape and cheap on a large file.

Declare the columns

muac <- read_csv(
  PATH,
  col_types = cols(
    child_id       = col_character(),
    commune        = col_character(),
    screening_date = col_date(format = "%Y-%m-%d"),
    age_months     = col_integer(),
    sex            = col_character(),
    muac_mm        = col_integer(),
    oedema         = col_character(),
    outcome        = col_character()
  ),
  na = c("", "NA", "-99")
)

Three things that buys you.

Leading zeros survive. col_character() on an identifier is the whole lesson: a facility code 007 read as a number becomes 7, the join to the facility list fails for exactly the codes that had a leading zero, and the failure looks like missing facilities rather than a type error.

The rule generalises: if you will never do arithmetic on it, it is not a number. Facility codes, phone numbers, cluster identifiers, household numbers and administrative codes are text that happens to be written with digits.

The date format is stated. An inferred format can change between files as the mix of values changes. 01/02/2024 is either 1 February or 2 January and the file will not tell you which.

The sentinel is declared. This register codes an unmeasured MUAC as -99. Read without na, that is a number:

mean(muac$muac_mm)
#> [1] NA

Which brings us to the most important difference from pandas.

mean() returns NA, and that is a feature

pandas skips missing values by default; R does not.

mean(muac$muac_mm)
#> [1] NA

mean(muac$muac_mm, na.rm = TRUE)
#> [1] 139.92

R is loud where pandas is silent. The NA is a question: do you know there are missing values here, and have you decided what they mean? Answering it with na.rm = TRUE is fine — answering it without noticing you were asked is how a denominator quietly changes.

na.rm = TRUE drops the missing from the numerator and from the denominator. That is usually right for a mean and usually wrong for a coverage rate, where the unmeasured child is still a child who was screened. Count them:

sum(is.na(muac$muac_mm))
#> [1] 72

na is file-wide, not per column

muac <- read_csv(PATH, na = c("", "NA", "-99"))

readr applies na across every column. pandas can scope a sentinel to one column with na_values = {"muac_mm": ["-99"]}; readr cannot, so if another column legitimately holds -99, handle it afterwards:

muac <- read_csv(PATH, na = c("", "NA")) |>
  dplyr::mutate(muac_mm = dplyr::na_if(muac_mm, -99L))

na_if() is the scoped form and is worth preferring whenever more than one column is numeric.

problems() is a habit, not a rescue

When a value does not fit its declared type, readr does not stop. It records the row, the column, what it expected and what it found.

issues <- problems(muac)
nrow(issues)
#> [1] 0

Zero on this file, and that is the point of checking: the number is only meaningful if you look at it every time. A run that quietly parsed 200 values as NA looks identical to a clean one until you ask.

if (nrow(problems(muac)) > 0) {
  print(problems(muac))
  stop("Input did not match its column specification.")
}

In a script that produces a reported figure, that stop() is correct. Refusing to run beats producing a table with a silently empty column.

Verify the read before doing anything with it

Four checks, one minute, and they catch nearly everything:

library(dplyr)

check <- function(df, expected_rows = NULL) {
  cat("rows/cols ", nrow(df), ncol(df), "\n")
  print(dplyr::glimpse(df))
  print(colSums(is.na(df)))
  if (!is.null(expected_rows)) {
    stopifnot(nrow(df) == expected_rows)
  }
}

check(muac, expected_rows = 4218)

The row-count assertion is the one people skip. A file that arrives with 4,190 rows instead of 4,218 has lost something between the server and you, and the script should say so rather than quietly report a smaller caseload.

Separators, encodings and thousands

A file produced on a French or Spanish Windows machine is frequently semicolon-separated, because the comma is the decimal mark there.

epi <- read_csv2(PATH)                       # ; separator, , decimal
epi <- read_delim(PATH, delim = "\t")        # tabs

read_csv2() is the European variant — semicolon and comma — and it exists precisely because this is common enough to deserve its own function.

Encoding shows up in commune names: Gonaïves, L’Estère, Anse-Rouge.

muac <- read_csv(PATH, locale = locale(encoding = "UTF-8"))
muac <- read_csv(PATH, locale = locale(encoding = "latin1"))   # what Excel often writes

latin1 never errors, because every byte is a valid latin1 character. That makes it a poor diagnostic and a decent fallback: if a file read as latin1 shows Gonaïves, it was UTF-8 all along and you told the reader otherwise.

Thousands separators turn a number into text:

epi <- read_csv(PATH, locale = locale(grouping_mark = ","))

Without it, target_population arrives as "1,480" — a character column on which every arithmetic operation fails or concatenates.

Excel

library(readxl)

excel_sheets(path)
#> [1] "Cover" "Data" "Codebook" "Sheet3"

data <- read_excel(path, sheet = "Data")

read_excel() without sheet returns the first sheet, which in a programme workbook is usually a cover page. Always list the sheets first.

The header is not row one

data <- read_excel(path, sheet = "Data", skip = 3)

Programme workbooks routinely carry a title, a logo row and a blank line above the real header. The symptom is column names like ...1, ...2.

Merged cells

A merged cell holds its value in the top-left position and nothing in the rest. When a district name is merged across its facilities, only the first row of each district has one:

data <- data |> tidyr::fill(district, .direction = "down")

fill() is correct here and dangerous in general: it is only right because the blanks came from merging, not from non-response. Never apply it to a column where a blank might mean “not answered”.

Serial dates

Excel stores dates as a number of days since 1899-12-30. read_excel() usually converts them, but a column typed as text in the workbook comes through as the raw serial.

data$screening_date <- as.Date(as.numeric(data$screening_date), origin = "1899-12-30")

If a date column arrives as five-digit numbers, this is why.

Types in Excel

data <- read_excel(
  path,
  sheet = "Data",
  col_types = c("text", "text", "date", "numeric", "text", "numeric", "text", "text")
)

readxl takes a positional vector rather than a named specification, so a column added to the export shifts everything after it. Check ncol() against the length of your vector, or read as text and convert with dplyr.

The read function, assembled

Everything above belongs in one function the scripts share:

# R/read_register.R
read_register <- function(path) {
  muac <- readr::read_csv(
    path,
    col_types = readr::cols(
      child_id       = readr::col_character(),
      commune        = readr::col_character(),
      screening_date = readr::col_date(format = "%Y-%m-%d"),
      age_months     = readr::col_integer(),
      sex            = readr::col_character(),
      muac_mm        = readr::col_integer(),
      oedema         = readr::col_character(),
      outcome        = readr::col_character()
    ),
    na = c("", "NA", "-99")
  )

  if (nrow(readr::problems(muac)) > 0) {
    print(readr::problems(muac))
    stop("Input did not match its column specification.")
  }
  stopifnot(!any(is.na(muac$child_id)))

  muac
}

One function, one place to fix when the next export changes.

What comes next

The CSV is in and its types are right. The next lesson handles the files that carry more than values — Stata and SPSS exports, where 1 = Yes travels with the data and haven is the reason to read them in R even when you will analyse them somewhere else.

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.