Foundations of R with Tidyverse

Bioinformatics Research Center — Workshop Companion

Author
Affiliation

Mery Touceda-Suarez

Published

July 29, 2026

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

# run this once — you don't need to repeat it every session
install.packages("tidyverse")
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.

TipRStudio orientation

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")
  • LogicalTRUE or FALSE values
  • 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 text

Two 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 structure
List 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 once
library(tidyverse)   # load it into this session — do this every time you restart R

Think 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                         
Note

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 category
  • geom_point() — relationship between two numeric variables
  • geom_line() — trends over time or an ordered sequence
  • geom_boxplot() — distribution of a numeric variable per group
  • geom_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:

  1. How many events of each sport awarded a medal?
  2. Which venue hosted the most events?
  3. 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()
Tip

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