Getting started with R

Foundation – for arriving with no R at all

This is the first module of the companion curriculum, and it is written for the person who arrives with no R at all: a student, a professional from another field, or anyone curious about how the workshop’s analysis is actually built. It does not assume you have programmed before. By the end you will know what R is, what the editor around it does, how to store and reshape data, how to draw figures from it and read the ones that the workshop shows you, how R’s package system and its main dialects fit together, and where the workshop’s own materials pick up from here.

One practical note before we start, the same one that the other companion modules make. Every command below is shown, not run for you. R is a language you learn by typing into it and watching what comes back, so the way to use this page is to try each snippet yourself, either in the workshop’s zero-install browser lab or in your own copy of R. Nothing on this page executes when it renders.

1. R the language, Positron the editor

R is a programming language built for working with data: reading it in, reshaping it, fitting statistical models, and drawing figures. It is free, open source, and the common language of quantitative social science, which is why the workshop’s analysis is written in it. R is the thing that does the calculation, and on its own it is just a program that reads instructions and returns answers.

Positron is the editor you drive R from. An editor (the general term is an integrated development environment, or IDE) is the working environment around R: a window with your files on one side, a place to write and run code, and panels that show results, figures and the objects you have created. Positron is free, made by the company behind R’s most-used tools, and it is the editor that the workshop demonstrates on the shared screen. Newcomers often conflate the two, so the relationship is worth stating plainly. You install R once, so the language is present on your machine. You install Positron once, as the place you work, and when you first open it Positron finds the R you installed and connects to it. R does the computing; Positron is where you sit while it happens.

You do not strictly need Positron to use R – R has its own bare console, and other editors exist – but a modern editor makes everything easier to see and to keep, so the workshop settles on Positron and this module assumes it. The setup page walks through installing both.

TipTry it

You do not have to install anything to begin. Open the workshop’s browser lab: a full copy of R starts up inside the tab after a few seconds, with no editor and no install to manage. Everything in the sections below can be typed straight into it, which makes it the fastest way to follow along on a first read.

2. Try it in the console, keep it in a script

There are three surfaces you type R into, and knowing which is for what saves a great deal of confusion later.

The console is the interactive prompt. You type one line, press Enter, and R answers immediately. It is where you try things out, check a value, or run a quick calculation. Its weakness is that the moment you close the session, everything you typed there is gone, and there is no record of how you reached a result.

A script is a plain text file, with the extension .R, that holds a sequence of commands you want to keep. Instead of typing into the console and losing it, you write the commands in the script and run them from there – all at once, or line by line. A script is a record: it runs the same way tomorrow and on someone else’s machine, and it shows exactly how a result was produced. The rule that the workshop follows: try things in the console, but the moment something matters, move it into a script.

A Quarto document, with the extension .qmd, is a script and a written report in one file. It holds ordinary prose (the sentences you are reading now are written this way) interleaved with ‘chunks’ of R code. Render the document and R runs every chunk, dropping its results – tables, figures, numbers – into the finished report at exactly the point they belong. This is the unit of work that the workshop is built around, because it keeps the analysis and the writing that explains it in a single file that cannot drift apart. Your workshop report is a Quarto document, and rendering it is how your preregistration block gets a timestamp.

3. Name a value, build a table

R works by putting values into named objects and then referring to them by name. You assign a value to a name with the arrow <- (type it as a less-than sign followed by a hyphen). After that, the name stands in for the value everywhere you use it.

unemployment_rate <- 7.5        # a single number
country           <- "Germany"  # text, called a 'character string', is quoted
in_crisis         <- TRUE       # a logical value: TRUE or FALSE

unemployment_rate               # typing the name prints the value back

Most data is not a single value but a column of them. A vector is an ordered run of values of one type, built with c() (for ‘combine’). Arithmetic and summary functions work on the whole vector at once, which is why R rarely needs the loops that other languages reach for.

unemp <- c(5.8, 5.6, 4.9, 4.1, 5.8)   # unemployment across five years

length(unemp)   # how many values
mean(unemp)     # their average
unemp * 100     # every element multiplied, in one step

You reach into a vector with square brackets, either by position or – far more useful in analysis – by a condition, which keeps only the elements where the condition is TRUE.

unemp[1]           # the first value
unemp[unemp > 5]   # only the years above five per cent

A data frame is R’s table: a set of equal-length vectors sitting side by side as columns, one row per observation. Almost everything in the workshop is a data frame. You pull a single column out by name with the $ operator, and a rectangular block with [rows, columns].

# A tiny data frame built by hand, three country-years:
d <- data.frame(
  country = c("Austria", "Austria", "Greece"),
  year    = c(2004, 2005, 2010),
  unemp   = c(5.8, 5.6, 12.7)
)

d              # the whole table
d$unemp        # one column, as a vector
d[1:2, ]       # the first two rows, all columns

4. Install once, load every session

Everything so far – c(), mean(), data.frame() – ships with R itself, a core known as base R. Everything else arrives as packages: bundles of functions, their documentation, and sometimes data, written by researchers and developers around the world and published on CRAN, the Comprehensive R Archive Network. CRAN holds over twenty thousand of them. When the next section reads a file with read_csv(), that function comes from a package called readr, not from R itself, and the sections after that lean on packages for nearly everything.

Using a package takes two steps, and the two of them trip nearly every newcomer at least once:

install.packages("readr")   # step 1: download it to your machine – once, ever
library(readr)              # step 2: load it into the session – once, each session

install.packages() fetches the package from CRAN and stores it in your machine’s package library. You run it once and never again, until you want an update. library() takes an installed package and attaches it to the running session so its functions are available by name, and it belongs at the top of every script that needs it. Installing is buying the book; library() is taking it off the shelf. Confuse the two and you meet R’s most common error messages. there is no package called 'readr' means step 1 never happened on this machine, while could not find function "read_csv" usually means step 2 is missing from the current session.

Two habits around packages are worth adopting from the start. First, put every library() call at the top of the script, together. The block then doubles as a plain statement of what the script depends on, and anyone opening the file sees at a glance what they need installed, which is a small but real piece of reproducibility. Second, know the :: form: readr::read_csv() calls a function through its package name without attaching anything, and you will meet it in code that wants the provenance of every function to be visible on the line that uses it.

The workshop’s own workspace applies all of this for you: its R/check_setup.R script checks every package that the exercise needs and, if any are missing, prints the ready-made line that installs them.

TipTry it

Run library(fakepackagename) and read the error carefully, then run library(readr) (preinstalled in the browser lab) and notice that success is silent. Learning to read error messages calmly – this one says nothing more alarming than ‘not on the shelf’ – is a genuine R skill, and this is the message you will see most often.

5. Every format lands as a data frame

You will rarely type data by hand. Most data arrives as a CSV – a ‘comma-separated values’ file, a plain text table where each line is a row and commas divide the columns. The workshop ships its data this way precisely because a CSV is readable, diff-able text rather than an opaque binary blob. The file that matters most here is EUframes_cy.csv, the country-year panel that every specification in the workshop is built on.

R reads a CSV into a data frame in one call. Base R has read.csv(); read_csv() from the readr package is a little faster and reports the column types it inferred, and it is the one that the workshop uses.

library(readr)

euframes <- read_csv("data/EUframes_cy.csv")   # a project-relative path

euframes            # print the table
nrow(euframes)      # 270 country-years
names(euframes)     # the column names

Note the path: "data/EUframes_cy.csv" is written relative to the project folder, not as C:/Users/yourname/Desktop/.... A relative path works on any machine that has the project; an absolute path breaks the moment that the work moves. Opening your workspace as a project and writing every path relative to it is one of the plainest reproducibility habits there is.

Two functions tell you what you just read without printing the whole thing:

head(euframes)              # the first few rows
summary(euframes$unemp)     # min, median, mean and max of one column
TipTry it

Read data/analysts5.csv into an object called analysts with read_csv(), then run nrow(analysts) and names(analysts). It holds the five Multi100 analysts’ recorded results, one row each, and it is the smallest file in the project – a good one to print whole and look at properly. Compare its column list against euframes: one file describes country-years, the other describes analyses, and noticing what a row is in each is the first question to ask of any dataset.

Real data rarely arrives as a CSV

The workshop’s own raw material is the plainest example. The Eurobarometer surveys behind the panel are distributed by the GESIS data archive as Stata (.dta) and SPSS (.sav) files, because those are the packages that much of survey research historically ran on. R reads both through the haven package, one function per format (the file names below are illustrative – none of these files ship with the workshop):

library(haven)

eb <- read_dta("eurobarometer_wave.dta")   # Stata; read_sav() reads SPSS

What makes these formats different from a CSV is labelled data. A Stata or SPSS survey file knows that a column called eu_meaning carries the question “What does the EU mean to you?”, and that its stored values 1, 2, 3 stand for named answer options. haven keeps all of that: labelled columns print with their question text attached, and as_factor() turns the numeric codes into their labels when you want to work with the words rather than the numbers.

eb$eu_meaning                # prints with its question and value labels attached
as_factor(eb$eu_meaning)     # codes become their labels: a factor

Excel files are where data is born in many organisations, and the readxl package reads them – one sheet at a time, since a workbook is really a stack of tables:

library(readxl)

rates <- read_excel("unemployment_rates.xlsx", sheet = "annual")

Treat what arrives from a spreadsheet with mild suspicion. Excel displays one thing and stores another often enough – dates especially – that checking column types with summary() straight after import is a habit worth keeping.

Finally, Parquet and Feather are the newer columnar formats you will increasingly meet where datasets are large or move between languages. They are compressed, fast, and – unlike a CSV – they carry their column types inside the file, so nothing has to be re-inferred on reading. The arrow package reads both, and the small nanoparquet package reads and writes Parquet with no dependencies at all:

library(arrow)

panel <- read_parquet("EUframes_cy.parquet")   # read_feather() for Feather

Whatever the source format, the landing object is the same data frame, and everything downstream – the verbs of the next sections, the models of the next module – proceeds identically. Every read_ function here also has a write_ counterpart (write_csv(), write_dta(), write_parquet(), and write_xlsx() from the writexl package), so R also serves as the converter between all of these formats. The workshop still ships CSV by choice. At 270 rows the efficiency of the binary formats buys nothing, and plain text is worth more: you can open it in anything, diff it, read it in an email.

6. Read the pipe left to right

Reading data is the start. The real work is reshaping it, and reshaping is almost always a sequence of steps. R’s pipe, written |>, exists to make such sequences readable. It takes whatever is on its left and passes it as the first argument to the function on its right, so instead of wrapping functions inside one another and reading them inside-out, you write the steps top to bottom in the order they happen:

# without the pipe: innermost first, hard to follow
round(mean(euframes$unemp), 1)

# with the pipe: left to right, in the order it happens
euframes$unemp |> mean() |> round(1)

On one short line the gain is small. Over the multi-step chains of the next section it is the difference between prose and a puzzle, which is why nearly all modern R is written this way. One historical note, because you will meet it in other people’s code: |> has been part of R itself only since 2021, and older code uses %>%, a pipe from a package called magrittr that does nearly the same job. Read them identically; write the native |>, as all the workshop’s materials do.

7. One task, three dialects

R is old enough to have grown dialects, families of packages with their own vocabulary and style, all doing the same underlying work. You will meet three – base R, the tidyverse and easystats – and it is worth seeing the same small task in each so that none of them ever looks foreign. The task is to keep the crisis-period rows of the panel, with a handful of columns.

Base R does it with the bracket-and-$ machinery of section 3, no packages required:

crisis <- euframes[euframes$year >= 2009, c("country", "year", "unemp", "mcosmo")]

It works, it always will, and code written this way twenty years ago still runs. Its weakness is that the brackets say how rather than what: the reader must decode positions and conditions to reconstruct the intent.

The tidyverse is the dominant modern dialect: a family of packages sharing one design philosophy, installable and attachable under the single name tidyverse (Wickham et al. 2019). You have already met two of its members, readr and – now – dplyr, which gives each common data operation a named verb:

  • filter() keeps rows that meet a condition;
  • select() chooses columns;
  • mutate() adds or changes a column;
  • summarise() collapses many rows into a summary.

Chained with the pipe, the same task reads as what it is:

library(dplyr)

euframes |>
  filter(year >= 2009) |>              # crisis-period rows only
  select(country, year, unemp, mcosmo) # the columns of interest

mutate() derives a new column from existing ones, and summarise() reduces the table to a single row (or, with group_by(), one row per group). The pipeline below adds a plain-language crisis flag, groups the country-years by whether a bailout programme was active, and reports the average cosmopolitan-framing score in each group:

euframes |>
  mutate(crisis = year >= 2009) |>            # a new TRUE/FALSE column
  group_by(bailout) |>                        # split by bailout status
  summarise(
    n_cells    = n(),                         # how many country-years
    mean_cosmo = mean(mcosmo),                # their average outcome
    mean_unemp = mean(unemp)
  )

Read the chain aloud and it is almost English: take the panel, add a crisis flag, group by bailout status, and for each group report the count and two averages. Readability of that kind is a reproducibility feature: a reviewer who can read the code can check it, which is why the workshop’s own scripts are built from these verbs.

easystats is the third dialect, and the one that this module most wants you to know exists, because the workshop’s facilitator uses and recommends it. It is likewise a family of packages under one installable name, easystats (Lüdecke et al. 2022), but its centre of gravity is different: where the tidyverse is at its best preparing data before a model, easystats is built for everything that happens after one. It has a data-preparation member too – datawizard, whose verbs deliberately mirror those of dplyr –

library(datawizard)

euframes |>
  data_filter(year >= 2009) |>
  data_select(c("country", "year", "unemp", "mcosmo"))

– but its real contribution begins once a model exists, and that is the subject of the next section. The three dialects are layers rather than rivals: base R is always underneath, the tidyverse gives data work its grammar, and easystats gives the modelling stage a grammar of its own.

TipTry it

Write a chain on euframes that keeps only Greece (filter(country == "Greece")), selects year, unemp and mcosmo, and prints the result. Then extend it with summarise() to report Greece’s mean unemployment across the whole period. You have now filtered, selected and summarised in one pipeline – the core loop of everyday data work. If you are curious, redo the first step in base R and in datawizard, and notice you can already read all three.

8. The grammar of graphics

Section 7 introduced the tidyverse as a family and named two of its members. ggplot2 is the third, and it is the one that draws (Wickham et al. 2019). Every figure that the workshop shows you is built with it – the slides, the live class chart on the results page, the small country panels in the browser lab – so reading ggplot2 code is how you satisfy yourself that a figure shows what its caption says it shows.

The name abbreviates ‘the grammar of graphics’, and the word grammar is meant literally. Rather than offer one function per chart type, ggplot2 splits a figure into parts that recombine freely: the data, the mapping from columns to visual properties, the geometric shapes drawn, the scales that decide what those shapes look like, the panels that the plot is divided into, and last the labels and the styling. A scatterplot and a bar chart are then two fillings of the same slots rather than two commands to remember. Learn the slots once and the figures on this site become legible, including the ones you have not met yet.

The three parts you cannot omit

A plot needs data, a mapping, and at least one geom. Everything else has a default good enough to start with.

library(ggplot2)

ggplot(euframes, aes(x = unemp, y = mcosmo)) +
  geom_point()

Three things happen there. ggplot() receives the data frame and declares which columns drive the picture. aes() is the aesthetic mapping, and it states that the unemployment rate governs horizontal position while the cosmopolitan-framing score governs vertical position. geom_point() adds a geom, a layer of geometric shapes, drawing one point per row – 270 points for 270 country-years. Axis ranges, tick marks, gridlines and the panel background are all inferred from the data, which is why this bare version is already a readable figure.

Layers are joined with +, never with the pipe |> of section 6. The reason is chronology rather than design, since ggplot2 settled on + years before R had a native pipe. The practical consequence is that a long chain changes operator exactly once, at the point where it stops reshaping data and starts drawing, and typing |> where + belongs is among the commonest first-week errors in R.

Mapped inside aes(), set outside it

Every visual property of a layer can be given two ways, and confusing them is the next commonest error. Put a property inside aes() and it is mapped: its value is read from a column, it varies row by row, and ggplot2 adds a legend explaining the correspondence. Put the same property outside aes(), as an ordinary argument to the geom, and it is set, taking one fixed value for the whole layer, with no legend because there is nothing to explain.

# MAPPED: colour comes from a column, so the points differ and a legend appears
ggplot(euframes, aes(x = unemp, y = mcosmo, colour = factor(bailout))) +
  geom_point()

# SET: colour is a constant, identical for every point, and no legend appears
ggplot(euframes, aes(x = unemp, y = mcosmo)) +
  geom_point(colour = "#0e3b6a", alpha = 0.5)

alpha controls opacity, running from 0 for invisible to 1 for solid, and setting it below 1 is the standard remedy for a scatterplot where points pile up on one another. The constant "#0e3b6a" is a hex colour code, the dark blue that the workshop’s own figures use.

The factor() wrapped around bailout is exactly the kind of detail that quietly produces the wrong figure when it is left out. bailout is stored as 0 and 1 in numeric form, so colour = bailout hands ggplot2 a number and it responds sensibly, with a continuous colour gradient and a legend running smoothly from 0.00 to 1.00. What you wanted was two categories in two colours, and factor(bailout) is how you say so. The same trap waits on year, which is also numeric – map it raw for a gradient across the decade, wrap it in factor() for ten discrete groups.

Layers stack, and the order shows

A figure can carry as many layers as the argument needs, each with its own geom, and they are drawn in the order written, so later layers sit on top of earlier ones.

ggplot(euframes, aes(x = unemp, y = mcosmo)) +
  geom_point(alpha = 0.4, colour = "#848c94") +
  geom_smooth(method = "lm", colour = "#0e3b6a", fill = "#0e3b6a", alpha = 0.15)

Grey points go down first and the trend line lands on top of them. geom_smooth() fits a line through the cloud and draws it with a band showing the uncertainty around it, and method = "lm" asks for a straight line from the same least-squares machinery that the next module fits models with. R answers with the message `geom_smooth()` using formula = 'y ~ x', which is information rather than a complaint, telling you which relationship it fitted. Reference layers behave the same way: geom_hline(yintercept = 0) rules a horizontal line at zero, and section 9 uses one to split a figure into results that support a claim and results that do not.

One trap in layer arguments is worth memorising, because a great many figures would warn without it. Line thickness is linewidth, whereas point size and type size are size.

geom_line(linewidth = 0.4)   # the thickness of a drawn line
geom_point(size = 0.7)       # the area of a plotted point
geom_text(size = 3.5)        # the height of a piece of type

Passing size to a line geom still works, but R answers “Using size aesthetic for lines was deprecated in ggplot2 3.4.0. Please use linewidth instead.” The reason that the two names survive side by side is that size on a point means an area and on text a height, neither of which is a line thickness.

Scales decide what the mapping looks like

The mapping says which column drives colour; the scale decides which colours it drives, and the same holds for positions, sizes and shapes. Every mapping gets a default scale, and you override it whenever the default is not what a reader needs.

ggplot(euframes, aes(x = unemp, y = mcosmo, colour = factor(bailout))) +
  geom_point(alpha = 0.8) +
  scale_colour_manual(
    name   = "Assistance programme",
    values = c("0" = "#848c94", "1" = "#a10000"),
    labels = c("0" = "none", "1" = "bailout")
  ) +
  scale_x_continuous(breaks = seq(0, 30, by = 5))

scale_colour_manual() names a colour for each level of the variable. Giving values and labels as named vectors, with "0" = … rather than a bare pair, means the assignment cannot silently reverse itself if the data ever arrives in a different order, and that small precaution has saved more than one published figure. The colours that the workshop uses throughout are #0e3b6a (dark blue), #E69F00 (orange), #009E73 (green), #a10000 (dark red) and #848c94 (mid grey), with #c7ccd2 reserved as the muted grey for results that are not statistically significant. The orange and green belong to a palette built to stay distinguishable under the commonest forms of colour vision deficiency, which is why the deck never rests an argument on a red-green contrast alone.

Position scales carry the same override logic, and one of their arguments catches people out. scale_x_continuous(breaks = …) decides where the tick marks fall, but the sibling argument limits removes every row outside the range before any statistic is computed, so a smoother is refitted on whatever survived.

p_trend <- ggplot(euframes, aes(x = unemp, y = mcosmo)) +
  geom_point(alpha = 0.4, colour = "#848c94") +
  geom_smooth(method = "lm", colour = "#0e3b6a")

p_trend + scale_y_continuous(limits = c(0.30, 0.50))   # drops rows, then refits
p_trend + coord_cartesian(ylim = c(0.30, 0.50))        # keeps every row, only zooms

Run the first of those and R warns “Removed 163 rows containing non-finite outside the scale range (stat_smooth())”, and the trend line you are looking at now runs through 107 country-years rather than 270. coord_cartesian() zooms the finished figure instead, leaving the computation untouched. When you only want a closer look, that is the one to reach for.

One panel per country

Faceting repeats the same plot once per group, on shared axes, so the groups can be compared by eye instead of by disentangling overlapping series. A country-year table is precisely the shape that rewards it.

ggplot(euframes, aes(x = year, y = mcosmo)) +
  geom_line(colour = "grey55", linewidth = 0.4) +
  geom_point(size = 0.7) +
  facet_wrap(~ cntry, ncol = 6) +
  scale_x_continuous(breaks = c(2004, 2008, 2013))

Twenty-seven small panels appear, one per country code, each carrying that country’s cosmopolitan-framing series across the decade. The browser lab draws this figure early in the exercise: it puts the crisis years in view before any model is fitted, and it makes plain how differently the countries move. The ~ cntry is a formula, the same one-sided syntax that the lm() call in section 10 uses, and ncol fixes the shape of the grid. Where two grouping variables matter, facet_grid(rows = vars(a), cols = vars(b)) crosses them into a matrix of panels.

Labels first, theme last

Two layers finish a figure. labs() supplies the words – axis titles, plot title, subtitle, caption, legend name – and it deserves more care than beginners give it, because a column name such as mcosmo means nothing to a reader who has not opened the codebook. The theme then governs everything with no data behind it: type size, gridlines, background, the position of the legend.

ggplot(euframes, aes(x = unemp, y = mcosmo)) +
  geom_point(alpha = 0.4, colour = "#848c94") +
  geom_smooth(method = "lm", colour = "#0e3b6a", fill = "#0e3b6a", alpha = 0.15) +
  labs(
    x       = "Unemployment rate (%)",
    y       = "Cosmopolitan framing (share of respondents)",
    title   = "Where unemployment is higher, cosmopolitan framing is lower",
    caption = "270 country-years, EU-frames dataset"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    panel.grid.minor = element_blank(),
    plot.title       = element_text(face = "bold"),
    legend.position  = "top"
  )

theme_minimal() is the workshop’s default look, and base_size is its most useful argument, scaling every piece of type at once: 9 for a dense panel of small multiples, 12 for a page figure, 15 and upwards for a slide. A bare theme() afterwards overrides individual pieces, and the order matters, because a complete theme applied after your adjustments would wipe them out. Inside it, element_blank() deletes a piece outright, element_text() restyles type, and element_line() restyles rules and gridlines.

A plot is an ordinary R object, so you can store it under a name, extend it later, and write it to a file:

p <- ggplot(euframes, aes(x = unemp, y = mcosmo)) +
  geom_point(alpha = 0.4, colour = "#848c94") +
  labs(x = "Unemployment rate (%)", y = "Cosmopolitan framing")

p + geom_smooth(method = "lm") + theme_minimal(base_size = 12)

ggsave("figures/framing_unemployment.png", p, width = 7, height = 5, dpi = 300)

ggsave() takes its dimensions in inches and writes whichever format the file extension names. Its habit of saving the last plot drawn when you do not name one is convenient and occasionally treacherous, so name the object.

TipTry it

Draw the scatterplot of unemp against mcosmo, then change one thing at a time and watch what moves. Swap geom_point() for geom_text(aes(label = cntry)) and see the countries name themselves; move colour = factor(bailout) from inside aes() to outside it and read the error it raises; drop the factor() and compare the two legends. Then add facet_wrap(~ year) and decide for yourself whether ten panels tell you more than one crowded cloud does.

The pipe hands over to the plot

Reshaping and drawing are usually one movement, and dplyr feeds ggplot2 directly. The chain runs on |> while it is still working on data and switches to + at the moment that ggplot() appears.

library(dplyr)

euframes |>
  filter(year >= 2009) |>
  group_by(cntry) |>
  summarise(mean_unemp = mean(unemp)) |>
  ggplot(aes(x = reorder(cntry, mean_unemp), y = mean_unemp)) +
  geom_col(fill = "#0e3b6a") +
  labs(x = NULL, y = "Mean unemployment, 2009–2013 (%)") +
  theme_minimal(base_size = 11)

reorder() sorts the country codes by the value being plotted rather than alphabetically, which turns a jumble of bars into a ranking you can read at a glance. geom_col() draws a bar whose height is the number sitting in the data, which is what you want once summarise() has already done the counting; its cousin geom_bar() counts the rows for you instead, and reaching for the wrong one of the two is a frequent small frustration.

Where two figures belong side by side, the patchwork package composes whole plots with the same + that adds layers. Attach it with library(patchwork), then p1 + p2 sets two plot objects in a row and patchwork::wrap_plots(plots, nrow = 1) does the same for a list of them.

TipTry it

Build a chain that keeps the crisis years, groups by year, and reports the mean of mcosmo and the mean of mneg per year, then pipe it into a plot with two geom_line() layers in different colours. You will find yourself wanting a legend and not getting one, because both colours were set rather than mapped. That frustration is what leads to reshaping data into long form, which the next module takes up.

9. Reading the class chart

The workshop’s central figure is a specification curve, and you now have every part needed to build one yourself. The idea behind it is the day’s whole argument compressed into a picture. A hypothesis about a dataset admits many defensible analyses rather than one, and if you fit all of them and line the results up in order, you can see the range of answers that the data supports instead of the single answer that one analyst happened to report.

data/spec_grid.csv holds that universe for the EU-frames case: 840 rows, one per defensible analysis, each carrying the partial correlation it produced, whether that result reached statistical significance, and the ingredients that distinguish it from its neighbours. Reading it needs one preparatory step, and the step is conceptual rather than technical.

library(readr)
library(dplyr)
library(ggplot2)

grid <- read_csv("data/spec_grid.csv")

curve <- grid |>
  mutate(r_ca = ifelse(positive_outcome, r, -r)) |>   # claim alignment
  arrange(r_ca) |>
  mutate(rank = row_number())

baseline_row <- filter(curve, baseline)

The claim under test is that economic hardship depresses cosmopolitan framing of the EU, so on a positively worded outcome such as mcosmo a negative correlation supports it. Three of the six outcomes in the grid are negative framings, where support means a positive correlation instead, and plotting the raw numbers together would scatter agreeing results on opposite sides of zero. Claim alignment repairs that. Flip the sign wherever the outcome is a negative framing, and afterwards a negative value means support for every row alike. positive_outcome is the flag recording which is which, ifelse() performs the flip, and the _ca suffix is the workshop’s naming habit for any claim-aligned quantity. Sorting by the aligned value and numbering the rows then gives each specification a rank, and that rank becomes the horizontal axis.

ggplot(curve, aes(x = rank, y = r_ca)) +
  geom_hline(yintercept = 0, linewidth = 0.4, colour = "#848c94") +
  geom_point(aes(colour = sig), size = 0.9) +
  geom_point(data = baseline_row, colour = "#a10000", size = 3) +
  annotate(
    "text",
    x = baseline_row$rank, y = baseline_row$r_ca - 0.09,
    label = "the specification actually submitted\nt = -3.804",
    colour = "#a10000", fontface = "bold", size = 3.2, hjust = 0.1
  ) +
  scale_colour_manual(
    name   = NULL,
    values = c("TRUE" = "#0e3b6a", "FALSE" = "#c7ccd2"),
    labels = c("TRUE" = "p < .05", "FALSE" = "not significant")
  ) +
  coord_cartesian(ylim = c(-0.75, 0.32)) +
  labs(
    x     = "specification, ranked by claim-aligned partial correlation",
    y     = "claim-aligned partial correlation",
    title = "840 defensible analyses of one hypothesis"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top", panel.grid.minor = element_blank())

Nothing in that block is new. There is a reference line, a layer of small points coloured by a mapped logical column, a second point layer drawn from a different data frame, a manual colour scale, a zoom, labels and a theme. data = baseline_row inside the geom overrides the frame given to ggplot(), and that is how a single row gets picked out and emphasised. annotate() is the one function you have not met, and it adds a mark that comes from no column at all: you supply the coordinates and the text yourself, which is why its position is read off baseline_row rather than mapped.

Now read what it draws. Every point below the grey line is an analysis supporting the claim, and 68.7% of the 840 are; 63.9% reach conventional significance. The aligned correlations run from −0.70 to +0.24, so the universe contains both firm support and results pointing the other way. The red point is the specification actually submitted to the Multi100 project, the one whose t-statistic of −3.804 the workshop reproduces in Part 3, and it sits at rank 418 with 49.6% of the universe more negative than it – almost exactly the middle. The submitted analysis was neither an outlier nor a cherry-pick, and it was also not the answer, because the same defensible menu, worked through in good faith by any competent analyst, would have supported a great many other numbers.

TipTry it

Rebuild the curve, then colour the points by outcome instead of by sig, dropping the scale_colour_manual() layer as you go, since its two named colours cannot serve six outcomes. The clumping you see is why the choice of outcome gets a fork of its own on the workshop’s menu. Then try facet_wrap(~ estimator) on the same figure and ask yourself which of the two pictures you would rather a reviewer saw.

10. Name the quantity, then estimate it

Fitting models is the next module’s subject, and everything there is read through base R’s summary() printout. Nothing that follows is needed for the workshop’s tasks. It is here because it completes the map of the dialects, and because it connects R’s tooling to the workshop’s central discipline.

Suppose the model exists, a plain linear model of cosmopolitan framing on unemployment and growth. The next module reads exactly this kind of output line by line.

m <- lm(mcosmo ~ unemp + growth, data = euframes)

Base R answers with summary(m): a printout to read with your eyes. The easystats packages instead treat the fitted model as an object to interrogate, one named question per function:

library(easystats)

model_parameters(m)    # the coefficient table as a tidy data frame, with confidence intervals
model_performance(m)   # fit indices – R2, AIC and friends – in one row
check_model(m)         # the assumption dashboard: residual plots, normality, influential rows
report(m)              # the model written out in publishable sentences

model_parameters() returns the coefficients as data you can work with rather than text to squint at (Lüdecke et al. 2020); check_model() turns the diagnostic checks that most people skip into one picture (Lüdecke et al. 2021); and report() writes the results paragraph itself, with the numbers drawn live from the object. The prose then cannot drift from the model, which is the guarantee that a rendered Quarto document gives and a copy-pasted number never does.

One member of the family, modelbased, connects straight to the workshop’s own discipline (Makowski et al. 2025). A coefficient table answers whatever question the model’s parameterisation happens to pose. These functions make you name the quantity you want – the estimand – and then compute exactly that from the model:

euframes <- euframes |> mutate(bailout = factor(bailout))
m2 <- lm(mcosmo ~ unemp + bailout, data = euframes)

estimate_slopes(m, trend = "unemp")           # the unemployment slope, with its uncertainty
estimate_means(m2, by = "bailout")            # expected framing with and without a bailout programme
estimate_contrasts(m2, contrast = "bailout")  # the difference between those two expectations

An expected outcome under each condition, a slope, a contrast between scenarios: each is a quantity you can state in words before touching the software. (Under the bonnet these functions drive the marginaleffects package, whose job Stata users will recognise from margins.) That habit – say what you want to estimate first, then derive the analysis that estimates it – is precisely what the workshop drills in Part 2, with the estimand and the graph, and again at the preregistration moment, where you commit to a target quantity before running anything. The tools and the discipline reinforce each other: the coding, the statistics, the conceptual work of naming estimands and the reproducibility habits are one connected practice.

11. The two routes into the day

Everything above is preparation for the two ways of taking part in the day.

The Positron workspace (Route 1). If you install R and Positron, you download a single zip file, a ready-made project folder holding the data, a set of short R/ scripts, and a report skeleton called index.qmd. That report skeleton is your first real script. It is a Quarto document you open, read, and fill in as the day goes, running its chunks and rendering it to a finished report. The smaller scripts around it do one job each – R/check_setup.R confirms your installation works, R/get_data.R fetches the data, R/report_result.R submits your result – and reading them is a gentle way to see the vocabulary of this module used in earnest. The setup page has the download and the step-by-step.

The browser lab (Route 2). If you would rather install nothing, the browser lab runs the whole exercise inside a web tab, with a full copy of R that starts up on its own. It is the zero-install place to try everything on this page, and it reaches identical numbers to the Positron route. Many people work through this module in the browser lab first, then decide whether to install the full workspace.

Whichever route you take, the sequence is the same one you have just practised in miniature: read the data, reshape it with a few verbs, draw it to see what you are dealing with, then fit a model and read what it says. The rest of the workshop repeats that sequence, with more care taken over each step.

Sources and further study

This module is written fresh for the workshop rather than adapted from an existing lesson, so there is no borrowed text to attribute. It teaches only the slice of R that the workshop’s own materials use, in the order that the day needs it, plus the map of dialects that puts those materials in context.

If you want a fuller, self-paced grounding in R after this, these open resources are worth the time, and all are recommended here as further reading rather than adapted from:

When you are ready for your first statistical model, the next module, the statistical methods behind the workshop, starts exactly where this page leaves off: with a single lm() on the panel you have just learned to read and reshape, and everything that the case then demands of it.

References

Lüdecke, Daniel, Mattan S. Ben-Shachar, Indrajeet Patil, and Dominique Makowski. 2020. “Extracting, Computing and Exploring the Parameters of Statistical Models Using R.” Journal of Open Source Software 5 (53): 2445. https://doi.org/10.21105/joss.02445.
Lüdecke, Daniel, Mattan S. Ben-Shachar, Indrajeet Patil, Philip Waggoner, and Dominique Makowski. 2021. “Performance: An R Package for Assessment, Comparison and Testing of Statistical Models.” Journal of Open Source Software 6 (60): 3139. https://doi.org/10.21105/joss.03139.
Lüdecke, Daniel, Dominique Makowski, Mattan S. Ben-Shachar, Indrajeet Patil, and Brenton M. Wiernik. 2022. Easystats: Framework for Easy Statistical Modeling, Visualization, and Reporting. Released August. https://CRAN.R-project.org/package=easystats.
Makowski, Dominique, Mattan S. Ben-Shachar, Brenton M. Wiernik, Indrajeet Patil, Rémi Thériault, and Daniel Lüdecke. 2025. “Modelbased: An R Package to Make the Most Out of Your Statistical Models Through Marginal Means, Marginal Effects, and Model Predictions.” Journal of Open Source Software 10 (109): 7969. https://doi.org/10.21105/joss.07969.
Wickham, Hadley, Mara Averick, Jennifer Bryan, et al. 2019. “Welcome to the Tidyverse.” Journal of Open Source Software 4 (43): 1686. https://doi.org/10.21105/joss.01686.