wage <- 24.50
wage
#> [1] 24.5Introduction 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.
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.
- 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. - 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. - 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.
- 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.
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 <-:
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.5Names 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 1200Vectors 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) # logicalclass() 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.0Data 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.00Five 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.00This 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 445More useful, you can select by condition:
earnings > 600
#> [1] FALSE TRUE FALSE TRUE TRUE
earnings[earnings > 600]
#> [1] 610 890 1200The 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.25The 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.25Picking 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.6You 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] 5Combine 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.625Typing ?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] NAThe 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] 805Getting 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)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)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.
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.6You 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.85For 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 136Which 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.
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+11An 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.33Now the mean describes the three people we actually have data for.
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] 733For 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] 373Note 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] 364Conditions 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] 66To 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.6mutate() — 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 136Only 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.746Three 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.794That 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 231This pairing is the workhorse of applied work. Most tables you will ever produce are some version of “group by one thing, summarise another.”
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 136Grouping 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.764Four 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")
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 ($)")
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 ($)")
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 ($)")
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)
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-16That is a lot of output, but only a few parts matter:
Estimate— the coefficient. Because the outcome is in logs, the estimate onyrschoolis 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.5117666Notice 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.2812379Now 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.04044463data = 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.
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.
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.
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.
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 |
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.