Lab: Forecasting Elections · Part 1 of 3

Lab · Module 10 · Wed Nov 4

Welcome to the Module 10 lab

Today is Day 1 of a three-lab analysis. Across the M10–M12 labs you’ll build one cumulative analysis notebook, bread_peace_analysis.qmd, that works through a real political-science dataset with each Module’s tools as you learn them: simple linear regression today, multiple linear regression in the M11 lab, and model diagnostics in the M12 lab. You’ll submit one rendered HTML on Canvas after the M12 lab, as in the M02–M09 labs (due 11:59 PM the Tuesday after it), so the notebook you start today is the one you keep extending each lab.

How these three labs work. This page is a coaching guide, not a code-with-blanks template: there are no sandboxes, Hints, or Spoilers. Each step gives you a Targets list (the object to build, the operations in order, and what to print to check yourself), and you write the code in your own notebook. Where a function is new, the code is given. Every Target also points to the closest worked example on the site, marked , usually in the M10 Module or pre-study: when you’re stuck, open it, find the pattern, and adapt it to bread_peace. (A pre-study link lands on an activity whose Your Code tab has blanks; its 👀 Spoiler tab shows the finished code.) Writing a pipeline from a description is a central skill Part 3 builds, and it’s what Project 2 asks of you.

There’s nothing to write up as you go. You’ll build the code in the session, then we’ll spend the last fifteen minutes in a debrief talking through what your output shows. The only writing in the three labs is a short, informal reflection after the M12 lab.

Hibbs (2000) · Bread and Peace voting in U.S. presidential elections

The political scientist Douglas Hibbs is known for a striking claim: U.S. presidential elections can be largely predicted from just two term-level features — bread (how the economy did) and peace (U.S. military fatalities, per million population, during the incumbent’s term). The dataset you’ll work with in all three labs, bread_peace, has one row for every presidential election from 1952 through 2016; the box below lists its variables. Today you’ll check each half of Hibbs’s claim on its own, with simple regression (one predictor per model), then put the bread half to a test:

  1. Bread. Did the incumbent party win a larger share of the vote when incomes grew faster during its term? (vote ~ growth)
  2. Peace. Did it win a smaller share when more U.S. service members died at war during its term? (vote ~ fatalities)
  3. The test. Even if bread fits the past elections well, can it predict two it has never seen, 2020 and 2024?

bread_peace · 17 observations · 8 variables · 17 elections · 1952–2016 · Hibbs (2000); Thomas (2020)

One row per presidential election. Full codebook at Course Datasets · bread_peace.

  • year integer — U.S. presidential election year
  • vote numeric — The incumbent party’s share of the two-party popular vote, as a percentage
  • growth numeric — Weighted-average per-capita real disposable income growth over the incumbent’s term, annualized and expressed as a percentage
  • fatalities numeric — Cumulative U.S. military fatalities per million population over the incumbent’s term
  • wars character — The major conflict or conflicts contributing to the fatalities figure, named for readability
  • inc_party_candidate character — The incumbent party’s candidate
  • other_party_candidate character — The challenging party’s candidate
  • inc_party character — Which party held the presidency going into the election

Full codebook for bread_peace — values, levels, missingness, and how the file was prepared.

What you’ll leave with

  • Your cumulative bread_peace_analysis.qmd, seeded with Steps 0–4 of the three-lab arc
  • Two simple linear regressions (SLRs), vote ~ growth and vote ~ fatalities, each read with broom
  • Labeled bread and peace scatters, side by side, and a residual lollipop that shows which elections bread alone misses
  • A 2020 + 2024 forecast with both a confidence interval and a prediction interval

Where today fits in the three-lab arc

Lab What you’ll add to bread_peace_analysis.qmd
M10 lab · today Steps 0–4: setup, exploratory data analysis (EDA), the bread and peace SLRs, and a 2020 + 2024 forecast
M11 lab Steps 5–7: combine growth and fatalities into the Bread + Peace multiple linear regression (MLR), and re-forecast
M12 lab Steps 8–10: the diagnostic toolkit, influence, and the final forecast. After the lab: Step 11, a short reflection on all three labs

How the time works

Everything today happens in the session. Work through Before you begin and Steps 0–4 in order, then we’ll stop for the debrief. If you don’t finish a step’s code in class, finish it before the M11 lab, which builds on Steps 2 and 4. There’s no write-up this week.


Before you begin · Set up your cumulative analysis notebook

This is the one time we’ll walk through notebook setup explicitly — once your bread_peace_analysis.qmd exists, the next two weeks just add sections to it.

  1. Open your PSY652_project in RStudio. Double-click PSY652_project.Rproj if it isn’t already open.

  2. Save a fresh copy of the lab template. In the Files pane open programs/lab_template.qmd, then File → Save As… → bread_peace_analysis.qmd in the same programs/ folder. (This is the file you’ll extend across these three labs. Don’t overwrite the template.)

  3. Clear out the template’s starter sections. The template opens with three stock sections: # Setup, # Import Data (which reads nhanes only as a worked example), and # Glimpse. Delete all three, headings and code chunks. (The short comment just below the YAML, which points to the annotated template, can stay.) Step 0 replaces them with your own setup and import chunks, and two chunks with the same label (two setup chunks, say) stop a render.

  4. Edit the YAML. Title: “Bread and Peace · A Cumulative Analysis Across M10–M12 Labs”; your name as author; date: today.

  5. Plan the section outline. Build the navigational scaffold for the whole three-lab notebook today. Paste the block below into your .qmd right after the YAML header (below that comment, if you kept it). The M10 lab sections are real H1 headers (lines starting with #) — when you render, each becomes a heading, and the table of contents in the right margin builds itself as you write content under each. The M11 lab and M12 lab sections sit inside HTML comments (<!-- ... -->), so they don’t appear in your rendered output today. In each later lab, you’ll move the Step lines in the next comment block out of the comment to “activate” them.

    # Step 0 · Setup
    # Step 1 · EDA
    # Step 2 · SLR · vote ~ growth
    # Step 3 · SLR · vote ~ fatalities
    # Step 4 · Forecasting 2020 + 2024 with the bread SLR
    
    <!-- Activate these in the M11 lab:
    # Step 5 · From SLR to MLR · the bread SLR's biggest misses
    # Step 6 · MLR · vote ~ growth + fatalities · the Bread + Peace model
    # Step 7 · Re-forecast 2020 + 2024 with the MLR
    -->
    
    <!-- Activate these in the M12 lab:
    # Step 8 · The diagnostic toolkit
    # Step 9 · Influence and Cook's distance
    # Step 10 · The final forecast
    # Step 11 · Reflection · what you did and learned
    -->

    Today you’ll write each step’s code chunks directly under its own header, before the next header: Step 2’s work sits between # Step 2 · … and # Step 3 · …, not at the bottom of the file. Each step on this page opens with an In your notebook line naming the header its code goes under. (Each # must start its line, with an empty line above it, or it won’t become a heading. The outline’s own lines can sit one after another, but once you add a chunk under a header, leave an empty line before the next # Step line. Without it, the header silently turns into plain text and drops out of the table of contents.) This page’s own Step headings differ from these outline lines; don’t copy them into your notebook, because the outline lines are already your headers.

  6. Render once now, before you add anything substantive. If it renders, your YAML is clean and you’re ready to work. If not, fix the error before going further — debugging at this stage is much easier than debugging at the end.


Step 0 · Setup

In your notebook — both of this step’s chunks go under # Step 0 · Setup.

Add a code chunk labeled setup that loads the packages you’ll need across all three labs:

Target packages

tidyverse, here, broom, ggrepel (for nicely spaced point labels in scatters), GGally (for the optional scatterplot matrix in Step 1’s Going-further box), patchwork (to place two plots side by side, as the M03 Module did), gtsummary (for an optional publication-ready regression table in the M11 lab), and performance (you’ll need it in the M12 lab).

Then, in a second chunk labeled import-data, read the data into an object named bread_peace with read_rds() + here::here() (the file lives at data/bread_peace.Rds in your project), and run a glimpse(). Then render again.

Closest example: M09 lab · Step 0, or the setup and import chunks at the top of any of your Part 2 lab notebooks — same pattern, different packages and data file.

Checkpoint 0 · Your notebook is live

You’ve saved bread_peace_analysis.qmd from the template, deleted its three starter sections, pasted the Step outline (M10 Steps as live headers, M11/M12 Steps commented out), loaded the packages, read in bread_peace (17 rows), and rendered again with no errors. Everything from here just adds content under those headers.


Step 1 · EDA · bread and peace side by side

In your notebook — this step’s chunks go under # Step 1 · EDA.

Before fitting anything, look at the two relationships Hibbs’s model is named for, as two labeled scatters side by side:

  • Bread: the standard Hibbs scatter, vote on the y-axis and growth on the x-axis. This is the picture Hibbs himself published; you’ll come back to it in Step 2 when you fit the model.
  • Peace: the same plot with fatalities on the x-axis.

Targets.

  1. Build the bread scatter. The code is given; copy it into a chunk under your Step 1 header. Three details in it matter later: geom_text_repel(aes(label = year)) labels each point by election year; color = inc_party sits inside geom_point(aes()), not the top-level ggplot(aes()), so Step 2’s fitted line won’t split into one line per party; and labs() gives short axis titles (otherwise ggplot2 uses the dataset’s long variable labels, which crowd each other side by side). The plot is saved as an object, p_growth, instead of printed, so you can add to it in Step 2. M03 Module · Labels

    p_growth <- bread_peace |>
      ggplot(aes(x = growth, y = vote)) +
      geom_point(aes(color = inc_party)) + # color by party here, not in ggplot(aes())
      geom_text_repel(aes(label = year)) + # label each point by election year
      labs(x = "Income growth (%)", y = "Incumbent vote share (%)",
           title = "Bread", color = "Incumbent party")
  2. Build the peace scatter yourself by adapting that code: fatalities on the x-axis, a matching x-axis title, and the title “Peace”. Save it as p_fatalities. Closest example: your own p_growth code

  3. Place them side by side with patchwork’s +, in a chunk with #| fig-width: 10 so both panels have room, and a #| fig-alt: line (see the box below). plot_layout(guides = "collect") replaces the two identical party legends with one. M03 Module · Part 4 (two saved plots joined with +, at the end of the section’s code)

p_growth + p_fatalities + plot_layout(guides = "collect")

Both panels share the same vote axis, so an election sits at the same height in each. Look for elections that sit far from the cloud of points in the bread panel, then find them in the peace panel; the debrief starts there.

Why every figure gets alt text

A #| fig-alt: line is the description a screen reader reads aloud in place of the figure, so a reader who is blind or has low vision, or anyone whose images fail to load, still gets what the chart shows. Without it, that reader gets nothing, or only a file name. Write what the chart shows, not what it’s called: the axes, what’s plotted, and the pattern a sighted reader would notice (the M04 lab’s figure box has a worked example). Where this page gives you a figure’s code, its alt text comes with it; for figures you build yourself, you write it.

A scatterplot matrix shows the pairwise relationships among several variables in one compact grid: which variables relate strongly to the outcome, which predictors are correlated with each other, and which variables have skewed or otherwise unusual distributions. GGally::ggpairs() builds one in a tidyverse-friendly way. Here it shows the outcome, vote; the two predictors you’ll fit one at a time today, growth and fatalities; and year, as a time-context column (not a predictor in any model):

bread_peace |>
  GGally::ggpairs(
    columns = c("vote", "growth", "fatalities", "year"),
    progress = FALSE
  )

The diagonal shows each variable’s distribution, the lower triangle shows scatterplots, and the upper triangle shows the Pearson correlation for each pair (Corr: 0.XX, with stars flagging p-value cutoffs). Treat the correlation cells descriptively and ignore the stars: this is EDA, not a set of hypothesis tests. Scan the vote row for the strongest predictor, the growth × fatalities cell for overlap between the two predictors (it matters for the M11 lab’s MLR), and the diagonal for skew; with only 17 elections, every distribution will look a little ragged.

Checkpoint 1 · You’ve looked before you modeled

Your notebook has the labeled bread and peace scatters side by side in one chunk, with a fig-alt line, and no models fit yet.


Step 2 · Simple linear regression · vote ~ growth

In your notebook — this step’s chunks go under # Step 2 · SLR · vote ~ growth.

This is the bread half of Hibbs’s model; his full Bread and Peace model uses growth and fatalities together, which is the model you’ll build in the M11 lab. Fit it, read the coefficients, and take the model apart with broom.

A small-sample caution to hold throughout the three labs

You’re working with N = 17 elections, a very small sample, so each election has more potential to influence the fitted line: dropping or adding a single election can shift it meaningfully. When an apparent pattern hinges on one or two elections, be careful about reading it as a general rule about how voters respond to the economy or to war. The M12 lab checks which elections the model leans on.

Targets

  1. Fit the model with lm(), and store it as m_growth; the M11 and M12 labs use that name. M10 pre-study · Activity 1.2
  2. Print the coefficient table with tidy(conf.int = TRUE), and find the slope’s \(b_1\), SE, 95% CI, \(t_\text{obs}\), and p in it. M10 Module · Computing the regression equation with lm()
  3. Print the model-level summary with glance(), and find R² and \(\sigma\) (the sigma column). M10 pre-study · Activity 2.1
  4. Build the augmented data frame with m_growth |> augment(data = bread_peace), and store it as aug_growth. It attaches .fitted, .resid, and a few diagnostic columns the M12 lab uses to a copy of the data; passing data = bread_peace keeps the year column, which the lollipop chart below labels by. M10 pre-study · Activity 2.1 (the same call without data =)
  5. Draw the fitted line over the bread scatter you saved in Step 1. Because p_growth is a saved ggplot2 object, you can add a layer to it with +, and the year labels come along: add geom_smooth(method = "lm", formula = y ~ x, se = TRUE), which draws the least-squares line plus a shaded 95% confidence band for the mean prediction. If the band hides some labels, lighten it with alpha = 0.2 inside geom_smooth(), and give this chunk its own #| fig-alt: line. M10 Module · Is the relationship linear? (If you get a warning that the label aesthetic was dropped, label = year is sitting in the top-level ggplot(aes()); move it into geom_text_repel(aes(label = year)).)
  6. Chart the residuals as a lollipop chart; the code is given in the next section.

The residual lollipop

A lollipop chart is a clean choice when you have one value per category (here, one residual per election year): each “lollipop” is a vertical line — the “stick” — running from the zero baseline to the residual, capped with a point — the “candy” — at the residual itself. The one new layer is geom_segment(), which draws a line from each point (x, y) to (xend, yend); the R Graph Gallery’s lollipop page has variations. The code is given, alt text included. Copy it into a chunk under your Step 2 header:

#| fig-alt: "Lollipop chart of residuals from vote ~ growth by election year: one vertical stick per election, running from zero to that election's residual. Sticks above zero are elections where the incumbent party did better than the model predicted; sticks below zero, worse."
aug_growth |>
  ggplot(aes(x = year, y = .resid)) +
  geom_hline(yintercept = 0) +                 # the zero baseline, drawn first
  geom_segment(aes(xend = year, yend = 0)) +   # each stick runs from the residual to 0
  geom_point() +                               # the candy at the residual itself
  geom_text_repel(aes(label = year)) +
  labs(
    x = "Election year",
    y = "Residual (actual − predicted vote, % pts)",
    title = "Residuals from vote ~ growth, by election year",
    subtitle = "Above zero: the incumbent party did better than predicted"
  )

Each stick’s height is how far the SLR missed for that election, and its direction is which way: a long stick reaching down is an election where the model expected the incumbent to do much better than they did.

Checkpoint 2 · Bread, on its own

Your notebook has m_growth read with tidy() and glance() (check your numbers: \(b_1\) = 2.97, R² = .49, \(\sigma\) = 4.01), aug_growth, the fitted line drawn over the labeled scatter, and the residual lollipop (its two longest downward sticks are 1952 and 1968).


Step 3 · Simple linear regression · vote ~ fatalities

In your notebook — this step’s chunks go under # Step 3 · SLR · vote ~ fatalities.

Now fit Hibbs’s peace half on its own.

Targets.

  1. Fit vote on fatalities with lm(), and call it m_fatalities.
  2. Print its tidy(conf.int = TRUE) and glance() output, and find the slope’s \(b_1\) and 95% CI, and the model’s R².

Closest example: your own Step 2 code, with fatalities in place of growth. If you copy a whole chunk, change or delete its #| label: line: two chunks with the same label stop a render.

Checkpoint 3 · Peace, on its own

m_fatalities is fit and read: its slope is \(b_1\) = -0.031, 95% CI [-0.076, 0.013], and its R² is .132 (against .491 for growth).


Step 4 · Forecasting the 2020 and 2024 elections

In your notebook — this step’s chunks go under # Step 4 · Forecasting 2020 + 2024 with the bread SLR.

In Step 2 you used augment() to see how well the bread SLR described the 17 elections it was fit on. That’s an in-sample check, and a forgiving one: least squares chose the line to fit exactly those elections, so part of how well it fits them reflects their particular quirks rather than a pattern that will hold for new ones. The more demanding test is out of sample: predicting cases the model never saw while it was being fit. It’s the test that matters for a forecasting model, whose whole job is elections that haven’t happened yet, and it’s also a check on whether the pattern you found reaches beyond the data you found it in.

So now take m_growth out of sample: predict two elections it has never seen (Trump 2020, Harris 2024), with both forms of uncertainty, a confidence interval for the mean prediction and a prediction interval for an individual new election. We’ll call these predictions forecasts for short, but they are retrospective: as the box below explains, the growth inputs use data that wasn’t all available on Election Day.

The 2020 and 2024 inputs

The dataset stops at 2016. For 2020 and 2024, the growth values were computed with Hibbs’s weighting formula, as specified by Thomas (2020, eq. 2): a weighted average of the annualized log-percentage change in real disposable personal income per capita over the 15 quarters preceding and including each election quarter, with lag weight \(\lambda = 0.85\) (the election quarter itself counts one-third as much as a full quarter, because the November election falls partway through it). The income series is Real Disposable Personal Income per capita, from the U.S. Bureau of Economic Analysis, accessed via the Federal Reserve Bank of St. Louis’s FRED database (series A229RX0Q048SBEA). The actual two-party vote shares are FEC certified totals; the bread_peace codebook documents the sources of the 17 elections in the dataset.

Scenario Incumbent growth Actual two-party vote
2020 Trump (R) 3.72 47.73
2024 Harris (D incumbent ticket) 0.96 49.25

Why these particular inputs

  • These are retrospective predictions, not Election-Day forecasts. The growth values use the whole election quarter (October through December) and today’s revised income data, so they include information that wasn’t available on Election Day. Comparing the model’s prediction with what happened is still a fair out-of-sample test, because neither election is in the data the model was fit on.
  • growth is high for 2020 (3.72) and low for 2024 (0.96). Real disposable income per capita rose sharply during 2020, when pandemic relief payments went out, and grew slowly over Biden’s term once inflation is taken into account. Hibbs’s formula counts real-income growth the same way whatever its source.
  • fatalities is 0 for both elections under Thomas’s grace-period rule (Thomas, 2020, p. 8): presidents who inherit unprovoked foreign wars from the opposite party get a one-term grace period before voters hold those casualties against them. Trump inherited Afghanistan and Iraq from Obama; Biden inherited the residual operations from Trump. You’ll use this 0 in the M11 lab.

Targets

  1. Build the scenarios. Make a tibble called new_elections with one row per scenario: tibble(year = c(2020, 2024), growth = c(3.72, 0.96)). Keep this name: the M11 lab rebuilds it with one more column, fatalities.

  2. Compute the 95% confidence interval for the mean prediction. The code is given, because predict() has one wrinkle: it is base R, so it hands back a matrix, not a tibble. as_tibble() converts it, and bind_cols() attaches it beside your scenario columns, so each row carries fit, lwr, and upr:

    ci_matrix <- predict(m_growth, newdata = new_elections,
                         interval = "confidence", level = 0.95) # a matrix: fit, lwr, upr
    new_elections_ci <- new_elections |>
      bind_cols(as_tibble(ci_matrix)) # the matrix as a tibble, beside the scenario columns
    new_elections_ci
  3. Compute the 95% prediction interval by adapting that code: change exactly one argument, to interval = "prediction", and save the result as new_elections_pi. Print it too.

  4. Compute each election’s forecast error, actual − predicted, from the fit you printed. Subtracting by hand is fine, or let R do it: 47.73 - (new_elections_ci |> filter(year == 2020) |> pull(fit)). It’s called a forecast error rather than a residual because these two elections weren’t in the data the model was fit on.

Closest examples: M10 Module · CI for the line vs PI for individuals (the same two predict() calls) · M10 pre-study · Activity 2.1 (predict() with a one-row newdata tibble)

CI vs PI · the heart of forecasting

  • CI for the mean prediction: “What range is plausible for the mean vote share among elections with growth = 3.72, according to the model?” Uncertainty about the line itself.
  • PI for an individual prediction: “for one election with growth = 3.72, what range covers its vote share?” The line’s uncertainty plus the scatter of single elections around it (\(\sigma\)), so it is always wider.

Forecasting one upcoming election calls for the PI. The M10 Module’s CI for the line vs PI for individuals section has the full picture.

Checkpoint 4 · You’ve forecast two real elections

Your notebook has new_elections, new_elections_ci, and new_elections_pi. Check your numbers: for 2020, fit = 56.09, 95% CI [52.99, 59.18], 95% PI [47.00, 65.18]; for 2024, fit = 47.88, 95% CI [44.78, 50.98], 95% PI [38.79, 56.97]. The two forecast errors are -8.36 (2020) and 1.37 (2024).


Lab debrief · 15 minutes

Before you do the final render, save your work and look up from your screen. We will spend the last fifteen minutes on what your output shows.

Take one quiet minute to skim the questions below, then we’ll discuss as a group. They come back in the reflection you’ll write after the M12 lab.

Lab debrief · what does the output show?

  1. The sticking point. Where did you get stuck today? The notebook setup, the side-by-side scatters, the lollipop chart, the predict() calls — somewhere else? Naming the sticking point helps the next student past it.

  2. Reading the bread slope. Say the growth slope in one sentence a journalist could read: comparing two elections whose growth differs by one point, how different is the predicted incumbent vote? Then the intercept, the predicted vote at growth = 0: where does 0 sit relative to the growth values in the data, and is it a useful anchor?

  3. How good is “good”? The bread SLR has R² = .49 and \(\sigma\) = 4.01. In plain language, what does each one say, and what does \(\sigma\) mean for forecasting an election that is likely to be decided by a few points?

  4. Who does bread alone miss, and why? Which elections sat far below the cloud in your bread scatter, and where did they sit in the peace panel? Name the two longest downward sticks in your lollipop chart: what do they have in common? Peace on its own (Step 3) couldn’t tell its slope apart from 0. Look at how the elections spread out along the peace panel’s x-axis: why might that be? And predict: what will happen to those two residuals when growth and fatalities enter one model together, in the M11 lab?

  5. The forecasts. Did each actual result land inside its CI? Inside its PI? What does it mean when a result falls outside the CI but inside the PI? Was the bread SLR up to forecasting 2020?

  6. Association, not causation. Growth predicts vote share well across these elections. Does that show that a stronger economy causes incumbents to gain votes? What would it take to support that claim?


Before you leave · render, save, and prep for the M11 lab

Before you leave the lab:

  1. Render your bread_peace_analysis.qmd. If it doesn’t render, debug before you walk out — coming back to a broken notebook a week from now is much harder.
  2. Save your work to GitHub (commit + push). This is the artifact you’ll keep extending, so a lost or unsynced notebook on M11 lab day would make that session much harder.
  3. If any step’s code is unfinished, finish it before the M11 lab, then render and push again.
  4. Glance at Steps 5–7, which we’ll add in the M11 lab: you’ll combine growth and fatalities into the Bread + Peace MLR, see what happens to the elections bread alone missed, and re-forecast 2020 and 2024.

Double check

Before you leave today:


What you just did, in research terms

You took a published political-science model — Hibbs’s Bread and Peace — and started rebuilding it from the data up, one predictor at a time, on a sample so small (N = 17) that every election matters. You looked before you modeled, read the bread fit through its residuals to find which cases it misses, and then used the model beyond its data: predicting two elections it had never seen, with the interval built for one new election, the PI, around each prediction. That “predict, then check against reality” loop is the empirical core of forecasting, and the residual blind spots you found today are what the M11 lab takes up when bread and peace enter one model together.