Introduction to R and Quarto

In this course you will use R to analyse labour market data, and Quarto to write up your results. This page introduces both. It is meant to be read at a keyboard: open RStudio and type the commands in as you go. Reading code is not the same as writing it.

No prior programming experience is assumed.

Before you start: download two files

Download both of these now, and save them in the same folder — for example a folder called EC306 on your Desktop.

ec306lfs.csv — the data used throughout this page

ec306-r-template.qmd — a practice Quarto document that runs the main steps on this page

The three pieces

It helps to be clear about what each piece of software does, because students often confuse them.

Software What it is
R The statistical language. It does the actual computing.
RStudio The program you work in. It is a comfortable front end for R.
Quarto The document system. It turns your code and writing into a PDF.

An analogy: R is the engine, RStudio is the dashboard and steering wheel, and Quarto is the report you hand in describing where you drove.

Install R from https://cran.r-project.org and RStudio from https://posit.co/download/rstudio-desktop/. Install R first. Quarto comes bundled with RStudio, so you do not need to download it separately.

Part 1: R and RStudio

This part is about R itself — the language you write, and the program you write it in. Work through it at a keyboard. By the end you will be able to load a dataset, describe it, reshape it, graph it, and run a regression.

Finding your way around RStudio

You installed two programs, but from now on you only ever open one of them. Opening RStudio starts R for you, running quietly in the background. You never need to open R itself, and most people never do.

You can watch this happen. The moment RStudio finishes loading, the Console prints R’s version number and startup message — that is R announcing itself, already running and waiting for instructions. To go back to the analogy: you get into the car, and the engine is already turning over.

When you open RStudio you will see four panes. They can be rearranged and re-coloured, so yours may not look identical to the picture, but every installation has these same four.

The RStudio window divided into four panes: an empty script at top left, the Console showing the R startup message at bottom left, an empty Environment pane at top right, and the Files pane at bottom right.
Figure 1: RStudio just after opening a project, before you have done anything. The panes fill up as you work.
  1. Source (top left) — where you write and save your code. In the picture it holds an empty script called Untitled1. When you open a Quarto document here, a Render button appears at the top of this pane.
  2. Console (bottom left) — where R actually runs. Code you send from Source appears here along with its results, and you can type directly at the > prompt for quick experiments.
  3. Environment (top right) — a list of every object you have created, with its size. It says “Environment is empty” in the picture because nothing has been run yet. Once you load data, this is where you check that it really has the 500 rows you expected.
  4. Files / Plots / Packages / Help (bottom right) — in the picture the Files tab is showing the contents of the project folder. Your graphs appear under Plots, and help pages under Help.
The one habit that matters most

Write your code in the Source pane and save it. Code typed into the Console disappears. Every assignment in this course requires you to submit your code, so if it is not saved in a file, you do not have it.

R basics

Everything you work with in R is an object: a named thing that holds something. A single wage is an object. A column of 50,000 wages is an object. A whole survey, a graph, the results of a regression — all objects. You give each one a name, and it appears in the Environment pane the moment you create it.

What distinguishes one kind of object from another is what it holds and how much of it. The rest of this section introduces them in order of size, starting with the smallest.

Scalars and vectors

R works by storing things in named objects. You create one with the assignment arrow <-:

wage <- 24.50
wage
#> [1] 24.5

The name goes on the left, the value on the right. An object holding a single value like this is called a scalar. Once it exists, you can compute with it:

hours <- 35
wage * hours
#> [1] 857.5

Names are case sensitive: wage and Wage are different objects. This is the single most common source of beginner errors.

Most of the time you want more than one value. A vector holds several values of the same type, in order, and you build one with c(), which is short for “combine”:

earnings <- c(520, 610, 445, 890, 1200)
earnings
#> [1]  520  610  445  890 1200

Vectors are the fundamental data structure in R. A variable in a dataset — everyone’s weekly earnings, everyone’s age — is a vector.

Vectors have a type

Every element of a vector must be the same type. The three you will meet constantly:

hourly <- c(18.50, 24.00, 31.25)     # numeric
worker <- c("Ali", "Bea", "Cal")     # character (text, in quotes)
member <- c(TRUE, FALSE, TRUE)       # logical

class() tells you which you have:

class(hourly)
#> [1] "numeric"
class(worker)
#> [1] "character"
class(member)
#> [1] "logical"

If you mix types, R quietly converts everything to the most accommodating one rather than complaining:

c(1, "two", 3)
#> [1] "1"   "two" "3"

Everything became text — note the quotation marks. This is worth remembering, because a numeric variable that arrives as text will refuse to average, and this is usually why. When it happens, convert it deliberately with as.numeric():

as.numeric(c("18.50", "24.00"))
#> [1] 18.5 24.0

Data frames

A dataset is vectors side by side

A single vector holds one variable. Real data has many variables measured on the same people, and we need to keep them lined up: the third age must belong to the same person as the third wage.

That is all a data frame is — a set of vectors of equal length, standing side by side as columns. Each column is one variable. Each row is one observation, usually one person.

We can build a small one by hand to see the structure. data.frame() takes named vectors and stacks them into columns:

workers <- data.frame(
  name = c("Ali", "Bea", "Cal", "Dee", "Eli"),
  age  = c(24, 38, 51, 29, 45),
  educ = c("High school", "University", "College", "University", "High school"),
  wage = c(18.50, 34.00, 26.75, 31.25, 21.00)
)

workers
#>   name age        educ  wage
#> 1  Ali  24 High school 18.50
#> 2  Bea  38  University 34.00
#> 3  Cal  51     College 26.75
#> 4  Dee  29  University 31.25
#> 5  Eli  45 High school 21.00

Five people, four variables. Notice that the columns have different types — name and educ are text, age and wage are numeric — but within a column the type is the same, exactly as with a vector.

Every dataset you meet in this course has this shape. The LFS extract for your assignment is the same thing with more rows and more columns.

Columns are vectors

The $ operator pulls one column out of a data frame, and what comes back is an ordinary vector:

workers$wage
#> [1] 18.50 34.00 26.75 31.25 21.00

This is the connection worth holding on to. A data frame is not a new kind of thing to learn — it is a tidy arrangement of the vectors you already understand.

Picking out elements

Square brackets select elements of a vector by position:

earnings[1]
#> [1] 520
earnings[2:3]
#> [1] 610 445

More useful, you can select by condition:

earnings > 600
#> [1] FALSE  TRUE FALSE  TRUE  TRUE
earnings[earnings > 600]
#> [1]  610  890 1200

The first line returns a logical vector answering the question for each element; the second keeps only the elements where the answer was TRUE.

Because a column of a data frame is just a vector, the very same brackets work on it:

workers$wage[2]
#> [1] 34
workers$wage[workers$wage > 25]
#> [1] 34.00 26.75 31.25

The condition can be about a different column from the one you are picking from, and & (and) combines two conditions. Here are the wages of university graduates under 40:

workers$wage[workers$age < 40 & workers$educ == "University"]
#> [1] 34.00 31.25

Picking out whole rows and columns of a data frame works a little differently, and we will do it with two much friendlier functions, filter() and select(), once the tidyverse is loaded.

Other kinds of object

Vectors and data frames are the two you will use constantly, and between them they cover almost everything in this course.

R does have other kinds of object, and a few of them will turn up later once we start running regressions. There is no need to learn them in advance — we will deal with each one when it appears and there is something useful to do with it.

Functions

Objects hold your data. Functions are what you do to them. You call one by writing its name followed by parentheses, with the thing you want it to work on inside:

sqrt(16)
#> [1] 4
round(24.567, 1)
#> [1] 24.6

You have already used one: c() is a function that takes a set of values and returns a vector.

The things inside the parentheses are called arguments. Above, round() took two: the number to round, and how many decimal places to keep. Many arguments are optional and have sensible defaults — writing round(24.567) alone rounds to a whole number.

Functions that summarise data take a whole vector and return a single number:

mean(earnings)
#> [1] 733
median(earnings)
#> [1] 610
length(earnings)
#> [1] 5

Combine one of these with the square brackets from earlier and you get a summary for just one group. The average wage of university graduates under 40 is a single number, which you can store and compute with like any other scalar:

uni_under40 <- mean(workers$wage[workers$age < 40 & workers$educ == "University"])
uni_under40
#> [1] 32.625
Getting help

Typing ?round in the Console opens the help page for a function, which lists every argument it accepts. Help pages are dense, but scroll to the bottom: there are almost always worked examples.

Missing values

Arguments matter more than they first appear. Real survey data is full of gaps, which R writes as NA, meaning “not available.” R refuses to guess what a missing value should be:

earnings_missing <- c(520, 610, NA, 890, 1200)
mean(earnings_missing)
#> [1] NA

The answer is NA, because the true mean genuinely is unknown. To average the values you do have, say so explicitly with an argument:

mean(earnings_missing, na.rm = TRUE)
#> [1] 805
Warning

Getting NA where you expected a number almost always means missing values in your data, not a mistake in your code. Check with sum(is.na(x)) to see how many there are before deciding what to do.

Packages

R comes with a great deal built in, but most of the convenient tools live in packages: bundles of extra functions that somebody has written and shared. There are thousands of them, and they are free.

Installing a package downloads it onto your computer. You do this once, in the Console:

install.packages("packagename")

Loading a package makes its functions available for use. You do this every time you start a new session, at the top of your document:

library(packagename)
Note

install.packages() is like buying a book; library() is like taking it off the shelf. You buy once, but you take it off the shelf every time you sit down to work.

Base R and the tidyverse

Everything we have done so far — c(), class(), data.frame(), square brackets — is base R: the functions that come with R itself, no packages needed.

Base R can do everything this course requires, but a lot of it is awkward. The tidyverse is a set of packages whose functions are built specifically for working with data, and they make the common jobs far easier to write and to read. Three of them do most of the work:

Package What its functions are for
readr Getting data from a file into R
dplyr Working with data once it is there: keeping certain rows, creating new variables, computing averages by group
ggplot2 Turning data into graphs

You will meet all three in the sections that follow. They are built to a common design, so the habits you pick up with one carry over to the others, and they are meant to be chained together — the output of one function becomes the input of the next.

The convenient part is that you do not install them one at a time. Installing the tidyverse installs the whole set, and a single library() call loads them all.

Install it once:

install.packages("tidyverse")

And load it now, since everything below depends on it:

library(tidyverse)
Tip

When you search for help online you will find answers written in both styles, sometimes for the same question. Neither is wrong. Use whichever you understand, but expect the code in lectures and assignments to look like the tidyverse.

Working with a realistic dataset

Five workers are not enough to do economics with. For the rest of this page we will use a larger dataset called lfs, containing 500 working-age people with their age, sex, education, labour force participation, and hourly wage. It is artificial, but it is built to behave like the Labour Force Survey extracts you will use in the assignments, and it is already loaded here.

Reading data from a file

In an assignment you would not be handed a dataset — you would read one in from a file you downloaded. The lfs data on this page is the file ec306lfs.csv that you downloaded at the start. Put the file in the same folder as your Quarto document, then read it with the function matching its format:

# comma-separated values
lfs <- read_csv("ec306lfs.csv")

# Stata format (a .dta file), using the haven package
library(haven)
lfs <- read_dta("filename.dta")

Reading a file gives you a data frame, which you then work with exactly as we are about to.

“Cannot open file”

This error nearly always means R is looking in a different folder than you are. R looks in its working directory. The reliable fix is to create an RStudio Project for the course (File → New Project) and keep your data and documents inside that project folder. Then paths just work.

Looking at your data

Never start analysing data you have not looked at. A handful of functions do most of the work.

glimpse() gives you the shape of the dataset and the type of every column:

glimpse(lfs)
#> Rows: 500
#> Columns: 7
#> $ id       <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18…
#> $ age      <int> 58, 44, 58, 60, 64, 28, 33, 38, 21, 33, 53, 36, 24, 34, 39, 2…
#> $ female   <int> 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0…
#> $ educ     <chr> "University", "College", "High school", "High school", "High …
#> $ yrschool <dbl> 16, 14, 12, 12, 12, 12, 16, 16, 12, 12, 16, 16, 10, 14, 10, 1…
#> $ lfp      <int> 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 1…
#> $ wage     <dbl> 47.43, 33.89, 20.58, 39.54, 42.49, 34.60, 27.17, 29.28, 20.43…

head() shows the first few rows, so you can see what actual values look like:

head(lfs)
#> # A tibble: 6 × 7
#>      id   age female educ        yrschool   lfp  wage
#>   <int> <int>  <int> <chr>          <dbl> <int> <dbl>
#> 1     1    58      0 University        16     0  47.4
#> 2     2    44      1 College           14     1  33.9
#> 3     3    58      1 High school       12     1  20.6
#> 4     4    60      1 High school       12     1  39.5
#> 5     5    64      1 High school       12     0  42.5
#> 6     6    28      0 High school       12     1  34.6

You will notice the word tibble in that output. A tibble is the tidyverse’s version of a data frame: the same rectangle of columns, but printed more helpfully — it shows you the type of each column and stops after ten rows instead of filling your screen. Anything that works on a data frame works on a tibble, so you can treat the two words as interchangeable.

And summary() describes a single variable — remember that lfs$wage is just a vector:

summary(lfs$wage)
#>    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
#>   13.80   26.20   32.84   34.01   40.12  115.85

For a categorical variable, count the categories instead:

count(lfs, educ)
#> # A tibble: 4 × 2
#>   educ             n
#>   <chr>        <int>
#> 1 College        177
#> 2 High school    147
#> 3 Less than HS    40
#> 4 University     136

Which summary you use depends on the kind of variable, and the distinction matters:

  • A continuous variable can take any value in a range — wage, age, hours worked. Averages and percentiles make sense, so use summary().
  • A categorical variable sorts people into groups — education, province, sex. The groups are often stored as numbers, but those numbers are labels, not quantities. The average of “1 = man, 2 = woman” means nothing. Count the categories with count() instead.

The data viewer

The Console is not the only way to look. RStudio has a spreadsheet-style viewer built in. Click the name lfs in the Environment pane, or run:

View(lfs)

Note the capital V — view() will not work. Either way, the dataset opens in a tab beside your document, where you can scroll through all 500 rows, sort by clicking a column heading, and filter to particular values.

This is often the fastest way to get a feel for a dataset you have never seen before, and it is worth doing alongside glimpse() rather than instead of it. Two things to know: nothing you do in the viewer changes your data or gets recorded in your document, so you cannot break anything by exploring; and for the same reason, sorting or filtering there is no substitute for writing the code. Only the code goes into your assignment.

Tip

Look at a dataset this way every time you open one, before doing anything else. Most analysis mistakes are really misunderstandings about what is in the data.

Missing values in real data

Survey files rarely mark missing values with a tidy NA. It is common to use an impossible number instead — in the Canadian Income Survey, for example, missing earnings are stored as 999999999996. R has no way to know that this is not a real income:

earnings_raw <- c(45000, 62000, 999999999996, 38000)
mean(earnings_raw)
#> [1] 2.5e+11

An average income of 250 billion dollars is a clue that something is wrong, but a subtler code would slip through unnoticed. Convert the code to NA first, with na_if():

earnings_clean <- na_if(earnings_raw, 999999999996)
earnings_clean
#> [1] 45000 62000    NA 38000
mean(earnings_clean, na.rm = TRUE)
#> [1] 48333.33

Now the mean describes the three people we actually have data for.

Read the documentation first

Every survey comes with documentation — a codebook listing what each variable measures and how missing values are coded. Read it before you compute anything. A missing-value code left in the data can invalidate every number in your assignment, and nothing in R will warn you.

Manipulating data

Five verbs cover most of what you will do. Each takes a data frame and returns a modified data frame.

The pipe

Before the verbs themselves, one piece of punctuation. You are about to see the symbol |> everywhere. It is the pipe, and it takes the thing on its left and feeds it into the function on its right. These two lines do exactly the same thing:

mean(earnings)
#> [1] 733
earnings |> mean()
#> [1] 733

For a single function the pipe is not worth the trouble. Its value is in what comes next: when several operations happen one after another, the pipe lets you read them left to right, in the order they actually happen, instead of from the inside out.

filter() — keep some rows

lfs |>
  filter(lfp == 1) |>
  nrow()
#> [1] 373

Note the double equals ==. A single = assigns; a double == tests for equality. Conditions can be combined with & (and) and | (or):

lfs |>
  filter(educ == "University" & age < 30) |>
  nrow()
#> [1] 24

!= means “is not equal to”:

lfs |>
  filter(educ != "University") |>
  nrow()
#> [1] 364

Conditions separated by commas must all hold, the same as joining them with &. This is the easiest way to combine an “or” condition with others, because it keeps each condition separate. People with a college or university education who are under 30:

lfs |>
  filter(educ == "College" | educ == "University", age < 30) |>
  nrow()
#> [1] 66

To keep the rows you filtered, store them under a new name. Storing them as lfs would throw away everyone else for the rest of your document.

under30 <- lfs |>
  filter(age < 30)

select() — keep some columns

lfs |>
  select(id, educ, wage) |>
  head(3)
#> # A tibble: 3 × 3
#>      id educ         wage
#>   <int> <chr>       <dbl>
#> 1     1 University   47.4
#> 2     2 College      33.9
#> 3     3 High school  20.6

mutate() — create variables

Log wages are the standard dependent variable in labour economics, because coefficients then read as approximate percentage effects:

lfs <- lfs |>
  mutate(lnwage = log(wage))

Note the lfs <- at the start. mutate() hands back a new version of the data, and unless you store it, the new variable is gone the moment the line finishes.

Dummy variables

A dummy variable is 1 if something is true of a person and 0 if it is not. To make one, write the “something” as a comparison. A comparison gives TRUE or FALSE for every person, and as.numeric() turns TRUE into 1 and FALSE into 0. You can create several variables in one mutate(), separated by commas:

lfs <- lfs |>
  mutate(
    university = as.numeric(educ == "University"),
    postsec    = as.numeric(educ == "College" | educ == "University"),
    over40     = as.numeric(age > 40)
  )

Always check a new dummy against the variable it came from. count() with two variables shows every combination that occurs:

count(lfs, educ, postsec)
#> # A tibble: 4 × 3
#>   educ         postsec     n
#>   <chr>          <dbl> <int>
#> 1 College            1   177
#> 2 High school        0   147
#> 3 Less than HS       0    40
#> 4 University         1   136

Only college and university graduates have postsec equal to 1, as intended. When you compare text, it has to match exactly, capitals, spaces and punctuation included: "university" would match nobody. Run count() on the original variable first to see how its categories are spelled.

summarise() — boil the data down

summarise() collapses a whole dataset into a single row of summary numbers. You name each thing you want, and say how to calculate it:

lfs |>
  summarise(
    n         = n(),
    mean_wage = mean(wage),
    lfp_rate  = mean(lfp)
  )
#> # A tibble: 1 × 3
#>       n mean_wage lfp_rate
#>   <int>     <dbl>    <dbl>
#> 1   500      34.0    0.746

Three numbers describing all 500 people: how many there are, their average wage, and the share in the labour force. n() is a special helper that counts rows.

Note what happened to the shape of things. filter(), select() and mutate() all handed back a dataset with one row per person. summarise() hands back one row, full stop — the people are gone, and what remains is a description of them.

group_by() — do it separately for each group

A single average for everyone is rarely what you want. The interesting question is almost always comparative: do wages differ by education, by sex, by province?

group_by() answers that. On its own it changes nothing you can see — it simply marks the dataset as divided into groups. The work happens when summarise() runs afterwards, because it now produces one row per group rather than one row overall:

lfs |>
  group_by(educ) |>
  summarise(
    n         = n(),
    mean_wage = mean(wage),
    lfp_rate  = mean(lfp)
  )
#> # A tibble: 4 × 4
#>   educ             n mean_wage lfp_rate
#>   <chr>        <int>     <dbl>    <dbl>
#> 1 College        177      34.5    0.797
#> 2 High school    147      30.2    0.680
#> 3 Less than HS    40      24.0    0.6  
#> 4 University     136      40.4    0.794

That is the identical summarise() call as before, with one line added above it — and the result is already a labour economics finding: both wages and participation rise with education.

Group by something else and you ask a different question. Here is the raw gender wage gap:

lfs |>
  group_by(female) |>
  summarise(mean_wage = mean(wage), n = n())
#> # A tibble: 2 × 3
#>   female mean_wage     n
#>    <int>     <dbl> <int>
#> 1      0      36.0   269
#> 2      1      31.7   231

This pairing is the workhorse of applied work. Most tables you will ever produce are some version of “group by one thing, summarise another.”

Ordering categories

Notice that education came out alphabetically, which is not the natural order. To fix that, make it a factor with the levels you want:

lfs <- lfs |>
  mutate(educ = factor(
    educ,
    levels = c("Less than HS", "High school", "College", "University")
  ))

count(lfs, educ)
#> # A tibble: 4 × 2
#>   educ             n
#>   <fct>        <int>
#> 1 Less than HS    40
#> 2 High school    147
#> 3 College        177
#> 4 University     136

Grouping by two variables

group_by() can take more than one variable, separated by commas. summarise() then produces one row for every combination. Here is the participation rate for each education level, separately for men and women, stored as rates so we can use it below:

rates <- lfs |>
  group_by(educ, female) |>
  summarise(lfp_rate = mean(lfp))

rates
#> # A tibble: 8 × 3
#> # Groups:   educ [4]
#>   educ         female lfp_rate
#>   <fct>         <int>    <dbl>
#> 1 Less than HS      0    0.52 
#> 2 Less than HS      1    0.733
#> 3 High school       0    0.702
#> 4 High school       1    0.651
#> 5 College           0    0.823
#> 6 College           1    0.765
#> 7 University        0    0.828
#> 8 University        1    0.764

Four education levels times two sexes gives eight rows. When you run this, R also prints a message about how the result is grouped. It is harmless and you can ignore it.

Tables

Printing a data frame, as above, is fine while you work, but in submitted work it counts as console output and is not acceptable. The modelsummary package makes presentable tables. You will also use it for regressions, below.

library(modelsummary)

datasummary() makes a table of summary statistics. Its formula lists the variables on the left of the ~ and the statistics on the right, each joined by +, and data = says which data frame to use:

datasummary(wage + age + lfp ~ N + Mean + SD, data = lfs)
N Mean SD
wage 500 34.01 10.69
age 500 42.31 12.84
lfp 500 0.75 0.44

N, Mean and SD are built-in statistics. Others include Median, Min, Max, P25 and P75.

datasummary_df() presents a data frame of results you have already calculated, such as rates. fmt = 3 shows three decimal places:

datasummary_df(rates, fmt = 3)
educ female lfp_rate
Less than HS 0.000 0.520
Less than HS 1.000 0.733
High school 0.000 0.702
High school 1.000 0.651
College 0.000 0.823
College 1.000 0.765
University 0.000 0.828
University 1.000 0.764

Graphs

Graphs in R are built with ggplot2, loaded as part of the tidyverse. Every plot has the same three ingredients: the data, a mapping from variables to visual properties (aes), and a geometry (geom_) saying what to draw.

ggplot(lfs, aes(x = wage)) +
  geom_histogram(bins = 30) +
  labs(x = "Hourly wage ($)", y = "Number of workers")
Figure 2: Distribution of hourly wages

Note that ggplot layers are joined with +, not the pipe. This is an historical quirk and a common source of confusion.

Mapping a variable to colour or to the x axis of a boxplot lets you compare groups:

ggplot(lfs, aes(x = educ, y = wage)) +
  geom_boxplot() +
  labs(x = NULL, y = "Hourly wage ($)")
Figure 3: Wages by education level

Grouping by a 0/1 variable such as female needs two extra steps. ggplot treats a number as continuous, so wrap the variable in factor() to tell it the values are categories. The axis then reads 0 and 1, which is not a readable label: the labels argument of scale_x_discrete() replaces them with words, in order. outliers = FALSE leaves out the individual points beyond the whiskers, which can crowd the graph:

ggplot(lfs, aes(x = factor(female), y = wage)) +
  geom_boxplot(outliers = FALSE) +
  scale_x_discrete(labels = c("Men", "Women")) +
  labs(x = NULL, y = "Hourly wage ($)")
Figure 4: Wages of men and women

And a scatterplot shows how two continuous variables move together. Adding geom_smooth(method = "lm") draws the regression line through the points:

ggplot(lfs, aes(x = age, y = wage)) +
  geom_point(alpha = 0.3) +
  geom_smooth(method = "lm") +
  labs(x = "Age", y = "Hourly wage ($)")
Figure 5: Age-earnings profile

With this many points the cloud is dense, which is normal for survey microdata. The line is what carries the message.

Bar charts of results you have calculated

The graphs above start from the people in lfs. Often you have already calculated the numbers you want to show, like the participation rates in rates. Then give ggplot that data frame, and use geom_col(), which draws one bar per row with the height you calculated.

To compare men and women within each education level, map female to fill, the colour of the bars, wrapped in factor() as before. By default the bars for men and women are stacked on top of each other; position = "dodge" puts them side by side. scale_fill_discrete() replaces the 0 and 1 in the legend with words, and in labs(), fill = sets the legend title (NULL leaves it out):

ggplot(rates, aes(x = educ, y = lfp_rate, fill = factor(female))) +
  geom_col(position = "dodge") +
  scale_fill_discrete(labels = c("Men", "Women")) +
  labs(x = "Education", y = "Participation rate", fill = NULL)
Figure 6: Labour force participation by education and sex
Assignment graphs need labels

Default axis labels are variable names, which are not acceptable in submitted work. Always set labs() with readable labels and units.

Regression

Linear regression uses lm(), with a formula written outcome ~ predictor1 + predictor2:

model1 <- lm(lnwage ~ yrschool, data = lfs)
summary(model1)
#> 
#> Call:
#> lm(formula = lnwage ~ yrschool, data = lfs)
#> 
#> Residuals:
#>      Min       1Q   Median       3Q      Max 
#> -0.78031 -0.17477  0.00494  0.16563  1.08767 
#> 
#> Coefficients:
#>             Estimate Std. Error t value Pr(>|t|)    
#> (Intercept) 2.423345   0.088013   27.53   <2e-16 ***
#> yrschool    0.077580   0.006396   12.13   <2e-16 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 0.2642 on 498 degrees of freedom
#> Multiple R-squared:  0.2281, Adjusted R-squared:  0.2265 
#> F-statistic: 147.1 on 1 and 498 DF,  p-value: < 2.2e-16

That is a lot of output, but only a few parts matter:

  • Estimate — the coefficient. Because the outcome is in logs, the estimate on yrschool is roughly the percentage increase in wages from one more year of schooling. About 7 to 8 percent here, which is close to what the literature finds.
  • Std. Error — how precisely the coefficient is estimated.
  • Pr(>|t|) — the p-value for the test that the coefficient is zero. Small means the estimate is distinguishable from no effect.
  • Multiple R-squared — the share of the variation in log wages the model explains.

Adding controls is a matter of extending the formula:

model2 <- lm(lnwage ~ yrschool + age + female, data = lfs)

This holds age and sex constant while estimating the return to schooling.

Categorical predictors

You can put a categorical variable straight into a regression, and R creates the dummy variables for you:

model3 <- lm(lnwage ~ educ, data = lfs)
coef(model3)
#>     (Intercept) educHigh school     educCollege  educUniversity 
#>       3.1465714       0.2305287       0.3606778       0.5117666

Notice there are only three coefficients for four education categories. R leaves one out — here “Less than HS” — as the base group, and every other coefficient says how that group compares to it. There is no way to include all four; the base group is what the others are measured against.

coef() was used above to show just the coefficients. It returns a named vector, which is the same kind of object from the start of this page.

To compare against a different group, change which level comes first:

lfs_hs <- mutate(lfs, educ = relevel(educ, ref = "High school"))
model4 <- lm(lnwage ~ educ, data = lfs_hs)
coef(model4)
#>      (Intercept) educLess than HS      educCollege   educUniversity 
#>        3.3771001       -0.2305287        0.1301491        0.2812379

Now everything is relative to high school graduates, and the “Less than HS” coefficient is negative, as you would expect.

Interactions

Sometimes the effect of one variable may depend on another: is the gender wage gap the same for university graduates as for everyone else? In a formula, female * university includes female, university, and their product. The product is called an interaction, and R names it female:university:

model5 <- lm(lnwage ~ female * university, data = lfs)
coef(model5)
#>       (Intercept)            female        university female:university 
#>        3.47247867       -0.13146268        0.27686909       -0.04044463

data = can be any data frame, including one you made with filter(). That is how you run a regression on only some of the people, such as those under 30.

Presenting results

Console output is not acceptable in submitted work. Use modelsummary(), from the package you loaded for tables, to produce a proper table. Give it a list of models, and the names in the list become the column headings:

modelsummary(
  list("Schooling only" = model1, "With controls" = model2),
  stars = TRUE
)
Schooling only With controls
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
(Intercept) 2.423*** 2.350***
(0.088) (0.092)
yrschool 0.078*** 0.082***
(0.006) (0.006)
age 0.002*
(0.001)
female -0.153***
(0.023)
Num.Obs. 500 500
R2 0.228 0.299
R2 Adj. 0.227 0.295
AIC 91.7 47.7
BIC 104.4 68.7
Log.Lik. -42.873 -18.830
F 147.120 70.464
RMSE 0.26 0.25

This is the format your assignments should use.

Part 2: Quarto

Part 1 was about getting answers. This part is about presenting them. Quarto is how you turn the code and results from Part 1 into the PDF you hand in.

What a Quarto document is

A Quarto file (extension .qmd) is a single document containing your writing, your code, and the output of that code. When you render it, Quarto runs every piece of code fresh and produces a finished PDF with the results embedded.

This matters for two reasons. Your work is reproducible, since the document cannot drift out of step with the code that produced it. And you never have to copy and paste a number or a graph again — a constant source of errors.

To create one in RStudio: File → New File → Quarto Document.

Download the practice file

Rather than starting from a blank document, start from this one. It is a working Quarto file containing everything on this page: it loads packages, reads in the practice data, summarises it, draws a graph, and runs a regression. If you downloaded both files at the start, you already have them.

ec306-r-template.qmd

ec306lfs.csv

Download both files into the same folder, open the .qmd in RStudio, and press Render. You should get a PDF. Then start changing things — that is how you learn this.

It uses the same lfs data as this page, read in with read_csv(), so every result you see above you can reproduce and alter yourself.

The three ingredients

A .qmd file has exactly three kinds of content.

1. The YAML header, at the very top, between three dashes. It sets the title and output format:

---
title: "EC306 Assignment 1"
author: "Your Name, 123456789"
format: pdf
---

2. Text, written in Markdown. A few marks do most of the work:

You type You get
## Question 1 A section heading
**important** important
*emphasis* emphasis
- item A bullet point
$\beta_1$ An inline equation, \(\beta_1\)

3. Code chunks, fenced by three backticks with {r}. In RStudio the keyboard shortcut is Ctrl+Alt+I (Cmd+Option+I on a Mac):

```{r}
mean(lfs$wage)
```

Chunk options go on lines starting with #| inside the chunk. The ones you will want:

Option Effect
#| echo: false Run the code but hide it; show only output
#| eval: false Show the code but do not run it
#| message: false Suppress package startup chatter
#| warning: false Suppress warnings
#| fig-cap: "..." Add a caption to a figure

For this course, leave code visible — showing your code is part of what is being marked.

Inline code

You can also put a computed number directly into a sentence, rather than typing it by hand. In your text, write this:

The mean wage is `r round(mean(lfs$wage), 2)` dollars.

and when you render, R replaces it with the answer:

The mean wage is 34.01 dollars.

That number was computed while this page was being built. If the data changed, the sentence would change with it — which is why you should never copy a number out of your output and retype it.

Rendering

Everything you hand in for this course is a PDF, and producing a PDF needs a LaTeX installation working quietly in the background. You almost certainly do not have one yet. Install it once, in the Console, and then never think about it again:

install.packages("tinytex")
tinytex::install_tinytex()

It takes a few minutes and downloads a few hundred megabytes. If you prefer, the same thing can be done from the Terminal tab with quarto install tinytex.

Warning

Do this now, not later. The first time most people try to produce a PDF is an hour before a deadline, and that is a bad moment to discover a missing LaTeX installation. If a render fails with an error mentioning LaTeX, tlmgr, or a missing .sty file, this is what it is telling you.

With that in place, press the Render button at the top of the Source pane, or Ctrl+Shift+K (Cmd+Shift+K on a Mac). Quarto runs your code from top to bottom in a clean session and writes a PDF next to your .qmd.

Render early and often

Because rendering starts from a clean session, a document can fail to render even though everything worked in your Console — usually because an object was created in the Console and never written into the document. Render after finishing each question. Discovering a problem at 9:00pm on the due date is avoidable.

Common errors

Everyone hits these. They are not a sign that you are bad at this.

Message Usual cause
could not find function "x" A package is not loaded — add the library() call
object 'x' not found Typo, wrong capitalisation, or the object was never created
unexpected symbol A missing comma, bracket, or quotation mark
cannot open file Wrong working directory — use an RStudio Project
A + in the Console R is waiting for you to finish an incomplete command; press Esc
How to read an error

Errors look intimidating but usually name the problem in the first line. Read that line, look at the line of code it points to, and check the simple things first: spelling, capitalisation, and matching brackets. Pasting the message into a search engine is a legitimate and widely used technique.

Where to get help

Everything below is free to read online. You do not need to buy anything for this course beyond the textbook.

  • R for Data Science — the standard reference for the tidyverse, free in full on that site. Chapters 1 through 5 cover most of what this course needs.
  • Posit Cheatsheets — free one-page references for dplyr, ggplot2, and RStudio itself. Print the dplyr and ggplot2 ones.
  • The Quarto guide — official documentation, well organised and full of examples.
  • ggplot2 reference — every geometry, with pictures.
  • Office hours. If you are stuck for more than twenty minutes on something mechanical, bring it to me rather than losing an evening to it.