12  Regression: presenting results

You have a model. Now somebody else has to read it.

The raw output of summary() is for you, while you are working. It is not for a reader: it prints things nobody needs, uses variable names instead of words, and gives eight significant digits for a quantity known to one. This session is about the difference.

library(ggplot2)
library(dplyr)
library(psych)
library(modelsummary)
library(ggeffects)

swe <- readRDS("data/swe.rds")

trust_items <- c(
  "tr_parl", "tr_govt", "tr_court",
  "tr_sci", "tr_party", "tr_media"
)
swe$trust <- fa(swe[, trust_items], nfactors = 1, fm = "ml")$scores[, 1]

Three models, of increasing size — which is itself the standard way to present regression results.

model1 <- lm(demlevel ~ trust, data = swe)
model2 <- lm(demlevel ~ trust + bloc, data = swe)
model3 <- lm(demlevel ~ trust + bloc + age + educ, data = swe)

12.1 Regression tables

modelsummary() takes a list of models and puts them side by side.

modelsummary(list(model1, model2, model3))
(1) (2) (3)
(Intercept) 7.812 7.909 7.534
(0.030) (0.043) (0.162)
trust 1.083 1.058 1.038
(0.033) (0.036) (0.037)
blocRight -0.118 -0.128
(0.064) (0.065)
age 0.002
(0.002)
educ 0.049
(0.017)
Num.Obs. 2525 2144 2097
R2 0.301 0.303 0.310
R2 Adj. 0.301 0.303 0.309
AIC 9299.4 7685.7 7503.4
BIC 9316.9 7708.4 7537.3
Log.Lik. -4646.703 -3838.843 -3745.708
RMSE 1.52 1.45 1.44

Better than three blocks of summary() output, but not yet publishable. Three things are wrong: the variable names are code, there is more in the bottom panel than a reader needs, and the columns are unlabelled.

labels <- c(
  "(Intercept)" = "Intercept",
  "trust" = "Trust in institutions",
  "blocRight" = "Voted for the right bloc",
  "age" = "Age (years)",
  "educ" = "Education (ISCED level)"
)

modelsummary(
  list("Trust only" = model1,
       "Plus bloc" = model2,
       "Full model" = model3),
  coef_map = labels,
  gof_map = c("nobs", "r.squared", "adj.r.squared"),
  stars = TRUE,
  title = "Evaluations of Swedish democracy (0-10)"
)
Evaluations of Swedish democracy (0-10)
Trust only Plus bloc Full model
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
Intercept 7.812*** 7.909*** 7.534***
(0.030) (0.043) (0.162)
Trust in institutions 1.083*** 1.058*** 1.038***
(0.033) (0.036) (0.037)
Voted for the right bloc -0.118+ -0.128*
(0.064) (0.065)
Age (years) 0.002
(0.002)
Education (ISCED level) 0.049**
(0.017)
Num.Obs. 2525 2144 2097
R2 0.301 0.303 0.310
R2 Adj. 0.301 0.303 0.309

coef_map renames the coefficients and, importantly, also sets their order — anything left out of the map is dropped from the table. Naming the list of models gives the columns headings. gof_map picks which goodness-of-fit rows to keep. stars = TRUE adds significance stars and a footnote explaining them.

The number in brackets under each coefficient is its standard error. Some journals want confidence intervals instead:

modelsummary(
  list("Full model" = model3),
  coef_map = labels,
  gof_map = c("nobs", "r.squared"),
  estimate = "{estimate}",
  statistic = "conf.int"
)
Full model
Intercept 7.534
[7.216, 7.851]
Trust in institutions 1.038
[0.965, 1.111]
Voted for the right bloc -0.128
[-0.255, -0.000]
Age (years) 0.002
[-0.002, 0.006]
Education (ISCED level) 0.049
[0.016, 0.082]
Num.Obs. 2097
R2 0.310

statistic controls what goes underneath. "conf.int" gives the 95% interval, which is more informative than a standard error because the reader does not have to do the arithmetic.

Stars or intervals? Stars are conventional and compact, and they encourage exactly the habit session 6 warned against — reading a table for which rows have asterisks. A confidence interval shows the size of the effect and its uncertainty at once. Give intervals where the journal permits it.

To save a table to a file, give output a filename. The format follows the extension.

modelsummary(
  list("Full model" = model3),
  coef_map = labels,
  output = "tables/democracy_model.docx"
)

.docx, .html, .tex and .md all work. Writing straight into the document you are drafting means the numbers cannot be mistyped on the way — the same argument as inline code in these materials.

You will also meet stargazer, which was the standard for years and appears in a great deal of published code. It still works for lm(), but it has not kept up with newer model types. Use modelsummary for new work; recognise stargazer when you read someone else’s.

12.2 Coefficient plots

A table is precise. A plot is quicker to read, and for a model with many predictors it is much quicker.

modelplot(
  model3,
  coef_map = rev(labels[-1]),
  conf_level = 0.95
) +
  geom_vline(xintercept = 0, linetype = "dashed") +
  labs(
    x = "Coefficient (points on the 0-10 scale)",
    y = NULL
  ) +
  theme_bw()
Figure 12.1

modelplot() comes from the same package and returns an ordinary ggplot, so everything from session 4 applies. labels[-1] drops the intercept, which is not a comparable quantity and would compress everything else; rev() reverses the order, because a plot builds upwards from the bottom while a table reads downwards.

The dashed line at zero is doing real work. Any interval crossing it is a coefficient we cannot distinguish from no relationship.

A question. On that plot, trust has a long bar far from zero and age has a very short bar sitting on it.

A reader might conclude that trust matters and age does not. What else would you need to know before agreeing — and what does the fact that the two variables are on completely different scales do to the comparison?

That question points at a real limitation. The coefficients are in different units — one standard deviation of trust, one year of age, one ISCED level — so their lengths on this plot are not comparable. One fix is to standardise the predictors so that every coefficient is “per standard deviation”:

model_std <- lm(
  scale(demlevel) ~ scale(trust) + bloc + scale(age) + scale(educ),
  data = swe
)

round(coef(model_std), 3)
##  (Intercept) scale(trust)    blocRight   scale(age)  scale(educ) 
##        0.062        0.517       -0.069        0.019        0.054

scale() subtracts the mean and divides by the standard deviation. Now every continuous coefficient is in the same units and the sizes can be compared directly — trust does about twenty times the work of education. The cost is that the coefficients no longer mean anything in the units of the data, which is why this is a supplement rather than a replacement.

12.3 Predicted values

The most useful thing you can show a reader is what the model expects to happen.

predictions <- predict_response(model3, terms = "trust [-2:2 by=0.5]")

predictions
## # Predicted values of demlevel
## 
## trust | Predicted |      95% CI
## -------------------------------
## -2.00 |      5.83 | 5.65,  6.02
## -1.50 |      6.35 | 6.20,  6.51
## -1.00 |      6.87 | 6.75,  7.00
## -0.50 |      7.39 | 7.29,  7.49
##  0.00 |      7.91 | 7.82,  8.00
##  0.50 |      8.43 | 8.34,  8.52
##  1.00 |      8.95 | 8.85,  9.05
##  2.00 |      9.99 | 9.84, 10.14
## 
## Adjusted for:
## * bloc =  Left
## *  age = 55.50
## * educ =  5.44

predict_response() from ggeffects computes predicted outcomes across the range of one variable, holding the others at representative values — it tells you which, underneath the table. terms names the variable to vary, and the bracket notation [-2:2 by=0.5] specifies the values to predict at.

plot(predictions) +
  labs(
    x = "Trust in institutions (standard deviations from the mean)",
    y = "Predicted rating of Swedish democracy",
    title = NULL
  ) +
  theme_bw()
Figure 12.2

The shaded band is the 95% confidence interval around the prediction.

This says the same thing as the coefficient 1.04, but in a form a reader can use: someone two standard deviations below average on trust is predicted to rate Swedish democracy around 5.83, and someone two above at around 9.99.

12.3.1 A warning the model will not give you

Look at the top of that prediction table.

predictions |>
  as.data.frame() |>
  filter(x >= 1.5) |>
  select(x, predicted, conf.low, conf.high) |>
  round(2)
##     x predicted conf.low conf.high
## 1 1.5      9.47     9.34      9.59
## 2 2.0      9.99     9.84     10.14

The predicted value at the top of the range is 9.99, and the upper end of its confidence interval is 10.14.

The scale stops at 10. The model has produced a prediction that cannot happen, and it did so without complaint.

This is session 5 arriving in the middle of a regression. OLS fits a straight line, and a straight line does not know that the outcome is bounded. Extend it far enough in either direction and it will predict impossible values — a rating of 12, a negative income, a probability of 1.4.

Three things follow.

  • Do not predict outside the range of your data. The model has no information there.
  • Check predictions against the limits of the scale, as a matter of routine.
  • When a bounded outcome is the whole problem, a straight line is the wrong model. That is what session 15 is about.

12.4 Formatting a table or figure

The rules from session 4 apply, plus a few specific to model output.

  • Name variables in words. “Trust in institutions”, not trust.
  • Say what one unit is, in the caption or a note. A coefficient without its units cannot be read.
  • Round sensibly. Two decimal places is almost always enough. 1.0381823 claims a precision that the standard error of 0.04 flatly denies.
  • Report n for every model, because listwise deletion makes it differ between columns. Ours run from 2525 down to 2097.
  • Keep the models in a sensible order — usually simplest first — and keep the coefficient order the same across columns.
  • Do not report everything. A table with forty control variables hides the three that matter. Put controls in an appendix and say so.
  • Text on a figure should be about the size of the text around it.

How to write it up.

Table 1 reports OLS models of how democratic respondents consider Sweden to be, on a 0-10 scale. Trust in institutions is the dominant predictor: a one standard deviation increase corresponds to a 1.04-point higher rating (95% CI [0.97, 1.11]). Having voted for the right-wing bloc is associated with a 0.13-point lower rating, an estimate that is only marginally distinguishable from zero once trust is accounted for (p = 0.049). Education has a small positive association and age none. The full model accounts for 31.0% of the variance (n = 2097).

Walk the reader through the table rather than repeating it. Mistakes to avoid:

  • Reproducing the table in prose. Say what it shows; do not read it aloud.
  • Reporting a coefficient without its units or its uncertainty.
  • Describing a coefficient whose interval crosses zero as an effect.
  • Comparing raw coefficients on different scales as though the sizes were comparable.
  • Predicting outside the range of the data, or reporting a prediction the scale cannot hold.
  • Giving four decimal places to a quantity with a standard error in the second.
  • Reporting a different n in the text than in the table, which is what happens when the table is regenerated and the prose is not.

In the seminar. swirl lesson 12 Regression Presentation. Building a table with several models, renaming and ordering coefficients, coefficient plots, and predicted values. Around 30 minutes.