# run this once — you don't need to repeat it every session
install.packages("tidyverse")Foundations of R with Tidyverse
Bioinformatics Research Center — Workshop Companion
1 Welcome
This workshop has one goal: get you comfortable enough in R and tidyverse that a data frame stops feeling like a wall of numbers and starts feeling like a tool you can shape. This is a two-hour, hands-on session — this document mirrors that arc, so you can run every chunk yourself as you go.
Think of today like learning to cook in a new kitchen. Base R is the kitchen itself — the stove, the knives, the basic physics of heat and cutting. The tidyverse is a really well-organized set of recipes and tools that all follow the same conventions, so once you learn one, the rest feel familiar.
We’ll use the built-in starwars dataset throughout, since it’s small, a little goofy, and has exactly the kind of messy real-world quirks (missing values, mixed types) that real biological data has too.
1.1 Basic Setup for this workshop
library(tidyverse)
data("starwars")2 The R programming language
R is a free, open-source programming language and environment built for statistical computing and graphics. It was created in the early 1990s by Ross Ihaka and Robert Gentleman at the University of Auckland, and today it’s maintained by the R Core Team alongside a massive global community of contributors. Unlike general-purpose languages that treat data as an afterthought, R was designed around it from day one — vectors, matrices, data frames, and lists are native, first-class citizens, not add-ons. That’s part of why it maps so naturally onto biological data: a data frame in R feels a lot like a table of samples, genes, or reads because that’s exactly what it was built to represent.
On top of this core, R is extensible through more than 20,000 packages on CRAN (the Comprehensive R Archive Network) — including staples like the tidyverse, ggplot2, and caret — and it runs identically on Windows, macOS, and Linux, most commonly paired with the RStudio IDE (which we’re using right now).
2.1 Why R has stuck around in data science
| Strength | What it means in practice |
|---|---|
| Specialized for data science | Built specifically for statistical computing and analysis, so it handles complex data problems — messy, high-dimensional, biological data included — without fighting the language. |
| Rich package ecosystem | 20,000+ CRAN packages cover almost any data task: machine learning, bioinformatics (Bioconductor sits alongside CRAN for this), finance, NLP, geospatial analysis, and more. |
| Advanced data visualization | ggplot2, lattice, and Shiny produce clear, interactive, publication-quality graphics — the kind you can drop straight into a manuscript or a dashboard. |
| Strong community support | A vibrant, growing community contributes documentation, tutorials, and libraries, with active help available on Stack Overflow and Posit Community. |
| Reproducibility | R Markdown and Quarto (like the very document you’re reading) make analyses fully reproducible and shareable as HTML, PDF, Word, or interactive dashboards. |
| Interoperability | R integrates with Python, SQL, and Spark, so it slots into a broader data pipeline rather than demanding you rebuild everything inside it. |
| Cross-platform compatibility | Runs seamlessly on Windows, macOS, and Linux. |
| Free and open source | No licensing costs — ideal for individual researchers, startups, and educational institutions alike. |
2.2 R vs Python
The two languages overlap heavily in data science but come from different roots, which still shows up in where each one shines:
| Feature | R | Python |
|---|---|---|
| Purpose | Built for statistical modeling, academic research, and data visualization. | General-purpose language excelling in data science, ML, and app development. |
| Statistical analysis | Superior stats functions; growing ML support via caret, mlr. |
Strong ML ecosystem — scikit-learn, TensorFlow, Keras; good but less native stats. |
| Community & support | Strong in academia and research sectors. | Very large, active, and diverse developer community. |
| Performance, integration & deployment | Great for statistical workloads; primarily local analysis. | Better for scalable systems; easily integrates with apps and APIs. |
| Data handling | Advanced data frames and manipulation with dplyr, tidyr. |
Comparable support via pandas and NumPy. |
In practice, this isn’t really an R-vs-Python contest — most working bioinformaticians end up fluent in both, reaching for R when the question is statistical (differential expression, hypothesis testing, publication-quality plots) and Python when the task calls for scale, pipelines, or integration with broader software systems.
If this is your first time in RStudio: the Console (bottom-left) is where code actually runs and prints output. The Environment pane (top-right) lists every object you’ve created — like a shelf where R keeps everything you’ve made so far. The Source pane (top-left) is this document.
3 Just Enough Base R to Survive
3.1 Objects and assignment
In R, you store a value in a named box using <-. Once it’s stored, you can reuse it anywhere.
x <- 5
my_favorite_number <- 5
greeting <- "Hello world!"
print(my_favorite_number)[1] 5
print(greeting)[1] "Hello world!"
R is case-sensitive, so score and Score are two completely different boxes — a common source of “why isn’t this working” bugs.
3.2 Data types
Every value in R has a type, and the type determines what you’re allowed to do with it — the same way you can’t divide a word by a number. R has a handful of basic types you’ll run into constantly:
- Numeric — numbers, with or without decimals (
3.14,100) - Character — text/strings (
"hello","North Carolina") - Logical —
TRUEorFALSEvalues - Integer — whole numbers only, marked with a trailing
L(1L,42L) - Factor — categorical data with a defined, ordered set of levels (e.g.,
"Low","Medium","High") - NA — a missing or undefined value; R handles these natively, which turns out to be a very big deal once you’re working with real data
my_age <- 30
pi_approx <- 3.14159
my_name <- "Obi-Wan Kenobi"
my_species <- "Human"
is_jedi <- TRUE
is_sith <- FALSE
lightsaber_count <- 2L # the trailing L marks this explicitly as an integer
pain_level <- factor("High", levels = c("Low", "Medium", "High"))
unknown_height <- NA # missing — R won't silently guess a value here
class(my_age) # "numeric"[1] "numeric"
class(my_name) # "character"[1] "character"
class(is_jedi) # "logical"[1] "logical"
class(lightsaber_count) # "integer"[1] "integer"
class(pain_level) # "factor"[1] "factor"
class(unknown_height) # "logical" — NA's default type, but it adapts to context[1] "logical"
my_age + 10 # works fine — numeric supports math[1] 40
# my_name + 10 # would error — you can't add 10 to textTwo things worth flagging early. First, most numbers you type (like 30) are stored as numeric (technically “double”) even without a decimal — you only get a true integer by adding the L. Second, a factor looks like text but isn’t: it has a fixed set of allowed levels, which matters a lot for things like ordering plots or building statistical models where “Low < Medium < High” needs to mean something.
3.3 Data structures
R gives you a handful of ways to organize values together, each suited to a different shape of problem.
3.3.1 Vector — the fundamental unit of R
A vector is a sequence of values of the same type — think of it as a single column in a spreadsheet, isolated and held up to the light.
heights <- c(172, 167, 96, 202, 150)
names_vec <- c("Luke", "Leia", "R2-D2", "Darth Vader", "Yoda")
is_human <- c(TRUE, TRUE, FALSE, TRUE, FALSE)
names(heights) <- names_vec
heights Luke Leia R2-D2 Darth Vader Yoda
172 167 96 202 150
Math on a numeric vector applies to every element at once — no loop needed:
heights / 100 # convert cm to meters Luke Leia R2-D2 Darth Vader Yoda
1.72 1.67 0.96 2.02 1.50
heights - mean(heights) # how far is each from the average? Luke Leia R2-D2 Darth Vader Yoda
14.6 9.6 -61.4 44.6 -7.4
length(heights)[1] 5
3.3.2 Matrix — a 2D grid, one type throughout
A matrix is a vector reshaped into rows and columns — still a single type throughout, but now arranged in two dimensions. You’ll see these mostly in math- and stats-heavy operations (e.g., distance matrices, count matrices).
m <- matrix(1:6, nrow = 2, ncol = 3)
m [,1] [,2] [,3]
[1,] 1 3 5
[2,] 2 4 6
dim(m)[1] 2 3
m[1, ] # first row[1] 1 3 5
m[, 2] # second column[1] 3 4
3.3.3 List — the flexible catch-all
A list can hold anything — different types, different lengths, even other lists nested inside it. It’s the closest thing R has to a general-purpose container.
character_profile <- list(
name = "Yoda",
height_cm = 66,
is_jedi = TRUE,
aliases = c("Master Yoda", "Grand Master")
)
character_profile$name[1] "Yoda"
character_profile$aliases[1] "Master Yoda" "Grand Master"
str(character_profile) # str() gives you a quick peek at any structureList of 4
$ name : chr "Yoda"
$ height_cm: num 66
$ is_jedi : logi TRUE
$ aliases : chr [1:2] "Master Yoda" "Grand Master"
3.3.4 Data frame — the workhorse of R
A data frame is a table: rows are observations, columns are variables, and — importantly — each column is itself a vector, which is why the data types above matter so much. It’s the structure you’ll spend most of your time in.
mini_df <- data.frame(
name = c("Luke", "Leia", "Yoda"),
height = c(172, 150, 66),
is_jedi = c(TRUE, FALSE, TRUE)
)
mini_df name height is_jedi
1 Luke 172 TRUE
2 Leia 150 FALSE
3 Yoda 66 TRUE
class(mini_df)[1] "data.frame"
3.3.5 Tibble — a modern, tidyverse-flavored data frame
A tibble is a data frame with some rough edges filed off: cleaner printing, no silent type conversions, and no partial-name matching surprises. Anything that expects a data frame will happily accept a tibble too — starwars, which we’ll use for the rest of this workshop, is actually loaded as one.
mini_tibble <- as_tibble(mini_df)
mini_tibble# A tibble: 3 × 3
name height is_jedi
<chr> <dbl> <lgl>
1 Luke 172 TRUE
2 Leia 150 FALSE
3 Yoda 66 TRUE
class(mini_tibble)[1] "tbl_df" "tbl" "data.frame"
class(starwars)[1] "tbl_df" "tbl" "data.frame"
3.4 Objects vs. functions
“Everything that exists is an object. Everything that happens is a function.”
Objects sit there holding a value. Functions do something — the parentheses are the giveaway: if it has (), it’s a function taking an input and handing something back, like a vending machine: you put something in, it does its thing, and something comes out.
my_number <- 42
my_vector <- c(1, 2, 3, 4, 5)
sqrt(my_number) # number in, number out[1] 6.480741
sum(my_vector)[1] 15
length(my_vector)[1] 5
toupper(greeting)[1] "HELLO WORLD!"
square_root <- sqrt(my_number) # save the vending machine's output
square_root[1] 6.480741
This is exactly what happens when you load data: read_csv() is a function — you hand it a file path, it hands back a data frame. starwars is an object already sitting in your environment.
3.5 What is a package?
Base R ships with a solid set of built-in functions, but it doesn’t try to do everything out of the box — that’s what packages are for. A package is a bundle of functions, data, and documentation that someone else has written and shared, so you don’t have to reinvent it yourself.
You install a package once per machine, and load it once per session:
install.packages("tidyverse") # download & install — do this oncelibrary(tidyverse) # load it into this session — do this every time you restart RThink of install.packages() as buying a cookbook and putting it on your shelf, and library() as actually opening it up on the counter before you start cooking — you only need to buy it once, but you do need to open it every time you want to use its recipes. The tidyverse you’re using throughout this workshop isn’t a single package at all — it’s a collection of packages (dplyr, ggplot2, tidyr, readr, and others) that all share the same design philosophy, which is exactly why filter(), mutate(), and ggplot() all feel like they belong to the same language.
4 A First Look at a Data Frame
A data frame is a table: rows are observations, columns are variables, and each column is itself a vector — which is why data types matter so much.
nrow(starwars)[1] 87
ncol(starwars)[1] 14
dim(starwars)[1] 87 14
glimpse(starwars)Rows: 87
Columns: 14
$ name <chr> "Luke Skywalker", "C-3PO", "R2-D2", "Darth Vader", "Leia Or…
$ height <int> 172, 167, 96, 202, 150, 178, 165, 97, 183, 182, 188, 180, 2…
$ mass <dbl> 77.0, 75.0, 32.0, 136.0, 49.0, 120.0, 75.0, 32.0, 84.0, 77.…
$ hair_color <chr> "blond", NA, NA, "none", "brown", "brown, grey", "brown", N…
$ skin_color <chr> "fair", "gold", "white, blue", "white", "light", "light", "…
$ eye_color <chr> "blue", "yellow", "red", "yellow", "brown", "blue", "blue",…
$ birth_year <dbl> 19.0, 112.0, 33.0, 41.9, 19.0, 52.0, 47.0, NA, 24.0, 57.0, …
$ sex <chr> "male", "none", "none", "male", "female", "male", "female",…
$ gender <chr> "masculine", "masculine", "masculine", "masculine", "femini…
$ homeworld <chr> "Tatooine", "Tatooine", "Naboo", "Tatooine", "Alderaan", "T…
$ species <chr> "Human", "Droid", "Droid", "Human", "Human", "Human", "Huma…
$ films <list> <"A New Hope", "The Empire Strikes Back", "Return of the J…
$ vehicles <list> <"Snowspeeder", "Imperial Speeder Bike">, <>, <>, <>, "Imp…
$ starships <list> <"X-wing", "Imperial shuttle">, <>, <>, "TIE Advanced x1",…
head(starwars)# A tibble: 6 × 14
name height mass hair_color skin_color eye_color birth_year sex gender
<chr> <int> <dbl> <chr> <chr> <chr> <dbl> <chr> <chr>
1 Luke Sky… 172 77 blond fair blue 19 male mascu…
2 C-3PO 167 75 <NA> gold yellow 112 none mascu…
3 R2-D2 96 32 <NA> white, bl… red 33 none mascu…
4 Darth Va… 202 136 none white yellow 41.9 male mascu…
5 Leia Org… 150 49 brown light brown 19 fema… femin…
6 Owen Lars 178 120 brown, gr… light blue 52 male mascu…
# ℹ 5 more variables: homeworld <chr>, species <chr>, films <list>,
# vehicles <list>, starships <list>
Pull out a single column with $ — this hands you back a plain vector:
mean(starwars$height, na.rm = TRUE)[1] 174.6049
max(starwars$height, na.rm = TRUE)[1] 264
min(starwars$height, na.rm = TRUE)[1] 66
na.rm is worth pausing on. R is strict: if there’s a missing value (NA) anywhere and you don’t explicitly say what to do about it, R refuses to guess and just hands back NA for the whole calculation.
mean(starwars$height) # NA — R won't guess past a gap[1] NA
mean(starwars$height, na.rm = TRUE) # tells R to skip the gaps[1] 174.6049
5 The dplyr Core Five
dplyr gives you five verbs that cover almost everything you’ll do to a data frame. Each one takes a data frame in and hands a data frame back — like stations on an assembly line, each doing exactly one job.
Before that, meet the pipe, %>% — read it as “and then.” It takes whatever’s on the left and feeds it into the function on the right, so you can read a chain of steps top to bottom like a recipe instead of nesting parentheses inside parentheses.
# For example
starwars %>%
select(height, mass, birth_year, sex) %>%
summary() height mass birth_year sex
Min. : 66.0 Min. : 15.00 Min. : 8.00 Length:87
1st Qu.:167.0 1st Qu.: 55.60 1st Qu.: 35.00 Class :character
Median :180.0 Median : 79.00 Median : 52.00 Mode :character
Mean :174.6 Mean : 97.31 Mean : 87.57
3rd Qu.:191.0 3rd Qu.: 84.50 3rd Qu.: 72.00
Max. :264.0 Max. :1358.00 Max. :896.00
NA's :6 NA's :28 NA's :44
Keyboard shortcut for %>%: Cmd/Ctrl + Shift + M. R also has a native pipe, |> — either works, we’ll stick to %>% today for consistency.
5.1 filter() — keep rows that match a condition
humans <- starwars %>% filter(species == "Human")
nrow(humans)[1] 35
# , between conditions means AND
tall_humans <- starwars %>% filter(species == "Human", height > 180)
# | means OR
humans_or_droids <- starwars %>% filter(species == "Human" | species == "Droid")
# shortcut for "matches any of these values"
humans_or_droids <- starwars %>% filter(species %in% c("Human", "Droid"))
# !is.na() reads as "is NOT missing"
no_missing_height <- starwars %>% filter(!is.na(height))5.2 select() — keep the columns you want
starwars %>% select(name, height, species)# A tibble: 87 × 3
name height species
<chr> <int> <chr>
1 Luke Skywalker 172 Human
2 C-3PO 167 Droid
3 R2-D2 96 Droid
4 Darth Vader 202 Human
5 Leia Organa 150 Human
6 Owen Lars 178 Human
7 Beru Whitesun Lars 165 Human
8 R5-D4 97 Droid
9 Biggs Darklighter 183 Human
10 Obi-Wan Kenobi 182 Human
# ℹ 77 more rows
starwars %>% select(-films, -vehicles, -starships) # drop with -# A tibble: 87 × 11
name height mass hair_color skin_color eye_color birth_year sex gender
<chr> <int> <dbl> <chr> <chr> <chr> <dbl> <chr> <chr>
1 Luke Sk… 172 77 blond fair blue 19 male mascu…
2 C-3PO 167 75 <NA> gold yellow 112 none mascu…
3 R2-D2 96 32 <NA> white, bl… red 33 none mascu…
4 Darth V… 202 136 none white yellow 41.9 male mascu…
5 Leia Or… 150 49 brown light brown 19 fema… femin…
6 Owen La… 178 120 brown, gr… light blue 52 male mascu…
7 Beru Wh… 165 75 brown light blue 47 fema… femin…
8 R5-D4 97 32 <NA> white, red red NA none mascu…
9 Biggs D… 183 84 black light brown 24 male mascu…
10 Obi-Wan… 182 77 auburn, w… fair blue-gray 57 male mascu…
# ℹ 77 more rows
# ℹ 2 more variables: homeworld <chr>, species <chr>
starwars %>% select(name:eye_color) # a range of columns# A tibble: 87 × 6
name height mass hair_color skin_color eye_color
<chr> <int> <dbl> <chr> <chr> <chr>
1 Luke Skywalker 172 77 blond fair blue
2 C-3PO 167 75 <NA> gold yellow
3 R2-D2 96 32 <NA> white, blue red
4 Darth Vader 202 136 none white yellow
5 Leia Organa 150 49 brown light brown
6 Owen Lars 178 120 brown, grey light blue
7 Beru Whitesun Lars 165 75 brown light blue
8 R5-D4 97 32 <NA> white, red red
9 Biggs Darklighter 183 84 black light brown
10 Obi-Wan Kenobi 182 77 auburn, white fair blue-gray
# ℹ 77 more rows
starwars %>% select(starts_with("s")) # pattern match# A tibble: 87 × 4
skin_color sex species starships
<chr> <chr> <chr> <list>
1 fair male Human <chr [2]>
2 gold none Droid <chr [0]>
3 white, blue none Droid <chr [0]>
4 white male Human <chr [1]>
5 light female Human <chr [0]>
6 light male Human <chr [0]>
7 light female Human <chr [0]>
8 white, red none Droid <chr [0]>
9 light male Human <chr [1]>
10 fair male Human <chr [5]>
# ℹ 77 more rows
Chain filter() and select() together — “give me the name and height of every human”:
starwars %>%
filter(species == "Human") %>%
select(name, height)# A tibble: 35 × 2
name height
<chr> <int>
1 Luke Skywalker 172
2 Darth Vader 202
3 Leia Organa 150
4 Owen Lars 178
5 Beru Whitesun Lars 165
6 Biggs Darklighter 183
7 Obi-Wan Kenobi 182
8 Anakin Skywalker 188
9 Wilhuff Tarkin 180
10 Han Solo 180
# ℹ 25 more rows
5.3 mutate() — create or modify a column
starwars %>%
mutate(height_m = height / 100) %>%
select(name, height, height_m)# A tibble: 87 × 3
name height height_m
<chr> <int> <dbl>
1 Luke Skywalker 172 1.72
2 C-3PO 167 1.67
3 R2-D2 96 0.96
4 Darth Vader 202 2.02
5 Leia Organa 150 1.5
6 Owen Lars 178 1.78
7 Beru Whitesun Lars 165 1.65
8 R5-D4 97 0.97
9 Biggs Darklighter 183 1.83
10 Obi-Wan Kenobi 182 1.82
# ℹ 77 more rows
starwars %>%
mutate(
height_m = height / 100,
bmi = mass / height_m^2
) %>%
select(name, height_m, mass, bmi)# A tibble: 87 × 4
name height_m mass bmi
<chr> <dbl> <dbl> <dbl>
1 Luke Skywalker 1.72 77 26.0
2 C-3PO 1.67 75 26.9
3 R2-D2 0.96 32 34.7
4 Darth Vader 2.02 136 33.3
5 Leia Organa 1.5 49 21.8
6 Owen Lars 1.78 120 37.9
7 Beru Whitesun Lars 1.65 75 27.5
8 R5-D4 0.97 32 34.0
9 Biggs Darklighter 1.83 84 25.1
10 Obi-Wan Kenobi 1.82 77 23.2
# ℹ 77 more rows
if_else() handles two outcomes; case_when() is the version for more than two — read it as “when THIS is true, give me THAT.”
starwars %>%
mutate(is_tall = if_else(height > 180, "tall", "not tall")) %>%
select(name, height, is_tall)# A tibble: 87 × 3
name height is_tall
<chr> <int> <chr>
1 Luke Skywalker 172 not tall
2 C-3PO 167 not tall
3 R2-D2 96 not tall
4 Darth Vader 202 tall
5 Leia Organa 150 not tall
6 Owen Lars 178 not tall
7 Beru Whitesun Lars 165 not tall
8 R5-D4 97 not tall
9 Biggs Darklighter 183 tall
10 Obi-Wan Kenobi 182 tall
# ℹ 77 more rows
starwars %>%
mutate(height_category = case_when(
height < 100 ~ "short",
height >= 100 & height < 180 ~ "average",
height >= 180 ~ "tall",
is.na(height) ~ "unknown"
)) %>%
select(name, height, height_category)# A tibble: 87 × 3
name height height_category
<chr> <int> <chr>
1 Luke Skywalker 172 average
2 C-3PO 167 average
3 R2-D2 96 short
4 Darth Vader 202 tall
5 Leia Organa 150 average
6 Owen Lars 178 average
7 Beru Whitesun Lars 165 average
8 R5-D4 97 short
9 Biggs Darklighter 183 tall
10 Obi-Wan Kenobi 182 tall
# ℹ 77 more rows
5.4 arrange() — sort rows
starwars %>% arrange(height) %>% select(name, height) # ascending# A tibble: 87 × 2
name height
<chr> <int>
1 Yoda 66
2 Ratts Tyerel 79
3 Wicket Systri Warrick 88
4 Dud Bolt 94
5 R2-D2 96
6 R4-P17 96
7 R5-D4 97
8 Sebulba 112
9 Gasgano 122
10 Watto 137
# ℹ 77 more rows
starwars %>% arrange(desc(height)) %>% select(name, height) # descending# A tibble: 87 × 2
name height
<chr> <int>
1 Yarael Poof 264
2 Tarfful 234
3 Lama Su 229
4 Chewbacca 228
5 Roos Tarpals 224
6 Grievous 216
7 Taun We 213
8 Rugor Nass 206
9 Tion Medon 206
10 Darth Vader 202
# ℹ 77 more rows
starwars %>% arrange(species, desc(height)) %>% select(name, species, height)# A tibble: 87 × 3
name species height
<chr> <chr> <int>
1 Ratts Tyerel Aleena 79
2 Dexter Jettster Besalisk 198
3 Ki-Adi-Mundi Cerean 198
4 Mas Amedda Chagrian 196
5 Zam Wesell Clawdite 168
6 IG-88 Droid 200
7 C-3PO Droid 167
8 R5-D4 Droid 97
9 R2-D2 Droid 96
10 R4-P17 Droid 96
# ℹ 77 more rows
5.5 summarize() — collapse rows to a single summary
starwars %>% summarize(mean_height = mean(height, na.rm = TRUE))# A tibble: 1 × 1
mean_height
<dbl>
1 175.
starwars %>%
summarize(
mean_height = mean(height, na.rm = TRUE),
median_height = median(height, na.rm = TRUE),
min_height = min(height, na.rm = TRUE),
max_height = max(height, na.rm = TRUE),
n = n() # n() counts rows
)# A tibble: 1 × 5
mean_height median_height min_height max_height n
<dbl> <int> <int> <int> <int>
1 175. 180 66 264 87
6 Group Operations
group_by() + summarize() is where dplyr starts to feel powerful: it splits the data frame into invisible sub-tables — one per group — runs your summary on each separately, and stitches the results back into one table.
starwars %>%
group_by(species) %>%
summarize(
mean_height = mean(height, na.rm = TRUE),
n = n()
)# A tibble: 38 × 3
species mean_height n
<chr> <dbl> <int>
1 Aleena 79 1
2 Besalisk 198 1
3 Cerean 198 1
4 Chagrian 196 1
5 Clawdite 168 1
6 Droid 131. 6
7 Dug 112 1
8 Ewok 88 1
9 Geonosian 183 1
10 Gungan 209. 3
# ℹ 28 more rows
Chain more steps in: filter first, group, summarize, then filter the result of the summary too.
starwars %>%
filter(!is.na(species)) %>%
group_by(species) %>%
summarize(
mean_height = mean(height, na.rm = TRUE),
mean_mass = mean(mass, na.rm = TRUE),
n = n()
) %>%
filter(n > 2) %>%
arrange(desc(mean_height))# A tibble: 3 × 4
species mean_height mean_mass n
<chr> <dbl> <dbl> <int>
1 Gungan 209. 74 3
2 Human 178 81.3 35
3 Droid 131. 69.8 6
count() is shorthand for group_by() %>% summarize(n = n()):
starwars %>% count(species, sort = TRUE)# A tibble: 38 × 2
species n
<chr> <int>
1 Human 35
2 Droid 6
3 <NA> 4
4 Gungan 3
5 Kaminoan 2
6 Mirialan 2
7 Twi'lek 2
8 Wookiee 2
9 Zabrak 2
10 Aleena 1
# ℹ 28 more rows
starwars %>% count(species, gender, sort = TRUE)# A tibble: 42 × 3
species gender n
<chr> <chr> <int>
1 Human masculine 26
2 Human feminine 9
3 Droid masculine 5
4 <NA> <NA> 4
5 Gungan masculine 3
6 Mirialan feminine 2
7 Wookiee masculine 2
8 Zabrak masculine 2
9 Aleena masculine 1
10 Besalisk masculine 1
# ℹ 32 more rows
7 First ggplot
ggplot2 builds a plot in layers, like clear plastic sheets stacked on an overhead projector: one layer sets up the axes, the next adds the shapes, the next adds color, and so on. The grammar is always ggplot(data, aes(x, y)) + geom_*() — aes() maps columns to visual properties, geom_*() decides what shape represents them.
7.1 Bar chart from a summary
species_counts <- starwars %>%
count(species, sort = TRUE) %>%
filter(n > 1)
ggplot(species_counts, aes(x = n, y = reorder(species, n))) +
geom_col(fill = "steelblue") +
labs(
title = "Number of characters per species",
x = "Count",
y = "Species"
)7.2 Scatter plot
starwars %>%
filter(mass > 1000) %>%
select(name, height, mass, species) # spot the outlier# A tibble: 1 × 4
name height mass species
<chr> <int> <dbl> <chr>
1 Jabba Desilijic Tiure 175 1358 Hutt
Jabba the Hutt is throwing off the scale — worth removing for a cleaner view:
starwars %>%
filter(mass < 1000, !is.na(gender)) %>%
ggplot(aes(x = height, y = mass, color = gender)) +
geom_point(size = 3, alpha = 0.7) +
geom_smooth(method = "lm", se = FALSE) +
labs(
title = "Height vs mass in Star Wars characters",
x = "Height (cm)",
y = "Mass (kg)",
color = "Gender"
)7.3 Choosing a geom
The geom you reach for depends on the shape of the question:
geom_col()/geom_bar()— counts or values per categorygeom_point()— relationship between two numeric variablesgeom_line()— trends over time or an ordered sequencegeom_boxplot()— distribution of a numeric variable per groupgeom_histogram()— distribution of a single numeric variable
starwars %>%
filter(!is.na(gender)) %>%
ggplot(aes(x = gender, y = height, fill = gender)) +
geom_boxplot() +
labs(title = "Height distribution by gender")starwars %>%
filter(!is.na(height)) %>%
ggplot(aes(x = height)) +
geom_histogram(bins = 20, fill = "steelblue", color = "white") +
labs(title = "Distribution of character heights")7.4 The full pattern
This is the shape you’ll write constantly, in bioinformatics as much as anywhere else: start with data, clean it, group it, summarize it, plot it.
starwars %>%
filter(!is.na(height)) %>%
group_by(species) %>%
summarize(mean_height = mean(height), n = n()) %>%
filter(n > 2) %>%
ggplot(aes(x = reorder(species, mean_height), y = mean_height)) +
geom_col(fill = "steelblue") +
coord_flip() +
labs(
title = "Mean height by species (species with >2 characters)",
x = "Species",
y = "Mean height (cm)"
)8 Cleaning Quiz
Match each request to the dplyr verb that does the job. Try it yourself before expanding the answer.
filter() — you’re keeping a subset of rows based on a condition.
starwars %>% filter(homeworld == "Tatooine")# A tibble: 10 × 14
name height mass hair_color skin_color eye_color birth_year sex gender
<chr> <int> <dbl> <chr> <chr> <chr> <dbl> <chr> <chr>
1 Luke Sk… 172 77 blond fair blue 19 male mascu…
2 C-3PO 167 75 <NA> gold yellow 112 none mascu…
3 Darth V… 202 136 none white yellow 41.9 male mascu…
4 Owen La… 178 120 brown, gr… light blue 52 male mascu…
5 Beru Wh… 165 75 brown light blue 47 fema… femin…
6 R5-D4 97 32 <NA> white, red red NA none mascu…
7 Biggs D… 183 84 black light brown 24 male mascu…
8 Anakin … 188 84 blond fair blue 41.9 male mascu…
9 Shmi Sk… 163 NA black fair brown 72 fema… femin…
10 Cliegg … 183 NA brown fair blue 82 male mascu…
# ℹ 5 more variables: homeworld <chr>, species <chr>, films <list>,
# vehicles <list>, starships <list>
select() — you’re keeping a subset of columns.
starwars %>% select(name, height)# A tibble: 87 × 2
name height
<chr> <int>
1 Luke Skywalker 172
2 C-3PO 167
3 R2-D2 96
4 Darth Vader 202
5 Leia Organa 150
6 Owen Lars 178
7 Beru Whitesun Lars 165
8 R5-D4 97
9 Biggs Darklighter 183
10 Obi-Wan Kenobi 182
# ℹ 77 more rows
mutate() — you’re creating a new column from existing ones.
starwars %>%
mutate(bmi = mass / (height / 100)^2) %>%
select(name, bmi)# A tibble: 87 × 2
name bmi
<chr> <dbl>
1 Luke Skywalker 26.0
2 C-3PO 26.9
3 R2-D2 34.7
4 Darth Vader 33.3
5 Leia Organa 21.8
6 Owen Lars 37.9
7 Beru Whitesun Lars 27.5
8 R5-D4 34.0
9 Biggs Darklighter 25.1
10 Obi-Wan Kenobi 23.2
# ℹ 77 more rows
arrange() — you’re reordering rows, descending on height.
starwars %>% arrange(desc(height)) %>% select(name, height)# A tibble: 87 × 2
name height
<chr> <int>
1 Yarael Poof 264
2 Tarfful 234
3 Lama Su 229
4 Chewbacca 228
5 Roos Tarpals 224
6 Grievous 216
7 Taun We 213
8 Rugor Nass 206
9 Tion Medon 206
10 Darth Vader 202
# ℹ 77 more rows
summarize() — you’re collapsing many rows into one number.
starwars %>% summarize(mean_height = mean(height, na.rm = TRUE))# A tibble: 1 × 1
mean_height
<dbl>
1 175.
9 Practice: A TidyTuesday-Style Warm-Up
#TidyTuesday is a weekly community project: one new public dataset dropped every week, purely for practicing exactly what you learned this morning. Today’s practice set uses results from the Milano 2026 Winter Olympics.
dataset_url <- "https://raw.githubusercontent.com/chendaniely/olympics-2026/refs/heads/main/data/final/olympics/olympics_events.csv"
df <- readr::read_csv(dataset_url)
summary(df)Try answering these using only filter(), select(), group_by(), summarize(), count(), and ggplot() — no new functions required:
- How many events of each sport awarded a medal?
- Which venue hosted the most events?
- Which day had the most events overall?
# starter scaffold — fill in the blanks
df %>%
group_by(discipline_name) %>%
summarize(n = n()) %>%
ggplot(aes(x = reorder(discipline_name, n), y = n)) +
geom_col()If you finish early, the same #TidyTuesday project has dozens of other approachable datasets — Simpsons episodes, Pokémon stats, British Library funding — all loadable with tidytuesdayR::tt_load(). Good weekend practice once today’s material has settled.
10 Resources
- Learn R interactively, inside R: https://swirlstats.com/
- Learn tidyverse: https://tidyverse.org/learn/ and https://jhudatascience.org/tidyversecourse/intro.html
- dplyr cheatsheet: https://nyu-cdsc.github.io/learningr/assets/data-transformation.pdf
- Data visualization deep-dive: https://r4ds.had.co.nz/data-visualisation.html
- A good general data-science-writing read: https://pudding.cool/