| SOCR ≫ | DSPA ≫ | DSPA3 Topics ≫ |
How to read this chapter. Sections 1.1–1.5 set up why the book exists and what responsible practice requires. Sections 1.6–1.12 build the computational foundation everything else stands on. If you already write R daily, skim 1.6–1.11, but do work through §1.6.6 (Reproducibility), §1.6.7 (Data access), and §1.12 (Simulation), the book’s later chapters assume the conventions established there.
After completing this chapter you will be able to:
Estimated time: 4–6 hours including exercises. Prerequisites: none. Some prior exposure to any programming language helps but is not assumed. Review the DSPA prerequisites and self-assessment pretest if you want to calibrate.
DSPA3 is the third edition of the textbook built around the HS650: Data Science and Predictive Analytics course at the University of Michigan (DSPA1 · DSPA2). It aims to give you a working understanding of the challenges, an appreciation of the opportunities, and a methodological foundation for designing, collecting, managing, processing, interrogating, analyzing, and interpreting complex health and biomedical data.
DSPA.info @ umich.edu. Corrections are incorporated into
subsequent revisions with attribution.Every method in this book exists because someone needed to answer a question they could not answer by looking at the data directly. This section introduces the problems that recur throughout DSPA3.
The rightmost column is a forward map. It tells you where each dataset returns, so you can trace a single scientific problem across multiple analytic treatments, usually the fastest way to understand what a method actually buys you.
Alzheimer’s disease. Can we relate observed clinical phenotypes to expected behavior? Can we prognosticate cognitive decline 3–12 months forward from imaging and clinical assessment? Can we recover meaningful subject clusters from harmonized multi-source data?
| Source | Modalities & scale | Recurs in |
|---|---|---|
| ADNI · NACC | Clinical (demographics, cognitive assessment); imaging (sMRI, fMRI, DTI, PiB/FDG PET); genetics (Illumina SNP); chemical biomarkers (labs, proteomics). Cohort sizes typically \(200 \le N \le 1200\) | Dimensionality reduction, feature selection |
A study of structural neuroimaging in AD illustrates the difficulty concretely: 808 ADNI subjects (200 AD, 383 MCI, 225 normal controls), sMRI parcellated with BrainParser, 80 neuroimaging biomarkers extracted by shape-analysis workflow, and 80 SNPs associated with those biomarkers via a pipeline implementation of PLINK. The correlations observed between genetic and imaging phenotypes suggest that AD/MCI/NC differences are best examined through joint models of morphometric, imaging, and genotypic data, not through any single modality.
Parkinson’s disease. Can we predict clinical diagnosis from available data, with and without the UPDRS assessment that currently defines the diagnosis? Can derived imaging and genetic biomarkers model progression well enough to support clinical decisions?
| Source | Modalities & scale | Recurs in |
|---|---|---|
| PPMI | Demographics; clinical (physical, verbal learning, neurological, UPSIT olfactory, vitals, MDS-UPDRS, ADL, MoCA, GDS-15); sMRI; genetics (Illumina ImmunoChip, 196,524 variants; NeuroX, ~240,000 exonic variants; APOE e2/e3/e4). Three cohorts: de-novo PD \(N=263\), SWEDD \(N=40\), controls \(N=127\). Visits at screening, baseline, 12, 24, 48 months | Simulation §1.12, dimensionality reduction, feature selection, longitudinal analysis |
Amyotrophic lateral sclerosis. Which variables jointly predict ALS progression (ALSFRS, muscle function)? Can adverse events be predicted from phenotype plus 0–3 month clinical change?
| Source | Modalities & scale | Recurs in |
|---|---|---|
| PRO-ACT | >100 clinical variables; 8,635 patients in the training set,
2,424 with valid gold-standard ALSFRS slopes. Demographics, ALSFRS,
adverse events, onset_delta, onset_site,
riluzole use |
Feature selection, the primary ALS case study |
Is the Risk for Alcohol Withdrawal Syndrome (RAWS) screen valid and reliable for predicting withdrawal among adult medical inpatients? What AUDIT-C cutoff optimizes prediction? Should items be added or removed?
| Source | Modalities & scale | Recurs in |
|---|---|---|
| MAWS / UMHS EHR · WHO Global Health Observatory | AUDIT-C scores (see Bush et al., 1998), including any current use (item 1), total AUDIT-C > 8, and prior withdrawal history. ~1,000 positive cases/year among ~10,000 adult inpatients | Classification |
Genetic forensics, the 2013–2016 Ebola outbreak. The HHMI BioInteractive disease-detective activity tracks Ebola virus spread through Sierra Leone using the fact that most of a genome is shared within a species, more so among related individuals. DNA profiling exploits the residual differences, particularly in noncoding regions, where variation is less exposed to selection. Short tandem repeats (STRs) are 2–5 base motifs repeated a variable number of times at defined loci, flanked by non-variable regions. An allele is named either by repeat count or by total length; the STR below is “6” (six GATA repeats) or equivalently 70 bp including flanks.
Next-generation sequencing. Whole-genome and exome sequencing carry the clues needed to identify genes behind Mendelian disorders, and population-genetic extensions push toward complex traits. The Graphical Pipeline for Computational Genomics (GPCG) implements alignment, quality control, SNP analysis, CNV identification, annotation, and visualization as composable workflow steps.
Neuroimaging-genetics. A computational infrastructure for high-throughput neuroimaging-genetics supports aggregation, harmonization, processing, and interpretation across imaging, genetics, clinical, and cognitive sources. Workflows are portable XML objects that carry execution instructions from a client to distributed pipeline servers, an early and durable example of computational provenance as a first-class artifact.
The SOCR Brain Visualization App preloads sMRI, ROI labels, and fiber tract models for a normal brain, and accepts drag-and-drop of your own stereotactic data.
Datasets used throughout DSPA3 are drawn from open-access archives that follow FAIR principles (§1.5.1):
The familiar three-V framing, volume, velocity, variety, originates with Doug Laney’s 2001 META Group research note, 3D Data Management. Veracity was added later and popularized by IBM, producing the common “4 V’s.” The framing is widely repeated and worth knowing, but it was written for enterprise data management, and it does not tell a biomedical analyst what tool to reach for.
A constructive characterization is more actionable, because each dimension names a methodological gap and therefore a class of tool:
| Dimension | What it means for your analysis | Where DSPA3 addresses it |
|---|---|---|
| Size | Data exceeds convenient memory or single-machine compute | Scaling & streaming |
| Complexity | Heterogeneous types in one study: images, sequences, text, tabular | Throughout |
| Incongruency | Sources disagree on units, coding, timing, or inclusion | Data handling |
| Multi-source | Joint modeling of elements never designed to be joined | Dimensionality reduction |
| Multi-scale | Macro → meso → micro observations of the same process | Longitudinal analysis |
| Time | Repeated, irregular, censored, or drifting measurement | Longitudinal analysis |
| Incomplete | Missingness that is itself informative | Missing data |
| Provenance | Where each value came from and what was done to it | §1.6.6, and every chapter |
The last row is our addition. Provenance is not usually listed among the V’s, and its absence is arguably the most common cause of irreproducible health-data analysis: not that the statistics were wrong, but that nobody can reconstruct which version of which file was processed by which code.
Data science is transdisciplinary, spanning theoretical, computational, experimental, and biosocial work, and deals with large volumes of complex, incongruent, dynamic, multi-source data. Its goal is algorithms, methods, tools, and services that ingest such data and produce semi-automated decision support: mining patterns, predicting outcomes, proposing groupings, computing signatures, and extracting actionable evidence. In practice this means data wrangling, harmonization and aggregation, exploratory and confirmatory analysis, predictive modeling, validation, and tuning.
Predictive analytics uses mathematical formulation, statistical computing, and software to represent, interrogate, and interpret data with the aim of forecasting: predicting patterns, or prognosticating process behavior inside or outside the observed range. Here process means the natural phenomenon under investigation, examined through proxy data.
The core task is to identify relationships, associations, arrangements, or motifs, across space, time, and features, that reduce the effective complexity of the data. From those, predictive analytics produces unknown outcomes, likelihood or parameter estimates, classification labels, and other aggregate or individualized forecasts. The assumptions of a technique determine its usability, bound its expected accuracy, and constrain what human action may responsibly follow from its output. The type of the target outcome, binary, polytomous, probability, scalar, vector, tensor, determines whether the appropriate strategy yields prediction, forecasting, labeling, likelihoods, grouping, or motif discovery.
This distinction is the most frequently blurred idea in applied data science, and blurring it produces confident, useless, and occasionally harmful results. Three different questions demand three different modeling strategies:
| Goal | Question | Optimizes | Typical failure |
|---|---|---|---|
| Prediction | What will happen? | Out-of-sample accuracy | Works until the data-generating process shifts; unusable for intervention |
| Explanation | What is associated with the outcome, and how strongly? | Interpretable, well-specified structure | Confuses association with mechanism |
| Causal inference | What happens if I intervene? | Identifiability under stated assumptions | Assumes away confounding that is present |
A model can be excellent at one and useless at the others. A model that predicts hospital readmission accurately from whether a discharge-planning note was filed tells you nothing about whether filing more notes would reduce readmissions. See Breiman’s Statistical Modeling: The Two Cultures (2001) and Shmueli’s To Explain or To Predict? (2010) for the canonical treatments.
Most of DSPA3 is about prediction, with explanation appearing in the regression and feature-selection chapters. When a method is being used for a purpose it was not designed for, we say so.
Technical skill is necessary and not sufficient. Analysts must also observe professional norms governing appropriate use, reproducibility, unbiased reporting, and both expected and unanticipated interpretation of their results. Five obligations organize the rest of this section: FAIR sharing, research ethics, honest accounting of benefits and harms, regulatory competence around sensitive data, and documented provenance.
Findable, Accessible, Interoperable, Reusable (Wilkinson et al., 2016) are the guiding principles for developing, deploying, and stewarding data, methods, tools, and services. FAIR is a property of the resource, not of the researcher’s intentions: a dataset with no persistent identifier is not findable no matter how much you wish it were.
Ethical data science demands responsible conduct and integrity across the whole investigation, planning, design, implementation, execution, and assessment. Being unaware of a norm is not an exemption from it.
Evidence-driven discovery generates questions as well as answers, and some are unexpected, unwelcome, or harmful. You are responsible for validating your results and for explicitly weighing the potential benefits against the probable detriments of both positive and negative findings.
Decisions about security, privacy, and confidentiality are not trivial, and the number of people, devices, algorithms, and services within arm’s length of raw data argues for layered protection. Under the U.S. HIPAA Privacy Rule, health data may be de-identified two ways:
Note what Safe Harbor is not: it is not a guarantee of anonymity, and it is not a statistical statement. It is a checklist. Expert determination requires actually computing something, which is the subject of §1.5.6.
Protection must persist across the data life cycle and may require preemptive, ongoing, and post-hoc analysis to find and patch vulnerabilities. There are genuine tradeoffs between the value of data and the risk of unrestrained inference from it; neither extreme is practical or sustainable.
The digitalization of human experience and the promise of AI have produced enormous investment, excitement, and anxiety, along with the expectation that abundant information translates directly into insight, prediction, and cost-effective decisions. Recording the data, algorithmic, scientific, computational, and human factors behind a forecast is not bureaucracy, it is the part of the work that makes the forecast knowledge rather than output. §1.6.6 makes this operational.
Ethics sections are usually prose. This one is code, because re-identification risk is a computable quantity and treating it as a matter of good intentions is how it gets ignored.
Quasi-identifiers are variables that do not identify anyone on their own, but do so in combination. The classic triple is ZIP code, date of birth, and sex. \(k\)-anonymity (Sweeney, 2002) holds for a dataset when every record shares its quasi-identifier combination with at least \(k-1\) others. When \(k = 1\) for some record, that record is unique in the data and can be matched against any external source containing the same variables.
Let us measure it.
set.seed(2026)
n <- 5000
patients <- data.frame(
zip5 = sample(48103:48109, n, replace = TRUE),
birth_year = sample(1935:2005, n, replace = TRUE),
sex = sample(c("F", "M"), n, replace = TRUE),
# A sensitive attribute that de-identification is meant to protect:
diagnosis = sample(c("HC", "PD", "SWEDD"), n, replace = TRUE,
prob = c(0.5, 0.35, 0.15))
)
# A reusable k-anonymity report over any set of quasi-identifiers.
k_anonymity <- function(data, quasi_ids) {
key <- do.call(paste, c(data[quasi_ids], sep = "\r"))
class_size <- table(key)
sizes <- as.integer(class_size[key])
list(
quasi_ids = quasi_ids,
k = min(sizes),
n_unique = sum(sizes == 1L),
pct_unique = round(100 * mean(sizes == 1L), 2),
pct_below_k5 = round(100 * mean(sizes < 5L), 2),
n_classes = length(class_size)
)
}
str(k_anonymity(patients, c("zip5", "birth_year", "sex")))#> List of 6
#> $ quasi_ids : chr [1:3] "zip5" "birth_year" "sex"
#> $ k : int 1
#> $ n_unique : int 38
#> $ pct_unique : num 0.76
#> $ pct_below_k5: num 25.3
#> $ n_classes : int 985
Now watch what generalization buys you. Truncating ZIP to three digits and binning birth year into decades are both Safe-Harbor-flavored moves:
patients$zip3 <- patients$zip5 %/% 100
patients$birth_decade <- (patients$birth_year %/% 10) * 10
designs <- list(
"zip5 + birth year + sex" = c("zip5", "birth_year", "sex"),
"zip3 + birth year + sex" = c("zip3", "birth_year", "sex"),
"zip3 + birth decade + sex" = c("zip3", "birth_decade", "sex"),
"zip3 + birth decade" = c("zip3", "birth_decade")
)
report <- do.call(rbind, lapply(names(designs), function(nm) {
r <- k_anonymity(patients, designs[[nm]])
data.frame(design = nm, k = r$k, unique_records = r$n_unique,
pct_unique = r$pct_unique, pct_below_k5 = r$pct_below_k5,
equivalence_classes = r$n_classes)
}))
reportTwo lessons the numbers make unavoidable:
The most-cited example in health privacy is the 1997 re-identification of Massachusetts Governor William Weld’s hospital record in a Group Insurance Commission dataset stripped of direct identifiers, linked against the Cambridge voter roll. The episode directly shaped the HIPAA de-identification provisions.
It is worth knowing that this account is contested. Barth-Jones (2012) re-examined the Cambridge demographics and argued that Weld was likely identifiable principally because he was a public figure whose hospitalization had been widely reported, not because the demographic linkage was reliable, and therefore that the attack does not generalize as cleanly as its popular retelling suggests.
We include both because the disagreement is instructive in two directions. Substantively, “how identifiable is de-identified data?” remains an open, quantitative question rather than a settled anecdote. Methodologically: read what you cite. The Barth-Jones paper is frequently cited as evidence for the Weld re-identification, which inverts its argument. Citations propagate faster than anyone re-checks them, and one of the more valuable habits this book can give you is the reflex to follow a reference to its source before repeating it, including references in this book.
Beyond the guiding principles, some concrete failures:
R is not the only reasonable choice, and this book’s use of it is a decision with tradeoffs rather than a verdict. The honest case:
What R is unusually good at
Rcpp), Python (reticulate), Java, databases,
and web services.What R is not good at
Let us verify a few claims rather than assert them:
#> [1] "R version 4.3.3 (2024-02-29 ucrt)"
#> [1] "Platform: x86_64-w64-mingw32"
At time of writing, the current release is R 4.6.1 (“Happy Hop”, 2026-06-24). If the line above reports something much older, see §1.6.4.
# Compute the CRAN package count rather than quoting a number that will be
# wrong by the time you read this.
cran <- available.packages(repos = "https://cloud.r-project.org")
cat("CRAN packages available today:", nrow(cran), "\n")#> CRAN packages available today: 24437
The general rule, applied here and everywhere in DSPA3: if a number can be computed at render time, compute it. A hard-coded package count is wrong within months; a computed one never is. Watch for this pattern in the wild, when a textbook or paper quotes a figure without a date or a method, that is information about how much to trust it.
R is also polyglot. An .Rmd or
.qmd document can execute chunks in many languages, which
is what makes it viable as a protocol format rather than just
an R notebook:
engines <- names(knitr::knit_engines$get())
cat("knitr supports", length(engines), "engines, including:\n")#> knitr supports 52 engines, including:
#> [1] "asis" "asy" "awk" "bash" "block" "block2"
#> [7] "bslib" "c" "cat" "cc" "coffee" "comment"
#> [13] "css" "ditaa" "dot" "embed" "eviews" "exec"
#> [19] "fortran" "fortran95" "gawk" "go" "groovy" "haskell"
You will encounter tables ranking languages by speed. Most are unusable, because a benchmark without a stated workload, machine, version, and implementation is not a measurement. Rather than reprint one, run one:
set.seed(7)
x <- rnorm(2e6)
# Three implementations of exactly the same computation.
loop_sum <- function(v) {
total <- 0
for (i in seq_along(v)) total <- total + v[i]
total
}
vapply_sum <- function(v) sum(vapply(v, identity, numeric(1)))
vectorized_sum <- function(v) sum(v)
timings <- rbind(
`explicit for-loop` = system.time(loop_sum(x))[["elapsed"]],
`vapply` = system.time(vapply_sum(x))[["elapsed"]],
`vectorized sum()` = system.time(vectorized_sum(x))[["elapsed"]]
)
colnames(timings) <- "elapsed_seconds"
timings#> elapsed_seconds
#> explicit for-loop 0.03
#> vapply 0.69
#> vectorized sum() 0.00
#>
#> Slowdown vs. vectorized:
#> explicit for-loop vapply vectorized sum()
#> Inf Inf NaN
The ratio between the slowest and fastest row is typically two to three orders of magnitude, larger than the gap most cross-language benchmarks report between R and a compiled language. The lesson is not that R is fast. It is that for most analytic code, the dominant performance factor is how you write it, not which language you write it in, and that a language-level speed ranking cannot tell you anything useful about your own code.
Three questions to ask of any benchmark you are shown:
How popular is R? The question sounds empirical and is mostly a measurement problem. Two commonly used proxies, and what is wrong with each:
Proxy 1, literature mentions. The dataset below counts Google Scholar hits for R, SAS, and SPSS by publication year.
pubs <- read.csv(
"https://umich.instructure.com/files/2361245/download?download_frd=1",
header = TRUE
)
head(pubs)library(ggplot2)
library(tidyr)
pubs_long <- pivot_longer(pubs, cols = c("R", "SAS", "SPSS"),
names_to = "software", values_to = "hits")
ggplot(pubs_long, aes(x = Year, y = hits, colour = software)) +
geom_line(linewidth = 1.1) +
geom_point(size = 1.6) +
labs(
title = "Google Scholar mentions of statistical software, 1995-2015",
subtitle = "Historical snapshot - see caveats below; not a measure of usage",
x = "Year", y = "Scholar hits", colour = NULL
) +
theme_minimal(base_size = 12) +
theme(legend.position = "bottom")At least four things are wrong with treating this as evidence of adoption:
We keep the figure because the caveats are the lesson. A plot can be correctly drawn from correctly-loaded data and still not support the claim placed underneath it.
Proxy 2, search interest. Google Trends is better in one respect (it is reproducible) and has a subtler flaw. Compare these two queries:
"R" returns
volume for a letter of the alphabet./m/0212jm returns
volume for the entity R (programming language), as Google’s
knowledge graph resolves it.These measure different things, and the difference is not small.
# Not run: gtrendsR is rate-limited (HTTP 429) under repeated queries.
# Note the topic IDs - NOT the bare keyword strings.
library(gtrendsR)
trends <- gtrends(
keyword = c("/m/0212jm", # R (programming language)
"/m/018fh1", # SAS (software)
"/m/02l0yf8", # SPSS
"/m/05z1_"), # Python (programming language)
gprop = "web", time = "all"
)[["interest_over_time"]]# Pre-fetched snapshot (retrieved July 2023) so the chapter knits offline.
gt <- read.csv(
"https://umich.instructure.com/files/31071103/download?download_frd=1",
header = TRUE
)
str(gt, give.attr = FALSE)#> 'data.frame': 235 obs. of 5 variables:
#> $ Month : chr "2004-01" "2004-02" "2004-03" "2004-04" ...
#> $ R : int 61 59 61 56 53 56 58 55 56 59 ...
#> $ SAS : int 11 12 11 11 12 12 12 12 12 12 ...
#> $ Python: int 13 13 12 12 12 12 11 12 13 12 ...
#> $ SPSS : int 3 4 4 4 4 3 3 3 3 3 ...
gt$Month <- as.Date(paste0(gt$Month, "-01"))
gt_long <- pivot_longer(gt, cols = -Month,
names_to = "software", values_to = "rsv")
ggplot(gt_long, aes(x = Month, y = rsv, colour = software)) +
geom_line(linewidth = 0.9) +
labs(
title = "Google Trends relative search volume, 2004-2023",
subtitle = "RSV is scaled to 100 at the series maximum - it is not a count",
x = NULL, y = "Relative search volume", colour = NULL
) +
theme_minimal(base_size = 12) +
theme(legend.position = "bottom")Construct validity is the question of whether a measurement measures the thing you named. It is not a statistical property you can test after the fact, it is a claim about the relationship between your operational definition and your concept, and it has to be argued. You will meet this again every time a model is trained on a proxy outcome: billing codes standing in for diagnosis, readmission standing in for treatment failure, engagement standing in for value. The failure mode is not a bad model; it is a good model of the wrong thing.
R is free and runs on Windows, macOS, and Linux. From r-project.org:
Verify the installation before continuing:
R currentUpgrade R roughly every 6–12 months and packages more
often. The procedure differs by platform.
| Platform | Upgrading R |
|---|---|
| Windows | install.packages("installr"); installr::updateR(),
Windows only |
| macOS | Download the new .pkg from CRAN and install over the
old version, or brew install --cask r |
| Linux | Distribution package manager, or the Posit r-builds project for side-by-side versions |
Updating packages, on any platform:
checkBuilt = TRUE matters after a minor-version R
upgrade: packages built against the previous version need recompilation,
and skipping this produces confusing load failures weeks later.
You can drive R from a terminal, but almost nobody does. Two mature options:
RStudio Desktop, the long-standing default. Free, open source, extremely well documented. Four panes:
Ctrl/Cmd + Enter sends the current line or selection to the
console.Positron,
Posit’s newer data-science IDE, built on Code OSS. Polyglot by design (R
and Python as equal citizens), integrated data explorer, native notebook
editor, and package management pane. For a book that mixes R with
reticulate-driven Python, Positron is the more natural
fit.
Either works for every example in DSPA3. If you already know RStudio, stay; if you are starting fresh and expect to write Python too, try Positron.
Base R ships with a modest core; everything else is a package. Two mechanisms:
# Classic
install.packages(c("ggplot2", "dplyr", "tidyr"))
# Modern: pak resolves dependencies better, installs in parallel, and gives
# far clearer errors when a system library is missing.
install.packages("pak")
pak::pak(c("ggplot2", "dplyr", "tidyr"))Installing puts a package on disk. Attaching makes its functions visible:
library(ggplot2) # attach - errors immediately if not installed
ggplot2::geom_point # qualified access - no attach neededPrefer pkg::function() in scripts that others will run:
it documents where each function came from and avoids masking surprises
when two packages export the same name.
Pitfall.
library(base)does nothing,base,stats,utils,graphics,grDevices,datasets, andmethodsare attached at startup. Callinglibrary()on them implies otherwise.
The full book touches roughly 150 packages. You do not need them all. This core set covers Chapters 1–4:
pak::pak(c(
"ggplot2", "dplyr", "tidyr", "readr", # data + graphics
"knitr", "rmarkdown", # authoring
"plotly", "DT", # interactivity
"renv", "here", # reproducibility
"Hmisc" # descriptive summaries
))Later chapters declare their own additional requirements at the top. Install per-chapter rather than all at once, it keeps the failure surface small.
The book’s stated values include reproducible. That word has to cash out as mechanics, or it is decoration. Five practices, in increasing order of effort and payoff.
setwd()setwd("/Users/you/Desktop/analysis") breaks the moment
anyone else, or future you on another machine, runs the code. Instead,
create an RStudio / Positron project (a directory with
an .Rproj file) and reference files relative to its
root:
here() resolves against the project root regardless of
where the session started.
Any code touching a pseudo-random number generator must be seeded, or its output changes on every run, including outputs your prose describes.
#> [1] 1.3710 -0.5647 0.3631
#> [1] 1.3710 -0.5647 0.3631
#> [1] 0.6328626 0.4042683 -0.1061245
Convention for DSPA3: every chunk containing a random draw
calls set.seed() first. Not once at the top of the
file, per chunk. Chunks get re-run, cached, and reordered during
editing; a single seed at the top does not survive that.
Knitting a document should not leave files in your source tree.
out <- file.path(tempdir(), "example.csv")
write.csv(head(iris), out, row.names = FALSE)
file.exists(out)#> [1] TRUE
renv“It worked last year” is not reproducibility. renv records the
exact version of every package in a project lockfile:
renv::init() # create a project-local library + renv.lock
renv::snapshot() # record current package versions into renv.lock
renv::restore() # on another machine: reinstall exactly those versionsCommit renv.lock to version control. It is the
difference between “install these packages” and “install these packages
as they were.”
Every DSPA3 chapter ends with its session information. Include it in anything you share:
Checkpoint 1. You have a colleague’s analysis script that runs on their laptop and fails on yours. Which of the five practices above, had they used it, would most likely have prevented the failure? (Answer: it depends on the error, but
renvandhere()between them eliminate the two most common causes, version drift and path assumptions.)
DSPA3 loads most datasets over the network. That creates three problems: the chapter fails offline, it re-downloads on every knit, and it breaks permanently when a URL rots. One small helper solves all three, and we use it book-wide.
# ---- Cache-aware data loader ----------------------------------------------
dspa_cache_dir <- function() {
d <- Sys.getenv("DSPA_CACHE", unset = file.path(tempdir(), "dspa-cache"))
if (!dir.exists(d)) dir.create(d, recursive = TRUE)
d
}
#' Download once, read many times.
#'
#' @param url Remote location of the file.
#' @param name Stable local filename used as the cache key.
#' @param reader Function applied to the cached path (default `read.csv`).
#' @param refresh Force re-download even if cached.
dspa_read <- function(url, name, reader = utils::read.csv, ..., refresh = FALSE) {
stopifnot(is.character(url), length(url) == 1L,
is.character(name), length(name) == 1L)
path <- file.path(dspa_cache_dir(), name)
if (refresh || !file.exists(path)) {
ok <- tryCatch({
utils::download.file(url, destfile = path, mode = "wb", quiet = TRUE)
file.exists(path) && file.size(path) > 0L
}, error = function(e) FALSE, warning = function(w) FALSE)
if (!ok) {
if (file.exists(path)) unlink(path)
stop("Could not retrieve '", name, "' from:\n ", url,
"\nIf you are offline, place the file at:\n ", path,
call. = FALSE)
}
}
reader(path, ...)
}
#' Fingerprint a cached file so you can prove which version you analyzed.
dspa_fingerprint <- function(name) {
path <- file.path(dspa_cache_dir(), name)
if (!file.exists(path)) return(NA_character_)
unname(tools::md5sum(path))
}Using it:
ppmi <- dspa_read(
url = "https://umich.instructure.com/files/330397/download?download_frd=1",
name = "05_PPMI_top_UPDRS_Integrated_LongFormat1.csv"
)
dim(ppmi)#> [1] 1764 31
#> MD5: 03e27492af90cc68f0a8497483d183df
The fingerprint is the point. A results table is not
reproducible unless you can demonstrate which bytes produced
it. Record the checksum in your methods section alongside the
URL and retrieval date. (tools::md5sum() needs no extra
package; for anything security-sensitive prefer
openssl::sha256().)
To persist the cache between sessions, point it somewhere durable:
?lm # help for a known function
help("lm") # equivalent
??"linear model" # fuzzy search across installed packages
example(lm) # run the documented examples
vignette(package = "dplyr") # long-form tutorials
browseVignettes() # everything installed
args(lm) # just the signatureBeyond the console:
[r],
post a reprex (minimal
reproducible example) and you will usually get an answer within
hours.Most readers of this edition will use an LLM assistant while working through it. Pretending otherwise would be unhelpful, so here is the honest guidance.
What they are good at
Where they fail, specifically in statistical work
A workable discipline
foo(bar = TRUE), run ?foo before believing
it.The skill this book teaches, knowing what a method assumes, what it estimates, and how it fails, is exactly the skill required to use an assistant safely. It gets more valuable as the tools improve, not less.
Quarto is the successor to R
Markdown: multi-language, multi-format, and maintained as the primary
authoring path by Posit. Existing .Rmd files render under
Quarto largely unmodified; new documents use .qmd.
Install from quarto.org/docs/get-started
(RStudio and Positron bundle a copy). Create one via File → New File
→ Quarto Document, or the command palette
(Ctrl/Cmd + Shift + P → “Quarto”).
Three features that matter for this book:
Inline code. Prose can compute. An inline R
expression, written as `r nrow(iris)`, is evaluated at
render time, so this sentence can state that the iris
dataset has 150 rows without anyone ever typing that number. This is the
mechanism behind the computed statistics throughout this chapter.
Multi-format output. One source renders to HTML, PDF, Word, or slides.
Multi-language chunks. {r},
{python}, {julia}, {ojs}, and many more, in a single
document with shared state where the runtimes support it. Chapter 16’s
Python integration depends on this.
Quarto also supports Observable JS for reactive, client-side interactive figures, useful when you want the reader to explore rather than watch.
This section covers what you need to read and write the code in the rest of the book. It is deliberately short. Comprehensive function tables live in Appendix A; reach for them when you need a name, not when you are learning.
x <- 2 # preferred assignment operator
y = 3 # also legal, but reserve `=` for function arguments
x + y#> [1] 5
R gained a native pipe in version 4.1. DSPA3 uses it throughout:
#> [1] 14
#> [1] 14
You will encounter %>% (from magrittr,
re-exported by dplyr) in older code and in many packages’
documentation. The two are near-equivalent for simple chains.
|> requires no package and is slightly faster;
%>% supports a . placeholder in more
positions. DSPA3 uses |>.
R also gained shorthand lambda syntax in 4.1:
#> [1] 1 4 9 16 25
#> [1] 1 4 9 16 25
Pitfall, T and F.
TRUE and FALSE are reserved words.
T and F are ordinary variables that happen to
be initialized to them, and can be reassigned:
#> [1] TRUE
#> [1] FALSE
Always write TRUE/FALSE.
Six extra keystrokes eliminate an entire category of silent bug.
The vector is R’s fundamental unit. There is no separate scalar type, a “single number” is a length-1 vector.
#> [1] 1 2 3 5 6 7 10 1 4
#> [1] 9
#> [1] "double"
An atomic vector holds one type. Mixing types
triggers coercion, along a fixed hierarchy:
logical → integer → double →
character.
#> [1] 1.0 1.0 2.5
#> [1] "TRUE" "1" "2.5" "a"
#> [1] "character"
This is the single most common source of confusing R bugs. One stray character value in a column silently converts the entire column to text, and every downstream numeric operation then either fails or, worse, succeeds on the wrong thing. You will see a full worked instance in §1.12.3.
Indexing is 1-based, unlike C, Python, or Java:
#> [1] 1
#> [1] 2 3 5 6 7 10 1 4
#> [1] 2 3 5
#> [1] 1 5 2
#> [1] 5 6 7 10
#> [1] 5 6 7 4
Logical subsetting deserves emphasis: nums > 4
produces a logical vector of the same length, and indexing by it keeps
the TRUE positions. Nearly all “filter the data” operations
in R are this pattern.
#> [1] 1.0 3.5 6.0 8.5
#> [1] 1 4 7 10
#> [1] 1 2 3
#> [1] 1 2 3 1 2 3 1 2 3 1 2 3
#> [1] 1 1 1 1 2 2 2 2 3 3 3 3
#> [1] 1 2 3 4 5
Pitfall.
1:length(x)givesc(1, 0)whenxis empty, silently iterating twice over nothing.seq_along(x)givesinteger(0), which is what you meant. Useseq_along()andseq_len().
A list can hold anything, including other lists, R’s general container.
#> List of 4
#> $ a: num [1:2] 1 2
#> $ b: chr "hi"
#> $ c: cplx -3+3i
#> $ d:List of 1
#> ..$ nested: logi TRUE
Extraction uses three distinct operators, and confusing them is a rite of passage:
#> $a
#> [1] 1 2
#> [1] 1 2
#> [1] 1 2
#> [1] 2
#> [1] "list"
#> [1] "numeric"
Mnemonic: a list is a train of boxcars. [ returns a
shorter train; [[ returns the cargo.
A matrix is a vector with a dim attribute, which is why
the coercion rules of §1.7.2 apply to it in
full.
#> [,1] [,2] [,3] [,4]
#> [1,] 1 4 7 10
#> [2,] 2 5 8 11
#> [3,] 3 6 9 12
#> [1] 3 4
#> [1] 8
#> [1] 2 5 8 11
#> [1] 7 8 9
#> [,1] [,2] [,3]
#> [1,] 1 2 3
#> [2,] 4 5 6
#> [3,] 7 8 9
#> [4,] 10 11 12
Arrays generalize to any number of dimensions. In machine learning these are usually called tensors, indexed by their order (number of dimensions):
#> [1] 3 4 2
#> [1] 8
#> [1] 2 5 8 11
Dimension names make code self-documenting:
#> C1 C2 C3 C4
#> R1 1 4 7 10
#> R2 2 5 8 11
#> R3 3 6 9 12
#> [1] 8
A data frame is a list of equal-length vectors presented as a table. It is the workhorse structure for statistics: columns may differ in type, rows are observations.
#> 'data.frame': 4 obs. of 3 variables:
#> $ id : int 1 2 3 4
#> $ group: chr "a" "B" "C" "d"
#> $ score: num 10.2 11.7 9.4 13.1
Recycling fills shorter columns, but only when lengths divide evenly:
#> Error in `data.frame()`:
#> ! arguments imply differing number of rows: 4, 3
The second case is far more common in practice than the first, and the error message is your friend, silent recycling of mismatched data would be worse.
Tibbles (tibble/dplyr) are
data frames with better printing, no partial matching, and no surprising
type conversions. DSPA3 uses plain data frames where base R suffices and
tibbles inside tidyverse pipelines; they are interchangeable for our
purposes.
stringsAsFactors storyBefore R 4.0.0 (April 2020),
data.frame() and read.table() silently
converted character columns to factor. Previous
recommendations to pass as.is = TRUE and
stringsAsFactors = FALSE to prevent it have changed more
recently to keep the character columns stay as characters.
#> [1] "character"
The old advice is now a no-op, and repeating it teaches a model of R that has been wrong for six years. Factors remain the right representation for genuinely categorical variables, you just now create them deliberately:
#> [1] PD HC SWEDD PD
#> Levels: HC PD SWEDD
#> [1] "HC" "PD" "SWEDD"
#> grp
#> HC PD SWEDD
#> 1 2 1
#> [1] 2 1 3 2
Setting levels explicitly is not optional in modeling
work: the first level is the reference category in
lm(), glm(), and most model formulas. Left to
R, levels are alphabetical, so your reference group is whichever label
sorts first, rarely what you intended.
Pitfall.
as.numeric(factor)returns the codes, not the labels. To recover numbers stored as a factor:as.numeric(as.character(f)).
R distinguishes three “nothing” values, and they are not interchangeable:
| Value | Means | Length |
|---|---|---|
NA |
A value exists but is unknown | 1 |
NULL |
There is no value at all | 0 |
NaN |
Undefined numeric result (0/0) |
1 |
#> [1] FALSE FALSE TRUE FALSE FALSE
#> [1] NA
#> [1] 21
#> [1] 5.25
NA propagating through arithmetic is deliberate: it
forces you to decide what missingness means rather than
silently ignoring it. Most summary functions accept
na.rm.
#> [1] TRUE TRUE FALSE TRUE TRUE
#> a b
#> 0 1
Warning.
na.omit()is complete-case analysis. It is unbiased only when data are missing completely at random, and it discards information even then. The missing-data chapter treats MCAR/MAR/MNAR and imputation properly; until then, at minimum always report how many rows you dropped.
#> 'data.frame': 150 obs. of 5 variables:
#> $ Sepal.Length: num 5.1 4.9 4.7 4.6 5 5.4 4.6 5 4.4 4.9 ...
#> $ Sepal.Width : num 3.5 3 3.2 3.1 3.6 3.9 3.4 3.4 2.9 3.1 ...
#> $ Petal.Length: num 1.4 1.4 1.3 1.5 1.4 1.7 1.4 1.5 1.4 1.5 ...
#> $ Petal.Width : num 0.2 0.2 0.2 0.2 0.2 0.4 0.3 0.2 0.2 0.1 ...
#> $ Species : Factor w/ 3 levels "setosa","versicolor",..: 1 1 1 1 1 1 1 1 1 1 ...
#> [1] 150 5
#> [1] 150
#> [1] 5
#> [1] "Sepal.Length" "Sepal.Width" "Petal.Length" "Petal.Width" "Species"
#> [1] "data.frame"
#> [1] "list"
#> Sepal.Length Sepal.Width Petal.Length Petal.Width
#> Min. :4.300 Min. :2.000 Min. :1.000 Min. :0.100
#> 1st Qu.:5.100 1st Qu.:2.800 1st Qu.:1.600 1st Qu.:0.300
#> Median :5.800 Median :3.000 Median :4.350 Median :1.300
#> Mean :5.843 Mean :3.057 Mean :3.758 Mean :1.199
#> 3rd Qu.:6.400 3rd Qu.:3.300 3rd Qu.:5.100 3rd Qu.:1.800
#> Max. :7.900 Max. :4.400 Max. :6.900 Max. :2.500
#> Species
#> setosa :50
#> versicolor:50
#> virginica :50
#>
#>
#>
str() deserves special mention: it is the single most
useful diagnostic function in R, and running it before anything else
prevents a large fraction of type-related confusion.
#> [1] "not big"
#> iteration 1
#> iteration 2
#> iteration 3
#> [1] 3
Prefer vectorized alternatives. They are shorter, faster (§1.6.2), and less error-prone:
scores <- c(55, 91, 78, 43, 88)
# Loop version
grade_loop <- character(length(scores))
for (i in seq_along(scores)) {
grade_loop[i] <- if (scores[i] >= 60) "pass" else "fail"
}
# Vectorized - preferred
grade_vec <- ifelse(scores >= 60, "pass", "fail")
identical(grade_loop, grade_vec)#> [1] TRUE
#> [1] "fail" "pass" "pass" "fail" "pass"
standardize <- function(x, na.rm = TRUE) {
if (!is.numeric(x)) stop("`x` must be numeric.", call. = FALSE)
(x - mean(x, na.rm = na.rm)) / sd(x, na.rm = na.rm)
}
round(standardize(c(2, 4, 4, 4, 5, 5, 7, 9)), 3)#> [1] -1.403 -0.468 -0.468 -0.468 0.000 0.000 0.935 1.871
#> Error:
#> ! `x` must be numeric.
Three habits worth adopting immediately:
return() is for early exits.The *apply family applies a function across elements or
margins.
#> [1] 9 12
#> [1] 3 7 11
#> $a
#> [1] 3
#>
#> $b
#> [1] 90
#> a b
#> 3 90
#> a b
#> 3 90
Prefer vapply() in code others will run: it declares the
expected return type and fails loudly if reality disagrees, where
sapply() will quietly hand back a list instead of a vector
and break something three steps later.
v <- c(1, 2, 4, 2, 2, 5, 6, 4, 7, 8, 8)
fac <- factor(rep(c("low", "mid", "high"), length.out = 11),
levels = c("low", "mid", "high"))
tapply(v, fac, sum) # split-apply-combine over a factor#> low mid high
#> 17 16 16
#> v
#> 1 2 4 5 6 7 8
#> 1 3 2 1 1 1 2
#> v
#> 1 2 4 5 6 7 8
#> 0.09090909 0.27272727 0.18181818 0.09090909 0.09090909 0.09090909 0.18181818
Pitfall.
prop.table(v), passing the raw vector rather thantable(v), divides each observation by the sum of all observations. It runs without complaint and returns something that is not a frequency distribution. The argument toprop.table()must already be a table.
Two-way tabulation and independence testing:
#> Admit
#> Gender Admitted Rejected
#> Male 1198 1493
#> Female 557 1278
#> Call: xtabs(formula = Freq ~ ., data = adm)
#> Number of cases in table: 4526
#> Number of factors: 3
#> Test for independence of all factors:
#> Chisq = 2000.3, df = 16, p-value = 0
This dataset is the textbook illustration of Simpson’s paradox: aggregated over departments, admission rates appear to favor men; within nearly every individual department, they do not. See Bickel, Hammel & O’Connell (1975). Keep it in mind, the same reversal appears whenever a confounder is marginalized away, which is most of the time in observational health data.
d1 <- data.frame(id = c(1, 1, 7, 6, 8), b = c(1, 1, NA, 9, 8))
d2 <- data.frame(id = c(1, 1, 7, 6, 8), c = 1:5)
merge(d1, d2, by = "id")#> [1] 1 2 2 2 4 4 5 6 7 8 8
#> [1] 8 8 7 6 5 4 4 2 2 2 1
#> [1] 10
#> [1] 1
#> [1] 3 8
#> [1] (0,3] (0,3] (3,6] (0,3] (0,3] (3,6] (3,6] (3,6] (6,10] (6,10]
#> [11] (6,10]
#> Levels: (0,3] (3,6] (6,10]
Note on
cbind(). For data frames,cbind()dispatches tocbind.data.frame()and preserves column types. For matrices and vectors it produces a matrix and coerces everything to a common type. The same function name, two very different behaviors, §1.12.3 shows what happens when you get it wrong.
#> [1] 10 2 8 1 7
#> [1] 5 6 5 6 7
#> [1] "PD" "PD" "PD" "PD" "PD" "PD" "PD" "PD"
set.seed(11)
train_idx <- sample(seq_len(nrow(iris)), size = 0.7 * nrow(iris))
train <- iris[train_idx, ]
test <- iris[-train_idx, ]
c(train = nrow(train), test = nrow(test))#> train test
#> 105 45
That last idiom, sample indices, then use -idx for the
complement, is the basic train/test split, and it reappears in every
supervised-learning chapter.
hdp <- dspa_read(
url = "https://umich.instructure.com/files/1628650/download?download_frd=1",
name = "01_hdp.csv"
)
dim(hdp)#> [1] 8525 27
#> 'data.frame': 8525 obs. of 27 variables:
#> $ tumorsize : num 68 64.7 51.6 86.4 53.4 ...
#> $ co2 : num 1.53 1.68 1.53 1.45 1.57 ...
#> $ pain : int 4 2 6 3 3 4 3 3 4 5 ...
#> $ wound : int 4 3 3 3 4 5 4 3 4 4 ...
#> $ mobility : int 2 2 2 2 2 2 2 3 3 3 ...
#> $ ntumors : int 0 0 0 0 0 0 0 0 2 0 ...
#> $ nmorphine : int 0 0 0 0 0 0 0 0 0 0 ...
#> $ remission : int 0 0 0 0 0 0 0 0 0 0 ...
#> [list output truncated]
The base readers and their defaults:
| Function | Default separator | Notes |
|---|---|---|
read.table() |
any whitespace | most general; header = FALSE by default |
read.csv() |
, |
header = TRUE by default |
read.delim() |
tab | header = TRUE by default |
read.fwf() |
fixed widths | supply widths = |
Useful arguments: skip = (lines to ignore),
comment.char = "" (stop treating # as a
comment), na.strings = (which tokens mean missing),
colClasses = (declare types up front, faster and prevents
surprise coercion).
readr::read_csv() is faster on large files, never
converts strings to factors, and reports a column-type summary you
should actually read. rio dispatches on file extension
across dozens of formats, which is convenient for exploration.
tmp_rds <- file.path(tempdir(), "one_object.rds")
tmp_rda <- file.path(tempdir(), "many_objects.RData")
x <- seq(1, 10, by = 0.5)
y <- list(a = 1, b = TRUE, c = "oops")
saveRDS(x, tmp_rds) # ONE object; you name it on read
x2 <- readRDS(tmp_rds)
identical(x, x2)#> [1] TRUE
save(x, y, file = tmp_rda) # MANY objects, with their names baked in
load(tmp_rda) # restores `x` and `y` into the environmentPrefer
saveRDS()/readRDS().
load() silently overwrites existing objects with whatever
names the file happens to carry, an unpleasant surprise in a long
session. readRDS() returns a value you assign yourself.
out_csv <- file.path(tempdir(), "iris_head.csv")
write.csv(head(iris), out_csv, row.names = FALSE)
readLines(out_csv, n = 2)#> [1] "\"Sepal.Length\",\"Sepal.Width\",\"Petal.Length\",\"Petal.Width\",\"Species\""
#> [2] "5.1,3.5,1.4,0.2,\"setosa\""
Pitfall.
write.table(x, file, sep = ", "), comma and space, is not CSV. Every field after the first acquires a leading space, so numeric columns come back as character on re-import. Usesep = ",", or justwrite.csv().
row.names = FALSE is almost always what you want;
otherwise R writes an unnamed first column of row labels that becomes a
mysterious X column when someone reads the file back.
Web services distinguish the URL that displays a file from
the URL that serves its bytes. Google Drive, for example, uses
.../open?id=<ID> for viewing and
.../uc?export=download&id=<ID> for download.
Do not build a workflow on this. Drive interposes a
virus-scan interstitial for larger files, so the download URL returns an
HTML page instead of your data, and read.csv() will
cheerfully parse that HTML into a nonsensical data frame rather than
erroring. For anything that must keep working, use a repository with a
persistent identifier (Zenodo, Dataverse, OSF) and record the checksum
(§1.6.7).
Nearly all tabular data can be arranged two ways:
score_t1, score_t2,
score_t3).time column and a value column.Tidy data (Wickham, 2014) means each variable is a column, each observation a row, each type of observational unit a table. Long format is usually tidy; most statistical modeling and plotting functions expect it. Wide format is usually what people hand you.
Reshaping between them is bijective, no information is lost, which is worth demonstrating rather than asserting.
raw_wide <- read.table(header = TRUE, text = '
CaseID Gender Age Condition1 Condition2
1 M 5 13.0 10.5
2 F 6 16.0 11.2
3 F 8 10.0 18.3
4 M 9 9.5 18.1
5 M 10 12.1 19.0
')
raw_widelibrary(tidyr)
data_long <- pivot_longer(
raw_wide,
cols = c(Age, Condition1, Condition2),
names_to = "Feature",
values_to = "Measurement"
)
head(data_long, 6)data_back <- pivot_wider(
data_long,
names_from = Feature,
values_from = Measurement
)
# Reorder columns to match the original, then compare.
data_back <- as.data.frame(data_back)[, names(raw_wide)]
all.equal(raw_wide, data_back)#> [1] TRUE
all.equal() confirming TRUE is the
demonstration: the transformation is invertible.
Historical note. Note that some older utilities, e.g.,
reshape2::melt()andreshape2::dcast(), along with the entirereshape2package, are now retired by their developers in favor of the newer tidyverse, e.g.,tidyrpackage. Older code may still involvemelt(), which now maps ontopivot_longer(). Base R’sreshape()also exists and does the same job with a more difficult interface. DSPA3 usespivot_longer()/pivot_wider()throughout.
The SOCR Housing Price Index dataset holds state-by-year housing price index, unemployment rate, region, population, and percent change, natively long.
library(rvest)
library(dplyr)
page <- read_html(
"https://wiki.socr.umich.edu/index.php/SOCR_Data_Dinov_010309_HousingPriceIndex"
)
housing_long <- (page |> html_table())[[1]] |>
mutate(
Year = as.integer(Year),
HPI = as.numeric(HPI),
UR = as.numeric(UR),
Pop = as.integer(Pop),
Percent = as.numeric(Percent)
)
dim(housing_long)#> [1] 357 7
housing_wide <- housing_long |>
pivot_wider(
names_from = Year,
values_from = c(HPI, UR, Region, Pop, Percent)
)
dim(housing_wide) # one row per state, year-tagged columns#> [1] 51 36
housing_back <- housing_wide |>
pivot_longer(
cols = -State,
names_to = c(".value", "Year"),
names_sep = "_"
) |>
mutate(Year = as.integer(Year))
# Same information, recovered.
dim(housing_back)#> [1] 357 7
#> [1] TRUE
The ".value" sentinel in names_to tells
pivot_longer() that part of each column name identifies
which variable the value belongs to, and the rest is data. It
is the mechanism that makes the round trip work when several variables
were widened at once.
Pitfall.
names_sep = "_"splits on the first underscore. If any of your value columns contains an underscore in its own name (birth_year), the split lands in the wrong place. Usenames_pattern =with an explicit regular expression when names are not simple.
Reference tables of every math and statistics function live in Appendix A. This section covers the ideas you need to read the rest of the book.
x <- c(2, 4, 4, 4, 5, 5, 7, 9)
c(mean = mean(x), median = median(x), sd = sd(x), var = var(x),
IQR = IQR(x), mad = mad(x))#> mean median sd var IQR mad
#> 5.000000 4.500000 2.138090 4.571429 1.500000 0.741300
#> 0% 25% 50% 75% 100%
#> 2.0 4.0 4.5 5.5 9.0
#> 5% 95%
#> 2.7 8.3
#> [1] 2 9
#> [1] 7
#> [1] -1.403 -0.468 -0.468 -0.468 0.000 0.000 0.935 1.871
mean and sd summarize a distribution well
only when it is roughly symmetric and light-tailed. For skewed or
heavy-tailed data, which is most clinical measurement,
median and IQR, or mad, describe
the data more faithfully. Report both when you are unsure; the
disagreement between them is itself informative.
#> [1] 0.8717538
#> [1] 0.8818981
#> Sepal.Length Sepal.Width Petal.Length Petal.Width
#> Sepal.Length 1.000 -0.118 0.872 0.818
#> Sepal.Width -0.118 1.000 -0.428 -0.366
#> Petal.Length 0.872 -0.428 1.000 0.963
#> Petal.Width 0.818 -0.366 0.963 1.000
Pearson correlation measures linear association only. A perfect deterministic relationship can have a Pearson correlation near zero:
u <- seq(-3, 3, length.out = 200)
round(cor(u, u^2), 10) # exactly determined, essentially uncorrelated#> [1] 0
Always plot before trusting a correlation coefficient. Anscombe’s
quartet (datasets::anscombe) exists precisely to make this
point and is worth five minutes.
R names distribution functions systematically. For any distribution
dist, four functions exist:
| Prefix | Returns | Normal example |
|---|---|---|
d |
density (or mass) \(f(x)\) | dnorm(x) |
p |
cumulative probability \(F(x) = P(X \le x)\) | pnorm(x) |
q |
quantile \(F^{-1}(p)\) | qnorm(p) |
r |
random draws | rnorm(n) |
#> [1] 0.3989423
#> [1] 0.9750021
#> [1] 1.959964
#> [1] -0.9619334 -0.2925257 0.2587882 -1.1521319
The same scheme covers the whole family:
set.seed(3)
c(binomial = rbinom(1, size = 10, prob = 0.3),
poisson = rpois(1, lambda = 4),
uniform = round(runif(1), 3),
exponential= round(rexp(1, rate = 2), 3),
chisq = round(rchisq(1, df = 3), 3),
t = round(rt(1, df = 10), 3))#> binomial poisson uniform exponential chisq t
#> 2.000 6.000 0.385 0.502 2.551 -1.161
Also available: beta, gamma,
weibull, cauchy, f,
geom, hyper, logis,
lnorm, nbinom, wilcox,
signrank. Each takes its own parameters, check
?rgamma before assuming scale versus
rate, a long-standing source of silently wrong
simulations.
Further exploration: the Probability Distributome Project, the SOCR distribution calculators, and the bivariate/trivariate interactive calculators.
#>
#> Welch Two Sample t-test
#>
#> data: g1 and g2
#> t = -2.4553, df = 57.81, p-value = 0.01711
#> alternative hypothesis: true difference in means is not equal to 0
#> 95 percent confidence interval:
#> -2.3505675 -0.2391239
#> sample estimates:
#> mean of x mean of y
#> 10.02260 11.31744
#> Estimate Std. Error t value Pr(>|t|)
#> (Intercept) 3.6835 0.1061 34.7188 0
#> Petal.Length 0.9046 0.0648 13.9624 0
#> Speciesversicolor -1.6010 0.1935 -8.2752 0
#> Speciesvirginica -2.1177 0.2735 -7.7439 0
#> AIC BIC df_resid
#> 106.2327 121.2859 146.0000
#> 1 2 3 4
#> 4.950 4.950 4.859 5.040
#> 1 2 3 4
#> 0.150 -0.050 -0.159 -0.440
Common generics that work across model classes:
predict(), coef(), fitted(),
residuals(), deviance(),
logLik(), AIC(), anova(),
confint(). This consistency is one of R’s real strengths,
swapping lm() for glm() or
randomForest() leaves most downstream code intact.
Other model fitters you will meet: glm() (generalized
linear models), nls() (nonlinear least squares),
loess() (local regression), aov() (ANOVA),
optim() and nlm() (general optimization,
covered in the optimization chapter).
Detailed treatment belongs to the visualization chapter; this is enough to read the figures in Chapters 2–4.
R has three graphics ecosystems, and DSPA3 uses each where it fits:
| System | Strength | Use it for |
|---|---|---|
base (plot, hist,
boxplot) |
Zero dependencies, instant | Quick diagnostic looks while working |
ggplot2 |
Grammar of graphics; consistent, composable | Nearly all published figures |
plotly |
Interactive: zoom, hover, rotate | Genuinely interactive or 3-D figures |
plotly figures embedded in a self_contained
HTML document inflate file size substantially. Reserve it for cases
where interaction adds information.
library(ggplot2)
ggplot(iris, aes(Sepal.Length, Petal.Length, colour = Species)) +
geom_point(alpha = 0.75, size = 2) +
geom_smooth(method = "lm", se = FALSE, linewidth = 0.7) +
labs(title = "Sepal vs. petal length by species",
x = "Sepal length (cm)", y = "Petal length (cm)", colour = NULL) +
theme_minimal(base_size = 12) +
theme(legend.position = "bottom")A ggplot2 figure is built from three required pieces,
data, an aesthetic mapping
(aes()), and at least one geom, plus
optional scales, facets, coordinates, and themes. Everything composes
with +. Once that structure is clear, unfamiliar plot types
become easy to read.
A Q-Q plot compares the quantiles of your data against a reference. If they agree, points fall on the identity line. It is the standard diagnostic for “does this look like the distribution I assumed?” and far more informative than a normality test, which mostly tells you about your sample size.
set.seed(2024)
n <- 500
p <- ppoints(n) # (i - 1/2)/n plotting positions
qq <- data.frame(
theoretical = qnorm(p),
Normal = quantile(rnorm(n), probs = p),
Cauchy = quantile(rcauchy(n), probs = p),
Exponential = quantile(rexp(n, rate = 1), probs = p)
)
qq_long <- tidyr::pivot_longer(qq, cols = -theoretical,
names_to = "sample", values_to = "quantile")
ggplot(qq_long, aes(theoretical, quantile)) +
geom_point(size = 0.8, alpha = 0.7) +
geom_abline(slope = 1, intercept = 0, colour = "firebrick",
linetype = "dashed") +
facet_wrap(~ sample, scales = "free_y") +
labs(title = "Q-Q plots against the standard normal",
subtitle = "Dashed line = perfect agreement",
x = "Theoretical N(0,1) quantiles", y = "Sample quantiles") +
theme_minimal(base_size = 11)Read the panels as three distinct diagnoses:
The shape of the departure tells you what is wrong, which a p-value from a normality test cannot.
Simulation is not a toy exercise. Four production uses:
Simulation starts by measuring what you are imitating. We use the PPMI-derived Parkinson’s dataset (SOCR case study).
ppmi <- dspa_read(
url = "https://umich.instructure.com/files/330397/download?download_frd=1",
name = "05_PPMI_top_UPDRS_Integrated_LongFormat1.csv"
)
dim(ppmi)#> [1] 1764 31
observed <- c(
n = sum(!is.na(ppmi$Age)),
mean = mean(ppmi$Age, na.rm = TRUE),
sd = sd(ppmi$Age, na.rm = TRUE),
median = median(ppmi$Age, na.rm = TRUE),
min = min(ppmi$Age, na.rm = TRUE),
max = max(ppmi$Age, na.rm = TRUE)
)
round(observed, 2)#> n mean sd median min max
#> 1764.00 61.07 10.27 62.15 31.18 83.03
Before building the simulation, a worked instance of the bug from §1.7.2, because it is the most common way a simulation silently produces garbage.
age <- c(62L, 71L, 55L)
dx <- c("PD", "HC", "SWEDD")
# WRONG: cbind() on vectors builds a MATRIX, which must hold one type.
bad <- cbind(age, dx)
class(bad)#> [1] "matrix" "array"
#> [1] "character"
#> age dx
#> [1,] "62" "PD"
#> [2,] "71" "HC"
#> [3,] "55" "SWEDD"
#> [1] NA
#> 'data.frame': 3 obs. of 2 variables:
#> $ age: int 62 71 55
#> $ dx : chr "PD" "HC" "SWEDD"
#> [1] 62.66667
The failure is loud here because mean() complains. In a
longer pipeline it is silent: the character column sorts
lexicographically ("100" < "62"), plots as a factor, and
joins on string equality. Run str() on every object
you build. It costs one line and catches this class of bug
immediately.
We now simulate a PPMI-like study: 282 subjects across three diagnostic groups, each measured at four visits, with imaging biomarkers, genetics, demographics, and longitudinally evolving clinical scores.
set.seed(1234)
n_subj <- 282
visits <- c(0, 6, 12, 18) # months
group_n <- c(HC = 100, PD = 100, SWEDD = 82)
stopifnot(sum(group_n) == n_subj)
# --- Imaging biomarkers: Poisson counts, one column per ROI measure ---------
# Every biomarker generated here is carried into the final dataset.
roi_lambda <- c(
L_caudate_area = 600, L_caudate_volume = 800,
R_caudate_area = 893, R_caudate_volume = 1000,
L_putamen_area = 900, L_putamen_volume = 1400,
R_putamen_area = 1300, R_putamen_volume = 3000,
L_hippocampus_area = 1300, L_hippocampus_volume = 3200,
R_hippocampus_area = 1500, R_hippocampus_volume = 3800,
cerebellum_area = 16700, cerebellum_volume = 14000,
L_lingual_area = 3300, L_lingual_volume = 11000,
R_lingual_area = 3300, R_lingual_volume = 12000,
L_fusiform_area = 3600, L_fusiform_volume = 11000,
R_fusiform_area = 3300, R_fusiform_volume = 10000
)
imaging <- as.data.frame(
lapply(roi_lambda, \(lambda) rpois(n_subj, lambda))
)
dim(imaging) # 282 x 22 - all 22 biomarkers retained#> [1] 282 22
# Distinct seed per chunk: each chunk is then reproducible on its own, without
# depending on how many draws an earlier chunk happened to consume.
set.seed(1235)
# --- Subject-level demographics, diagnosis, genetics -----------------------
dx <- factor(rep(names(group_n), times = group_n),
levels = c("HC", "PD", "SWEDD"))
# Genotype risk differs by group (0/1 minor-allele carrier indicator).
carrier_prob_chr12 <- c(HC = 0.30, PD = 0.60, SWEDD = 0.40)
carrier_prob_chr17 <- c(HC = 0.70, PD = 0.40, SWEDD = 0.50)
subjects <- data.frame(
case_id = sprintf("SIM%04d", seq_len(n_subj)),
dx = dx,
sex = factor(sample(c("F", "M"), n_subj, replace = TRUE),
levels = c("F", "M")),
age = as.integer(rnorm(n_subj, mean = 62, sd = 10)),
weight_kg = as.integer(rnorm(n_subj, mean = 80, sd = 10)),
chr12_rs34637584_GT = rbinom(n_subj, 1, carrier_prob_chr12[as.character(dx)]),
chr17_rs11868035_GT = rbinom(n_subj, 1, carrier_prob_chr17[as.character(dx)])
)
# cbind() on two DATA FRAMES preserves types - contrast with Section 1.12.3.
subjects <- cbind(subjects, imaging)
str(subjects[, 1:8])#> 'data.frame': 282 obs. of 8 variables:
#> $ case_id : chr "SIM0001" "SIM0002" "SIM0003" "SIM0004" ...
#> $ dx : Factor w/ 3 levels "HC","PD","SWEDD": 1 1 1 1 1 1 1 1 1 1 ...
#> $ sex : Factor w/ 2 levels "F","M": 1 1 2 2 1 1 1 2 2 2 ...
#> $ age : int 55 83 64 29 63 66 68 78 59 65 ...
#> $ weight_kg : int 83 84 96 73 87 77 87 85 95 77 ...
#> $ chr12_rs34637584_GT: int 1 1 1 1 1 0 0 0 1 0 ...
#> $ chr17_rs11868035_GT: int 1 1 1 0 0 0 1 1 0 0 ...
#> $ L_caudate_area : int 570 607 608 582 612 585 624 584 588 557 ...
set.seed(1236)
# --- Expand to one row per subject-visit -----------------------------------
pd_sim <- subjects[rep(seq_len(n_subj), each = length(visits)), ]
pd_sim$visit_month <- rep(visits, times = n_subj)
rownames(pd_sim) <- NULL
# --- Clinical scores that actually evolve over time ------------------------
# Baseline severity and progression slope both depend on diagnostic group.
baseline_iii <- c(HC = 3, PD = 22, SWEDD = 12)
slope_iii <- c(HC = 0.0, PD = 0.45, SWEDD = 0.15) # points per month
g <- as.character(pd_sim$dx)
pd_sim$UPDRS_III <- pmax(0, round(
baseline_iii[g] + slope_iii[g] * pd_sim$visit_month +
rnorm(nrow(pd_sim), mean = 0, sd = 3)
))
baseline_ii <- c(HC = 1, PD = 11, SWEDD = 6)
slope_ii <- c(HC = 0.0, PD = 0.20, SWEDD = 0.08)
pd_sim$UPDRS_II <- pmax(0, round(
baseline_ii[g] + slope_ii[g] * pd_sim$visit_month +
rnorm(nrow(pd_sim), mean = 0, sd = 2)
))
pd_sim$UPDRS_I <- rbinom(nrow(pd_sim), size = 2,
prob = c(HC = 0.15, PD = 0.35, SWEDD = 0.25)[g])
dim(pd_sim)#> [1] 1128 33
#> 'data.frame': 1128 obs. of 8 variables:
#> $ case_id : chr "SIM0001" "SIM0001" "SIM0001" "SIM0001" ...
#> $ dx : Factor w/ 3 levels "HC","PD","SWEDD": 1 1 1 1 1 1 1 1 1 1 ...
#> $ sex : Factor w/ 2 levels "F","M": 1 1 1 1 1 1 1 1 2 2 ...
#> $ age : int 55 55 55 55 83 83 83 83 64 64 ...
#> $ visit_month: num 0 6 12 18 0 6 12 18 0 6 ...
#> $ UPDRS_I : int 0 1 0 1 0 0 0 0 0 1 ...
#> $ UPDRS_II : num 2 0 2 0 1 0 3 0 3 0 ...
#> $ UPDRS_III : num 0 2 2 6 0 4 0 7 0 5 ...
Note what this simulation does that a simpler one would not: the clinical scores change over time, at group-specific rates. Note that repeating a single baseline value across all four visits produces data with zero within-subject variation. This is clearly useless for demonstrating any longitudinal method. When using synthetic data to test a mixed model or a GEE, the simulation must contain the structure the model is meant to find.
Generating data is easy. Generating data that resembles the target is the actual task, and it requires checking.
sim_age <- subjects$age # subject-level, not row-duplicated
comparison <- rbind(
observed = c(n = length(na.omit(ppmi$Age)),
mean = mean(ppmi$Age, na.rm = TRUE),
sd = sd(ppmi$Age, na.rm = TRUE),
q25 = unname(quantile(ppmi$Age, 0.25, na.rm = TRUE)),
q75 = unname(quantile(ppmi$Age, 0.75, na.rm = TRUE))),
simulated = c(n = length(sim_age), mean = mean(sim_age),
sd = sd(sim_age),
q25 = unname(quantile(sim_age, 0.25)),
q75 = unname(quantile(sim_age, 0.75)))
)
round(comparison, 2)#> n mean sd q25 q75
#> observed 1764 61.07 10.27 54.07 68.82
#> simulated 282 61.53 9.92 55.00 68.00
fidelity <- rbind(
data.frame(age = as.numeric(na.omit(ppmi$Age)), source = "Observed (PPMI)"),
data.frame(age = sim_age, source = "Simulated")
)
ggplot(fidelity, aes(age, fill = source, colour = source)) +
geom_density(alpha = 0.35, linewidth = 0.8) +
labs(title = "Simulation fidelity: age distribution",
subtitle = "Marginal agreement only - joint structure is not shown here",
x = "Age (years)", y = "Density", fill = NULL, colour = NULL) +
theme_minimal(base_size = 12) +
theme(legend.position = "bottom")# A formal comparison of the two distributions.
suppressWarnings(ks.test(sim_age, as.numeric(na.omit(ppmi$Age))))#>
#> Asymptotic two-sample Kolmogorov-Smirnov test
#>
#> data: sim_age and as.numeric(na.omit(ppmi$Age))
#> D = 0.05131, p-value = 0.544
#> alternative hypothesis: two-sided
Read that result carefully. A large p-value does not establish that the simulated data match the observed data; it establishes only that this test failed to detect a difference at this sample size. Failing to reject is not evidence of equivalence, a distinction worth internalizing now, because it recurs throughout model validation.
More importantly: matching marginals is not matching the joint distribution. Our simulation draws age independently of every biomarker, so any real correlation between age and hippocampal volume is absent by construction. Synthetic data built this way is suitable for testing that code runs and for teaching; it is not suitable for drawing scientific conclusions, and it should never be presented as though it were.
out_path <- file.path(tempdir(), "pd_simulated_cohort.csv")
write.csv(pd_sim, out_path, row.names = FALSE)
cat("Rows:", nrow(pd_sim),
"\nCols:", ncol(pd_sim),
"\nBytes:", file.size(out_path),
"\nMD5:", unname(tools::md5sum(out_path)), "\n")#> Rows: 1128
#> Cols: 33
#> Bytes: 171348
#> MD5: b94f78fb020db2d527719b2c4cf30729
Writing to tempdir() keeps the repository clean (§1.6.6). The checksum lets you prove later which
version of the synthetic cohort produced a given result, the same
provenance discipline applied to real data in §1.6.7.
A consolidated list of the traps introduced above, plus a few more. Most bugs you will hit in your first year of R are on this list.
| # | Pitfall | Symptom | Fix |
|---|---|---|---|
| 1 | T/F instead of
TRUE/FALSE |
Code that worked yesterday now behaves oppositely | Always spell them out |
| 2 | cbind() on mixed-type vectors |
Numbers become strings; mean() returns
NA |
Use data.frame() |
| 3 | prop.table(v) instead of
prop.table(table(v)) |
Plausible numbers that are not proportions | Tabulate first |
| 4 | 1:length(x) on an empty vector |
Loop body runs twice on nothing | seq_along(x) |
| 5 | as.numeric(factor) |
Returns 1,2,3… instead of the labels | as.numeric(as.character(f)) |
| 6 | Unset factor levels |
Reference category is alphabetical, not intended | Set levels = explicitly |
| 7 | Partial argument matching (rnorm(n, m = 5)) |
Works until a new argument shadows it | Spell arguments in full |
| 8 | No set.seed() |
Results change every run; prose contradicts output | Seed every stochastic chunk |
| 9 | setwd() in a script |
Runs on one machine only | Projects + here() |
| 10 | Writing output to the working directory | Knitting litters the repo | tempfile() / tempdir() |
| 11 | sep = ", " in write.table() |
Columns re-import as character | sep = "," or write.csv() |
| 12 | load() over an existing object |
Silent overwrite of a variable you needed | saveRDS()/readRDS() |
| 13 | na.omit() without reporting |
Silent, possibly biased, sample-size reduction | Report rows dropped; consider imputation |
| 14 | library(base) |
Nothing; implies otherwise | Delete it |
| 15 | = vs == |
Accidental assignment inside a condition | R errors on if (x = 1), but check subset()
calls |
| 16 | Floating-point equality | 0.1 + 0.2 == 0.3 is FALSE |
all.equal() or isTRUE(all.equal()) |
| 17 | sapply() returning a list |
Downstream code breaks far from the cause | vapply() with an explicit template |
| 18 | Trusting a correlation without a plot | Nonlinear structure invisible | Plot first, always |
#> [1] FALSE
#> [1] TRUE
#> [1] TRUE
Attempt each before opening the solution. The point is the attempt.
Without running the code, predict typeof() for each
expression. Then verify.
a <- c(1L, 2L, 3L)
b <- c(1L, 2.5)
d <- c(TRUE, FALSE, 1L)
e <- c(1, "2", TRUE)
f <- list(1L, "2", TRUE)
g <- c(list(1), 2)#> [1] "integer"
#> [1] "double"
#> [1] "integer"
#> [1] "character"
#> [1] "list"
#> [1] "list"
The hierarchy is logical → integer →
double → character, and any element of the
higher type pulls the whole atomic vector up. Lists escape this
entirely, which is why data frames (lists of columns) can mix types
while matrices (dimensioned vectors) cannot.
Using the patients data from §1.5.6, find a generalization of the
quasi-identifiers that achieves \(k \ge
5\). Then check whether it is vulnerable to a homogeneity attack,
that is, whether any equivalence class has a single distinct
diagnosis.
set.seed(2026)
n <- 5000
patients <- data.frame(
zip5 = sample(48103:48109, n, replace = TRUE),
birth_year = sample(1935:2005, n, replace = TRUE),
sex = sample(c("F", "M"), n, replace = TRUE),
diagnosis = sample(c("HC", "PD", "SWEDD"), n, replace = TRUE,
prob = c(0.5, 0.35, 0.15))
)
patients$zip3 <- patients$zip5 %/% 100
patients$birth_decade <- (patients$birth_year %/% 10) * 10
candidates <- list(
A = c("zip3", "birth_decade", "sex"),
B = c("zip3", "birth_decade"),
C = c("birth_decade", "sex")
)
for (nm in names(candidates)) {
qi <- candidates[[nm]]
key <- do.call(paste, c(patients[qi], sep = "\r"))
size <- as.integer(table(key)[key])
# Homogeneity: distinct diagnoses within each equivalence class
ndx <- tapply(patients$diagnosis, key, \(z) length(unique(z)))
cat(sprintf("%s: k = %4d | min distinct diagnoses per class = %d\n",
nm, min(size), as.integer(min(ndx))))
}#> A: k = 155 | min distinct diagnoses per class = 3
#> B: k = 347 | min distinct diagnoses per class = 3
#> C: k = 155 | min distinct diagnoses per class = 3
Design A already reaches \(k \ge 5\)
here because the simulated population is uniform over a small set of
ZIPs. Real populations are not uniform, and sparse
cells appear wherever a rare combination exists, which is exactly where
the vulnerable individuals are. Note also that even at \(k \ge 5\), a class whose members all share
one diagnosis leaks that diagnosis without identifying anyone. That is
the gap \(\ell\)-diversity is designed
to close.
The frame below is wide with an inconsistent naming scheme. Reshape
it to long with separate measure and timepoint
columns, then reshape it back and prove the round trip is lossless.
messy <- data.frame(
subject = c("s1", "s2", "s3"),
bp_pre = c(140, 135, 150),
bp_post = c(128, 130, 141),
hr_pre = c( 88, 76, 92),
hr_post = c( 74, 71, 80)
)library(tidyr)
messy <- data.frame(
subject = c("s1", "s2", "s3"),
bp_pre = c(140, 135, 150), bp_post = c(128, 130, 141),
hr_pre = c( 88, 76, 92), hr_post = c( 74, 71, 80)
)
tidy <- pivot_longer(
messy,
cols = -subject,
names_to = c("measure", "timepoint"),
names_sep = "_",
values_to = "value"
)
tidyback <- pivot_wider(
tidy,
names_from = c(measure, timepoint),
values_from = value,
names_sep = "_"
)
back <- as.data.frame(back)[, names(messy)]
all.equal(messy, back)#> [1] TRUE
names_to = c("measure", "timepoint") with
names_sep = "_" splits each column name into two variables
at once. Had a measure been called resp_rate_pre, the split
would land in the wrong place, that is when you switch to
names_pattern = "^(.*)_(pre|post)$".
Write a function that simulates a two-group comparison (\(n\) per group, true mean difference \(\delta\), common SD \(\sigma\)), runs a two-sample \(t\)-test, and returns whether \(p < 0.05\). Use it to estimate power at \(n = 20, 40, 80\) for \(\delta = 0.5\), \(\sigma = 1\).
simulate_one <- function(n, delta, sigma = 1, alpha = 0.05) {
g1 <- rnorm(n, mean = 0, sd = sigma)
g2 <- rnorm(n, mean = delta, sd = sigma)
t.test(g1, g2)$p.value < alpha
}
estimate_power <- function(n, delta, sigma = 1, reps = 2000, seed = 99) {
set.seed(seed)
mean(replicate(reps, simulate_one(n, delta, sigma)))
}
powers <- vapply(c(20, 40, 80),
\(n) estimate_power(n, delta = 0.5),
numeric(1))
names(powers) <- paste0("n=", c(20, 40, 80))
round(powers, 3)#> n=20 n=40 n=80
#> 0.342 0.594 0.872
# Cross-check against the closed-form calculation
vapply(c(20, 40, 80),
\(n) power.t.test(n = n, delta = 0.5, sd = 1)$power,
numeric(1)) |> round(3)#> [1] 0.338 0.598 0.882
The two approaches agree to within Monte Carlo error, which validates
the simulation. That agreement is the point: once you trust the
machinery on a problem with a known answer, you can apply it to designs,
clustered, unequal variance, non-normal, with dropout, where no closed
form exists. This is exactly the “test on data with a known answer”
discipline from §1.6.8.
Extend dspa_read() into
dspa_read_provenance() that returns both the data and a
metadata record: source URL, retrieval timestamp, file size, MD5
checksum, and dimensions. Explain why a Methods section should contain
this.
dspa_read_provenance <- function(url, name, reader = utils::read.csv, ...) {
dat <- dspa_read(url, name, reader = reader, ...)
path <- file.path(dspa_cache_dir(), name)
list(
data = dat,
provenance = list(
source_url = url,
local_name = name,
retrieved_at = format(Sys.time(), "%Y-%m-%d %H:%M:%S %Z"),
bytes = unname(file.size(path)),
md5 = unname(tools::md5sum(path)),
n_rows = nrow(dat),
n_cols = ncol(dat),
r_version = R.version.string
)
)
}rec <- dspa_read_provenance(
"https://umich.instructure.com/files/330397/download?download_frd=1",
"05_PPMI_top_UPDRS_Integrated_LongFormat1.csv"
)
str(rec$provenance)#> List of 8
#> $ source_url : chr "https://umich.instructure.com/files/330397/download?download_frd=1"
#> $ local_name : chr "05_PPMI_top_UPDRS_Integrated_LongFormat1.csv"
#> $ retrieved_at: chr "2026-08-25 12:27:58 EDT"
#> $ bytes : num 357211
#> $ md5 : chr "03e27492af90cc68f0a8497483d183df"
#> $ n_rows : int 1764
#> $ n_cols : int 31
#> $ r_version : chr "R version 4.3.3 (2024-02-29 ucrt)"
Why it belongs in Methods: a URL alone identifies a
location, not content. Files at stable URLs get
corrected, re-exported, and re-uploaded. Absent a checksum, a reader who
downloads “the same” dataset in three years and gets different numbers
has no way to tell whether you made an error or the file changed. The
checksum converts “we used the PPMI data” into a falsifiable claim. This
is the mechanical form of the provenance obligation in §1.5.5.
You are reviewing a manuscript containing this sentence:
“We selected Julia because it is 2,941 times faster than R (see Table 2).”
Table 2 has no source, no workload description, and no hardware details. Write a three-sentence reviewer comment. Then state what evidence would justify the sentence.
A defensible comment:
Table 2 reports speed ratios without specifying the benchmark workload, hardware, language versions, or BLAS configuration, so the reported factor cannot be reproduced or interpreted. Relative language performance is highly workload-dependent, vectorized R and optimized Julia often differ by less than an order of magnitude on array-heavy tasks, while tight scalar loops can differ by three, so a single scalar ratio is not informative about this study’s computations. Please replace the table with a timing of the actual analysis pipeline on the study hardware, with code and versions deposited so the comparison is reproducible.
Evidence that would justify it: a benchmark of the specific computation the paper performs, on stated hardware, with stated versions, with the implementation in both languages made available, and, because a single timing is a sample of size one, repeated enough times to report variability.
Compare with your own measurement in §1.6.2: the loop-versus- vectorized gap within R exceeded the gap most cross-language tables report between languages. Implementation dominates language choice for nearly all analytic code.Answer without scrolling back.
setwd("C:/Users/pat/thesis"). Name two things that break
and the fix for each.k_anonymity() reports \(k =
1\) for 340 of 5,000 records. What does that mean operationally,
and what are two ways to address it?set.seed() in every stochastic
chunk rather than once at the top of a file?NA, a value exists but is unknown (an unrecorded lab
result). NULL, there is no value at all (an absent list
element; length 0). NaN, an undefined numeric result
(0/0).here() for all paths; pin
dependencies with renv so package versions travel with the
code.What this chapter established
Where each thread continues
| Thread from this chapter | Continues in |
|---|---|
| Data import, quality, missingness | Data handling |
| Visualization beyond first principles | Visual analytics & EDA |
| Matrices, linear models | Linear algebra & regression |
| What accuracy and cross-validation mean | Model assessment |
| Supervised prediction | Classification |
| High-dimensional structure | Dimensionality reduction |
| Which variables matter | Feature selection |
| Repeated measures over time | Longitudinal analysis |
| Fitting as optimization | Function optimization |
| Scale, streaming, deployment | Specialized topics |
Before moving on, confirm you can: install and
attach a package; create a project and read data through
dspa_read(); explain what str() tells you
about an object; reshape a table both directions; and simulate a dataset
with a known answer and verify a method recovers it.
Forward references throughout this chapter resolve here. Each entry names the DSPA3 chapter that develops the thread, so you can jump straight to a topic without reading linearly.
Lookup material. Skim once; return when you need a name.
| Function | Returns |
|---|---|
abs, sqrt, exp,
log(x, base), log10, log2,
log1p, expm1 |
Elementary functions |
sin, cos, tan,
asin, acos, atan,
atan2 |
Trigonometric |
ceiling, floor, round(x, n),
signif, trunc |
Rounding |
choose(n, k), factorial(n),
gamma, lgamma, beta |
Combinatorial / special |
max, min, range,
sum, prod, diff |
Aggregation |
cumsum, cumprod, cummax,
cummin |
Cumulative |
pmax(x, y), pmin(x, y) |
Element-wise extremes across vectors |
union, intersect, setdiff,
setequal, is.element |
Set operations |
Re, Im, Mod,
Arg, Conj |
Complex numbers |
fft, mvfft, convolve,
filter |
Signal processing |
Most aggregation functions accept na.rm = TRUE.
| Function | Returns |
|---|---|
mean, median,
weighted.mean(x, w) |
Central tendency |
var, sd, IQR,
mad |
Spread |
quantile(x, probs), fivenum |
Order statistics |
cor(x, y, method), cov |
Association |
scale(x, center, scale) |
Standardization |
rank, order, sort,
rev |
Ordering |
table, prop.table, xtabs,
ftable |
Frequencies |
t.test, wilcox.test,
chisq.test, fisher.test |
Common tests |
prop.test, binom.test,
shapiro.test, ks.test |
More tests |
aov, anova, TukeyHSD |
ANOVA |
density(x), ecdf(x) |
Distribution estimates |
help.search("test") lists everything installed.
| Expression | Meaning |
|---|---|
t(x) |
Transpose |
diag(x) |
Diagonal (or build a diagonal matrix) |
A %*% B |
Matrix product |
A * B |
Element-wise product |
solve(A) |
Inverse |
solve(A, b) |
Solve \(Ax = b\) (preferred over
solve(A) %*% b) |
crossprod(A) |
\(A^\top A\), faster than
t(A) %*% A |
det(A), qr(A), chol(A),
svd(A), eigen(A) |
Decompositions |
rowSums, colSums, rowMeans,
colMeans |
Fast margins |
nrow, ncol, dim,
dimnames |
Shape |
#> [1] 1.785714 2.357143
#> [,1] [,2]
#> [1,] 1 0
#> [2,] 0 1
Full treatment in the linear algebra chapter.
| Function | Purpose |
|---|---|
paste(..., sep), paste0(...) |
Concatenate |
sprintf(fmt, ...) |
Formatted construction |
nchar, substr, substring |
Length, extraction |
strsplit(x, split, fixed) |
Split |
grep, grepl, regexpr,
gregexpr |
Search |
sub, gsub |
Replace first / all |
toupper, tolower, trimws |
Case, whitespace |
startsWith, endsWith |
Prefix / suffix tests |
format, formatC,
prettyNum |
Display formatting |
#> [1] "going gets tough, the tough get"
#> [1] "a" "b" "c"
#> [1] "When the going gets TOUGH, the TOUGH get going!"
#> [1] "77.78% complete (7 of 9)"
The stringr package offers a more consistent interface
(str_detect, str_replace,
str_extract) over the same regular-expression engine.
Date stores calendar dates; POSIXct stores
date-times with time zones.
#> [1] "2026-09-22"
#> [1] "Sunday, August 23, 2026"
#> [1] "2026-08-23" "2026-09-23" "2026-10-23" "2026-11-23"
#> Time difference of 124 days
| Code | Meaning | Code | Meaning |
|---|---|---|---|
%Y |
4-digit year | %M |
Minute (00–59) |
%y |
2-digit year (avoid) | %S |
Second (00–61, leap seconds) |
%m |
Month (01–12) | %p |
AM/PM |
%b, %B |
Month name, abbrev./full | %j |
Day of year (001–366) |
%d |
Day of month (01–31) | %U, %W |
Week (Sunday-, Monday-based) |
%a, %A |
Weekday name, abbrev./full | %z |
UTC offset (output) |
%H, %I |
Hour (00–23 / 01–12) | %Z |
Time zone name (output) |
See ?strptime. The lubridate package
simplifies parsing (ymd(), mdy_hms()) and
arithmetic across time zones and daylight-saving transitions, worth it
as soon as dates become non-trivial.
High-level (start a plot): plot,
hist, boxplot, barplot,
pie, dotchart, matplot,
pairs, coplot, mosaicplot,
assocplot, fourfoldplot, stars,
symbols, contour, filled.contour,
image, persp, qqnorm,
qqplot, curve, sunflowerplot,
interaction.plot, ts.plot,
plot.ts, termplot.
Low-level (add to an existing plot):
points, lines, text,
mtext, segments, arrows,
abline, rect, polygon,
legend, title, axis,
rug, grid, box.
Common parameters (via par() or passed
directly): main, sub, xlab,
ylab, xlim, ylim,
col, pch, lty, lwd,
cex, font, las, bty,
bg, adj, mar, mfrow,
mfcol, pty, xaxt,
yaxt, tck.
#> [1] 657
#> [1] "white" "aliceblue" "antiquewhite" "antiquewhite1"
#> [5] "antiquewhite2" "antiquewhite3" "antiquewhite4" "aquamarine"
DSPA3 uses ggplot2 for nearly all published figures;
base graphics remain excellent for fast diagnostic looks while you
work.
Programs that produce wrong answers usually contain logical errors, not syntax errors. Debugging is the systematic process of locating them.
An important distinction: some tools require an interactive session and do nothing useful inside a knitted document. The table marks which is which.
| Tool | Purpose | Works in a knit? |
|---|---|---|
traceback() |
Show the call stack after an error | Interactive only |
tryCatch() |
Catch a condition and handle it | ✅ Yes |
withCallingHandlers() |
Observe conditions without stopping | ✅ Yes |
try() |
Continue past an error | ✅ Yes |
stopifnot() |
Assert an invariant | ✅ Yes |
browser() |
Pause at a point in a function | Interactive only |
debug() / debugonce() |
Step through a function | Interactive only |
trace() |
Inject code into an existing function | Interactive only |
options(error = recover) |
Post-mortem environment inspection | Interactive only |
f <- function(x) x - g(x)
g <- function(y) y * h(y)
h <- function(z) {
r <- log(z) # log of a negative number -> NaN
if (r < 10) r^2 else r^3
}
f(-1)#> Error in `if (r < 10) ...`:
#> ! missing value where TRUE/FALSE needed
The error is not in f(). log(-1) returns
NaN with a warning, then if (NaN < 10)
fails because the condition is missing. The reported location
and the actual defect are two calls apart, which is precisely
why a call stack matters.
Interactively, run traceback() immediately after the
error:
Read it bottom-up: f called g called
h, and h is where it broke.
safe_log <- function(z) {
tryCatch(
{
r <- log(z)
if (is.nan(r)) stop("log() produced NaN for input ", z, call. = FALSE)
r
},
error = function(e) { message("Handled error: ", conditionMessage(e)); NA_real_ },
warning = function(w) { message("Handled warning: ", conditionMessage(w)); NA_real_ }
)
}
safe_log(10)#> [1] 2.302585
#> [1] NA
Look closely at the second result. log(-1) emits a
warning (“NaNs produced”) before our
stop() is ever reached, so the warning handler fires and
the error branch never runs. tryCatch() responds to the
first matching condition and abandons the rest of the block, which is
exactly the sort of control-flow detail that turns into a mysterious bug
when a handler quietly swallows something you meant to see.
tryCatch() stops execution at the
condition and runs your handler. When you want to observe a
warning without aborting, use withCallingHandlers() plus
invokeRestart("muffleWarning"):
noisy <- function() { warning("something is off"); "finished anyway" }
withCallingHandlers(
noisy(),
warning = function(w) {
message("Logged: ", conditionMessage(w))
invokeRestart("muffleWarning")
}
)#> [1] "finished anyway"
The cheapest debugging is the error you never have to find:
compute_bmi <- function(weight_kg, height_m) {
stopifnot(
is.numeric(weight_kg), is.numeric(height_m),
length(weight_kg) == length(height_m),
all(height_m > 0, na.rm = TRUE)
)
weight_kg / height_m^2
}
round(compute_bmi(c(70, 85), c(1.75, 1.80)), 2)#> [1] 22.86 26.23
#> Error in `compute_bmi()`:
#> ! all(height_m > 0, na.rm = TRUE) is not TRUE
Assertions convert a wrong answer three steps downstream into an immediate, locatable failure. In a data pipeline they are worth writing liberally: check row counts after a join, check that proportions sum to one, check that no identifier is duplicated.
Run these in the console, not in a knit:
SS <- function(mu, x) {
d <- x - mu
d2 <- d^2
sum(d2)
}
set.seed(100); x <- rnorm(100)
debugonce(SS) # step through the next call only
SS(1, x)Inside the Browse[1]> prompt:
| Key | Action |
|---|---|
n |
Execute the current line, print the next |
s |
Step into the function being called |
f |
Finish the current loop or function |
c |
Continue to the end (or next breakpoint) |
Q |
Quit the debugger |
where |
Print the call stack |
ls() |
List objects in the current frame |
To pause at a chosen point rather than the start, insert
browser(). Guard it so it never fires during a render:
debug(f) flags a function permanently until
undebug(f); debugonce(f) is almost
always what you want, since a forgotten debug()
flag makes later code behave mysteriously.
For post-mortem inspection of a failure deep in a call stack:
traceback(), or bisect by
commenting out.str() on
every input.Step 6 is the one people skip. A fixed bug without a regression check is a bug waiting to be reintroduced.
Books (free online)
Tutorials and courses
Reference
SOCR / DSPA
Reproducibility
renv · here · targets for
pipeline orchestration.Every DSPA3 chapter records the environment that produced it. Include this in anything you share.
#> R version 4.3.3 (2024-02-29 ucrt)
#> Platform: x86_64-w64-mingw32/x64 (64-bit)
#> Running under: Windows 11 x64 (build 26200)
#>
#> Matrix products: default
#>
#>
#> locale:
#> [1] LC_COLLATE=English_United States.utf8
#> [2] LC_CTYPE=English_United States.utf8
#> [3] LC_MONETARY=English_United States.utf8
#> [4] LC_NUMERIC=C
#> [5] LC_TIME=English_United States.utf8
#>
#> time zone: America/New_York
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] dplyr_1.1.4 rvest_1.0.4 tidyr_1.3.1 ggplot2_4.0.1
#>
#> loaded via a namespace (and not attached):
#> [1] Matrix_1.6-5 gtable_0.3.6 jsonlite_1.8.9 compiler_4.3.3
#> [5] promises_1.3.2 Rcpp_1.0.14 tidyselect_1.2.1 xml2_1.3.6
#> [9] later_1.4.1 jquerylib_0.1.4 splines_4.3.3 scales_1.4.0
#> [13] yaml_2.3.10 fastmap_1.2.0 lattice_0.22-6 R6_2.6.1
#> [17] labeling_0.4.3 generics_0.1.3 curl_6.2.0 knitr_1.51
#> [21] tibble_3.2.1 bslib_0.9.0 pillar_1.10.1 RColorBrewer_1.1-3
#> [25] rlang_1.1.5 websocket_1.4.1 cachem_1.1.0 xfun_0.52
#> [29] sass_0.4.9 S7_0.2.1 otel_0.2.0 cli_3.6.3
#> [33] mgcv_1.9-1 withr_3.0.2 magrittr_2.0.3 ps_1.9.0
#> [37] processx_3.8.6 digest_0.6.37 grid_4.3.3 rstudioapi_0.18.0
#> [41] nlme_3.1-165 chromote_0.4.0 lifecycle_1.0.5 vctrs_0.6.5
#> [45] evaluate_1.0.3 glue_1.8.0 farver_2.1.2 rmarkdown_2.31
#> [49] purrr_1.0.2 httr_1.4.7 tools_4.3.3 pkgconfig_2.0.3
#> [53] htmltools_0.5.8.1