Back to the lesson·Lesson 2 of 8·A project that reopens
Reading an export with readr and readxl
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.
What this lesson covers
- readr guesses, and tells you what it guessed
- The guess uses the first 1,000 rows
- Declare the columns
mean()returns NA, and that is a featurenais file-wide, not per columnproblems()is a habit, not a rescue- Verify the read before doing anything with it
- Separators, encodings and thousands
- Excel
- The read function, assembled
- What comes next
Speaker notes
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.readr guesses, and tells you what it guessed — In R
library(readr) library(here) PATH <- here("data", "raw", "muac-screening-artibonite-2024.v1.csv") muac <- read_csv(PATH)readr guesses, and tells you what it guessed — Example
child_id character commune character screening_date date age_months numeric sex character muac_mm numeric oedema character outcome characterSpeaker notes
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: Most of that is right. Two parts are worth taking control of.The guess uses the first 1,000 rows — In R
# Deliberate: read everything as text, look, then convert. peek <- read_csv(PATH, col_types = cols(.default = col_character()), n_max = 200) print(peek)Speaker notes
guess_maxdefaults to 1,000. A column that is empty for the first thousand rows and numeric afterwards is guessed as logical — becauseNAis logical — and every later value becomesNA. This is the readr equivalent of pandas' mixed-dtype warning, and it fails more quietly: you get a column full of missing values and aproblems()table you did not look at. Two hundred rows is enough to see the shape and cheap on a large file.Declare the columns — In R
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") )Declare the columns
- Leading zeros survive —
col_character()on an identifier is the whole lesson: a facility code007read as a number… - The date format is stated — An inferred format can change between files as the mix of values changes
- The sentinel is declared — This register codes an unmeasured MUAC as
-99
Speaker notes
Three things that buys you. Leading zeros survive.col_character()on an identifier is the whole lesson: a facility code007read as a number becomes7, 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/2024is 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 withoutna, that is a number:- Leading zeros survive —
Declare the columns — In R
mean(muac$muac_mm) #> [1] NASpeaker notes
Which brings us to the most important difference from pandas.mean()returns NA, and that is a feature — In Rmean(muac$muac_mm) #> [1] NA mean(muac$muac_mm, na.rm = TRUE) #> [1] 139.92Speaker notes
pandas skips missing values by default; R does not.mean()returns NA, and that is a feature- R is loud where pandas is silent — The
NAis a question: *do you know there are missing values here, and have you…
Speaker notes
R is loud where pandas is silent. TheNAis a question: do you know there are missing values here, and have you decided what they mean? Answering it withna.rm = TRUEis fine — answering it without noticing you were asked is how a denominator quietly changes.na.rm = TRUEdrops 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:- R is loud where pandas is silent — The
nais file-wide, not per column — In Rmuac <- read_csv(PATH, na = c("", "NA")) |> dplyr::mutate(muac_mm = dplyr::na_if(muac_mm, -99L))Speaker notes
readr appliesnaacross every column. pandas can scope a sentinel to one column withna_values = {"muac_mm": ["-99"]}; readr cannot, so if another column legitimately holds-99, handle it afterwards:na_if()is the scoped form and is worth preferring whenever more than one column is numeric.problems()is a habit, not a rescue — In Rissues <- problems(muac) nrow(issues) #> [1] 0Speaker notes
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.problems()is a habit, not a rescue — In Rif (nrow(problems(muac)) > 0) { print(problems(muac)) stop("Input did not match its column specification.") }Speaker notes
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 asNAlooks identical to a clean one until you ask. In a script that produces a reported figure, thatstop()is correct. Refusing to run beats producing a table with a silently empty column.Verify the read before doing anything with it — In R
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)Speaker notes
Four checks, one minute, and they catch nearly everything: 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 — In R
epi <- read_csv2(PATH) # ; separator, , decimal epi <- read_delim(PATH, delim = "\t") # tabsSpeaker notes
A file produced on a French or Spanish Windows machine is frequently semicolon-separated, because the comma is the decimal mark there.Separators, encodings and thousands — In R
muac <- read_csv(PATH, locale = locale(encoding = "UTF-8")) muac <- read_csv(PATH, locale = locale(encoding = "latin1")) # what Excel often writesSpeaker notes
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.Separators, encodings and thousands — In R
epi <- read_csv(PATH, locale = locale(grouping_mark = ","))Speaker notes
latin1never errors, because every byte is a valid latin1 character. That makes it a poor diagnostic and a decent fallback: if a file read aslatin1showsGonaïves, it was UTF-8 all along and you told the reader otherwise. Thousands separators turn a number into text: Without it,target_populationarrives as"1,480"— a character column on which every arithmetic operation fails or concatenates.Excel — In R
library(readxl) excel_sheets(path) #> [1] "Cover" "Data" "Codebook" "Sheet3" data <- read_excel(path, sheet = "Data")Excel
- The header is not row one
Speaker notes
read_excel()withoutsheetreturns the first sheet, which in a programme workbook is usually a cover page. Always list the sheets first.Excel
- Merged cells
Speaker notes
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. 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:Excel
- Serial dates
Speaker notes
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". 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.Excel — In R
data$screening_date <- as.Date(as.numeric(data$screening_date), origin = "1899-12-30")Excel
- Types in Excel
Speaker notes
If a date column arrives as five-digit numbers, this is why.Excel — In R
data <- read_excel( path, sheet = "Data", col_types = c("text", "text", "date", "numeric", "text", "numeric", "text", "text") )Speaker notes
readxltakes a positional vector rather than a named specification, so a column added to the export shifts everything after it. Checkncol()against the length of your vector, or read as text and convert with dplyr.The read function, assembled — In R (cont.)
# 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") )Speaker notes
Everything above belongs in one function the scripts share:The read function, assembled — In R (cont.)
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 }Speaker notes
One function, one place to fix when the next export changes.What comes next
- The CSV is in and its types are right.
Speaker notes
The CSV is in and its types are right. The next lesson handles the files that carry more than values — Stata and SPSS exports, where1 = Yestravels with the data andhavenis the reason to read them in R even when you will analyse them somewhere else.