
Assumptions, categorical predictors & how to show your results
2026-10-26
check the OLS assumptions that matter: spot outliers with Cook’s D, judge the linearity assumption, and decide what to do about a suspicious case;
use a categorical predictor: dummy-code it, choose a reference category, and read the coefficients as differences between groups;
visualize regression results: a coefficient plot for categorical predictors, and predicted values for a continuous one.
Part 1 of 3
Regression is easy to run and easy to fool.
The climate crisis is the defining North–South injustice of your generation: the countries that emit most are rarely the ones that suffer most.
The gap is staggering: the average Qatari emits hundreds of times more CO₂ than the average person in Chad or the DR Congo (Chancel, 2022).
Research question of the day: across the world’s countries, how does CO₂ emitted per person relate to a country’s wealth?

Earth at night, a satellite composite. Where the lights are, the carbon is. Image: NASA / NOAA, public domain (Wikimedia Commons).
# Both indicators in ONE call: name them, and wbstats names the columns for us
wb_raw <- wb_data(c(co2 = "EN.GHG.CO2.PC.CE.AR5", # CO2, tonnes per person
gdp = "NY.GDP.PCAP.PP.KD"), # GDP p.c., PPP (const. 2021 $)
start_date = 2000, end_date = 2026)
# Region & income group are part of a different API, so a second call
wb_meta <- wb_countries()(Dat <- wb_raw %>%
drop_na(co2, gdp) %>% # Keep country-years where we observe both
group_by(country) %>%
filter(date == max(date)) %>% # ...and of those, the most recent one
ungroup() %>%
inner_join(wb_meta %>% select(iso3c, region, income_level), by = "iso3c") %>%
select(iso3c, country, date, co2, gdp, region, income_level) %>%
mutate(
gdp_k = gdp / 1000, # GDP in THOUSANDS of $, for readable slopes
region = fct_relevel(region, "Sub-Saharan Africa") # reference group
))
# # A tibble: 191 × 8
# iso3c country date co2 gdp region income_level gdp_k
# <chr> <chr> <dbl> <dbl> <dbl> <fct> <chr> <dbl>
# 1 ABW Aruba 2024 5.15 45428. Latin America & Caribbean High income 45.4
# 2 AFG Afghanistan 2024 0.283 1964. Middle East, North Africa, Afghanistan & Paki… Low income 1.96
# 3 AGO Angola 2024 0.742 8753. Sub-Saharan Africa Lower middl… 8.75
# 4 ALB Albania 2024 1.79 21641. Europe & Central Asia Upper middl… 21.6
# 5 ARE United Arab Emirates 2024 18.3 69702. Middle East, North Africa, Afghanistan & Paki… High income 69.7
# 6 ARG Argentina 2024 4.01 26772. Latin America & Caribbean Upper middl… 26.8
# 7 ARM Armenia 2024 2.47 20079. Europe & Central Asia Upper middl… 20.1
# 8 ATG Antigua and Barbuda 2024 3.80 29299. Latin America & Caribbean High income 29.3
# 9 AUS Australia 2024 14.1 60310. East Asia & Pacific High income 60.3
# 10 AUT Austria 2024 6.32 63788. Europe & Central Asia High income 63.8
# # ℹ 181 more rows
The extremes are oil-and-gas economies (Qatar, Kuwait, Brunei); at the other end sit some of the world’s poorest states.
Ask every scatter plot four questions:

| CO2 per person | |
|---|---|
| (Intercept) | 1.445+ |
| (0.742) | |
| gdp_k | 0.112*** |
| (0.026) | |
| Num.Obs. | 191 |
| R2 | 0.151 |
| + p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001 |
Discuss: put the gdp_k coefficient into one plain sentence. How much of what goes with how much of what — and in which units?
| CO2 per person | |
|---|---|
| (Intercept) | 1.445+ |
| (0.742) | |
| gdp_k | 0.112*** |
| (0.026) | |
| Num.Obs. | 191 |
| R2 | 0.151 |
| + p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001 |
Each extra $1,000 of GDP per person comes with about 0.11 more tonnes of CO₂ per person.
For OLS to describe the data honestly:
lm_robust() handles it.\(\rightarrow\) The first two we check with plots.
Beware: a single strange country, or a curved relationship, can bend the OLS line away from the pattern in the bulk of the data, and quietly change your conclusion.
A model is a reduced representation of reality. It should capture the general pattern; not be dictated by one or two singular cases.
Standardized residuals: how many far (in standard deviations) a country sits from the OLS line.
Leverage: influence on the OLS line; \(x_i\) far from \(\bar{x}\) (Qatar’s huge GDP).
Cook’s D: how much all predictions shift if case \(i\) were removed \((\hat{\beta} - \hat{\beta_{-i}})\).
Fitted values \(\hat{Y}\) on the X-axis; residuals on the Y-axis.
Red line: the smoothed relationship between the two.
Ideal: the red line is flat \(\rightarrow\) OLS captures the relationship well.
Our case: the red line bends slightly; the linearity assumption is hardly strained.
Discuss: the plots flag two countries. Palau, which is a tiny Pacific island whose per-person figure is distorted by a tanker-refuelling port and ~18,000 residents. Qatar, which is a genuine petro-state. Should we delete them?

Palau — Koror, the main island. Photo: Luka Peternel, CC BY-SA 4.0.

Qatar — the Ras Laffan LNG terminal. Photo: Matthew Smith, CC BY 2.0.
Tough call! Palau is a real case, but a measurement artefact for our question (national wealth → emissions): one can argue to drop it. Qatar is a real, important case; we keep it, but stay aware it has high leverage.
Never delete a case just because it is an inconvenient outlier by DFbeta standards.
With Palau gone, Qatar becomes a clear outlier. Sometimes, removing an outlier starts a cascade.




Two lines is an interaction — wealth buying a different amount of CO₂ depending on where the wealth comes from. We only put that in a model in Lecture 11; today, just look. The story: wealth drives emissions above all when the wealth is fossil fuel.

On log axes the bend disappears and the cloud tightens around a straight line — the same data, a fairer description. Re-scaling a curved relationship this way is Lecture 12’s job; today we only notice that it would help.
Same specification, one artefact removed. The slope is now more stable and \(R^2\) has roughly doubled: dropping one distorting case sharpened the whole picture.
| CO2 per person | |
|---|---|
| (Intercept) | 0.888+ |
| (0.486) | |
| gdp_k | 0.117*** |
| (0.026) | |
| Num.Obs. | 190 |
| R2 | 0.343 |
| + p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001 |
Part 2 of 3
Wealth is continuous. But much of what we compare is categorical.

Take just two regions and code them 0/1:

\(\hat{Y}\) at X = 0 is the intercept: 0.92 t — Sub-Saharan Africa’s mean.
\(\hat{Y}\) at X = 1 is intercept + slope: 5.48 t — Europe’s mean.
So \(\hat{\beta}\) = 4.56 is simply the difference between two group means.

A regression needs numbers, not words. R turns each category into a dummy: 1 if the case is in that category, 0 otherwise — leaving one category out as the reference.
\[x = \begin{cases} 1, & \text{if in the category} \\ 0 & \text{otherwise} \end{cases}\]
| Country | N. America | Europe | … |
|---|---|---|---|
| USA | 1 | 0 | 0 |
| Canada | 1 | 0 | 0 |
| Denmark | 0 | 1 | 0 |
| Germany | 0 | 1 | 0 |
| … | 0 | 0 | 1 |
| Reference (Sub-Saharan Africa, see slide 5) |
0 | 0 | 0 |
Each coefficient = that region’s average difference in CO₂ from the reference.
# R dummy-codes a factor (or character) automatically,
# using its FIRST level as the reference category.
ols_2 <- lm_robust(co2 ~ region, data = Dat)
modelsummary(
list("CO2 per person" = ols_2),
stars = TRUE,
# Shorten the region labels for the table
coef_rename = c("regionEast Asia & Pacific" = "East Asia & Pacific",
"regionEurope & Central Asia" = "Europe & Central Asia",
"regionLatin America & Caribbean" = "Latin America",
"regionMiddle East, North Africa, Afghanistan & Pakistan" = "Middle East & N. Africa",
"regionNorth America" = "North America",
"regionSouth Asia" = "South Asia"),
gof_map = c("nobs", "r.squared"),
output = "kableExtra"
)| CO2 per person | |
|---|---|
| (Intercept) | 0.922*** |
| (0.224) | |
| East Asia & Pacific | 3.320*** |
| (0.926) | |
| Europe & Central Asia | 4.557*** |
| (0.463) | |
| Latin America | 2.109*** |
| (0.573) | |
| Middle East & N. Africa | 8.617*** |
| (2.571) | |
| North America | 9.667** |
| (3.236) | |
| South Asia | 0.756 |
| (0.517) | |
| Num.Obs. | 190 |
| R2 | 0.241 |
| + p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001 |
Reference (Sub-Saharan Africa): average CO₂ is the intercept, 0.92 t per person; the value when all region dummies are 0.
North America vs. Sub-Saharan Africa: 9.67 t per person more, on average → an average of (0.92 + 9.67 =) 10.59 t.
Every coefficient is a difference of group means from the reference. No line, no linearity assumption to check.
You practice today’s diagnostics and a categorical predictor. This time with income groups instead of regions.
Open exercise 1 in a new tab ↗
Part 3 of 3
Figures are often much better for your reader. Two go-to plots: coefficient plots, and predictions.
(plotdata <- lm_robust(co2 ~ region, data = Dat) %>%
tidy() %>% # Turn the results into a tibble #<<
mutate(term = term %>% # Tidy up the region labels
str_remove("region") %>%
str_replace(", North Africa, Afghanistan & Pakistan", " & N. Africa")) %>%
filter(term != "(Intercept)")) # Drop the reference
# term estimate std.error statistic p.value conf.low conf.high df outcome
# 1 East Asia & Pacific 3.320 0.926 3.59 4.31e-04 1.493 5.15 183 co2
# 2 Europe & Central Asia 4.557 0.463 9.85 1.27e-18 3.644 5.47 183 co2
# 3 Latin America & Caribbean 2.109 0.573 3.68 3.07e-04 0.978 3.24 183 co2
# 4 Middle East & N. Africa 8.617 2.571 3.35 9.75e-04 3.545 13.69 183 co2
# 5 North America 9.667 3.236 2.99 3.20e-03 3.281 16.05 183 co2
# 6 South Asia 0.756 0.517 1.46 1.46e-01 -0.265 1.78 183 co2ggplot(data = plotdata,
aes(y = estimate,
x = reorder(term, estimate))) + # Order by size
# Reference line at "no difference"
geom_hline(yintercept = 0, color = "#901A1E", lty = "dashed") +
# A point with its 95% confidence interval
geom_pointrange(aes(ymin = conf.low, ymax = conf.high)) + #<<
coord_flip() + # Flip the axes so labels are readable #<<
labs(x = "World region",
y = "Average difference in CO2 (t per person)\nvs. Sub-Saharan Africa") +
theme_minimal(base_size = 15)
# Estimate Std. Error t value Pr(>|t|) CI Lower CI Upper DF
# (Intercept) 0.888 0.4859 1.83 6.91e-02 -0.0702 1.847 188
# gdp_k 0.117 0.0255 4.60 7.90e-06 0.0669 0.168 188
# We want the line for a RANGE of wealth levels, so we invent countries to
# predict FOR. They are fictional on purpose: no country has to exist at
# exactly $37k - we only need X values to feed the model.
(fict_dat <- tibble(
# One fictional country per $1,000 step, from $1k up to $120k.
# seq() builds 1, 2, 3, ... 120 - stay inside the OBSERVED range,
# because a model says nothing about wealth levels it never saw.
gdp_k = seq(from = 1, to = 120, by = 1)
))
# # A tibble: 120 × 1
# gdp_k
# <dbl>
# 1 1
# 2 2
# 3 3
# 4 4
# 5 5
# 6 6
# 7 7
# 8 8
# 9 9
# 10 10
# # ℹ 110 more rows(fict_dat <- predict(
object = ols_1, # The fitted model: what we learned from real countries
newdata = fict_dat, # The fictional countries: where we want the line
# "confidence" = uncertainty about the AVERAGE prediction (a narrow ribbon),
# NOT where individual countries fall. level = 0.95 -> the usual 95%.
interval = "confidence", level = 0.95)$fit %>% # $fit = the fit/lwr/upr matrix
as_tibble() %>% # predict() hands back a matrix; make it a tibble
bind_cols(fict_dat, .)) # Glue it beside the gdp_k it belongs to
# # A tibble: 120 × 4
# gdp_k fit lwr upr
# <dbl> <dbl> <dbl> <dbl>
# 1 1 1.01 0.0928 1.92
# 2 2 1.12 0.255 1.99
# 3 3 1.24 0.417 2.06
# 4 4 1.36 0.578 2.14
# 5 5 1.47 0.739 2.21
# 6 6 1.59 0.898 2.29
# 7 7 1.71 1.06 2.36
# 8 8 1.83 1.21 2.44
# 9 9 1.94 1.37 2.52
# 10 10 2.06 1.52 2.60
# # ℹ 110 more rowsggplot(data = fict_dat, # The FICTIONAL countries, not Dat #<<
aes(y = fit, # fit = the predicted CO2 #<<
x = gdp_k)) +
# Two benchmarks to talk about: a poor country and a rich one
geom_vline(xintercept = c(5, 60), color = "#901A1E", lty = "dashed") +
# The 95% interval, drawn FIRST so the line sits on top of it
geom_ribbon(aes(ymin = lwr, ymax = upr), alpha = 0.3) + #<<
geom_line(linewidth = 1) + # The predicted line itself #<<
labs(x = "GDP per person (thousands of $, PPP)",
y = "Predicted tonnes of CO2 per person") +
theme_minimal(base_size = 14)


Richer countries and richer regions emit far more per person. Yet the people who emit the least (across Sub-Saharan Africa and South Asia) are the ones most vulnerable to the global warming those emissions cause (Chancel, 2022).
You practice visualizing regression: a coefficient plot and a prediction plot of your own.
Open exercise 2 in a new tab ↗
Everything so far counted CO2 where it is produced. But Denmark burns little at home and imports carbon-heavy goods, like steel, electronics, and gasoline.
Consumption-based accounting re-assigns them to whoever consumes the goods: “Green” Denmark’s footprint jumps from 4.8 to 8.3 t; factory economies (China, India) produce what we outsourced.

Whose carbon is in these containers? Photo: Quintin Soloviev, CC BY 4.0.

# Join each country's consumption footprint, then run the SAME regression
Dat_cons <- Dat %>%
inner_join(
readRDS("data/owid_consumption.rds") %>%
select(iso3c = iso_code, consumption),
by = "iso3c"
)
modelsummary(
list("Produced (WB)" = lm_robust(co2 ~ gdp_k, data = Dat_cons),
"Consumed (GCP)" = lm_robust(consumption ~ gdp_k, data = Dat_cons)),
stars = TRUE,
coef_rename = c("gdp_k" = "GDP per person ($1,000s)"),
gof_map = c("nobs", "r.squared"),
output = "kableExtra"
)| Produced (WB) | Consumed (GCP) | |
|---|---|---|
| (Intercept) | 0.846 | 0.590 |
| (0.809) | (0.588) | |
| GDP per person ($1,000s) | 0.136*** | 0.175*** |
| (0.035) | (0.025) | |
| Num.Obs. | 118 | 118 |
| R2 | 0.358 | 0.599 |
| + p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001 |
Same predictor, same countries, but a different outcome. Wealth explains far more of the consumption footprint (\(R^2 \approx 0.6\)) than of production (\(R^2 \approx 0.36\)), and the slope is steeper. What a country consumes tracks its wealth more tightly than what it happens to emit at home; and Qatar is no longer the lone outlier.

Outliers can bend the OLS line. Cook’s D (plot(which = 5)) finds the influential ones.
OLS assumes a linear relationship for continuous predictors: check it (plot(which = 1)); it does not apply to categorical predictors.
Not every outlier should be deleted: separate artefacts (drop) from real, important cases (keep, but note their leverage).
Categorical predictors are dummy-coded: each coefficient is a group’s average difference from the reference category. R does this automatically.
Coefficient plots show categorical results; prediction plots show what a continuous model implies across a range of values.
lm() diagnostics:
plot(model, which = 5) — identify influential outliers (Cook’s D).plot(model, which = 1) — check the linearity assumption.tidy() — turn a model into a tibble of coefficients + confidence intervals.geom_pointrange() + coord_flip() — the coefficient plot.predict(model, newdata = ..., interval = "confidence") — \(\hat{Y}\) for synthetic data.fct_relevel() — set which category is the reference.Look back at the goals from the start. Can you tick all three?
plot(model, which = 5) and which = 1 and say, in words, what each one is telling you.Anything shaky? That is what this week’s Absalon quiz and the Friday exercise class are for.
Chancel, L. (2022). “Global Carbon Inequality over 1990-2019”. In: Nature Sustainability, pp. 931-938.
What if the outcome is yes/no? A short preview of the Linear Probability Model.
Linear Probability Model (LPM): just OLS with a 0/1 outcome — e.g. is this a high-emitting country? (above 2 t per person).

The red line pierces 1 at high GDP — a probability above 100%. That is the LPM’s controversial side.
\[\begin{aligned} \text{logit}(y_i) &= \alpha + \beta x, \\ y_i &= \text{logit}^{-1}(\alpha + \beta x) \\ &= \frac{1}{1 + e^{-(\alpha + \beta x)}}. \end{aligned}\]
A link function squeezes the linear model into the \([0, 1]\) range. The sigmoid-shaped logistic function does exactly that.
GLMs bring their own interpretation traps (Breen, Karlson, and Holm, 2018). In this course we use Linear Probability Models for binary outcomes — and 0/1 coding for categorical ones.


Lecture 4 · OLS wisdoms