Data Summaries

Data Skills for Psychology — Module 5

Welcome!

These slides are adapted from Applied Data Skills Ch. 4 and Appendix D: Data Types.

Today’s Goals

By the end of this class, you will be able to:

  • Identify R’s basic data types (numeric, character, logical, factor)
  • Understand vectors, lists, and tables as containers for data
  • Import data from CSV files
  • Explore and clean a dataset
  • Summarize data by groups
  • Use pipes to chain functions together
  • Count, group, and calculate percentages

Setup

Make sure you have these packages loaded:

library(tidyverse)   # readr, dplyr, ggplot2, etc.
library(janitor)     # clean_names()

Tip

You will probably need to install the janitor package before you try to load it. In the Console, run:
install.packages(janitor)

You will only need to do this once!

Data Types

Why Data Types Matter

Every value in R has a type. Knowing the type tells you what you can do with it.

You can’t take the average of someone’s name, and you can’t capitalize a number — the type determines what operations make sense.

The three basic types: numeric, character, and logical.

Numeric Data

Numbers are stored as either doubles (decimals) or integers (whole numbers):

typeof(10)    # double (the default)
typeof(10.0)  # also double
typeof(10L)   # integer (the L suffix forces it)

In practice, the double vs. integer distinction rarely matters. What matters is: is it a number?

is.numeric(10)
is.numeric("10")   # quotes make it text, not a number!

Character Data

Characters (also called “strings”) are text wrapped in quotes:

typeof("hello")
typeof("123")       # still a character — it has quotes!

Single or double quotes both work:

greeting <- "Hello, world"
greeting <- 'Hello, world'   # same thing

Warning

"10" and 10 are not the same thing. One is text, the other is a number.

Logical Data

Logical values are TRUE or FALSE — the result of yes/no questions:

class(TRUE)
class(FALSE)

They arise naturally from comparisons:

10 > 5
10 == 5

Note

You might see T and F as shortcuts, but always spell out TRUE and FALSE to avoid confusion.

Checking Types

Two useful functions for inspecting types:

Function What it tells you
typeof() The low-level storage type
class() The high-level category
is.numeric() Is it a number? (TRUE/FALSE)
is.character() Is it text?
is.logical() Is it TRUE/FALSE?

For most purposes, class() is the one you’ll use most often.

Activity: What Type Is It?

Predict, then check!

What type will R report for each of these? Use typeof() or class() to check.

100
100L
"100"
TRUE
"TRUE"
1 == 2

Factors

A factor is a special type for categorical data. It stores a fixed set of possible values (called levels) in a specific order.

seasons <- factor("Spring",
                  levels = c("Spring", "Summer",
                             "Fall", "Winter"))
seasons

Why use factors? Because they control the order that categories appear in plots and tables.

We’ll use factors when we import data later today, to control how delivery types and age groups are ordered.

Dates and Times

Dates and times are stored as numbers with a special class:

today <- as.Date("2025-01-15")
class(today)     # Date

R stores dates as the number of days since January 1, 1970. The class tells R to display them as readable dates instead of raw numbers.

We won’t work much with dates today, but it’s good to know they exist!

Containers

Vectors: One Type, Many Values

A vector holds multiple values of the same type. Create one with c():

ages <- c(19, 34, 7, 67)
names <- c("Alice", "Bob", "Carol", "Dave")

What happens if you mix types?

mixed <- c(1, "two", TRUE)
mixed    # everything becomes character!

Warning

R will silently coerce mixed types to the most flexible type (usually character). This can cause sneaky bugs!

Selecting from Vectors

Use square brackets [] to pull out elements by position:

ages[1]          # first element
ages[c(2, 4)]   # second and fourth
ages[-3]         # everything except the third

You can also use logical vectors to filter:

adults <- ages >= 18
ages[adults]

Lists: Mixed Types Allowed

A list can hold different types of data in one container:

participant <- list(
  id = 101,
  name = "Maureen",
  scores = c(85, 92, 78),
  consent = TRUE
)

Access items with $ or double brackets [[]]:

participant$name
participant[["scores"]]

Tip

We are primarily going to use a special kind of list: tibbles (a type of data frame).

Tables (Tibbles)

A table (or tibble) is the most important container for data analysis. It’s a list of columns where every column has the same length:

library(tidyverse)

survey <- tibble(
  id = 1:3,
  name = c("Alice", "Bob", "Carol"),
  score = c(85, 92, 78)
)
survey

Useful functions for inspecting tables:

nrow(survey)     # number of rows
ncol(survey)     # number of columns
glimpse(survey)  # quick overview

Activity: Build a Tibble

Try it!

  1. Create a tibble called pets with three columns: name (character), species (character), and age (numeric). Include at least 3 rows.
  2. Use glimpse() to check the column types.
  3. Use $ to extract just the name column.
  4. Bonus: What class is the column you extracted?

Importing Data

Reading a CSV File

Often when we work with data, we want to load it from a separate file, such as a csv (comma-separated value) file.

Use read_csv() from the tidyverse to load data:

# The csv file must be in a folder called "data" that lives in the same folder as this Quarto file.

births <- read_csv("data/12.1_delivery.csv")

Always check the loading summary! Look for:

  • Correct number of rows and columns
  • Expected data types (chr, dbl, etc.)
  • Any parsing problems

Exploring the Import

spec(births)     # column types in detail
glimpse(births)  # quick overview of all columns

Do you remember the other methods we have for exploring data?

Activity: Load and Explore

Try it!

  1. Load the births dataset
  2. Run spec() and glimpse() — how many rows and columns?
  3. Are there any columns that look useless?

Skipping Columns on Import

If a column has no useful info, skip it at load time using col_skip:

# cols refers to "columns"
# Here we are telling R about the data columns BEFORE we load the data
birth_cols <- cols(
  SIMDQuintileQF = col_skip()
)

# Now reload
births <- read_csv("data/12.1_delivery.csv",
                   col_types = birth_cols)

This is cleaner than loading everything and dropping columns later.

Setting Factor Levels on Import

You can control the order of categorical variables right from the start, by defining them as a factor and setting their levels:

# Again, here we're telling R about the data columns BEFORE we load the data
birth_cols <- cols(
  SIMDQuintileQF = col_skip(),
  AgeGroup = col_factor(
    levels = c("Under 25", "25-34",
               "35 and over", "Unknown")),
  Induced = col_factor(
    levels = c("Not Induced", "Induced", "Unknown"))
)

# Now reload
births <- read_csv("data/12.1_delivery.csv",
                   col_types = birth_cols)

Why bother? Because plots and tables will display groups in this order.

Cleaning Column Names

Column names with mixed case are annoying. The janitor package fixes this:

births <- clean_names(births)
colnames(births)

clean_names() converts everything to lowercase with underscores: AgeGroup becomes age_group.

Good habit

Run clean_names() every time you import a new dataset!

The $ Operator and Pipes

Selecting Columns with $

The $ lets you pull out a single column from a tibble or data frame:

years <- births$financial_year
years

You can also grab specific rows with square brackets:

births$financial_year[1]
births$livebirths[c(20, 30, 40)]

Pipes: The Big Idea

Without pipes — lots of intermediate objects:

c_sections <- filter(births,
                     delivery == "Caesarean - Emergency")
c_per_year <- summarise(c_sections,
                        n = sum(livebirths),
                        .by = financial_year)
c_sorted <- arrange(c_per_year, desc(n))

. . .

With pipes — one clean chain:

c_per_year <- births |>
  filter(delivery == "Caesarean - Emergency") |>
  summarise(n = sum(livebirths),
            .by = financial_year) |>
  arrange(desc(n))

How to Read a Pipe

Translate |> as “and then”:

births |>                                     # start with births AND THEN
  filter(delivery == "Caesarean - Emergency") |>  # keep only emergency c-sections AND THEN
  summarise(n = sum(livebirths),                  # sum up livebirths AND THEN
            .by = financial_year) |>
  arrange(desc(n))                                # sort largest to smallest

The pipe sends the result of each step as the first argument to the next function.

Note

You’ll see two pipes in the wild: |> (base R, newer) and %>% (tidyverse/magrittr, older). They work almost identically. We’ll use |>.

Activity: Practice with Pipes

Rewrite this using pipes

spontaneous <- filter(births, delivery == "Spontaneous")
spont_by_age <- summarise(spontaneous,
                          n = sum(livebirths),
                          .by = age_group)
spont_sorted <- arrange(spont_by_age, desc(n))

Try rewriting this as a single piped chain!

For now, don’t worry too much about what each step does - pay attention to the way that the variables are passed from one line to the next.

Counting

The count() Function

count() tallies up rows per group:

count(births, delivery)

Add sort = TRUE to see the most common first:

count(births, induced, sort = TRUE)

Counting Multiple Variables

You can count combinations of columns:

count(births, age_group, induced, sort = TRUE)

Note

count() counts rows, not births! The livebirths column contains the actual numbers per row. To get total births, you need a different function that we’ll go over shortly: summarise() with sum().

Adding Percentages

Use mutate() to add a percentage column:

count(births, delivery) |>
  mutate(percent = n / sum(n),
         percent = round(100 * percent))

Breaking it into two steps (divide, then round) is cleaner than nesting: round(100 * n / sum(n)). But either is technically correct.

Activity: Counting Practice

Try these

  1. Count the number of rows per age_group
  2. Count the number of rows by delivery and age_group together
  3. Add a percentage column to your delivery counts

Which delivery type is most common in the data?

Summarize

The summarize() Function

summarize() (or sometimes, summarise()) computes summary statistics and returns a new, smaller table:

summarize(births,
          total_births = sum(livebirths))

Common summary functions:

Function What it does
n() Count rows
sum() Add up values
mean() Average
median() Middle value
min(), max() Extremes
quantile() Percentiles

A Note on Missing Values

If you get NA from a summary function, it usually means missing data:

values <- c(1, 2, 4, 3, NA, 2)
mean(values)              # returns NA!
mean(values, na.rm = TRUE) # removes NAs first

This is logical: the average of 1 + 2 + “I don’t know” isn’t 1.5 — it’s “I don’t know.”

Grouping

group_by() + summarize()

You can group the data before you summarize it:

births |>
  group_by(delivery) |>
  summarize(total = sum(livebirths)) |>
  ungroup()

Warning

Always call ungroup() when you’re done! Forgetting this causes mysterious bugs later.

Activity: Summarize the Data

Answer these questions with code

  1. What is the total number of live births across the whole dataset?
  2. What is the total number of live births per age group?
  3. What is the mean number of live births per row, broken down by delivery type?

Sorting with arrange()

arrange() sorts your results:

births |>
  group_by(delivery) |>
  summarise(total = sum(livebirths)) |>
  ungroup() |>
  arrange(desc(total))

Use desc() for descending order (largest first).

Multiple Groupings

Group by multiple variables:

births |>
  group_by(delivery, financial_year) |>
  summarise(n = sum(livebirths)) |>
  ungroup()

Try it

Reverse the order of delivery and financial_year in group_by(). How does the output change?

Putting It All Together

A Full Analysis Pipeline

Combine everything into one clean chain:

births_per_year_type_age <- births |>
  mutate(
    year = str_extract(financial_year, ".{4}"),
    year = as.integer(year)
  ) |>
  filter(age_group != "Unknown",
         delivery != "Not Known") |>
  summarise(
    n = sum(livebirths),
    .by = c(year, delivery, age_group)
  ) |>
  mutate(
    pcnt = n / sum(n) * 100,
    .by = c(year, age_group)
  )

head(births_per_year_type_age)

Visualizing the Result

births_per_year_type_age |>
  ggplot(aes(x = year, y = pcnt, color = age_group)) +
  geom_line() +
  facet_wrap(~delivery, scales = "free_y") +
  labs(x = NULL,
       y = "Percent of Live Births per Year",
       color = "Age Group") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

From raw data to publication-quality plot — all in one reproducible pipeline!

Wrapping Up

Key Takeaways

  • read_csv() loads data; use col_skip() and col_factor() to clean on import
  • clean_names() standardizes column names (always do this!)
  • |> (pipe) chains functions together — read it as “and then”
  • count() tallies rows per group
  • summarise() computes stats like sum(), mean(), n()
  • group_by() computes summaries per group
  • mutate() adds or modifies columns (e.g., percentages)
  • Always ungroup() after group_by()!

Before Next Time

📖 Read: Chapter 4 of Applied Data Skills to solidify today’s material

✍️ Assignment: You will practice these skills using a real psychology dataset. See the website for more information.

❓ Questions?