Exploratory Data Analysis: Covariation

PSY 410: Data Science for Psychology

Dr. Sara Weston

2026-05-04

From variation to covariation

Psychology is about relationships

Last time, you explored how individual variables behave — distributions, outliers, missing data.

But psychology is about relationships:

  • Does treatment predict depression?
  • Does age relate to reaction time?
  • Does anxiety co-occur with insomnia?

Today we learn to see those relationships in data — before testing them statistically.

Categorical + Continuous

Example: Mental health by treatment group

# Simulated therapy outcome data
therapy_data <- tibble(
  condition = rep(c("Control", "CBT", "Mindfulness"), each = 50),
  depression_post = c(
    rnorm(50, mean = 18, sd = 5),  # Control
    rnorm(50, mean = 12, sd = 5),  # CBT
    rnorm(50, mean = 14, sd = 5)   # Mindfulness
  )
)

Example: Mental health by treatment group

glimpse(therapy_data)
Rows: 150
Columns: 2
$ condition       <chr> "Control", "Control", "Control", "Control", "Control",…
$ depression_post <dbl> 10.322840, 19.810435, 19.234806, 19.058651, 23.269446,…

Boxplots: The classic choice

ggplot(therapy_data,
       aes(x = condition, y = depression_post, fill = condition)) +
  geom_boxplot(alpha = 0.7, show.legend = FALSE) +
  scale_fill_manual(values = c(
    "Control" = "#0072B2", "CBT" = "#E69F00", "Mindfulness" = "#009E73")) +
  labs(
    title = "CBT shows the lowest depression scores",
    x = NULL,
    y = "Depression score (BDI-II)"
  ) +
  theme_minimal(base_size = 14) +
  theme(panel.grid.major.x = element_blank())

Boxplots: The classic choice

Boxplot of post-treatment depression scores across Control, CBT, and Mindfulness groups using colorblind-safe fills, showing CBT has the lowest median depression score.

What boxplots show

Boxplot of post-treatment depression scores across Control, CBT, and Mindfulness groups, with colorblind-safe fills.

  • Line in middle: median
  • Box: 25th–75th percentile (IQR)
  • Whiskers: 1.5 × IQR
  • Dots: outliers

. . .

Great for comparison — but they hide the actual data.

The problem with boxplots

Boxplots summarize, but they hide important information:

  • The actual distribution shape (is it bimodal? skewed?)
  • Individual data points (how many observations are there?)
  • The raw data (where do specific values fall?)

We can do better.

Raincloud plots

The modern psych visualization

Raincloud plots combine three elements:

  1. Density curve (distribution shape)
  2. Boxplot (summary stats)
  3. Jittered points (individual data)

They’re increasingly popular in psychology publications because they show everything.

Use the ggrain package. (More details here.)

Building a raincloud

Horizontal raincloud plot of post-treatment depression scores by condition, combining one-sided density curves, boxplots, jittered points, and diamond-shaped means. CBT shows the lowest scores, followed by Mindfulness, then Control.
library(ggrain)
therapy_data |>
  ggplot(aes(
    x = condition, 
    y = depression_post, 
    fill = condition, 
    color = condition)) +
  # geom_rain creates all parts of your raincloud
  geom_rain(
    alpha = .6,
    # change just the boxplot part
    boxplot.args = list(color = "black")) + 
  # The mean
  stat_summary(fun = mean, geom = "point", shape = 18, size = 5, color = "black") +
  scale_fill_manual(
    values = c("Control" = "#0072B2", "CBT" = "#E69F00", "Mindfulness" = "#009E73")) +
  scale_color_manual(
    values = c("Control" = "#0072B2", "CBT" = "#E69F00", "Mindfulness" = "#009E73")) +
  labs(
    title = "Post-Treatment Depression by Condition",
    subtitle = "Diamond = mean. Density = distribution. Box = IQR. Dots = individual scores.",
    x = "Treatment condition",
    y = "Depression score (BDI-II)"
  ) +
  coord_flip() +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    panel.grid = element_blank())

When to use which

Boxplot

  • Median, IQR, outliers
  • Fast to read
  • Best for quick EDA and large datasets where individual points would clutter

Raincloud

  • Density + summary + raw data
  • Shows shape, sample size, and outliers all at once
  • Best for publications and presentations

Pair coding break

Your turn: Build a raincloud

Using therapy_data, build a raincloud plot of depression scores by condition.

  1. Load ggrain
  2. Map x = condition, y = depression_post, and fill = condition
  3. Add geom_rain()
  4. Give it an informative title and labeled axes

Time: 10 minutes

Before we move on

📤 Submit your code on Canvas for participation credit. Paste what you have — it doesn’t need to work perfectly.

Categorical + Categorical

Example: Diagnosis by referral source

# Simulated diagnostic data
diagnosis_data <- tibble(
  referral = sample(c("Self", "Physician", "School"), 200, replace = TRUE),
  diagnosis = sample(c("Depression", "Anxiety", "Both", "Other"),
                     200, replace = TRUE)
)

head(diagnosis_data)
# A tibble: 6 × 2
  referral  diagnosis 
  <chr>     <chr>     
1 School    Both      
2 Physician Depression
3 Physician Depression
4 Physician Anxiety   
5 Physician Other     
6 Self      Both      

Option 1: Stacked bar chart

ggplot(diagnosis_data, aes(x = referral, fill = diagnosis)) +
  geom_bar() +
  labs(
    title = "Diagnosis by referral source",
    x = "Referral source",
    y = "Count",
    fill = "Diagnosis"
  ) +
  theme_minimal()

Option 1: Stacked bar chart

Stacked bar chart showing counts of diagnoses (Depression, Anxiety, Both, Other) stacked within each referral source category (Physician, School, Self).

Option 2: Side-by-side bars

ggplot(diagnosis_data, aes(x = referral, fill = diagnosis)) +
  geom_bar(position = "dodge") +
  labs(
    title = "Diagnosis by referral source",
    x = "Referral source",
    y = "Count",
    fill = "Diagnosis"
  ) +
  theme_minimal()

Option 2: Side-by-side bars

Side-by-side bar chart comparing diagnosis counts across referral sources, with separate bars for each diagnosis category placed next to each other within each referral source.

Option 3: Proportions

ggplot(diagnosis_data, aes(x = referral, fill = diagnosis)) +
  geom_bar(position = "fill") +
  labs(
    title = "Diagnosis distribution by referral source",
    x = "Referral source",
    y = "Proportion",
    fill = "Diagnosis"
  ) +
  theme_minimal()

Option 3: Proportions

Proportional stacked bar chart showing the relative proportion of each diagnosis within each referral source, with all bars normalized to 100%.

Continuous + Continuous

The classic: Scatterplots

# Simulated reaction time data
rt_data <- tibble(
  age = runif(100, 18, 70),
  reaction_time = 200 + age * 3 + rnorm(100, 0, 40)
)

glimpse(rt_data)
Rows: 100
Columns: 2
$ age           <dbl> 43.53466, 46.08907, 55.75717, 42.43055, 51.37091, 64.599…
$ reaction_time <dbl> 390.1471, 307.7071, 368.6429, 304.2600, 413.9945, 425.64…

Basic scatterplot

ggplot(rt_data, aes(x = age, y = reaction_time)) +
  geom_point() +
  labs(
    title = "Reaction time by age",
    x = "Age (years)",
    y = "Reaction time (ms)"
  ) +
  theme_minimal()

Basic scatterplot

Scatterplot of reaction time versus age showing a positive trend where older participants tend to have slower reaction times.

Add a trend line

ggplot(rt_data, aes(x = age, y = reaction_time)) +
  geom_point() +
  geom_smooth(method = "lm", se = TRUE) +
  labs(
    title = "Reaction time increases with age",
    x = "Age (years)",
    y = "Reaction time (ms)"
  ) +
  theme_minimal()

Add a trend line

Scatterplot of reaction time versus age with a linear trend line and confidence band, showing reaction time increases with age.

The problem: Overplotting

With lots of data, points overlap and hide the true density:

# Lots of data
big_rt_data <- tibble(
  age = runif(5000, 18, 70),
  reaction_time = 200 + age * 3 + rnorm(5000, 0, 40)
)

Overplotting problem demonstrated

ggplot(big_rt_data, aes(x = age, y = reaction_time)) +
  geom_point() +
  labs(title = "Hard to see where the data is dense") +
  theme_minimal()

Overplotting problem demonstrated

Scatterplot of 5,000 reaction time observations that appears as a dense, nearly solid mass of overlapping points, demonstrating the overplotting problem.

Solution 1: Transparency (alpha)

ggplot(big_rt_data, aes(x = age, y = reaction_time)) +
  geom_point(alpha = 0.1) +
  labs(title = "Using alpha = 0.1 to show density") +
  theme_minimal()

Solution 1: Transparency (alpha)

Scatterplot of 5,000 reaction time observations using transparent points (alpha = 0.1), revealing that the densest concentration of data follows a positive linear trend.

Solution 2: geom_bin2d()

ggplot(big_rt_data, aes(x = age, y = reaction_time)) +
  geom_bin2d() +
  scale_fill_viridis_c() +
  labs(
    title = "2D bins show density",
    fill = "Count"
  ) +
  theme_minimal()

Solution 2: geom_bin2d()

Two-dimensional bin plot of reaction time versus age, with rectangular bins colored by count using a viridis scale, showing highest density along the central trend.

Correlation coefficient

A single number summary of the linear relationship:

cor(rt_data$age, rt_data$reaction_time)
[1] 0.6894228

Note

  • r = 1: perfect positive relationship
  • r = 0: no linear relationship
  • r = -1: perfect negative relationship

But always plot your data first! (See: Anscombe’s Quartet)

Patterns and models

What patterns tell us

When you see covariation, ask:

  1. Could it be coincidence? (Maybe, especially with small samples)
  2. What’s the mechanism? (How are these variables related?)
  3. Is there a confound? (Could a third variable explain both?)

Warning

Correlation ≠ Causation

Covariation suggests a relationship, but doesn’t prove one variable causes the other.

Your class data: Covariation

Stress by chronotype

ggplot(class_survey, aes(x = chronotype, y = stress, fill = chronotype)) +
  geom_boxplot(alpha = 0.7, show.legend = FALSE) +
  labs(
    title = "Are Night Owls More Stressed?",
    x = "Chronotype",
    y = "Current stress level (1–10)"
  ) +
  theme_minimal(base_size = 14)

Stress by chronotype

Boxplots comparing self-reported stress levels across chronotype groups (Morning, Evening, Neither), showing how stress varies by sleep preference.

Coding excitement vs. coding anxiety

ggplot(class_survey, aes(x = coding_excited, y = coding_anxious)) +
  geom_jitter(width = 0.2, height = 0.2, alpha = 0.6, size = 3) +
  geom_smooth(method = "lm", se = FALSE, color = "steelblue") +
  labs(
    title = "Does Excitement Buffer Anxiety About Coding?",
    x = "Excitement about learning to code (1–10)",
    y = "Anxiety about learning to code (1–10)"
  ) +
  theme_minimal(base_size = 14)

Coding excitement vs. coding anxiety

Scatterplot of coding excitement versus coding anxiety scores, showing whether students who are more excited about coding also tend to be more or less anxious about it.

Caffeine and sleep

ggplot(class_survey, aes(x = caffeine_per_day, y = sleep_hrs)) +
  geom_jitter(width = 0.2, height = 0.2, alpha = 0.6, size = 3) +
  geom_smooth(method = "lm", se = FALSE, color = "steelblue") +
  labs(
    title = "More Caffeine, Less Sleep?",
    x = "Caffeinated drinks per day",
    y = "Average hours of sleep per night"
  ) +
  theme_minimal(base_size = 14)

Caffeine and sleep

Scatterplot of caffeine drinks per day versus average hours of sleep, with a trend line showing whether caffeine consumption is associated with sleep duration.

Wrapping up

Key takeaways

  1. Covariation = relationships between variables
  2. Different plot types for different variable combinations:
    • Categorical + continuous: boxplot, raincloud
    • Categorical + categorical: stacked, dodged, or proportional bars
    • Continuous + continuous: scatterplot (with alpha or 2D bins for big data)
  3. Rainclouds show distribution, summary stats, and raw data all at once
  4. Watch for overplotting — use alpha, jitter, or binning
  5. Always visualize first before computing correlations
  6. Patterns suggest but don’t prove causation

Before next class

📖 Read:

  • R4DS Ch 12: Logical vectors
  • R4DS Ch 13: Numbers

✅ Do:

  • Get started on Assignment 5 (due Sun May 10)
  • Start thinking about how you’ll compute scale scores — that’s where we’re headed Wednesday

The one thing to remember

Relationships hide in data. Your job is to make them visible — carefully, honestly.

See you Wednesday!

Get a head start

Assignment 5 preview

Assignment 5 asks you to do EDA on the BFI personality dataset. Two of the tasks line up directly with what we did today:

  • Task 2.2: Scatterplot of two Extraversion items with a trend line
  • Task 2.3: Boxplots of one Extraversion item by education level

If you have time tonight, take a first pass at either one — the code patterns are the same as today’s class survey examples.