HSS 2381: Quantitative Methods in Health Sciences

Continuous variables from A to Z: a complete R workbook using World Bank open data

Author

Teaching workbook

Welcome and scope

Health sciences students need more than a list of statistical tests. They need to understand what a variable means, how a dataset was made, what a graph shows, which comparison answers a question, and how much confidence a conclusion deserves. This workbook builds those skills in order, using one connected set of real, freely accessible World Bank indicators.

Every substantive dataset in the analyses comes from the World Bank Indicators API. There are no invented countries, fabricated health measurements or synthetic patient records. Bootstrap samples, permutations and teaching subsamples reuse the downloaded observations; they do not create new observed data. Numerical constants such as a 70-year reference mean or a two-year planning difference are explicitly hypothetical teaching choices, not World Bank estimates.

ImportantWhat these data can and cannot teach

The unit is a country or economy, not a patient. Country-level life expectancy is a population indicator, not an individual’s observed lifespan. Countries are not a simple random sample of people, and available economies are not necessarily a probability sample of all economies. Formal p-values and confidence intervals in this workbook are model-based teaching illustrations, conditional on assumptions about an underlying country-level process. They do not automatically justify population sampling claims, individual-level conclusions or causal effects. Geographic dependence, shared history and measurement uncertainty can invalidate simple independent-observation models.

0.1 Learning outcomes

By the end, you should be able to:

  1. Distinguish variables, observations, parameters, statistics, populations and samples.
  2. Import, audit, reshape and document real open data reproducibly.
  3. Describe centre, spread, shape, missingness and unusual observations.
  4. Use probability, normal and t distributions, standardisation and sampling distributions.
  5. Explain standard errors, confidence intervals, hypotheses, p-values, errors and power.
  6. Conduct and interpret one-sample, independent and paired t-tests.
  7. Conduct one-way ANOVA, Welch ANOVA, post-hoc comparisons and rank-based alternatives.
  8. Measure association with Pearson and Spearman correlations.
  9. Fit, interpret, diagnose and compare simple and multiple linear regression models.
  10. Distinguish statistical evidence, practical importance, prediction and causation.
  11. Report estimates, uncertainty, assumptions, sample sizes and limitations together.
  12. Complete a reproducible mini-project with tables, figures and a transparent analysis trail.

0.2 Suggested teaching sequence

Block Main topics Suggested practical task
1 Research questions, measurement, R and provenance Download and audit the indicators
2 Data preparation, missingness and descriptive statistics Build a descriptive table
3 Visualisation, probability and distributions Explain a histogram and a normal approximation
4 Sampling, standard errors and confidence intervals Bootstrap a country-level mean
5 Hypothesis testing and one-sample t-tests Compare with a stated reference
6 Independent and paired comparisons Compare income groups and matched years
7 ANOVA and multiple comparisons Compare four income groups
8 Non-parametric methods and correlation Contrast rank and mean-based analyses
9 Simple linear regression Model life expectancy and log expenditure
10 Multiple regression and diagnostics Adjust for GDP and urbanisation
11 Extensions, sensitivity and prediction Evaluate a held-out prediction model
12 Reporting and synthesis Complete the capstone and explain limitations

This sequence is a teaching suggestion, not a statement about the university’s timetable.

1 Getting started with R and Quarto

1.1 Software and rendering

Validation note: the document structure, YAML, chunk labels and code delimiter balance were checked during preparation. R and Quarto were not installed in the preparation environment, so the analyses have not been executed or render-tested here. The World Bank API also could not be downloaded from that environment; no data snapshot or numerical results are claimed. The code downloads the real data on your first successful render and retains a cache.

Install R, a current RStudio or another suitable editor, and Quarto. Save this file in a project folder. The first render needs internet access to download packages if absent and to obtain World Bank data. Later renders use a local RDS cache unless refresh_data: true is set in the YAML. The rendered HTML embeds its visual resources, but the QMD source itself is not an offline data snapshot.

Run the following once in the R console if your computer does not permit package installation during rendering:

install.packages(c("rmarkdown", "knitr", "jsonlite", "curl"),
                 repos = "https://cloud.r-project.org")

Then use Render in RStudio, or run this in a terminal:

quarto render HSS2381_Continuous_Variables_World_Bank.qmd --to html

The Quarto R guide explains executable chunks and rendering. All executable chunks below are intended to run in document order in a fresh R session. A later chunk can depend on objects created earlier. Do not begin in the middle without running the setup and data preparation.

1.2 Reproducibility settings

needed <- c("knitr", "jsonlite", "curl")
missing_packages <- needed[!vapply(needed, requireNamespace,
                                  quietly = TRUE, FUN.VALUE = logical(1))]
if (length(missing_packages)) {
  install.packages(missing_packages, repos = "https://cloud.r-project.org")
}
if (!all(vapply(needed, requireNamespace, quietly = TRUE,
                FUN.VALUE = logical(1)))) {
  stop("Install knitr, jsonlite and curl, then render again.")
}
knitr::opts_chunk$set(fig.width = 8, fig.height = 5, dpi = 120)
options(stringsAsFactors = FALSE, scipen = 5, timeout = 180)
set.seed(params$seed)
analysis_year <- as.integer(params$analysis_year)
baseline_year <- as.integer(params$baseline_year)
B <- as.integer(params$bootstrap_reps)
P <- as.integer(params$permutation_reps)
stopifnot(baseline_year < analysis_year, B >= 200, P >= 199)
dir.create("hss2381_cache", showWarnings = FALSE)
dir.create("hss2381_outputs", showWarnings = FALSE)
fmt <- function(x, digits = 2) formatC(x, digits = digits, format = "f")
pfmt <- function(p) format.pval(p, digits = 3, eps = 0.001)
tab <- function(x, caption = NULL, digits = 3) {
  knitr::kable(x, caption = caption, digits = digits, row.names = FALSE)
}

A seed makes resampling reproducible for a fixed R version, dataset and code. It does not stop the World Bank from revising historical estimates. Keeping the cached data and session information is therefore important.

1.3 R from the very beginning

R stores values in named objects. <- assigns a value. A function performs an operation. # starts a comment. Names are case-sensitive: life_exp and Life_Exp are different objects. NA means missing, not zero.

course_code <- "HSS 2381"          # Character string
reference_mean <- 70              # Hypothetical teaching reference
is_continuous_course <- TRUE       # Logical value
reference_mean + 2
[1] 72
sqrt(reference_mean)
[1] 8.3666
c(analysis_year, baseline_year)    # Combine values into a vector
[1] 2023 2010
class(reference_mean)
[1] "numeric"

A data frame is a rectangular table. $ selects a column, square brackets select rows and columns, and a logical condition identifies rows. mean(x, na.rm = TRUE) calculates a mean after excluding missing values; it does not explain why those values are missing. We will use base R for most analyses so the statistical operations remain visible and package requirements stay small.

2 Research questions, measurement and study design

2.1 Start with the question, not the test

Our connected questions are:

  • What is the distribution of country-level life expectancy in a fixed year?
  • How different are the means in low-income and high-income economies?
  • How did life expectancy change between two years within the same economies?
  • Do mean life expectancies differ across four income classifications?
  • How is life expectancy associated with health expenditure, GDP and urbanisation?
  • How well can a model predict life expectancy for held-out economies?

These are descriptive, associational or predictive questions. None becomes causal merely because a regression coefficient has a small p-value.

Before choosing a method, write down the outcome, predictor or grouping variable, unit, time, target quantity and design. For example: outcome = life expectancy in years; predictor = health expenditure per capita; unit = economy; time = 2023; target = change in expected life expectancy associated with a doubling of expenditure; design = ecological cross-sectional observational analysis.

2.2 Measurement scales

Type Meaning Example in this workbook Common mistake
Nominal Categories without numerical order World Bank region Averaging category codes
Ordinal Ordered categories Income classification Treating steps as equally spaced amounts
Continuous Values on a numerical continuum Life expectancy, GDP per capita Assuming a rounded value is a count
Discrete count Whole-number counts Population Treating a count as a proportion
Percentage A proportion multiplied by 100 Urban population percentage Confusing percentage points with percentage change

Income group is a categorical predictor even though the outcome is continuous. A continuous-variable course can therefore use group labels in t-tests and ANOVA. We do not turn life expectancy into a binary outcome here, because that discards information and changes the question.

Life expectancy and expenditure have meaningful units. A five-year difference is interpretable directly. A standardised difference is unitless and useful for some comparisons, but it does not replace the original units. GDP and expenditure in current US dollars are affected by prices and exchange rates. Cross-year financial comparisons require more care than a single-year comparison; this workbook uses paired years for life expectancy, not for nominal expenditure growth.

2.3 Design, bias and independence

A cross-sectional dataset has one row per economy in a selected year. A longitudinal dataset has repeated rows for the same economy. The paired t-test respects matching between years. A simple regression on all country-year rows would not automatically respect that dependence.

Selection bias can arise because countries with missing indicators differ from those with complete data. Confounding occurs when another variable helps explain an observed association. Measurement error can occur even in official indicators, which may include estimates or harmonised series. These problems cannot be diagnosed from a p-value alone.

3 Downloading and documenting real World Bank data

3.1 Indicators and sources

The World Bank Indicators API documentation describes the version 2 API and notes that authentication is not required. The country metadata documentation describes country identifiers, region and income fields. The aggregate documentation explains that the API also contains regional and income aggregates. We must remove those aggregate rows before analysing economies.

R name World Bank code Indicator and unit
life_exp SP.DYN.LE00.IN Life expectancy at birth, total, years
health_pc SH.XPD.CHEX.PC.CD Current health expenditure per capita, current US dollars
gdp_pc NY.GDP.PCAP.CD GDP per capita, current US dollars
urban_pct SP.URB.TOTL.IN.ZS Urban population, percentage of total
population SP.POP.TOTL Total population, people

Indicator pages: life expectancy, health expenditure, GDP per capita, urban population, and total population. Read the metadata before interpreting any indicator. The World Bank republishes series from contributing organisations; the health expenditure page identifies WHO as its source. Preserve that attribution.

WarningA fixed year is not a latest-value dataset

The default analysis year is 2023, chosen as a teaching setting rather than a claim that it is the latest year. We request that exact year. Missing 2023 values stay missing. We never silently substitute a country’s most recent available year. Current metadata income classifications are retrieved at download time and are not historical 2023 classifications. All income-group examples use those current labels explicitly.

3.2 A transparent downloader with pagination, retries and caching

The next chunk downloads metadata and each indicator separately. It checks pagination rather than assuming the first page is complete. It records request URLs and retrieval time. If the API is unavailable and no valid cache exists, rendering stops with an informative error rather than fabricating data.

indicators <- c(life_exp = "SP.DYN.LE00.IN",
                health_pc = "SH.XPD.CHEX.PC.CD",
                gdp_pc = "NY.GDP.PCAP.CD",
                urban_pct = "SP.URB.TOTL.IN.ZS",
                population = "SP.POP.TOTL")

fetch_json <- function(url) {
  last_error <- "Unknown download error"
  for (attempt in seq_len(3L)) {
    ans <- tryCatch({
      h <- curl::new_handle(useragent = "HSS2381 educational R workbook",
                            connecttimeout = 30, timeout = 180)
      response <- curl::curl_fetch_memory(url, handle = h)
      if (response$status_code != 200L) {
        stop("HTTP status ", response$status_code)
      }
      jsonlite::fromJSON(rawToChar(response$content), simplifyVector = FALSE)
    }, error = function(e) e)
    if (!inherits(ans, "error")) return(ans)
    last_error <- conditionMessage(ans)
    if (attempt < 3L) Sys.sleep(attempt)
  }
  stop("World Bank download failed: ", last_error,
       "\nRequest: ", url,
       "\nCheck internet/API availability or retain a previous valid cache.")
}

fetch_pages <- function(endpoint, query = "") {
  records <- list()
  urls <- character()
  page <- 1L
  repeat {
    url <- paste0("https://api.worldbank.org/v2/", endpoint,
                  "?format=json&per_page=10000&page=", page, query)
    obj <- fetch_json(url)
    if (length(obj) != 2L || is.null(obj[[1]]$pages) ||
        !is.list(obj[[2]])) {
      stop("Unexpected or empty API response for ", endpoint)
    }
    records <- c(records, obj[[2]])
    urls <- c(urls, url)
    if (page >= as.integer(obj[[1]]$pages)) break
    page <- page + 1L
  }
  list(records = records, urls = urls)
}

scalar <- function(x, default = NA_character_) {
  if (is.null(x) || length(x) == 0L) default else as.character(x[[1]])
}

cache_file <- file.path("hss2381_cache",
                       paste0("wb_v1_", baseline_year, "_", analysis_year, ".rds"))
if (file.exists(cache_file) && !isTRUE(params$refresh_data)) {
  bundle <- readRDS(cache_file)
} else {
  meta_response <- fetch_pages("country")
  metadata <- do.call(rbind, lapply(meta_response$records, function(z) {
    data.frame(iso3c = scalar(z$id), country = scalar(z$name),
               region = scalar(z$region$value),
               region_id = scalar(z$region$id),
               income_id = scalar(z$incomeLevel$id),
               income = scalar(z$incomeLevel$value))
  }))
  # The metadata identifies aggregates with region_id == "NA".
  metadata <- metadata[!is.na(metadata$region_id) &
                         metadata$region_id != "NA" &
                         metadata$region != "Aggregates", ]
  requested_urls <- meta_response$urls
  series <- vector("list", length(indicators))
  names(series) <- names(indicators)
  for (nm in names(indicators)) {
    response <- fetch_pages(
      paste0("country/all/indicator/", indicators[[nm]]),
      paste0("&source=2&date=", baseline_year, ":", analysis_year))
    series[[nm]] <- do.call(rbind, lapply(response$records, function(z) {
      data.frame(iso3c = scalar(z$countryiso3code),
                 year = as.integer(scalar(z$date)),
                 value = as.numeric(scalar(z$value)))
    }))
    names(series[[nm]])[3] <- nm
    requested_urls <- c(requested_urls, response$urls)
  }
  panel <- Reduce(function(a, b) merge(a, b, by = c("iso3c", "year"),
                                       all = TRUE), series)
  panel <- merge(panel, metadata, by = "iso3c", all = FALSE)
  panel <- panel[order(panel$iso3c, panel$year), ]
  rownames(panel) <- NULL
  bundle <- list(panel = panel, metadata = metadata,
                 retrieved_utc = format(Sys.time(), tz = "UTC", usetz = TRUE),
                 indicators = indicators, requested_urls = requested_urls,
                 analysis_year = analysis_year, baseline_year = baseline_year)
  saveRDS(bundle, cache_file)
}
stopifnot(is.list(bundle), is.data.frame(bundle$panel),
          identical(bundle$indicators, indicators),
          bundle$analysis_year == analysis_year,
          bundle$baseline_year == baseline_year)
panel <- bundle$panel
stopifnot(!anyDuplicated(panel[c("iso3c", "year")]))
cat("Retrieved (UTC):", bundle$retrieved_utc, "\n")
Retrieved (UTC): 2026-10-17 12:45:09 UTC 
cat("Country-year rows:", nrow(panel), "\n")
Country-year rows: 3038 
cat("Distinct economies:", length(unique(panel$iso3c)), "\n")
Distinct economies: 217 

The cache includes the metadata and all requested series. It is not a permanent guarantee of historical classification accuracy. Keep a copy with any submitted analysis. If you change indicator definitions in the source, use a new cache version or refresh deliberately.

3.3 Audit the imported values

str(panel)
'data.frame':   3038 obs. of  12 variables:
 $ iso3c     : chr  "ABW" "ABW" "ABW" "ABW" ...
 $ year      : int  2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 ...
 $ life_exp  : num  74.3 74.6 74.8 75.1 75.3 ...
 $ health_pc : num  NA NA NA NA NA NA NA NA NA NA ...
 $ gdp_pc    : num  24093 25712 25120 25814 26130 ...
 $ urban_pct : num  64.2 64 63.8 63.6 63.4 ...
 $ population: num  101838 102591 104110 105675 106807 ...
 $ country   : chr  "Aruba" "Aruba" "Aruba" "Aruba" ...
 $ region    : chr  "Latin America & Caribbean " "Latin America & Caribbean " "Latin America & Caribbean " "Latin America & Caribbean " ...
 $ region_id : chr  "LCN" "LCN" "LCN" "LCN" ...
 $ income_id : chr  "HIC" "HIC" "HIC" "HIC" ...
 $ income    : chr  "High income" "High income" "High income" "High income" ...
head(panel[c("country", "iso3c", "year", names(indicators))])
stopifnot(all(is.na(panel$life_exp) |
                (panel$life_exp > 0 & panel$life_exp < 130)))
stopifnot(all(is.na(panel$urban_pct) |
                (panel$urban_pct >= 0 & panel$urban_pct <= 100)))
stopifnot(all(is.na(panel$health_pc) | panel$health_pc >= 0))
stopifnot(all(is.na(panel$gdp_pc) | panel$gdp_pc >= 0))
stopifnot(all(is.na(panel$population) | panel$population > 0))

These are broad plausibility checks, not a substitute for metadata review. A failed check should trigger investigation. Do not automatically delete a value merely because it is unusual.

4 Preparing analysis datasets

4.1 One economy, one selected year

cross <- panel[panel$year == analysis_year, ]
stopifnot(nrow(cross) > 0L, !anyDuplicated(cross$iso3c))
# Use stable metadata IDs rather than assuming English label spellings.
cross$income_group <- factor(cross$income_id,
                            levels = c("LIC", "LMC", "UMC", "HIC"),
                            labels = c("Low income", "Lower middle income",
                                       "Upper middle income", "High income"))
cross$log2_health <- ifelse(!is.na(cross$health_pc) & cross$health_pc > 0,
                           log2(pmax(cross$health_pc, .Machine$double.xmin)), NA_real_)
cross$log2_gdp <- ifelse(!is.na(cross$gdp_pc) & cross$gdp_pc > 0,
                        log2(pmax(cross$gdp_pc, .Machine$double.xmin)), NA_real_)
cross$urban10 <- cross$urban_pct / 10
cross$high_income <- factor(ifelse(is.na(cross$income_group), NA,
                                  ifelse(cross$income_id == "HIC", "High", "Other")),
                            levels = c("Other", "High"))
life <- cross[is.finite(cross$life_exp), ]
stopifnot(nrow(life) >= 30)
cat("Selected year:", analysis_year, "\n")
Selected year: 2023 
cat("Economies with observed life expectancy:", nrow(life), "\n")
Economies with observed life expectancy: 217 
tab(as.data.frame(table(cross$income_group, useNA = "ifany")),
    "Current metadata income groups in the selected-year dataset")
Current metadata income groups in the selected-year dataset
Var1 Freq
Low income 25
Lower middle income 47
Upper middle income 59
High income 86

A base-2 logarithm makes doubling easy to interpret. If expenditure rises from 100 to 200, or from 1,000 to 2,000, its log base 2 increases by one. Zero or negative values cannot enter this log transformation. We mark them missing rather than adding an arbitrary constant. All transformations here are explicit analytical choices and are documented before modelling.

4.2 Missingness is part of the result

missing_table <- data.frame(
  variable = names(indicators),
  observed = vapply(cross[names(indicators)], function(x) sum(!is.na(x)), integer(1)),
  missing = vapply(cross[names(indicators)], function(x) sum(is.na(x)), integer(1)))
missing_table$missing_percent <- 100 * missing_table$missing / nrow(cross)
tab(missing_table, "Indicator availability in the fixed analysis year")
Indicator availability in the fixed analysis year
variable observed missing missing_percent
life_exp 217 0 0.000
health_pc 191 26 11.982
gdp_pc 204 13 5.991
urban_pct 217 0 0.000
population 217 0 0.000
missing_by_income <- aggregate(
  cbind(life_missing = as.numeric(is.na(cross$life_exp)),
        expenditure_missing = as.numeric(is.na(cross$health_pc))) ~ income_group,
  data = cross, FUN = mean)
tab(missing_by_income, "Proportion missing within current income groups")
Proportion missing within current income groups
income_group life_missing expenditure_missing
Low income 0 0.040
Lower middle income 0 0.000
Upper middle income 0 0.051
High income 0 0.256

MCAR means missingness is unrelated to observed or unobserved values. MAR means missingness can depend on observed variables after conditioning appropriately. MNAR means it still depends on unobserved values. These are assumptions about a data-generating process, not labels that a simple missingness table proves.

Complete-case analysis is convenient but can change the population represented. Mean imputation artificially reduces variation and can distort relationships. This workbook does not impute missing values. Multiple imputation is an extension requiring a defensible imputation model, not a button to press automatically.

4.3 Build separate datasets for separate questions

independent <- droplevels(cross[
  complete.cases(cross[c("life_exp", "income_group")]) &
    cross$income_id %in% c("LIC", "HIC"), ])
# Direction is explicitly Low minus High throughout the independent comparison.
independent$income_group <- factor(independent$income_group,
                                   levels = c("Low income", "High income"))
anova_data <- droplevels(cross[
  complete.cases(cross[c("life_exp", "income_group")]), ])
reg_vars <- c("life_exp", "log2_health", "log2_gdp", "urban10")
reg <- cross[complete.cases(cross[reg_vars]), ]
reg <- reg[apply(reg[reg_vars], 1, function(z) all(is.finite(z))), ]
rownames(reg) <- NULL
stopifnot(all(table(independent$income_group) >= 3),
          nlevels(anova_data$income_group) == 4,
          all(table(anova_data$income_group) >= 3), nrow(reg) >= 30)

before <- panel[panel$year == baseline_year, c("iso3c", "country", "life_exp")]
after <- panel[panel$year == analysis_year, c("iso3c", "life_exp")]
names(before)[3] <- "before"
names(after)[2] <- "after"
paired <- merge(before, after, by = "iso3c", all = FALSE)
paired <- paired[complete.cases(paired[c("before", "after")]), ]
paired$change <- paired$after - paired$before
stopifnot(nrow(paired) >= 10, !anyDuplicated(paired$iso3c))

tab(data.frame(analysis = c("Life expectancy description", "Low vs high income",
                            "Four-group ANOVA", "Multiple regression", "Paired years"),
               n_economies = c(nrow(life), nrow(independent), nrow(anova_data),
                               nrow(reg), nrow(paired))),
    "Analysis-specific denominators")
Analysis-specific denominators
analysis n_economies
Life expectancy description 217
Low vs high income 111
Four-group ANOVA 217
Multiple regression 187
Paired years 217

Matching is by identifier, never by row position. We do not assume the first country in one year is the first country in another. Different analyses legitimately use different denominators, but the report must say so.

5 Descriptive statistics: centre, spread and shape

5.1 Population quantities and sample summaries

A parameter describes a target population or model. A statistic is calculated from observed data. The arithmetic mean is

\[\bar{x}=\frac{1}{n}\sum_{i=1}^{n}x_i.\]

The sample variance and standard deviation are

\[s^2=\frac{\sum_i(x_i-\bar{x})^2}{n-1},\qquad s=\sqrt{s^2}.\]

The denominator \(n-1\) reflects estimation of the mean from the same observations in the usual sample-variance calculation. The SD has the original measurement unit. Variance has squared units. Neither is the same as a standard error.

The median is the middle ordered value. The interquartile range is \(Q_3-Q_1\) and describes the middle half of observations. A range uses only the minimum and maximum and is highly sensitive to extremes. A mean can be useful with skewed data, but it should not be presented without information about shape and spread.

summarise_numeric <- function(x) {
  x <- x[is.finite(x)]
  if (length(x) < 2) return(c(n = length(x), mean = NA, sd = NA,
                             median = NA, q1 = NA, q3 = NA, min = NA, max = NA))
  c(n = length(x), mean = mean(x), sd = sd(x), median = median(x),
    q1 = unname(quantile(x, .25)), q3 = unname(quantile(x, .75)),
    min = min(x), max = max(x))
}
descriptives <- do.call(rbind, lapply(names(indicators), function(nm) {
  data.frame(variable = nm, as.list(summarise_numeric(cross[[nm]])))
}))
tab(descriptives, "Descriptive statistics: each economy receives equal weight")
Descriptive statistics: each economy receives equal weight
variable n mean sd median q1 q3 min max
life_exp 217 73.791 7.123 74.552 68.484 79.594 54.462 86.372
health_pc 191 1433.837 2328.267 470.403 94.383 1611.698 17.440 13473.193
gdp_pc 204 22295.539 33559.351 7862.027 2945.927 30638.403 249.795 256799.911
urban_pct 217 62.610 23.243 63.663 43.250 81.165 14.678 100.000
population 217 37048618.129 142175383.814 6623183.000 826353.000 26418204.000 9816.000 1438069596.000

5.2 Verify the mean and SD by hand

x <- life$life_exp
n <- length(x)
manual_mean <- sum(x) / n
manual_variance <- sum((x - manual_mean)^2) / (n - 1)
manual_sd <- sqrt(manual_variance)
tab(data.frame(quantity = c("Mean", "Variance", "SD"),
               manual = c(manual_mean, manual_variance, manual_sd),
               built_in = c(mean(x), var(x), sd(x))))
quantity manual built_in
Mean 73.791 73.791
Variance 50.735 50.735
SD 7.123 7.123
stopifnot(isTRUE(all.equal(manual_mean, mean(x))),
          isTRUE(all.equal(manual_sd, sd(x))))

5.3 Country averages versus population weighting

weighted_data <- cross[complete.cases(cross[c("life_exp", "population")]), ]
tab(data.frame(
  summary = c("Unweighted mean across the same economies",
              "Population-weighted mean of economy indicators"),
  value = c(mean(weighted_data$life_exp),
            weighted.mean(weighted_data$life_exp, weighted_data$population))))
summary value
Unweighted mean across the same economies 73.791
Population-weighted mean of economy indicators 73.310

The unweighted mean answers a question about an average economy. The population-weighted mean gives larger populations more influence. It is not automatically global life expectancy: life expectancy is derived from mortality schedules, and averaging national life expectancies does not reconstruct a global life table. Population weights are also not survey sampling weights. Do not use lm(weights = population) and claim that it solves the ecological inference problem.

5.4 Group summaries

group_summary <- do.call(rbind, lapply(levels(anova_data$income_group), function(g) {
  z <- anova_data$life_exp[anova_data$income_group == g]
  data.frame(group = g, as.list(summarise_numeric(z)))
}))
tab(group_summary, "Life expectancy by current metadata income classification")
Life expectancy by current metadata income classification
group n mean sd median q1 q3 min max
Low income 25 64.202 4.601 63.651 61.183 67.353 55.069 73.642
Lower middle income 47 67.910 4.896 67.649 65.053 71.712 54.462 77.817
Upper middle income 59 73.770 3.778 74.184 71.354 77.274 63.707 81.041
High income 86 79.807 4.177 81.134 77.367 82.941 62.109 86.372

Read the output: compare the mean and median within each group, the SD across groups, and the group sizes. A small group with a large SD has less precise mean estimation than a large group with a small SD, all else equal. Group differences remain country-level descriptions.

6 Visualisation before testing

6.1 Histograms and density curves

A histogram shows frequencies in intervals. Its appearance depends on bin boundaries and width. A density curve smooths the distribution and has total area one; its height is not a probability at an exact value.

oldpar <- par(mfrow = c(1, 2))
hist(life$life_exp, breaks = "FD", main = "Life expectancy",
     xlab = "Years", ylab = "Number of economies")
hist(cross$health_pc[is.finite(cross$health_pc)], breaks = "FD",
     main = "Health expenditure", xlab = "Current US dollars per capita",
     ylab = "Number of economies")
par(oldpar)
Figure 1: Life expectancy and health expenditure have different units and may have different distribution shapes.
z <- cross$log2_health[is.finite(cross$log2_health)]
hist(z, probability = TRUE, breaks = "FD", main = "Log expenditure",
     xlab = "Log2 current health expenditure per capita", ylab = "Density")
lines(density(z), lwd = 2)
Figure 2: Log base 2 expenditure: one unit on this axis represents a doubling.

A log transformation can reduce right skew, but it changes the scale and therefore the interpretation. It does not guarantee normal residuals or make an observational analysis causal.

6.2 Boxplots and raw observations

boxplot(life_exp ~ income_group, data = anova_data, outline = FALSE,
        xlab = "Current metadata income group", ylab = "Life expectancy (years)",
        names = c("Low", "Lower middle", "Upper middle", "High"))
stripchart(life_exp ~ income_group, data = anova_data, vertical = TRUE,
           method = "jitter", jitter = .12, add = TRUE, pch = 16, cex = .5)
Figure 3: Country-level life expectancy by current income classification. Points show individual economies.

The box spans the interquartile range. A conventional whisker rule flags observations beyond 1.5 IQR from a quartile. A flagged observation is not necessarily an error. The raw points reveal unequal sample sizes and patterns that a box alone can hide.

6.3 Scatterplots and a useful first look at association

plot(reg$log2_health, reg$life_exp, pch = 16,
     xlab = "Log2 health expenditure per capita",
     ylab = "Life expectancy (years)")
abline(lm(life_exp ~ log2_health, data = reg), lwd = 2)
Figure 4: Life expectancy versus log expenditure. The line is a descriptive least-squares fit, not a causal effect.

Look for direction, curvature, clusters, changing spread and influential points. A single correlation coefficient cannot describe all of those features.

7 Standardisation and unusual observations

A z-score expresses distance from a mean in SD units:

\[z_i=\frac{x_i-\bar{x}}{s}.\]

A z-score of 1 means one SD above the mean, not one year above it. Standardising a variable does not make its distribution normal.

life$z_life <- as.numeric(scale(life$life_exp))
quartiles <- quantile(life$life_exp, c(.25, .75))
iqr <- diff(quartiles)
life$iqr_flag <- life$life_exp < quartiles[1] - 1.5 * iqr |
                 life$life_exp > quartiles[2] + 1.5 * iqr
tab(life[order(abs(life$z_life), decreasing = TRUE),
         c("country", "life_exp", "z_life", "iqr_flag")][1:10, ],
    "Largest absolute standardised deviations: investigate, do not automatically delete")
Largest absolute standardised deviations: investigate, do not automatically delete
country life_exp z_life iqr_flag
Nigeria 54.462 -2.714 FALSE
Chad 55.069 -2.628 FALSE
Lesotho 57.375 -2.305 FALSE
Central African Republic 57.408 -2.300 FALSE
South Sudan 57.617 -2.271 FALSE
Somalia, Fed. Rep. 58.816 -2.102 FALSE
Mali 60.439 -1.875 FALSE
Guinea 60.740 -1.832 FALSE
Benin 60.774 -1.827 FALSE
Burkina Faso 61.092 -1.783 FALSE

An extreme outcome, a high-leverage predictor value and an influential regression observation are different concepts. We return to this distinction in the diagnostics section.

8 Probability and continuous distributions

8.1 Probability rules in a real dataset

An empirical probability can be a proportion of observed economies satisfying a condition. It describes the observed set unless a sampling or superpopulation interpretation is justified.

prob_over75 <- mean(life$life_exp > 75)
prob_65to80 <- mean(life$life_exp >= 65 & life$life_exp <= 80)
prob_high <- mean(anova_data$income_group == "High income")
prob_over75_given_high <- with(anova_data,
  mean(life_exp[income_group == "High income"] > 75))
tab(data.frame(quantity = c("P(life expectancy > 75)",
                            "P(65 <= life expectancy <= 80)",
                            "P(current high-income classification)",
                            "P(life expectancy > 75 | current high income)"),
               empirical_proportion = c(prob_over75, prob_65to80, prob_high,
                                         prob_over75_given_high)))
quantity empirical_proportion
P(life expectancy > 75) 0.484
P(65 <= life expectancy <= 80) 0.631
P(current high-income classification) 0.396
P(life expectancy > 75 | current high income) 0.895

For events \(A\) and \(B\), \(P(A\mid B)=P(A\cap B)/P(B)\) when \(P(B)>0\). Independence means \(P(A\cap B)=P(A)P(B)\). Conditional and marginal probabilities are different. A difference between them is an association, not proof of a causal mechanism.

For a genuinely continuous random variable, probability is area under a density. The probability of one exact real-valued point is zero in the mathematical model, even though recorded data may be rounded.

8.2 The normal distribution

A normal model is described by mean \(\mu\) and SD \(\sigma\). It is symmetric and bell-shaped. In R, dnorm() gives density, pnorm() cumulative probability, and qnorm() a quantile. We use those functions with parameters estimated from the observed life expectancy distribution as an approximation, not as a claim that country life expectancy is exactly normal.

mu_hat <- mean(life$life_exp)
sigma_hat <- sd(life$life_exp)
tab(data.frame(
  calculation = c("Normal approximation: P(X < 70)",
                  "Normal approximation: P(65 < X < 80)",
                  "Normal approximation: 90th percentile"),
  result = c(pnorm(70, mu_hat, sigma_hat),
             pnorm(80, mu_hat, sigma_hat) - pnorm(65, mu_hat, sigma_hat),
             qnorm(.90, mu_hat, sigma_hat))))
calculation result
Normal approximation: P(X < 70) 0.297
Normal approximation: P(65 < X < 80) 0.700
Normal approximation: 90th percentile 82.919
hist(life$life_exp, probability = TRUE, breaks = "FD",
     main = "Observed distribution and normal approximation",
     xlab = "Life expectancy (years)", ylab = "Density")
curve(dnorm(x, mu_hat, sigma_hat), add = TRUE, lwd = 2)
Figure 5: Observed country distribution with a fitted normal density. This is a diagnostic comparison, not proof of normality.

8.3 The t and F distributions

The t distribution is symmetric with heavier tails than the standard normal, especially at low degrees of freedom. It appears when estimating a mean with an estimated SD under the usual assumptions. The F distribution is non-negative and appears in variance-ratio statistics such as ANOVA.

oldpar <- par(mfrow = c(1, 2))
curve(dnorm(x), from = -4, to = 4, ylab = "Density", xlab = "Statistic",
      main = "Normal and t reference densities")
curve(dt(x, df = 5), add = TRUE, lty = 2)
curve(dt(x, df = 30), add = TRUE, lty = 3)
legend("topright", c("Normal", "t: df = 5", "t: df = 30"), lty = 1:3, bty = "n")
curve(df(x, df1 = 3, df2 = 100), from = 0, to = 5,
      main = "F reference density", xlab = "F statistic", ylab = "Density")
par(oldpar)
Figure 6: Reference distributions used in mean comparisons and ANOVA.

The reference distributions are mathematical objects, not fabricated datasets. The numerical degrees of freedom here are teaching settings.

9 Sampling, standard errors and the central limit theorem

9.1 SD is not SE

The SD describes variation among observations. The standard error describes uncertainty in an estimator under a specified sampling or model framework. For an independent, identically distributed sample, the estimated SE of the mean is

\[SE(\bar{x})=\frac{s}{\sqrt{n}}.\]

Increasing \(n\) generally reduces this SE. It does not necessarily reduce the underlying SD, remove bias or make correlated observations independent.

x <- life$life_exp
tab(data.frame(n = length(x), mean = mean(x), sd = sd(x),
               se_independent_model = sd(x) / sqrt(length(x))))
n mean sd se_independent_model
217 73.791 7.123 0.484

9.2 Empirical resampling illustration

The central limit theorem concerns a distribution of sample means under repeated sampling, not the shape of individual observations. The next exercise draws with replacement from the observed empirical distribution. It illustrates sampling behaviour conditional on that distribution, not a new World Bank dataset or a true survey design.

set.seed(params$seed + 1L)
sample_sizes <- c(5, 20, 60)
resampled_means <- lapply(sample_sizes, function(m) {
  replicate(B, mean(sample(life$life_exp, size = m, replace = TRUE)))
})
oldpar <- par(mfrow = c(1, 3))
for (j in seq_along(sample_sizes)) {
  hist(resampled_means[[j]], breaks = "FD",
       main = paste("Resample n =", sample_sizes[j]),
       xlab = "Mean life expectancy (years)", ylab = "Frequency")
}
par(oldpar)
Figure 7: Means of resamples drawn from the observed country distribution.

Compare the centres and spreads. The means should concentrate as resample size increases. Do not conclude that a small sample is always adequate, or that the CLT repairs a biased selection mechanism. A simple finite-population random sample without replacement would require a different SE calculation, including a finite-population correction when appropriate.

10 Confidence intervals and bootstrap uncertainty

10.1 A t interval for a mean

A two-sided 95% t interval is

\[\bar{x}\pm t_{0.975,n-1}\frac{s}{\sqrt n}.\]

Under the model and repeated application of the procedure, 95% of such intervals cover the fixed target mean. A particular frequentist interval does not assign a 95% probability to a fixed parameter. It also does not contain 95% of individual country values.

x <- life$life_exp
se_mean <- sd(x) / sqrt(length(x))
critical_t <- qt(.975, df = length(x) - 1)
ci_mean <- mean(x) + c(-1, 1) * critical_t * se_mean
tab(data.frame(mean = mean(x), se = se_mean,
               lower95 = ci_mean[1], upper95 = ci_mean[2], n = length(x)))
mean se lower95 upper95 n
73.791 0.484 72.838 74.744 217

A narrower interval indicates greater precision under the model, not necessarily less bias. Changing from 95% to 99% makes the interval wider. The country-level independence assumption remains substantive.

10.2 Non-parametric bootstrap of the mean and median

set.seed(params$seed + 2L)
boot_stats <- replicate(B, {
  z <- sample(life$life_exp, replace = TRUE)
  c(mean = mean(z), median = median(z))
})
boot_intervals <- t(apply(boot_stats, 1, quantile, probs = c(.025, .975)))
tab(data.frame(statistic = rownames(boot_intervals),
               estimate = c(mean(life$life_exp), median(life$life_exp)),
               lower95 = boot_intervals[, 1], upper95 = boot_intervals[, 2]),
    "Percentile bootstrap intervals conditional on the observed economy distribution")
Percentile bootstrap intervals conditional on the observed economy distribution
statistic estimate lower95 upper95
mean 73.791 72.841 74.726
median 74.552 72.975 76.142

The percentile bootstrap is easy to teach but is not universally accurate. It can perform poorly with small samples, heavy tails or boundary parameters. Resampling economies assumes the resampling units are exchangeable and approximately independent. It does not incorporate all uncertainty in World Bank source estimates or fix missing-data bias.

11 Hypothesis testing without shortcuts

11.1 The full reasoning chain

  1. State the question and target quantity before seeing the p-value.
  2. Specify a null hypothesis \(H_0\) and an alternative \(H_A\).
  3. Choose a method appropriate to the design and assumptions.
  4. Calculate an estimate, SE, test statistic and reference probability.
  5. Report effect size and uncertainty, not only a threshold decision.
  6. Interpret the result in the correct population and unit.
  7. Explain limitations and any sensitivity analyses.

A p-value is the probability, under the null model and its assumptions, of a result at least as incompatible with the null as the observed statistic. It is not the probability that the null is true, the probability that the result happened by chance, or the probability that a replication will be significant.

The significance level \(\alpha\) is a pre-specified decision threshold. A Type I error rejects a true null. A Type II error fails to reject a false null. Power is \(1-\beta\) for a specified alternative. Failure to reject does not prove equality. A tiny p-value does not guarantee an important effect, a valid design or an unbiased estimate.

11.2 Choosing a method

Question Design Starting method Alternative or sensitivity
Mean versus a fixed reference One numerical sample One-sample t-test Bootstrap; signed-rank only for its own estimand
Two means Independent groups Welch t-test Rank-sum or robust methods for a different target
Mean within-unit change Matched pairs Paired t-test Signed-rank or sign test
Three or more means Independent groups Welch ANOVA Classical ANOVA if appropriate; Kruskal-Wallis for ranks
Linear association Paired numerical variables Pearson correlation Spearman correlation
Continuous outcome and predictors Independent observational units Linear regression Transformations, robust SE, nonlinear terms
Three repeated measurements Same units at each time Repeated-measures model Friedman test; mixed models as extension

The table is a starting point, not an automatic decision tree. Independence comes from the design. Normality tests cannot verify it. Do not choose the method solely by whether Shapiro-Wilk returns p below .05.

12 One-sample t-test

12.1 Question, hypotheses and assumptions

Teaching question: is the mean country-level life expectancy different from a hypothetical reference of 70 years? The value 70 is an arbitrary teaching benchmark, not an official policy target or a World Bank world mean.

\[H_0:\mu=70,\qquad H_A:\mu\ne70.\]

The statistic is

\[t=\frac{\bar{x}-\mu_0}{s/\sqrt n},\qquad df=n-1.\]

The exact small-sample reference assumes independent normal observations. With sufficiently well-behaved independent data, large-sample approximations may be useful. Severe outliers and dependence still matter.

mu0 <- 70
one_test <- t.test(life$life_exp, mu = mu0, conf.level = .95)
one_test

    One Sample t-test

data:  life$life_exp
t = 7.8401, df = 216, p-value = 2.036e-13
alternative hypothesis: true mean is not equal to 70
95 percent confidence interval:
 72.83790 74.74399
sample estimates:
mean of x 
 73.79094 
manual_t <- (mean(life$life_exp) - mu0) /
             (sd(life$life_exp) / sqrt(nrow(life)))
manual_p <- 2 * pt(-abs(manual_t), df = nrow(life) - 1)
tab(data.frame(manual_t = manual_t, r_t = unname(one_test$statistic),
               manual_p = manual_p, r_p = one_test$p.value))
manual_t r_t manual_p r_p
7.84 7.84 0 0
stopifnot(isTRUE(all.equal(manual_t, unname(one_test$statistic))))

12.2 Read and report the result

The estimate printed by t.test() is the mean, not the mean minus 70. The interval is for the mean. To obtain an interval for the difference from 70, subtract 70 from both limits.

one_difference <- mean(life$life_exp) - mu0
one_difference_ci <- one_test$conf.int - mu0
cat(sprintf(paste0("In %d, the unweighted mean among %d observed economies was ",
                   "%.2f years (95%% model-based CI %.2f to %.2f). ",
                   "The difference from the hypothetical 70-year reference was ",
                   "%.2f years; t(%d) = %.2f, p = %s.\n"),
            analysis_year, nrow(life), mean(life$life_exp),
            one_test$conf.int[1], one_test$conf.int[2], one_difference,
            nrow(life) - 1, unname(one_test$statistic), pfmt(one_test$p.value)))
In 2023, the unweighted mean among 217 observed economies was 73.79 years (95% model-based CI 72.84 to 74.74). The difference from the hypothetical 70-year reference was 3.79 years; t(216) = 7.84, p = <0.001.

A two-sided test is used because either direction would be relevant. A one-sided test requires a justified direction chosen before inspecting results; it is not a way to halve an inconvenient p-value. See the R t-test documentation.

13 Independent-samples t-tests

13.1 Compare low-income and high-income economies

We compare life expectancy between two current metadata classifications. The contrast is Low income minus High income. The groups contain different economies. They are not matched pairs and were not randomised to income categories.

\[H_0:\mu_L-\mu_H=0,\qquad H_A:\mu_L-\mu_H\ne0.\]

Welch’s test estimates each group’s variance separately:

\[t=\frac{\bar{x}_L-\bar{x}_H}{\sqrt{s_L^2/n_L+s_H^2/n_H}}.\]

It uses a Satterthwaite degrees-of-freedom approximation. It does not require equal group variances. R’s t.test() uses Welch’s method by default for independent samples.

low <- independent$life_exp[independent$income_group == "Low income"]
high <- independent$life_exp[independent$income_group == "High income"]
welch_test <- t.test(low, high, var.equal = FALSE)
welch_test

    Welch Two Sample t-test

data:  low and high
t = -15.232, df = 36.292, p-value < 2.2e-16
alternative hypothesis: true difference in means is not equal to 0
95 percent confidence interval:
 -17.68188 -13.52759
sample estimates:
mean of x mean of y 
 64.20236  79.80710 
welch_se <- sqrt(var(low) / length(low) + var(high) / length(high))
welch_df <- (var(low) / length(low) + var(high) / length(high))^2 /
  ((var(low) / length(low))^2 / (length(low) - 1) +
   (var(high) / length(high))^2 / (length(high) - 1))
tab(data.frame(contrast = "Low minus High", difference = mean(low) - mean(high),
               se = welch_se, df = welch_df,
               lower95 = welch_test$conf.int[1], upper95 = welch_test$conf.int[2],
               p = welch_test$p.value))
contrast difference se df lower95 upper95 p
Low minus High -15.605 1.024 36.292 -17.682 -13.528 0

13.2 The pooled-variance Student t-test

The pooled test assumes a common variance. Its pooled SD is

\[s_p=\sqrt{\frac{(n_L-1)s_L^2+(n_H-1)s_H^2}{n_L+n_H-2}}.\]

pooled_test <- t.test(low, high, var.equal = TRUE)
pooled_test

    Two Sample t-test

data:  low and high
t = -16.069, df = 109, p-value < 2.2e-16
alternative hypothesis: true difference in means is not equal to 0
95 percent confidence interval:
 -17.52946 -13.68002
sample estimates:
mean of x mean of y 
 64.20236  79.80710 
pooled_sd <- sqrt(((length(low) - 1) * var(low) +
                    (length(high) - 1) * var(high)) /
                   (length(low) + length(high) - 2))

Use this comparison to understand the assumption, not to shop for the smaller p-value. A non-significant variance test does not establish equal variances. Welch’s method is often a sensible starting choice for unequal group sizes and variances, but it still requires a defensible independent-observation model.

13.3 Standardised effect sizes and a bootstrap interval

Cohen’s \(d=(\bar{x}_L-\bar{x}_H)/s_p\). Hedges’ \(g\) applies a small-sample correction. We use the common approximation \(J=1-3/(4df-1)\), with \(df=n_L+n_H-2\). The raw difference in years is the primary interpretable effect. The standardised effect depends on the chosen SD and is not a universal measure of health importance.

cohen_d <- (mean(low) - mean(high)) / pooled_sd
df_pooled <- length(low) + length(high) - 2
hedges_g <- (1 - 3 / (4 * df_pooled - 1)) * cohen_d
set.seed(params$seed + 3L)
boot_difference <- replicate(B,
  mean(sample(low, replace = TRUE)) - mean(sample(high, replace = TRUE)))
boot_difference_ci <- quantile(boot_difference, c(.025, .975))
tab(data.frame(difference_years = mean(low) - mean(high),
               cohen_d = cohen_d, hedges_g_approx = hedges_g,
               bootstrap_lower95 = boot_difference_ci[1],
               bootstrap_upper95 = boot_difference_ci[2]))
difference_years cohen_d hedges_g_approx bootstrap_lower95 bootstrap_upper95
-15.605 -3.651 -3.626 -17.46 -13.592

Resampling separately within groups preserves group sizes. The bootstrap interval targets the raw mean difference, not \(g\). When variances differ substantially, explain the limitations of a pooled-SD effect size rather than presenting it as uniquely correct.

13.4 Report the comparison

cat(sprintf(paste0("Using current metadata income classifications, mean life ",
                   "expectancy was %.2f years in low-income economies (n = %d) ",
                   "and %.2f years in high-income economies (n = %d). ",
                   "The Low-minus-High difference was %.2f years ",
                   "(95%% model-based CI %.2f to %.2f), Welch t(%.1f) = %.2f, ",
                   "p = %s. This is an ecological association, not an income intervention effect.\n"),
            mean(low), length(low), mean(high), length(high), mean(low) - mean(high),
            welch_test$conf.int[1], welch_test$conf.int[2],
            unname(welch_test$parameter), unname(welch_test$statistic),
            pfmt(welch_test$p.value)))
Using current metadata income classifications, mean life expectancy was 64.20 years in low-income economies (n = 25) and 79.81 years in high-income economies (n = 86). The Low-minus-High difference was -15.60 years (95% model-based CI -17.68 to -13.53), Welch t(36.3) = -15.23, p = <0.001. This is an ecological association, not an income intervention effect.

14 Paired t-test: within-economy change

14.1 Why pairing changes the analysis

The same economy is observed in two years. The analysis uses differences \(d_i=\text{after}_i-\text{before}_i\):

\[t=\frac{\bar d-0}{s_d/\sqrt n},\qquad df=n-1.\]

The normality assumption concerns the differences, not a requirement that both marginal year distributions be normal. Independence is required between pairs, not within each pair. A paired t-test can be implemented as a one-sample t-test on differences.

paired_test <- t.test(paired$after, paired$before, paired = TRUE)
difference_test <- t.test(paired$change, mu = 0)
paired_test

    Paired t-test

data:  paired$after and paired$before
t = 15.547, df = 216, p-value < 2.2e-16
alternative hypothesis: true mean difference is not equal to 0
95 percent confidence interval:
 2.633461 3.398144
sample estimates:
mean difference 
       3.015802 
stopifnot(isTRUE(all.equal(unname(paired_test$statistic),
                           unname(difference_test$statistic))))
tab(data.frame(n_pairs = nrow(paired), mean_change = mean(paired$change),
               sd_change = sd(paired$change),
               lower95 = paired_test$conf.int[1], upper95 = paired_test$conf.int[2],
               p = paired_test$p.value))
n_pairs mean_change sd_change lower95 upper95 p
217 3.016 2.858 2.633 3.398 0
hist(paired$change, breaks = "FD", xlab = "Later minus earlier life expectancy (years)",
     main = paste("Matched changes:", baseline_year, "to", analysis_year),
     ylab = "Number of economies")
abline(v = 0, lty = 2)
Figure 8: Distribution of matched country-level changes. Positive values indicate higher life expectancy in the analysis year.

14.2 Paired effect size and interpretation

paired_dz <- mean(paired$change) / sd(paired$change)
cat(sprintf(paste0("Among %d matched economies, mean life expectancy changed by ",
                   "%.2f years from %d to %d (95%% model-based CI %.2f to %.2f), ",
                   "paired t(%d) = %.2f, p = %s. Standardised mean change dz = %.2f.\n"),
            nrow(paired), mean(paired$change), baseline_year, analysis_year,
            paired_test$conf.int[1], paired_test$conf.int[2], nrow(paired) - 1,
            unname(paired_test$statistic), pfmt(paired_test$p.value), paired_dz))
Among 217 matched economies, mean life expectancy changed by 3.02 years from 2010 to 2023 (95% model-based CI 2.63 to 3.40), paired t(216) = 15.55, p = <0.001. Standardised mean change dz = 1.06.

\(d_z\) uses the SD of changes. It is not interchangeable with an independent-groups Cohen’s \(d\). A temporal change is not automatically the effect of a programme. Many processes changed between the two years, and the same country’s estimates can be revised retrospectively.

15 One-way ANOVA: comparing more than two means

15.1 The question and the F statistic

For four current income groups, the null is

\[H_0:\mu_1=\mu_2=\mu_3=\mu_4.\]

The alternative is that at least one mean differs. It is not that all means differ. Classical one-way ANOVA partitions variation:

\[SS_T=SS_B+SS_W,\]

\[F=\frac{MS_B}{MS_W}=\frac{SS_B/(k-1)}{SS_W/(N-k)}.\]

\(SS_B\) describes between-group variation around the grand mean. \(SS_W\) describes within-group variation around group means. The classical F reference assumes independent observations, approximately normal errors within groups and a common error variance. With unequal group sizes, unequal variances deserve particular attention.

anova_fit <- aov(life_exp ~ income_group, data = anova_data)
summary(anova_fit)
              Df Sum Sq Mean Sq F value Pr(>F)    
income_group   3   7037  2345.7   127.4 <2e-16 ***
Residuals    213   3922    18.4                   
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

15.2 Calculate the sums of squares directly

split_y <- split(anova_data$life_exp, anova_data$income_group)
grand_mean <- mean(anova_data$life_exp)
N <- nrow(anova_data)
k <- length(split_y)
ss_between <- sum(vapply(split_y, function(z) length(z) * (mean(z) - grand_mean)^2,
                         numeric(1)))
ss_within <- sum(vapply(split_y, function(z) sum((z - mean(z))^2), numeric(1)))
ss_total <- sum((anova_data$life_exp - grand_mean)^2)
f_manual <- (ss_between / (k - 1)) / (ss_within / (N - k))
tab(data.frame(source = c("Between", "Within", "Total"),
               SS = c(ss_between, ss_within, ss_total),
               df = c(k - 1, N - k, N - 1)))
source SS df
Between 7037.01 3
Within 3921.81 213
Total 10958.82 216
stopifnot(isTRUE(all.equal(ss_total, ss_between + ss_within)))
tab(data.frame(F = f_manual, p = pf(f_manual, k - 1, N - k, lower.tail = FALSE)))
F p
127.397 0

15.3 ANOVA effect sizes

For one-way ANOVA, \(\eta^2=SS_B/SS_T\). A less upward-biased estimate is

\[\omega^2=\frac{SS_B-(k-1)MS_W}{SS_T+MS_W}.\]

ms_within <- ss_within / (N - k)
eta_squared <- ss_between / ss_total
omega_squared <- (ss_between - (k - 1) * ms_within) / (ss_total + ms_within)
tab(data.frame(eta_squared = eta_squared, omega_squared = omega_squared))
eta_squared omega_squared
0.642 0.636

A negative estimated omega squared can occur through sampling variation and is often interpreted as no explained variance; do not silently alter the calculation. These quantities describe variation associated with the grouping in the observed analysis, not a causal fraction of life expectancy determined by income.

16 ANOVA assumptions, Welch ANOVA and post-hoc comparisons

16.1 Examine residuals and spread

oldpar <- par(mfrow = c(1, 2))
plot(fitted(anova_fit), residuals(anova_fit), pch = 16,
     xlab = "Fitted group mean", ylab = "Residual", main = "Residuals vs fitted")
abline(h = 0, lty = 2)
qqnorm(residuals(anova_fit), main = "ANOVA residual Q-Q plot")
qqline(residuals(anova_fit))
par(oldpar)
Figure 9: ANOVA residual diagnostics: look for changing spread and substantial departures from the normal reference.

A Q-Q plot compares ordered residuals with theoretical normal quantiles. A roughly straight pattern is compatible with normality; departures can indicate skewness, heavy tails or outliers. It does not verify independence or correct model specification.

A median-centred Levene-style check, often called the Brown-Forsythe variance test, analyses absolute deviations from group medians. It is a diagnostic, not a gatekeeper that automatically selects a final test.

anova_data$abs_deviation <- with(anova_data,
  abs(life_exp - ave(life_exp, income_group, FUN = median)))
summary(aov(abs_deviation ~ income_group, data = anova_data))
              Df Sum Sq Mean Sq F value Pr(>F)
income_group   3   25.3   8.430   1.073  0.362
Residuals    213 1673.8   7.858               

16.2 Welch ANOVA

welch_anova <- oneway.test(life_exp ~ income_group, data = anova_data,
                           var.equal = FALSE)
welch_anova

    One-way analysis of means (not assuming equal variances)

data:  life_exp and income_group
F = 115.17, num df = 3.000, denom df = 81.929, p-value < 2.2e-16

Welch ANOVA compares means without requiring equal group variances. It is not the same as a rank test. See R’s one-way mean test documentation.

16.3 Tukey comparisons under the classical model

tukey <- TukeyHSD(anova_fit, conf.level = .95)
tukey
  Tukey multiple comparisons of means
    95% family-wise confidence level

Fit: aov(formula = life_exp ~ income_group, data = anova_data)

$income_group
                                             diff        lwr       upr
Lower middle income-Low income           3.707189  0.9567336  6.457643
Upper middle income-Low income           9.567413  6.9158571 12.218970
High income-Low income                  15.604736 13.0800963 18.129377
Upper middle income-Lower middle income  5.860225  3.6878478  8.032602
High income-Lower middle income         11.897548  9.8820382 13.913058
High income-Upper middle income          6.037323  4.1590185  7.915627
                                            p adj
Lower middle income-Low income          0.0032663
Upper middle income-Low income          0.0000000
High income-Low income                  0.0000000
Upper middle income-Lower middle income 0.0000000
High income-Lower middle income         0.0000000
High income-Upper middle income         0.0000000

Tukey’s method addresses the family of pairwise mean comparisons under the classical ANOVA framework. Its intervals are simultaneous within that family. It does not automatically become appropriate after Welch ANOVA, because the variance assumptions differ.

16.4 Pairwise Welch tests with Holm adjustment

pairwise_welch <- pairwise.t.test(anova_data$life_exp, anova_data$income_group,
                                 pool.sd = FALSE, p.adjust.method = "holm")
pairwise_welch

    Pairwise comparisons using t tests with non-pooled SD 

data:  anova_data$life_exp and anova_data$income_group 

                    Low income Lower middle income Upper middle income
Lower middle income 0.0025     -                   -                  
Upper middle income 9.8e-11    3.3e-09             -                  
High income         < 2e-16    < 2e-16             6.2e-15            

P value adjustment method: holm 
# A separate table retains raw contrasts and their ordinary individual CIs.
comparisons <- combn(levels(anova_data$income_group), 2, simplify = FALSE)
pairwise_table <- do.call(rbind, lapply(comparisons, function(g) {
  a <- anova_data$life_exp[anova_data$income_group == g[1]]
  b <- anova_data$life_exp[anova_data$income_group == g[2]]
  tt <- t.test(a, b)
  data.frame(contrast = paste(g[1], "minus", g[2]),
             difference = mean(a) - mean(b), lower95_individual = tt$conf.int[1],
             upper95_individual = tt$conf.int[2], p_raw = tt$p.value)
}))
pairwise_table$p_holm <- p.adjust(pairwise_table$p_raw, method = "holm")
tab(pairwise_table, "Welch pairwise contrasts: CIs are individual, not multiplicity-adjusted")
Welch pairwise contrasts: CIs are individual, not multiplicity-adjusted
contrast difference lower95_individual upper95_individual p_raw p_holm
Low income minus Lower middle income -3.707 -6.045 -1.370 0.002 0.002
Low income minus Upper middle income -9.567 -11.679 -7.456 0.000 0.000
Low income minus High income -15.605 -17.682 -13.528 0.000 0.000
Lower middle income minus Upper middle income -5.860 -7.584 -4.136 0.000 0.000
Lower middle income minus High income -11.898 -13.577 -10.218 0.000 0.000
Upper middle income minus High income -6.037 -7.357 -4.718 0.000 0.000

Do not describe the individual 95% intervals in this table as simultaneous intervals. Holm-adjusted p-values and unadjusted intervals answer different inferential questions. Games-Howell is another unequal-variance post-hoc option, but is not required for this dependency-light workbook.

16.5 Why multiplicity matters

With many tests, the chance of at least one false rejection can increase. Under independent true-null tests, that chance is \(1-(1-\alpha)^m\). Holm controls family-wise error for a defined family. Benjamini-Hochberg targets the false discovery rate under its relevant conditions. Neither method repairs biased data or a post-hoc choice of hypotheses.

m <- nrow(pairwise_table)
tab(data.frame(number_of_tests = m,
               independent_null_family_error_at_05 = 1 - (1 - .05)^m))
number_of_tests independent_null_family_error_at_05
6 0.265
tab(data.frame(contrast = pairwise_table$contrast,
               raw = pairwise_table$p_raw,
               holm = p.adjust(pairwise_table$p_raw, "holm"),
               BH = p.adjust(pairwise_table$p_raw, "BH")))
contrast raw holm BH
Low income minus Lower middle income 0.002 0.002 0.002
Low income minus Upper middle income 0.000 0.000 0.000
Low income minus High income 0.000 0.000 0.000
Lower middle income minus Upper middle income 0.000 0.000 0.000
Lower middle income minus High income 0.000 0.000 0.000
Upper middle income minus High income 0.000 0.000 0.000

The R p-value adjustment documentation explains the available methods. Define the family of comparisons before choosing an adjustment.

17 Non-parametric methods

17.1 Rank-sum test for two independent groups

The Wilcoxon rank-sum or Mann-Whitney test uses ranks. It is not a general-purpose test of equality of medians. A location-shift or median interpretation requires additional assumptions about distribution shapes. A rank-based result can differ from a mean-based result because the estimand and assumptions differ.

rank_sum <- wilcox.test(low, high, exact = FALSE, conf.int = TRUE)
rank_sum

    Wilcoxon rank sum test with continuity correction

data:  low and high
W = 27, p-value = 1.418e-13
alternative hypothesis: true location shift is not equal to 0
95 percent confidence interval:
 -17.91896 -14.07842
sample estimates:
difference in location 
              -15.8435 
# Probability-of-superiority estimate with half weight for ties.
comparison_matrix <- outer(low, high, "-")
prob_low_exceeds_high <- mean(comparison_matrix > 0) +
                         .5 * mean(comparison_matrix == 0)
rank_biserial <- 2 * prob_low_exceeds_high - 1
tab(data.frame(P_low_exceeds_high_half_ties = prob_low_exceeds_high,
               rank_biserial_low_minus_high = rank_biserial))
P_low_exceeds_high_half_ties rank_biserial_low_minus_high
0.013 -0.975

The probability-of-superiority estimate compares a randomly selected observed low-income economy with a randomly selected observed high-income economy. It is not an individual patient’s probability of living longer. The confidence interval printed by wilcox.test() is for its location-shift parameter, not automatically the difference of sample medians. See the Wilcoxon documentation.

17.2 Signed-rank and sign tests for paired changes

The signed-rank test assumes an appropriate symmetry condition for a location interpretation. It is not simply a paired t-test with no assumptions. A sign test uses only whether each non-zero change is positive or negative and discards magnitude.

signed_rank <- wilcox.test(paired$change, mu = 0, exact = FALSE,
                           conf.int = TRUE)
signed_rank

    Wilcoxon signed rank test with continuity correction

data:  paired$change
V = 23203, p-value < 2.2e-16
alternative hypothesis: true location is not equal to 0
95 percent confidence interval:
 2.366970 2.932022
sample estimates:
(pseudo)median 
      2.642955 
nonzero_change <- paired$change[paired$change != 0]
sign_test <- binom.test(sum(nonzero_change > 0), length(nonzero_change), p = .5)
sign_test

    Exact binomial test

data:  sum(nonzero_change > 0) and length(nonzero_change)
number of successes = 207, number of trials = 217, p-value < 2.2e-16
alternative hypothesis: true probability of success is not equal to 0.5
95 percent confidence interval:
 0.9168867 0.9776833
sample estimates:
probability of success 
             0.9539171 

The binomial calculation here supports a sign test for continuous paired changes; it is not a separate categorical-outcome curriculum. Zero differences are excluded explicitly. With a continuous change distribution and suitable assumptions, the sign test addresses whether the median change is zero.

17.3 Kruskal-Wallis for more than two groups

kw <- kruskal.test(life_exp ~ income_group, data = anova_data)
kw

    Kruskal-Wallis rank sum test

data:  life_exp by income_group
Kruskal-Wallis chi-squared = 141.68, df = 3, p-value < 2.2e-16
pairwise.wilcox.test(anova_data$life_exp, anova_data$income_group,
                     p.adjust.method = "holm", exact = FALSE)

    Pairwise comparisons using Wilcoxon rank sum test with continuity correction 

data:  anova_data$life_exp and anova_data$income_group 

                    Low income Lower middle income Upper middle income
Lower middle income 0.0032     -                   -                  
Upper middle income 6.5e-10    1.4e-08             -                  
High income         5.7e-13    < 2e-16             2.1e-13            

P value adjustment method: holm 

Kruskal-Wallis is an omnibus rank-based comparison. Under common-shape location-shift assumptions it can be interpreted as a location comparison. If spreads and shapes differ, a rejection is not automatically evidence that medians differ. Pairwise Wilcoxon comparisons with Holm adjustment are shown as a follow-up family; they are not Dunn’s test. See R’s Kruskal-Wallis documentation.

18 Correlation: association between continuous variables

18.1 Pearson correlation

Pearson’s correlation measures linear association:

\[r=\frac{\sum_i(x_i-\bar{x})(y_i-\bar{y})} {\sqrt{\sum_i(x_i-\bar{x})^2\sum_i(y_i-\bar{y})^2}}.\]

It lies between -1 and 1 and is unitless. A value near zero does not rule out a strong nonlinear relationship. Outliers can strongly affect it. The usual exact t test for zero correlation relies on independent paired observations and a bivariate normal model.

pearson <- cor.test(reg$log2_health, reg$life_exp, method = "pearson")
pearson

    Pearson's product-moment correlation

data:  reg$log2_health and reg$life_exp
t = 20.607, df = 185, p-value < 2.2e-16
alternative hypothesis: true correlation is not equal to 0
95 percent confidence interval:
 0.7851204 0.8734768
sample estimates:
      cor 
0.8345893 

18.2 Spearman correlation

Spearman’s correlation measures association between ranks and is useful for monotonic relationships. It does not require the same bivariate normal model as the usual Pearson test. It still requires a defensible independent-unit framework and can be affected by ties.

spearman <- cor.test(reg$log2_health, reg$life_exp,
                     method = "spearman", exact = FALSE)
spearman

    Spearman's rank correlation rho

data:  reg$log2_health and reg$life_exp
S = 167948, p-value < 2.2e-16
alternative hypothesis: true rho is not equal to 0
sample estimates:
      rho 
0.8458965 
cor_matrix <- cor(reg[reg_vars], method = "pearson")
round(cor_matrix, 3)
            life_exp log2_health log2_gdp urban10
life_exp       1.000       0.835    0.858   0.563
log2_health    0.835       1.000    0.960   0.639
log2_gdp       0.858       0.960    1.000   0.632
urban10        0.563       0.639    0.632   1.000

We use the same complete-case dataset for this matrix so every entry has the same denominator. Pairwise deletion would give different denominators and can produce a correlation matrix with problematic mathematical properties. See R’s correlation-test documentation.

Interpretation prompt: explain why correlation between GDP and expenditure can complicate a multiple regression that includes both. Do not use a fixed label such as ‘strong’ without considering the context and graph.

19 Simple linear regression

19.1 Model, coefficients and residuals

The simple model is

\[Y_i=\beta_0+\beta_1X_i+\epsilon_i,\]

where \(Y_i\) is life expectancy and \(X_i\) is log base 2 health expenditure. Ordinary least squares chooses coefficients that minimise the sum of squared residuals. A residual is \(e_i=y_i-\hat y_i\). It is an observed model discrepancy, not the unobservable true error itself.

simple_fit <- lm(life_exp ~ log2_health, data = reg)
summary(simple_fit)

Call:
lm(formula = life_exp ~ log2_health, data = reg)

Residuals:
     Min       1Q   Median       3Q      Max 
-16.9791  -1.7987   0.1428   2.7877   9.2032 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)  51.0499     1.1141   45.82   <2e-16 ***
log2_health   2.5118     0.1219   20.61   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 3.959 on 185 degrees of freedom
Multiple R-squared:  0.6965,    Adjusted R-squared:  0.6949 
F-statistic: 424.6 on 1 and 185 DF,  p-value: < 2.2e-16
confint(simple_fit)
                2.5 %   97.5 %
(Intercept) 48.851820 53.24795
log2_health  2.271315  2.75227

The slope is the expected difference in country-level life expectancy associated with a doubling of health expenditure in this model. The intercept corresponds to expenditure of one current US dollar per capita because log2(1) = 0; it may be far outside the observed range and should not be given a substantive interpretation.

19.2 Derive the least-squares slope

x <- reg$log2_health
y <- reg$life_exp
b1 <- sum((x - mean(x)) * (y - mean(y))) / sum((x - mean(x))^2)
b0 <- mean(y) - b1 * mean(x)
tab(data.frame(term = c("Intercept", "Log2 expenditure"),
               manual = c(b0, b1), fitted = unname(coef(simple_fit))))
term manual fitted
Intercept 51.050 51.050
Log2 expenditure 2.512 2.512
stopifnot(isTRUE(all.equal(b1, unname(coef(simple_fit)[2]))))

19.3 A readable coefficient table

coef_table <- function(model) {
  sm <- coef(summary(model))
  ci <- confint(model)
  data.frame(term = rownames(sm), estimate = sm[, 1], se = sm[, 2],
             lower95 = ci[, 1], upper95 = ci[, 2],
             t = sm[, 3], p = sm[, 4], row.names = NULL)
}
simple_table <- coef_table(simple_fit)
tab(simple_table, "Simple linear regression: life expectancy in years")
Simple linear regression: life expectancy in years
term estimate se lower95 upper95 t p
(Intercept) 51.050 1.114 48.852 53.248 45.820 0
log2_health 2.512 0.122 2.271 2.752 20.607 0
cat(sprintf(paste0("A doubling of health expenditure was associated with a ",
                   "%.2f-year difference in expected country-level life expectancy ",
                   "(95%% model-based CI %.2f to %.2f). This is not a causal estimate.\n"),
            coef(simple_fit)[2], confint(simple_fit)[2, 1], confint(simple_fit)[2, 2]))
A doubling of health expenditure was associated with a 2.51-year difference in expected country-level life expectancy (95% model-based CI 2.27 to 2.75). This is not a causal estimate.

19.4 R-squared and residual standard error

\[R^2=1-\frac{\sum_i(y_i-\hat y_i)^2}{\sum_i(y_i-\bar y)^2}.\]

\(R^2\) describes the fraction of observed outcome variation accounted for by the fitted model relative to an intercept-only model. It is not the percentage of life expectancy caused by expenditure. A high \(R^2\) does not prove good causal identification or good prediction in new settings.

ss_residual <- sum(residuals(simple_fit)^2)
ss_outcome <- sum((reg$life_exp - mean(reg$life_exp))^2)
r2_manual <- 1 - ss_residual / ss_outcome
tab(data.frame(n = nobs(simple_fit), r_squared = r2_manual,
               adjusted_r_squared = summary(simple_fit)$adj.r.squared,
               residual_standard_error_years = sigma(simple_fit)))
n r_squared adjusted_r_squared residual_standard_error_years
187 0.697 0.695 3.959
# In simple regression with an intercept and the same rows, R-squared = r^2.
stopifnot(isTRUE(all.equal(r2_manual, cor(x, y)^2)))

20 Multiple linear regression and confounding

20.1 Add GDP and urbanisation

Our working model is

\[E(Y_i\mid X_i)=\beta_0+\beta_1\log_2(H_i) +\beta_2\log_2(G_i)+\beta_3(U_i/10).\]

\(H_i\) is health expenditure per capita, \(G_i\) is GDP per capita, and \(U_i\) is urban population percentage. Dividing urban percentage by 10 makes its coefficient correspond to a 10-percentage-point difference. That is not a 10% relative increase.

multiple_fit <- lm(life_exp ~ log2_health + log2_gdp + urban10, data = reg)
summary(multiple_fit)

Call:
lm(formula = life_exp ~ log2_health + log2_gdp + urban10, data = reg)

Residuals:
    Min      1Q  Median      3Q     Max 
-14.473  -2.003   0.370   2.578   7.711 

Coefficients:
            Estimate Std. Error t value    Pr(>|t|)    
(Intercept) 37.69450    2.70969  13.911     < 2e-16 ***
log2_health  0.39212    0.40942   0.958       0.339    
log2_gdp     2.44799    0.46231   5.295 0.000000338 ***
urban10      0.08959    0.15714   0.570       0.569    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 3.697 on 183 degrees of freedom
Multiple R-squared:  0.7382,    Adjusted R-squared:  0.7339 
F-statistic:   172 on 3 and 183 DF,  p-value: < 2.2e-16
multiple_table <- coef_table(multiple_fit)
tab(multiple_table, "Multiple linear regression: adjusted country-level associations")
Multiple linear regression: adjusted country-level associations
term estimate se lower95 upper95 t p
(Intercept) 37.694 2.710 32.348 43.041 13.911 0.000
log2_health 0.392 0.409 -0.416 1.200 0.958 0.339
log2_gdp 2.448 0.462 1.536 3.360 5.295 0.000
urban10 0.090 0.157 -0.220 0.400 0.570 0.569

The expenditure coefficient now compares economies with the same modelled GDP and urbanisation values. ‘Holding constant’ is a mathematical conditioning statement, not evidence that an experiment held those factors fixed. Adjustment can reduce some confounding only under substantive assumptions. It can also introduce bias if inappropriate variables, such as colliders or mediators for the intended causal question, are controlled.

20.2 Compare crude and adjusted coefficients on the same rows

crude_adjusted <- data.frame(
  model = c("Expenditure only", "Expenditure + GDP + urbanisation"),
  n = c(nobs(simple_fit), nobs(multiple_fit)),
  expenditure_slope = c(coef(simple_fit)["log2_health"],
                         coef(multiple_fit)["log2_health"]))
tab(crude_adjusted)
model n expenditure_slope
Expenditure only 187 2.512
Expenditure + GDP + urbanisation 187 0.392

Because both models use reg, a change in coefficient is not caused merely by changing the complete-case sample. It still does not prove which covariate is a confounder or establish causality. GDP and health expenditure can be highly related, which can make their separate conditional coefficients imprecise.

20.3 Nested model comparison

anova(simple_fit, multiple_fit)

The partial F test asks whether the added GDP and urbanisation terms jointly improve fit under the classical linear-model assumptions. Models must be nested and fitted to the same observations. This is not a test of causal correctness. See R’s linear-model documentation.

21 Regression assumptions and diagnostics

21.1 What is actually assumed?

For the standard model, ask whether the conditional mean is specified appropriately, whether errors are independent, whether the error variance is constant for conventional SEs, and whether the design matrix has full rank. Normal errors support exact small-sample t and F inference. Predictors do not need to be normally distributed. The outcome does not need to have a normal marginal distribution.

A causal interpretation additionally needs strong identification assumptions, including an appropriate design and control of relevant confounding. Diagnostic plots cannot establish those assumptions.

oldpar <- par(mfrow = c(2, 2))
plot(multiple_fit, which = c(1, 2, 3, 5))
par(oldpar)
Figure 10: Standard regression diagnostics. Patterns should be investigated rather than reduced to a single pass/fail rule.
  • Residuals versus fitted: curvature suggests a misspecified conditional mean; a fan suggests changing variance.
  • Normal Q-Q: substantial tail departures can affect small-sample inference and intervals.
  • Scale-location: helps inspect changing residual spread.
  • Residuals versus leverage: combines unusual predictor combinations with residual behaviour.

21.2 Formal checks as supporting information

# Shapiro-Wilk is defined for 3 to 5000 non-missing observations.
e <- residuals(multiple_fit)
if (length(e) >= 3 && length(e) <= 5000) shapiro.test(e)

    Shapiro-Wilk normality test

data:  e
W = 0.9709, p-value = 0.0006128
# Auxiliary n*R-squared check for variance related to these predictors.
# This is a teaching LM diagnostic with an asymptotic chi-square reference.
aux_data <- reg
aux_data$squared_residual <- residuals(multiple_fit)^2
aux_fit <- lm(squared_residual ~ log2_health + log2_gdp + urban10, data = aux_data)
lm_stat <- nrow(aux_data) * summary(aux_fit)$r.squared
tab(data.frame(variance_LM_statistic = lm_stat, df = 3,
               asymptotic_p = pchisq(lm_stat, df = 3, lower.tail = FALSE)))
variance_LM_statistic df asymptotic_p
9.775 3 0.021

A normality test can detect tiny deviations in a large sample and miss serious deviations in a small one. The auxiliary variance check is asymptotic and tests only variance patterns represented by its predictors. Neither result should replace graphical inspection and design reasoning.

21.3 Multicollinearity

A variance inflation factor for a numerical predictor is \(VIF_j=1/(1-R_j^2)\), where \(R_j^2\) comes from regressing that predictor on the others. This helper is restricted to the numerical main-effect predictors used here; it is not a generalised VIF implementation for multi-degree-of-freedom factors.

predictors <- c("log2_health", "log2_gdp", "urban10")
vifs <- vapply(predictors, function(v) {
  others <- setdiff(predictors, v)
  auxiliary <- lm(reformulate(others, response = v), data = reg)
  1 / (1 - summary(auxiliary)$r.squared)
}, numeric(1))
tab(data.frame(predictor = names(vifs), VIF = unname(vifs)))
predictor VIF
log2_health 12.936
log2_gdp 12.751
urban10 1.702

Large VIFs indicate difficulty separating conditional associations. They do not necessarily make prediction poor, and a fixed cutoff is not a universal law. Do not remove a substantively necessary confounder solely to reduce a VIF.

21.4 Outliers, leverage and influence

influence_table <- data.frame(country = reg$country, iso3c = reg$iso3c,
                              studentised_residual = rstudent(multiple_fit),
                              leverage = hatvalues(multiple_fit),
                              cooks_distance = cooks.distance(multiple_fit))
influence_table <- influence_table[order(influence_table$cooks_distance,
                                         decreasing = TRUE), ]
tab(head(influence_table, 10), "Most influential observations by Cook's distance")
Most influential observations by Cook’s distance
country iso3c studentised_residual leverage cooks_distance
Naoero NRU -4.181 0.044 0.185
Nigeria NGA -3.750 0.022 0.073
Tuvalu TUV -1.576 0.082 0.055
Guyana GUY -1.776 0.059 0.049
Lesotho LSO -2.099 0.041 0.046
Sri Lanka LKA 2.138 0.030 0.035
Lebanon LBN 2.043 0.032 0.034
Chad TCD -2.568 0.020 0.032
Bangladesh BGD 1.866 0.034 0.030
Afghanistan AFG 1.266 0.062 0.026

An outlier has an unusual outcome conditional on the model. A high-leverage point has an unusual predictor combination. An influential point materially affects fitted coefficients or predictions. Cook’s distance combines aspects of residual size and leverage. Rules such as \(4/n\) are screening heuristics, not deletion criteria.

Robust uncertainty and sensitivity analysis

21.5 Extension: HC3 heteroskedasticity-robust standard errors

Heteroskedasticity-consistent covariance estimates allow non-constant error variance under suitable large-sample conditions. They do not fix dependence, confounding, incorrect functional form or measurement error. The next implementation calculates HC3 directly for a full-rank ordinary least-squares model. It uses a t reference with residual degrees of freedom as a conventional approximation, not an exact finite-sample guarantee.

hc3_table <- function(model) {
  X <- model.matrix(model)
  if (qr(X)$rank != ncol(X)) stop("HC3 helper requires a full-rank model.")
  bread <- solve(crossprod(X))
  h <- hatvalues(model)
  if (any(h >= 1 - 1e-10)) stop("Leverage too close to one for this HC3 helper.")
  weights_hc3 <- residuals(model)^2 / (1 - h)^2
  meat <- crossprod(X, sweep(X, 1, weights_hc3, "*"))
  covariance <- bread %*% meat %*% bread
  se <- sqrt(diag(covariance))
  b <- coef(model)
  df <- df.residual(model)
  critical <- qt(.975, df)
  data.frame(term = names(b), estimate = unname(b), se_HC3 = unname(se),
             lower95_approx = unname(b - critical * se),
             upper95_approx = unname(b + critical * se),
             p_approx = unname(2 * pt(-abs(b / se), df)))
}
robust_table <- hc3_table(multiple_fit)
tab(robust_table, "HC3 uncertainty: independent-unit large-sample sensitivity analysis")
HC3 uncertainty: independent-unit large-sample sensitivity analysis
term estimate se_HC3 lower95_approx upper95_approx p_approx
(Intercept) 37.694 3.227 31.327 44.062 0.000
log2_health 0.392 0.522 -0.637 1.421 0.453
log2_gdp 2.448 0.598 1.268 3.628 0.000
urban10 0.090 0.182 -0.269 0.448 0.622

The coefficient estimates do not change because we changed the covariance estimator, not the least-squares fit. If economies are dependent, HC3 alone is insufficient. Cluster-robust or spatial methods require a defensible dependence structure and enough independent clusters.

21.6 Extension: leave-one-out coefficient sensitivity

loo_slopes <- vapply(seq_len(nrow(reg)), function(i) {
  coef(lm(life_exp ~ log2_health + log2_gdp + urban10, data = reg[-i, ]))[
    "log2_health"]
}, numeric(1))
tab(data.frame(full_sample_slope = coef(multiple_fit)["log2_health"],
               minimum_leave_one_out = min(loo_slopes),
               maximum_leave_one_out = max(loo_slopes)))
full_sample_slope minimum_leave_one_out maximum_leave_one_out
0.392 0.249 0.617

This asks whether any single economy drives the expenditure coefficient. It does not justify removing the economy that produces an inconvenient answer. Report the full-sample analysis and describe sensitivity transparently.

Categorical predictors, interactions and ANCOVA

21.7 Extension: a categorical predictor is still a linear model

ANOVA and regression share the linear-model framework. A factor with four levels is represented by three indicator columns plus an intercept under treatment coding.

factor_fit <- lm(life_exp ~ income_group, data = anova_data)
tab(coef_table(factor_fit), "Factor regression: reference is the first income level")
Factor regression: reference is the first income level
term estimate se lower95 upper95 t p
(Intercept) 64.202 0.858 62.511 65.894 74.811 0.000
income_groupLower middle income 3.707 1.062 1.613 5.801 3.490 0.001
income_groupUpper middle income 9.567 1.024 7.549 11.586 9.343 0.000
income_groupHigh income 15.605 0.975 13.683 17.527 16.005 0.000
head(model.matrix(factor_fit))
   (Intercept) income_groupLower middle income income_groupUpper middle income
14           1                               0                               0
28           1                               0                               0
42           1                               1                               0
56           1                               0                               1
70           1                               0                               0
84           1                               0                               0
   income_groupHigh income
14                       1
28                       0
42                       0
56                       0
70                       1
84                       1

The intercept is the mean for the reference group. Each other coefficient is a difference from that reference. The overall F test from this model corresponds to the classical one-way ANOVA on the same rows.

21.8 Extension: two-factor ANOVA with an interaction

A second factor can be a World Bank region. The example uses two regions with the largest complete observed counts, chosen by a transparent coverage rule rather than a significance result. The second factor is current high-income versus other classified economies. This is an illustrative unbalanced observational factorial design, not a randomised experiment.

factorial_data <- cross[complete.cases(cross[c("life_exp", "region", "high_income")]), ]
region_counts <- sort(table(factorial_data$region), decreasing = TRUE)
chosen_regions <- names(region_counts)[seq_len(min(2L, length(region_counts)))]
factorial_data <- droplevels(factorial_data[factorial_data$region %in% chosen_regions, ])
factorial_data$region <- factor(factorial_data$region)
cell_counts <- table(factorial_data$region, factorial_data$high_income)
cell_counts
                       
                        Other High
  Europe & Central Asia    18   40
  Sub-Saharan Africa       47    1
factorial_fit <- NULL
if (nrow(cell_counts) == 2 && ncol(cell_counts) == 2 && all(cell_counts >= 3)) {
  factorial_additive <- lm(life_exp ~ region + high_income, data = factorial_data)
  factorial_fit <- lm(life_exp ~ region * high_income, data = factorial_data)
  print(anova(factorial_additive, factorial_fit))
  print(coef_table(factorial_fit))
} else {
  cat("Insufficient coverage in one or more cells: factorial fit omitted, not fabricated.\n")
}
Insufficient coverage in one or more cells: factorial fit omitted, not fabricated.

The nested comparison tests the interaction jointly. In an unbalanced design, sequential sums of squares depend on term order; do not interpret a default ANOVA table as if all types of sums of squares were identical. A main effect in a model with an interaction is conditional on the other factor’s reference level.

21.9 Extension: ANCOVA and slope interactions

ANCOVA combines categorical groups with numerical covariates. Here we use current high-income classification and log expenditure. We first centre log expenditure so the group coefficient refers to a typical observed value rather than expenditure of one dollar.

interaction_data <- reg[!is.na(reg$high_income), ]
interaction_data$high_income <- droplevels(interaction_data$high_income)
interaction_centre <- mean(interaction_data$log2_health)
interaction_data$health_c <- interaction_data$log2_health - interaction_centre
ancova_fit <- NULL
interaction_fit <- NULL
if (nlevels(interaction_data$high_income) == 2 &&
    all(table(interaction_data$high_income) >= 5)) {
  ancova_fit <- lm(life_exp ~ health_c + high_income + log2_gdp + urban10,
                   data = interaction_data)
  interaction_fit <- lm(life_exp ~ health_c * high_income + log2_gdp + urban10,
                         data = interaction_data)
  print(coef_table(interaction_fit))
  print(anova(ancova_fit, interaction_fit))
} else {
  cat("Insufficient group coverage for the ANCOVA extension.\n")
}
                      term    estimate        se    lower95    upper95
1              (Intercept) 39.70063410 6.1610023 27.5440091 51.8572591
2                 health_c  0.41310883 0.4311435 -0.4376050  1.2638226
3          high_incomeHigh -1.09551688 1.3073418 -3.6751075  1.4840737
4                 log2_gdp  2.58992262 0.4859157  1.6311346  3.5487106
5                  urban10  0.07802942 0.1580571 -0.2338421  0.3899009
6 health_c:high_incomeHigh  0.08128229 0.4670966 -0.8403726  1.0029372
           t                 p
1  6.4438597 0.000000001022515
2  0.9581702 0.339255058705781
3 -0.8379728 0.403151146835412
4  5.3299832 0.000000289840230
5  0.4936786 0.622131695404800
6  0.1740160 0.862047345971205
Analysis of Variance Table

Model 1: life_exp ~ health_c + high_income + log2_gdp + urban10
Model 2: life_exp ~ health_c * high_income + log2_gdp + urban10
  Res.Df    RSS Df Sum of Sq      F Pr(>F)
1    182 2488.2                           
2    181 2487.8  1   0.41621 0.0303  0.862

health_c * high_income expands to the two main effects plus their interaction. The interaction coefficient is the difference in expenditure slopes between the groups. A significant interaction does not mean one subgroup has a significant slope and the other does not; the difference itself must be assessed. Limited overlap in expenditure across groups can make adjusted comparisons rely heavily on extrapolation.

Nonlinearity and transformations

21.10 Extension: a quadratic term

A model can be linear in its coefficients while nonlinear in a predictor. A quadratic term allows curvature:

\[E(Y)=\beta_0+\beta_1X_c+\beta_2X_c^2+\cdots.\]

reg$health_c <- reg$log2_health - mean(reg$log2_health)
linear_centred <- lm(life_exp ~ health_c + log2_gdp + urban10, data = reg)
quadratic_fit <- lm(life_exp ~ health_c + I(health_c^2) + log2_gdp + urban10,
                     data = reg)
anova(linear_centred, quadratic_fit)
tab(coef_table(quadratic_fit))
term estimate se lower95 upper95 t p
(Intercept) 41.142 5.962 29.379 52.905 6.901 0.000
health_c 0.385 0.410 -0.425 1.195 0.938 0.349
I(health_c^2) -0.029 0.047 -0.121 0.063 -0.616 0.539
log2_gdp 2.465 0.464 1.550 3.380 5.313 0.000
urban10 0.082 0.158 -0.229 0.394 0.521 0.603

The slope now depends on \(X_c\): \(\beta_1+2\beta_2X_c\). Do not interpret \(\beta_1\) as one constant slope across the entire range. Polynomials can behave badly outside the observed range. A spline is another option, but flexibility must be justified and evaluated rather than added until p-values look attractive.

21.11 Extension: a log outcome changes the estimand

log_outcome_fit <- lm(log(life_exp) ~ log2_health + log2_gdp + urban10, data = reg)
log_outcome_table <- coef_table(log_outcome_fit)
tab(log_outcome_table)
term estimate se lower95 upper95 t p
(Intercept) 3.794 0.039 3.717 3.872 96.822 0.000
log2_health 0.005 0.006 -0.007 0.017 0.826 0.410
log2_gdp 0.035 0.007 0.021 0.048 5.171 0.000
urban10 0.001 0.002 -0.004 0.005 0.416 0.678
log_slope <- coef(log_outcome_fit)["log2_health"]
cat("Modelled multiplicative contrast in the conditional geometric mean for a doubling:",
    fmt(exp(log_slope), 4), "\n")
Modelled multiplicative contrast in the conditional geometric mean for a doubling: 1.0049 
cat("Corresponding percentage contrast:", fmt(100 * (exp(log_slope) - 1)), "%\n")
Corresponding percentage contrast: 0.49 %

This model concerns the conditional mean of log life expectancy, or its exponentiated geometric-mean interpretation. Exponentiating a predicted log mean does not automatically give the arithmetic mean on the original scale. Back-transformation for arithmetic prediction requires attention to the residual distribution and possible smearing corrections. Life expectancy is not transformed here because it is inherently necessary; this is an explicit extension to teach scale changes.

22 Confidence intervals, prediction intervals and validation

22.1 Predict at observed covariate combinations

A confidence interval for a fitted mean concerns the modelled expected outcome at a predictor combination. A prediction interval concerns a new individual economy-level observation under the model and includes residual variation. It is not a patient-level prediction interval.

# Select actual rows near the 25th, 50th and 75th expenditure quantiles.
ord <- order(reg$log2_health)
positions <- pmax(1L, pmin(nrow(reg), round(c(.25, .50, .75) * nrow(reg))))
new_data <- reg[ord[positions], ]
mean_intervals <- predict(multiple_fit, newdata = new_data, interval = "confidence")
prediction_intervals <- predict(multiple_fit, newdata = new_data, interval = "prediction")
prediction_table <- data.frame(country_covariate_profile = new_data$country,
                                observed_life_exp = new_data$life_exp,
                                fitted = mean_intervals[, "fit"],
                                mean_lower95 = mean_intervals[, "lwr"],
                                mean_upper95 = mean_intervals[, "upr"],
                                prediction_lower95 = prediction_intervals[, "lwr"],
                                prediction_upper95 = prediction_intervals[, "upr"])
tab(prediction_table)
country_covariate_profile observed_life_exp fitted mean_lower95 mean_upper95 prediction_lower95 prediction_upper95
Liberia 62.163 64.419 62.891 65.947 56.967 71.871
Botswana 69.163 73.462 72.899 74.025 66.146 80.777
Latvia 75.427 77.921 77.234 78.609 70.595 85.248

These intervals use the conventional homoskedastic model, not the HC3 covariance extension. Robust coefficient SEs do not automatically produce valid heteroskedastic prediction intervals. The selected profiles are observed covariate combinations, not fabricated countries.

22.2 Extension: a held-out prediction exercise

Training performance is optimistic for evaluating prediction. We split the observed economies into training and test sets, fit only on the training set, and compare predictions with held-out outcomes. This is a teaching exercise; a random split may not reflect prediction to a new region or future year.

set.seed(params$seed + 4L)
train_id <- sample(seq_len(nrow(reg)), size = floor(.8 * nrow(reg)))
train <- reg[train_id, ]
test <- reg[-train_id, ]
train_fit <- lm(life_exp ~ log2_health + log2_gdp + urban10, data = train)
test_prediction <- predict(train_fit, newdata = test)
baseline_prediction <- rep(mean(train$life_exp), nrow(test))
rmse <- function(observed, predicted) sqrt(mean((observed - predicted)^2))
mae <- function(observed, predicted) mean(abs(observed - predicted))
tab(data.frame(model = c("Training mean baseline", "Multiple regression"),
               test_n = nrow(test),
               RMSE_years = c(rmse(test$life_exp, baseline_prediction),
                              rmse(test$life_exp, test_prediction)),
               MAE_years = c(mae(test$life_exp, baseline_prediction),
                             mae(test$life_exp, test_prediction))))
model test_n RMSE_years MAE_years
Training mean baseline 38 6.826 5.210
Multiple regression 38 3.678 2.574

The baseline must use the training mean, not the test mean. Avoid using test outcomes to select predictors, tune transformations or choose a final model. One split is unstable. Repeated cross-validation can assess that variability, while geographic or temporal validation may better match the intended use.

Repeated measurements beyond two years

22.3 Extension: three matched years and Friedman test

The Friedman test compares repeated measurements using within-unit ranks. It is not an ordinary independent-groups Kruskal-Wallis test. We use three actual years and retain only economies observed at all three times.

middle_year <- as.integer(floor((baseline_year + analysis_year) / 2))
repeat_years <- unique(c(baseline_year, middle_year, analysis_year))
repeat_wide <- NULL
repeat_long <- NULL
if (length(repeat_years) == 3) {
  pieces <- lapply(repeat_years, function(yr) {
    z <- panel[panel$year == yr, c("iso3c", "life_exp")]
    names(z)[2] <- paste0("y", yr)
    z
  })
  repeat_wide <- Reduce(function(a, b) merge(a, b, by = "iso3c"), pieces)
  repeat_wide <- repeat_wide[complete.cases(repeat_wide), ]
  repeat_long <- do.call(rbind, lapply(repeat_years, function(yr) {
    data.frame(iso3c = repeat_wide$iso3c, year = factor(yr, levels = repeat_years),
               life_exp = repeat_wide[[paste0("y", yr)]])
  }))
  repeat_long$iso3c <- factor(repeat_long$iso3c)
  if (nrow(repeat_wide) >= 5) {
    print(friedman.test(life_exp ~ year | iso3c, data = repeat_long))
  } else cat("Too few complete three-year profiles.\n")
} else cat("The chosen years do not provide three distinct time points.\n")

    Friedman rank sum test

data:  life_exp and year and iso3c
Friedman chi-squared = 339.31, df = 2, p-value < 2.2e-16

22.4 Extension: classical repeated-measures ANOVA

if (!is.null(repeat_long) && nrow(repeat_wide) >= 5) {
  repeated_fit <- aov(life_exp ~ year + Error(iso3c / year), data = repeat_long)
  print(summary(repeated_fit))
}

Error: iso3c
           Df Sum Sq Mean Sq F value Pr(>F)
Residuals 216  40717   188.5               

Error: iso3c:year
           Df Sum Sq Mean Sq F value Pr(>F)    
year        2  987.3   493.7   162.1 <2e-16 ***
Residuals 432 1315.6     3.0                   
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

This classical analysis is shown to explain the repeated-measures error structure. With more than two time points, the usual univariate F test involves a sphericity assumption. The code does not estimate Greenhouse-Geisser or Huynh-Feldt corrections, so do not treat its uncorrected p-value as automatically valid. Time intervals are not necessarily equal. Mixed-effects or generalised least-squares models with a justified covariance structure are more flexible extensions, especially for incomplete panels. A country’s repeated rows are never independent replicates merely because the dataset is long.

Permutation inference and its assumptions

22.5 Extension: a studentised two-group permutation exercise

Permutation tests rearrange labels under an exchangeability assumption. Income labels were not randomised, so this is not randomisation-based causal inference. A raw mean-difference permutation test can be problematic for a weak equal-means null with unequal variances. We therefore use a Welch-style studentised statistic, while still making the exchangeability limitation explicit.

welch_stat <- function(a, b) (mean(a) - mean(b)) /
  sqrt(var(a) / length(a) + var(b) / length(b))
observed_stat <- welch_stat(low, high)
combined <- c(low, high)
n_low <- length(low)
set.seed(params$seed + 5L)
permuted_stat <- replicate(P, {
  idx <- sample.int(length(combined), n_low, replace = FALSE)
  welch_stat(combined[idx], combined[-idx])
})
permutation_p <- (1 + sum(abs(permuted_stat) >= abs(observed_stat))) / (P + 1)
tab(data.frame(observed_studentised_statistic = observed_stat,
               Monte_Carlo_permutations = P, two_sided_p = permutation_p))
observed_studentised_statistic Monte_Carlo_permutations two_sided_p
-15.232 4999 0

The plus-one correction avoids reporting a zero Monte Carlo p-value. Studentisation can improve large-sample behaviour for unequal variances, but it does not turn observational group labels into randomised assignments or remove country dependence.

23 Power, sample size and practical importance

23.1 Planning a study is not interpreting an observed p-value

Power depends on the target difference, variability, sample size, significance level and design. A scientifically important difference should be chosen from context before data collection. The next example uses a hypothetical two-year difference and the observed pooled SD only as a planning illustration. It is not a recommendation for a clinical study or a claim that economies can be recruited at will.

planning_difference <- 2
power_plan <- power.t.test(delta = planning_difference, sd = pooled_sd,
                           sig.level = .05, power = .80,
                           type = "two.sample", alternative = "two.sided")
power_plan

     Two-sample t test power calculation 

              n = 72.65903
          delta = 2
             sd = 4.273942
      sig.level = 0.05
          power = 0.8
    alternative = two.sided

NOTE: n is number in *each* group
cat("Rounded-up planned observations per group:", ceiling(power_plan$n), "\n")
Rounded-up planned observations per group: 73 

n is per group for this two-sample calculation. It assumes the corresponding equal-variance t-test framework and equal group sizes. It is not a Welch-specific or clustered design calculation. See R’s power calculation documentation.

23.2 Paired planning uses the SD of differences

paired_plan <- power.t.test(delta = planning_difference, sd = sd(paired$change),
                            sig.level = .05, power = .80, type = "paired")
paired_plan

     Paired t test power calculation 

              n = 18.03738
          delta = 2
             sd = 2.857546
      sig.level = 0.05
          power = 0.8
    alternative = two.sided

NOTE: n is number of *pairs*, sd is std.dev. of *differences* within pairs
cat("Rounded-up planned pairs:", ceiling(paired_plan$n), "\n")
Rounded-up planned pairs: 19 

Do not substitute the SD of one year’s measurements for the SD of changes. Pairing can improve precision when measurements within a unit are positively related, but its benefit depends on that correlation and the design.

23.3 Extension: precision-based planning

For a rough normal-approximation interval half-width \(h\), \(n\approx(z_{.975}s/h)^2\). The example below uses a hypothetical one-year half-width and the observed country SD. It ignores finite-population and dependence corrections and is not an exact t-based design.

hypothetical_half_width <- 1
rough_n <- ceiling((qnorm(.975) * sd(life$life_exp) / hypothetical_half_width)^2)
tab(data.frame(target_half_width_years = hypothetical_half_width,
               approximate_independent_observations = rough_n))
target_half_width_years approximate_independent_observations
1 195

Avoid ‘observed power’ calculated from the observed effect as a substitute for interpreting a confidence interval. A wide interval already communicates imprecision. A non-significant result is not proof that a practically important effect is absent.

23.4 Extension: equivalence is not non-significance

To argue that an effect is small enough to be practically negligible, specify an equivalence margin in advance and use an equivalence procedure. As a teaching illustration, a two-one-sided-tests approach at alpha .05 can be related to whether a 90% CI lies entirely within the pre-specified equivalence bounds. Here the hypothetical margin is plus or minus two years for the paired mean change. This margin is a teaching choice, not an established health standard.

equivalence_margin <- 2
paired_ci90 <- t.test(paired$change, conf.level = .90)$conf.int
within_margin <- paired_ci90[1] > -equivalence_margin &&
                  paired_ci90[2] < equivalence_margin
tab(data.frame(lower90 = paired_ci90[1], upper90 = paired_ci90[2],
               lower_equivalence_bound = -equivalence_margin,
               upper_equivalence_bound = equivalence_margin,
               CI_entirely_within_bounds = within_margin))
lower90 upper90 lower_equivalence_bound upper_equivalence_bound CI_entirely_within_bounds
2.695 3.336 -2 2 FALSE

This is not the same as failing to reject a zero mean change. The paired t assumptions and the limitations of country-level inference still apply.

24 Building a complete analysis report

24.1 What every results section should contain

A complete report states the data source and retrieval date, analysis year, unit, exclusions, missingness, analysis-specific sample sizes, transformations, method, assumptions, estimates, intervals, p-values when relevant, sensitivity analyses and limitations. A reader should be able to identify exactly what was compared without reading the R code.

Do not write ‘health expenditure increases life expectancy’ from these models. Write ‘higher country-level expenditure was associated with higher or lower expected life expectancy under the fitted model’, using the actual coefficient direction. Avoid claiming that a country’s income category caused its observed outcome.

24.2 A compact inferential summary

inferential_summary <- data.frame(
  analysis = c("One-sample mean versus hypothetical 70 years",
               "Independent mean difference: Low minus High",
               "Paired mean change: later minus earlier"),
  n_units = c(nrow(life), nrow(independent), nrow(paired)),
  estimate = c(mean(life$life_exp) - 70, mean(low) - mean(high), mean(paired$change)),
  lower95 = c(one_test$conf.int[1] - 70, welch_test$conf.int[1],
              paired_test$conf.int[1]),
  upper95 = c(one_test$conf.int[2] - 70, welch_test$conf.int[2],
              paired_test$conf.int[2]),
  p = c(one_test$p.value, welch_test$p.value, paired_test$p.value))
tab(inferential_summary, "Different questions and denominators: all estimates are in years")
Different questions and denominators: all estimates are in years
analysis n_units estimate lower95 upper95 p
One-sample mean versus hypothetical 70 years 217 3.791 2.838 4.744 0
Independent mean difference: Low minus High 111 -15.605 -17.682 -13.528 0
Paired mean change: later minus earlier 217 3.016 2.633 3.398 0

These are separate teaching questions, not one pre-registered confirmatory family. If a real project treats them as a single family of confirmatory tests, specify and apply the relevant multiplicity strategy.

24.3 A coefficient plot with confidence intervals

plot_table <- multiple_table[multiple_table$term != "(Intercept)", ]
ypos <- seq_len(nrow(plot_table))
oldpar <- par(mar = c(5, 9, 3, 1))
plot(plot_table$estimate, ypos,
     xlim = range(c(plot_table$lower95, plot_table$upper95, 0)),
     ylim = c(.5, nrow(plot_table) + .5), yaxt = "n", pch = 16,
     xlab = "Adjusted coefficient (life expectancy years)", ylab = "",
     main = "Adjusted country-level associations")
segments(plot_table$lower95, ypos, plot_table$upper95, ypos)
axis(2, at = ypos, labels = c("Expenditure: doubling", "GDP: doubling",
                             "Urban share: +10 points"), las = 1)
abline(v = 0, lty = 2)
par(oldpar)
Figure 11: Adjusted regression coefficients with conventional 95% model-based confidence intervals. Predictor units differ.

The three coefficients use different predictor units, so their absolute sizes are not a direct variable-importance ranking. Standardisation changes units but does not solve confounding or collinearity.

24.4 Export tables, data and models

write.csv(descriptives, "hss2381_outputs/descriptive_statistics.csv", row.names = FALSE)
write.csv(group_summary, "hss2381_outputs/income_group_descriptives.csv", row.names = FALSE)
write.csv(missing_table, "hss2381_outputs/missingness.csv", row.names = FALSE)
write.csv(inferential_summary, "hss2381_outputs/t_test_summary.csv", row.names = FALSE)
write.csv(pairwise_table, "hss2381_outputs/pairwise_welch_comparisons.csv", row.names = FALSE)
write.csv(multiple_table, "hss2381_outputs/regression_coefficients.csv", row.names = FALSE)
write.csv(robust_table, "hss2381_outputs/regression_HC3.csv", row.names = FALSE)
write.csv(cross, "hss2381_outputs/analysis_year_data.csv", row.names = FALSE)
write.csv(paired, "hss2381_outputs/paired_year_data.csv", row.names = FALSE)
write.csv(influence_table, "hss2381_outputs/influence_diagnostics.csv", row.names = FALSE)
saveRDS(list(simple = simple_fit, multiple = multiple_fit, quadratic = quadratic_fit,
             anova = anova_fit), "hss2381_outputs/fitted_models.rds")
writeLines(c(paste("Retrieved UTC:", bundle$retrieved_utc),
             paste("Analysis year:", analysis_year),
             paste("Baseline year:", baseline_year),
             "Income groups: current metadata at retrieval, not historical labels.",
             "Source: World Bank Indicators API, source=2 (WDI).",
             "Original contributing organisations are identified on indicator pages.",
             "Request URLs:", bundle$requested_urls),
           "hss2381_outputs/data_provenance.txt")
writeLines(capture.output(sessionInfo()), "hss2381_outputs/session_info.txt")
cat("Outputs saved in hss2381_outputs; raw download bundle retained in hss2381_cache.\n")
Outputs saved in hss2381_outputs; raw download bundle retained in hss2381_cache.

The export filenames are fixed and are overwritten on a new render. Archive a project folder or rename outputs if you need to preserve multiple versions. A self-contained HTML is convenient for reading; reproducibility still requires the source, cached data and software information.

25 Guided practical exercises

25.1 Lab 1: understand the dataset

Tasks

  1. Identify the observational unit and explain why it is not a patient.
  2. List each indicator’s unit and source page.
  3. Count economies with observed life expectancy and expenditure in the selected year.
  4. Explain why regional aggregate rows were removed.
  5. Explain why current income classifications cannot be described as historical classifications.

Starter analysis

lab1 <- data.frame(
  year = analysis_year,
  economies = nrow(cross),
  life_observed = sum(!is.na(cross$life_exp)),
  expenditure_observed = sum(!is.na(cross$health_pc)),
  both_observed = sum(complete.cases(cross[c("life_exp", "health_pc")]))
)
tab(lab1)
year economies life_observed expenditure_observed both_observed
2023 217 217 191 191

Interpretation check: the denominator for both_observed can be smaller than either single-indicator denominator. No observation is created to fill the gap.

25.2 Lab 2: describe and visualise a continuous variable

Tasks

  1. Summarise expenditure with mean, median, SD and IQR.
  2. Draw raw and log-scale histograms.
  3. Explain why the mean and median can differ.
  4. Identify one limitation of a boxplot without raw points.
  5. Explain why log transformation does not prove normality.
health_observed <- cross$health_pc[is.finite(cross$health_pc) & cross$health_pc > 0]
tab(data.frame(mean = mean(health_observed), median = median(health_observed),
               sd = sd(health_observed), IQR = IQR(health_observed)))
mean median sd IQR
1433.837 470.403 2328.267 1517.316

Interpretation check: the SD is in dollars per capita; the IQR is also in dollars per capita. Variance would be in squared dollars per capita. Log-scale summaries have different units and cannot be relabelled as dollar summaries.

25.3 Lab 3: inference for one mean

Tasks

  1. Use a hypothetical 75-year reference instead of 70.
  2. State the null and alternative before reading the result.
  3. Calculate the difference and its CI.
  4. Explain what changes and what stays the same when the reference changes.
  5. Explain why the reference is not an estimated World Bank world mean.
lab_reference <- 75
lab_one <- t.test(life$life_exp, mu = lab_reference)
tab(data.frame(difference = mean(life$life_exp) - lab_reference,
               lower95 = lab_one$conf.int[1] - lab_reference,
               upper95 = lab_one$conf.int[2] - lab_reference,
               p = lab_one$p.value))
difference lower95 upper95 p
-1.209 -2.162 -0.256 0.013

Interpretation check: the observed mean and its SE do not change when only the reference changes. The difference, test statistic and p-value do change.

25.4 Lab 4: independent versus paired comparisons

Tasks

  1. Explain why low-income versus high-income economies are independent groups in this design.
  2. Explain why earlier versus later values within an economy are paired.
  3. Identify the direction of each reported contrast.
  4. Explain why matching by row number is unsafe.
  5. Explain why a paired temporal change is not a programme effect.
lab_independent <- t.test(low, high)
lab_paired <- t.test(paired$after, paired$before, paired = TRUE)
tab(data.frame(design = c("Independent: Low minus High", "Paired: later minus earlier"),
               estimate = c(mean(low) - mean(high), mean(paired$change)),
               se = c(lab_independent$stderr, lab_paired$stderr)))
design estimate se
Independent: Low minus High -15.605 1.024
Paired: later minus earlier 3.016 0.194

Interpretation check: these analyses answer different questions on different sets of units. Comparing their p-values does not tell you which scientific relationship is more important.

25.5 Lab 5: ANOVA and follow-up comparisons

Tasks

  1. State the four-group omnibus null.
  2. Compare classical and Welch ANOVA assumptions.
  3. Explain why an omnibus rejection does not identify every differing pair.
  4. Interpret one pairwise mean difference and its direction.
  5. Distinguish Holm-adjusted p-values from individual confidence intervals.
summary(anova_fit)
              Df Sum Sq Mean Sq F value Pr(>F)    
income_group   3   7037  2345.7   127.4 <2e-16 ***
Residuals    213   3922    18.4                   
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
welch_anova

    One-way analysis of means (not assuming equal variances)

data:  life_exp and income_group
F = 115.17, num df = 3.000, denom df = 81.929, p-value < 2.2e-16
head(pairwise_table)

Interpretation check: an omnibus test asks whether all means can be equal under the model. A pairwise contrast asks about a particular difference. Neither proves an income intervention would change life expectancy.

25.6 Lab 6: correlation and regression

Tasks

  1. Interpret the expenditure slope per doubling.
  2. Explain why a log2 predictor is not interpreted per one-dollar increase.
  3. Compare crude and adjusted slopes on the same rows.
  4. Inspect the residual plot and identify one concern, if present.
  5. Explain why an adjusted coefficient is not automatically causal.
lab_slope <- coef(multiple_fit)["log2_health"]
# A quadrupling is two doublings in the additive log2 model.
tab(data.frame(expenditure_contrast = c("Doubling", "Quadrupling"),
               modelled_life_expectancy_difference = c(lab_slope, 2 * lab_slope)))
expenditure_contrast modelled_life_expectancy_difference
Doubling 0.392
Quadrupling 0.784

Interpretation check: this calculation assumes the model’s constant log-scale slope and unchanged other predictors. It does not guarantee that such a change is feasible or causal.

26 Capstone: a complete country-level health analysis

26.1 Research brief

Prepare a short report answering: How is country-level life expectancy associated with health expenditure, and how does the association change after accounting for GDP and urbanisation? Add one group comparison and one paired-year analysis to demonstrate the broader methods.

Your report should include:

  1. A clear ecological research question, target year and unit.
  2. A data dictionary, retrieval date and current-classification caveat.
  3. A missingness table and analysis-specific denominators.
  4. A descriptive table and at least three informative figures.
  5. A Welch two-group comparison with raw difference and CI.
  6. A paired comparison with matching explained.
  7. A four-group mean comparison with an appropriate follow-up strategy.
  8. Pearson and Spearman correlations with their different interpretations.
  9. Simple and multiple regression on the same rows.
  10. Residual, influence and collinearity diagnostics.
  11. At least one sensitivity analysis, clearly labelled exploratory.
  12. A conclusion separating association, practical importance and causation.

26.2 Suggested assessment rubric

Component Suggested share Evidence of understanding
Question, design and provenance 15% Correct unit, year, source and target quantity
Data preparation and missingness 15% Reproducible exclusions and honest denominators
Description and visualisation 15% Appropriate summaries, units and readable figures
Inference and effect interpretation 20% Correct contrasts, assumptions, estimates and uncertainty
Regression and diagnostics 20% Correct coefficient units and substantive diagnostics
Reporting and limitations 15% No causal overclaiming; reproducible outputs

This rubric is a proposed teaching tool, not an official grading policy.

26.3 Reusable analysis template

The following function is an additional reusable example. It fits a continuous-outcome model after complete-case selection, checks numerical variables, and returns the data, model, coefficient table and fit statistics. It does not automatically select predictors or establish that the model is scientifically appropriate.

analyse_continuous <- function(data, outcome, predictors) {
  required <- c(outcome, predictors)
  if (!all(required %in% names(data))) stop("One or more variables are absent.")
  d <- data[complete.cases(data[required]), , drop = FALSE]
  numeric_vars <- required[vapply(d[required], is.numeric, logical(1))]
  if (length(numeric_vars)) {
    keep <- apply(d[numeric_vars], 1, function(z) all(is.finite(z)))
    d <- d[keep, , drop = FALSE]
  }
  if (!is.numeric(d[[outcome]])) stop("Outcome must be numerical.")
  d <- droplevels(d)
  model <- lm(reformulate(predictors, response = outcome), data = d)
  if (df.residual(model) < 5) stop("Too few residual degrees of freedom.")
  if (anyNA(coef(model))) stop("Rank-deficient model: inspect predictors.")
  list(data = d, model = model, coefficients = coef_table(model),
       fit = data.frame(n = nobs(model), R2 = summary(model)$r.squared,
                         adjusted_R2 = summary(model)$adj.r.squared,
                         residual_SD = sigma(model)))
}
# Change these names only after checking the new question and assumptions.
DV <- "life_exp"
IV <- c("log2_health", "log2_gdp", "urban10")
capstone <- analyse_continuous(cross, DV, IV)
tab(capstone$coefficients)
term estimate se lower95 upper95 t p
(Intercept) 37.694 2.710 32.348 43.041 13.911 0.000
log2_health 0.392 0.409 -0.416 1.200 0.958 0.339
log2_gdp 2.448 0.462 1.536 3.360 5.295 0.000
urban10 0.090 0.157 -0.220 0.400 0.570 0.569
tab(capstone$fit)
n R2 adjusted_R2 residual_SD
187 0.738 0.734 3.697

A reusable function improves consistency, but it does not make every combination of variables meaningful. Recheck units, transformations, missingness, functional form, overlap and design each time.

27 Worked conceptual answers and common errors

27.1 Short-answer checks

1. Why is a country’s life expectancy not a patient’s lifespan? Life expectancy is a population-level indicator derived from mortality conditions. The dataset contains economies, not individual follow-up records. A country-level relationship cannot be assigned directly to individuals.

2. Does a normal-looking histogram prove a t-test is valid? No. It says nothing about independence, selection bias, the target population or the correctness of matching. For paired tests, the relevant distribution is that of differences.

3. Is a standard error a smaller version of the SD? They measure different things. SD describes observational variation; SE describes uncertainty in an estimator under a model or design. The formula \(s/\sqrt n\) depends on independence and related assumptions.

4. Does p = .03 mean a 3% probability that the null is true? No. It is a tail probability under the null model and its assumptions. It is not a posterior probability for the null.

5. Does p > .05 prove two means are equal? No. The data may be imprecise. Examine the interval and consider whether it includes differences that matter. Equivalence requires a specified margin and a suitable procedure.

6. Why prefer Welch as a starting independent-groups comparison? It estimates variances separately and does not impose a common variance. It still requires appropriate units, independence and a defensible inferential framework.

7. Why is an ANOVA rejection not a complete answer? It says at least one mean differs under the model. It does not identify all pairs, quantify every contrast or establish causation.

8. Is Kruskal-Wallis always a test of medians? No. A median or location interpretation needs additional common-shape assumptions. Distributional differences can involve spread and shape.

9. Must regression predictors be normal? No. The standard normal-error assumption concerns the conditional errors, not the marginal predictor distributions.

10. What does the expenditure coefficient mean here? In the additive log2 model, it is a difference in expected country-level life expectancy associated with a doubling of expenditure, conditional on included predictors. It is not an effect per dollar and not automatically causal.

11. Does robust SE solve all assumption violations? No. HC3 addresses a covariance issue under independent-unit large-sample conditions. It does not fix dependence, confounding, omitted nonlinearities or selection bias.

12. Why can a prediction interval be wider than a confidence interval? Predicting a new outcome includes residual variation in addition to uncertainty about the fitted mean. Both intervals remain conditional on model assumptions.

13. Why not pool all years and run an ordinary t-test? Repeated measurements within an economy are dependent. Ignoring that structure can misstate uncertainty and changes the scientific question.

14. Why retain current income classifications as a caveat? The metadata retrieved today does not automatically reconstruct a country’s classification in the selected historical year. Historical classification analysis needs a separate dated source.

15. Does population weighting create a global life table? No. A weighted average of national life expectancy indicators does not reconstruct mortality schedules for a global life table.

27.2 Common errors and corrections

Error Better practice
Replace missing expenditure with zero Keep missingness explicit and justify any imputation
Use latest available values for a fixed-year comparison Request the exact year and report unavailable values
Include regional aggregates as if they were countries Filter using country metadata before analysis
Match paired data by row position Join by stable economy identifier
Choose one-sided testing after seeing the result Pre-specify direction and rationale
Delete every boxplot outlier Investigate source, context and influence
Use a normality p-value as the entire method decision Consider design, estimand, graphs and robustness
Report only whether p is below .05 Report estimate, interval, unit, n and limitations
Interpret log2 expenditure per dollar Interpret one unit as a doubling
Call a 10-point urban-share difference a 10% increase Say 10 percentage points
Interpret rank tests as universal median tests State the rank/location estimand and assumptions
Compare nested models on different rows Use a common complete-case dataset
Call HC3 a correction for country clustering Model dependence explicitly if needed
Rank predictor importance by raw coefficient magnitude Account for units, collinearity and the question
Claim adjusted regression proves causality Describe conditional association and identification limits

28 Formula and R command reference

Concept Formula or idea Main R command
Mean \(\sum x_i/n\) mean(x)
Median Middle ordered value median(x)
Sample variance \(\sum(x_i-\bar x)^2/(n-1)\) var(x)
Standard deviation Square root of variance sd(x)
Interquartile range \(Q_3-Q_1\) IQR(x)
Standardisation \((x-\bar x)/s\) scale(x)
Mean SE \(s/\sqrt n\) under independence sd(x)/sqrt(length(x))
Normal cumulative probability Area to the left pnorm()
Normal quantile Inverse cumulative probability qnorm()
t critical value Quantile with degrees of freedom qt()
One-sample t Mean versus reference t.test(x, mu = ...)
Welch t Independent means, separate variances t.test(x, y)
Pooled t Independent means, common variance t.test(x, y, var.equal = TRUE)
Paired t One-sample test of matched differences t.test(after, before, paired = TRUE)
Classical ANOVA Between/within mean squares aov(y ~ group)
Welch ANOVA Unequal-variance mean comparison oneway.test(y ~ group)
Tukey comparisons Classical pairwise family TukeyHSD()
Multiplicity Defined family adjustment p.adjust()
Rank-sum Independent rank comparison wilcox.test(x, y)
Signed-rank Paired rank comparison wilcox.test(after, before, paired = TRUE)
Kruskal-Wallis Multi-group rank comparison kruskal.test(y ~ group)
Friedman Repeated rank comparison friedman.test(y ~ time \| id)
Pearson correlation Linear association cor.test(x, y)
Spearman correlation Rank/monotonic association cor.test(x, y, method = "spearman")
Linear regression Conditional mean model lm(y ~ x1 + x2)
Coefficient interval Parameter uncertainty confint(model)
Prediction Mean or new-observation interval predict(model, interval = ...)
Influence Change in fitted model cooks.distance(model)
Power planning Difference, SD, alpha and design power.t.test()

29 Troubleshooting and reproducibility checklist

29.1 If rendering fails

  • Package unavailable: install the named package in the same R library used by Quarto. Restart R and render from the beginning.
  • API timeout or HTTP error: check network access and the official API. A valid local cache can support later renders. The document never substitutes invented values.
  • Unexpected API response: inspect the request and indicator metadata. A series may have changed, been retired or returned an error payload.
  • Too few complete observations: inspect the selected year and missingness table. Do not silently change the year or relax the question.
  • A group has no observations: inspect current metadata and group coverage. Do not create observations to make a test run.
  • Object not found: run all earlier chunks in order or render in a fresh session.
  • Singular model: inspect duplicated predictors, empty factor levels and perfect collinearity.
  • Different results on another date: compare cached data, retrieval dates, parameter settings and package versions.

29.2 Before submitting a report

Sources and further reading

This workbook’s explanations and code are original teaching material. The links below document the course scope, data and software methods; no course slides or proprietary teaching notes are reproduced.

  1. University of Ottawa: Health Sciences course catalogue, including HSS 2381.
  2. World Bank: About the Indicators API.
  3. World Bank: Country API queries and metadata.
  4. World Bank: Aggregate API queries.
  5. World Bank: Life expectancy at birth, total.
  6. World Bank: Current health expenditure per capita.
  7. World Bank: GDP per capita, current US dollars.
  8. World Bank: Urban population percentage.
  9. World Bank: Total population.
  10. R documentation: t-tests.
  11. R documentation: one-way mean tests, including Welch ANOVA.
  12. R documentation: Wilcoxon tests.
  13. R documentation: Kruskal-Wallis test.
  14. R documentation: correlation tests.
  15. R documentation: linear models.
  16. R documentation: p-value adjustment.
  17. R documentation: t-test power calculations.
  18. Quarto: using R and rendering executable documents.
  19. OpenStax: Introductory Statistics 2e, a free companion textbook.

Indicator pages identify their original contributing organisations and applicable licences. Retain attribution when redistributing downloaded data. Software documentation can describe newer versions than the installed R; the session information below records the actual local environment.

Session information

sessionInfo()
R version 4.5.2 (2025-10-31)
Platform: aarch64-apple-darwin20
Running under: macOS Tahoe 26.6.2

Matrix products: default
BLAS:   /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib 
LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1

locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8

time zone: America/Toronto
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

loaded via a namespace (and not attached):
 [1] digest_0.6.39     fastmap_1.2.0     xfun_0.55         knitr_1.51       
 [5] htmltools_0.5.9   rmarkdown_2.30    cli_3.6.5         vctrs_0.6.5      
 [9] compiler_4.5.2    rstudioapi_0.17.1 tools_4.5.2       curl_7.0.0       
[13] evaluate_1.0.5    yaml_2.3.12       otel_0.2.0        rlang_1.3.0      
[17] jsonlite_2.0.0    htmlwidgets_1.6.4