The two-sample Welch’s t-test from assumptions to scientific report
today
read_excel()group_by() + summarize() to compute mean, SD, and SE per groupsum(!is.na())stat_summary()var.equal = FALSE, two-tailed)t.test() outputHow to use this worksheet
- Work through each part in order — the steps build on each other. Type the code into your script in Positron and run it line by line.
- Code blocks marked ▶ Run this should be executed as written.
- Blocks marked ✏️ Your turn ask you to write, modify, or interpret something.
- The Going further section is optional if you finish early.
🔮 Predict before you run — and type, don’t paste
Before you run any ▶ Run this block, cover the output and predict what R will print. Jot your guess down, then run it and compare. Two reasons it is worth the extra few seconds:
- Predicting makes it stick. Guessing forces you to retrieve what you already know, and the little jolt of being wrong is when real learning happens — especially for p-values, which are easy to misread.
- Typing beats pasting. Typing every line builds muscle memory and trains your eye to catch small errors (
=vs==, a missing)or%>%) that you would otherwise spend real time hunting.
🧩 Chunk 1 — Setup, hypotheses & recap (after lecture Chunk 1)
Parts 1–3: load the data, commit to your hypotheses, and recompute the descriptive stats.
▶ Run this at the top of your script — in this exact order:
⚠️ Watch out!
- If R says
"could not find function 'leveneTest'"
- you forgot
library(car).- Install it once with
install.packages("car"), then load it every sessionlibrary(car)
▶ Run this:
✏️ Your turn: Before anything else — do you remember the structure of this data frame? Fill in from memory, then check with glimpse(tree_df).
Number of rows:
Number of columns:
Column holding the grouping variable (sunny/shady):
Column we are testing today:
💡 Key idea: Always write down your hypotheses before you look at the data or run any test. This is how you stay scientifically honest.
✏️ Your turn: Write the null and alternate hypotheses for leaf weight. Use words first, then symbols.
In words:
H₀ (null hypothesis):
Hₐ (alternate hypothesis):
In symbols (use μ for "mean"):
H₀:
Hₐ:
Test type (circle one): one-tailed / two-tailed
Significance level α:
✏️ Your turn: Why do we use a two-tailed test here even though we predicted shady leaves would be heavier?
Your answer:
🔮 Predict first: Before running, guess the mean weight for each side (shady vs sunny) and the gap between them. Write your guesses, then check.
▶ Run this:
✏️ Your turn: Record the values you will need for the t-test formula:
Shady: mean = _____ g SD = _____ n = _____
Sunny: mean = _____ g SD = _____ n = _____
Difference in means = _____ g
✏️ Your turn: Looking at the SD values, do the two groups appear to have similar or different variance? (Does one group have much more spread than the other?)
Your observation:
🧩 Chunk 2 — Checking assumptions (after lecture Chunk 2)
Parts 4–7: histogram, QQ plot, Shapiro-Wilk, and Levene’s test — check before you test.
The t-test assumes that data within each group are approximately normally distributed.
▶ Run this:
# Histogram per group — look for bell-shaped distribution
hist_norm_plot <- tree_df %>%
ggplot(aes(x = weight_g, fill = side)) +
geom_histogram(binwidth = 1, color = "white", alpha = 0.8) +
facet_wrap(~side, ncol = 2) +
labs(
title = "Leaf Weight Distribution by Side",
x = "Leaf Weight (g)",
y = "Count"
) +
theme_minimal() +
theme(legend.position = "none")
hist_norm_plot✏️ Your turn: Describe the shape of each histogram:
Sunny side shape (bell-shaped, skewed, flat, bimodal?):
Shady side shape:
Any obvious outliers? Y / N
✏️ Your turn: With only 10 values per group, histograms will look lumpy even if the data are normal. Does this mean we should reject normality? Why or why not?
Your answer:
A QQ (quantile-quantile) plot compares your data’s quantiles to what a perfect normal distribution would look like. Points on the diagonal line = normal.
▶ Run this:
# Q-Q plot — points should fall along the diagonal ----
qq_norm_plot <- tree_df %>%
ggplot(aes(sample = weight_g, color = side)) +
stat_qq() +
stat_qq_line(color = "black", linewidth = 0.8) +
facet_wrap(~side, scales = "free") +
labs(
title = "Normal Q-Q Plots by Side",
x = "Theoretical Quantiles",
y = "Sample Quantiles"
) +
theme_minimal() +
theme(legend.position = "none")
qq_norm_plot✏️ Your turn: Do the points for each group fall approximately along the diagonal line? What would it mean if they curved strongly away at the ends?
Sunny side — on the line? Y / mostly / no
Shady side — on the line? Y / mostly / no
What strong curvature at the ends would indicate:
🔮 Predict first: Shapiro-Wilk’s H₀ is “data are normal.” From your histogram and QQ plot, predict whether p will be above or below 0.05 for each side before you run it.
The Shapiro-Wilk test formally tests whether a sample could have come from a normal distribution.
▶ Run this:
✏️ Your turn: Record the results and state your decision:
Sunny: W = _____ p = _____ Decision (normal / not normal):
Shady: W = _____ p = _____ Decision (normal / not normal):
✏️ Your turn: With n = 10 per group, the Shapiro-Wilk test has low statistical power. What does that mean in plain language for interpreting this result?
Your answer:
⚠️ Watch out! Even if Shapiro-Wilk returns p < 0.05 with very small samples, the t-test may still be appropriate — the QQ plot and histogram give important visual context. None of these checks alone is definitive.
🔮 Predict first: Look back at the two SD values from Part 3. Predict whether Levene’s will call the variances equal (p > 0.05) or not, before you run it.
Levene’s test checks whether the two groups have the same population variance.
▶ Run this:
✏️ Your turn: Record the result and decide:
F-statistic = _____ p = _____
Decision (variances equal / not equal):
✏️ Your turn: We are going to use Welch’s t-test (var.equal = FALSE) regardless of Levene’s result. In your own words, why is Welch’s the safe default even when Levene’s says variances are equal?
Your answer:
🧩 Chunk 3 — Run & interpret (after lecture Chunk 3)
Parts 8–11: run the test, decode the output, reproduce t by hand, and decide.
🔮 Predict first: Write down a guess for the p-value now — above or below 0.05? Then run
t.test()and see how close you were.
Now that we have checked our assumptions, we can run the test.
▶ Run this:
✏️ Your turn: Copy the full output below:
Paste or write the t.test() output here:
▶ Run this to extract individual values:
# Pull specific values from the model object ---------
cat("t-statistic:", round(leaf_ttest_model$statistic, 3), "\n")
cat("df (Welch): ", round(leaf_ttest_model$parameter, 2), "\n")
cat("p-value: ", signif(leaf_ttest_model$p.value, 3), "\n")
cat("95% CI: ", round(leaf_ttest_model$conf.int[1],2),
"to", round(leaf_ttest_model$conf.int[2], 2), "g\n")
cat("Mean shady: ", round(leaf_ttest_model$estimate[1],2), "g\n")
cat("Mean sunny: ", round(leaf_ttest_model$estimate[2],2), "g\n")✏️ Your turn: Match each piece of output to its meaning:
t-statistic = _____
→ This is large/small (circle one) because the difference in means
is large/small relative to the variability (circle one).
df = _____
→ This is a non-integer. Why? (hint: Welch-Satterthwaite equation)
p-value = _____
→ In plain language, this means: if H₀ were true, the probability
of seeing a t-statistic this extreme just by chance is _____.
95% CI = _____ to _____ g
→ This CI is for the ________________ (the true difference in means).
It does / does not include zero (circle one).
What does it mean when the CI excludes zero?
Mean shady = _____ g
Mean sunny = _____ g
▶ Run this:
# Reproduce the t-statistic manually -----------------
m_sha <- recap_df$mean_wt[recap_df$side == "shady"]
m_sun <- recap_df$mean_wt[recap_df$side == "sunny"]
s_sha <- recap_df$sd_wt[recap_df$side == "shady"]
s_sun <- recap_df$sd_wt[recap_df$side == "sunny"]
n_sha <- recap_df$n[recap_df$side == "shady"]
n_sun <- recap_df$n[recap_df$side == "sunny"]
# Welch's SE of the difference
se_diff <- sqrt((s_sha^2 / n_sha) + (s_sun^2 / n_sun))
# t-statistic
t_manual <- (m_sha - m_sun) / se_diff
cat("Difference in means:", round(m_sha - m_sun, 2), "g\n")
cat("SE of difference: ", round(se_diff, 4), "\n")
cat("Manual t: ", round(t_manual, 3), "\n")✏️ Your turn: Does your manually calculated t match the value from t.test()? (They may differ very slightly due to rounding in recap_df.)
Manual t = _____
t.test() t = _____
Match? Y / close / no
✏️ Your turn: Write the Welch’s t-test formula below and label each part:
t = _________________
Numerator means:
Denominator means:
✏️ Your turn: State your decision and conclusion. Use the formal language of hypothesis testing.
Our p-value:
Our α:
Decision (circle one): REJECT H₀ / FAIL TO REJECT H₀
In one sentence, what does this mean biologically?
💡 Key idea: We never say “H₀ is true” or “H₀ is false.” We say “we reject H₀” (evidence was strong enough) or “we fail to reject H₀” (evidence was not strong enough). Statistics gives us a decision rule, not certainty.
🧩 Chunk 4 — Visualize & report (after lecture Chunk 4)
Parts 12–13: build the figure with the statistics embedded, then write the results sentence.
▶ Run this:
# Boxplot with t and p embedded in subtitle ----------
leaf_box_04_plot <- tree_df %>%
ggplot(aes(x = side, y = weight_g, fill = side)) +
geom_boxplot(alpha = 0.5, outlier.shape = NA) +
geom_point(
position = position_jitter(width = 0.15, seed = 42),
alpha = 0.6, size = 2.5
) +
labs(
title = "Leaf Weight by Tree Side",
subtitle = paste0("Welch's t-test: t = ",
round(leaf_ttest_model$statistic, 2),
", p = ",
signif(leaf_ttest_model$p.value, 2)),
x = "Side of Tree",
y = "Leaf Weight (g)"
) +
theme_minimal() +
theme(legend.position = "none")
leaf_box_04_plot✏️ Your turn: The subtitle line uses paste0() to build the label from the model object. What happens to the subtitle if you re-run this with different data? Why is this better than typing the numbers yourself?
Your answer:
▶ Run this:
✏️ Your turn: Write a complete results sentence as you would in a lab report or paper. Include:
Use this template:
"[Group] leaves were significantly [heavier / lighter] than [group] leaves
(Welch's two-sample t-test: t(___) = ___, p = ___).
Mean ± SE: [side 1] = ___ ± ___ g, [side 2] = ___ ± ___ g."
Write your sentence here:
Your results sentence:
💡 Key idea: Never write
p = 0.000. Writep < 0.001instead. A p-value is never exactly zero — it is just too small to display at three decimal places.
At this point you should be able to:
✏️ Your turn: Run your entire script from top to bottom with Ctrl/Cmd + Shift + Enter. Does it run without errors?
Ran cleanly? Y / N
If not, what error appeared:
This section is optional — work through it if you finish early or want to push deeper.
height_cm▶ Try this:
✏️ Your turn: Is leaf height also significantly different between sides? Fill in the results:
t = _____ df = _____ p = _____
Decision:
One-sentence conclusion:
By default t.test() reports a 95% CI (conf.level = 0.95). Try 99%:
▶ Try this:
✏️ Your turn: How did the 99% CI compare to the 95% CI? Why is the 99% CI wider?
95% CI was: _____ to _____
99% CI was: _____ to _____
Why wider:
✏️ Your turn: Think about what would happen if both sides had leaves with weight_g around 5.5 ± 2.0 g. Would the t-test be significant? What would change in the output?
Your prediction:
figures/ folder should containproject/
├── data/
│ └── 2026_06_25_tree_experiment_raw_data.xlsx
├── figures/
│ ├── leaf_weight_boxplot.png <- from Worksheet 03
│ ├── leaf_weight_mean_se.png <- from Worksheet 03
│ └── leaf_weight_boxplot_ttest.png <- from this worksheet
└── scripts/
└── 04_leaf_ttest.R <- your script
library(car), library(readxl), library(tidyverse)?names(tree_df)) or %>%?leveneTest not found? You need library(car) — not just car::leveneTest.t.test() formula: the grouping variable goes on the right of ~ and must have exactly two levels. Check with unique(tree_df$side).💡 Key idea: The five-step framework (hypotheses → normality → variance → test → report) is the same for nearly every parametric test you will ever run. Master it here and it transfers directly to ANOVA and regression.
End of Worksheet 04. Next: Worksheet 05 — one-way ANOVA for comparing more than two groups. ### Recap from Worksheet 03
read_excel()group_by() + summarize() to compute mean, SD, and SE per groupsum(!is.na())stat_summary()var.equal = FALSE, two-tailed)t.test() outputHow to use this worksheet
- Work through each part in order — the steps build on each other. Type the code into your script in Positron and run it line by line.
- Code blocks marked ▶ Run this should be executed as written.
- Blocks marked ✏️ Your turn ask you to write, modify, or interpret something.
- The Going further section is optional if you finish early.
▶ Run this at the top of your script — in this exact order:
⚠️ Watch out!
- If R says
"could not find function 'leveneTest'"
- you forgot
library(car).- Install it once with
install.packages("car"), then load it every sessionlibrary(car)
▶ Run this:
✏️ Your turn: Before anything else — do you remember the structure of this data frame? Fill in from memory, then check with glimpse(tree_df).
Number of rows:
Number of columns:
Column holding the grouping variable (sunny/shady):
Column we are testing today:
💡 Key idea: Always write down your hypotheses before you look at the data or run any test. This is how you stay scientifically honest.
✏️ Your turn: Write the null and alternate hypotheses for leaf weight. Use words first, then symbols.
In words:
H₀ (null hypothesis):
Hₐ (alternate hypothesis):
In symbols (use μ for "mean"):
H₀:
Hₐ:
Test type (circle one): one-tailed / two-tailed
Significance level α:
✏️ Your turn: Why do we use a two-tailed test here even though we predicted shady leaves would be heavier?
Your answer:
▶ Run this:
✏️ Your turn: Record the values you will need for the t-test formula:
Shady: mean = _____ g SD = _____ n = _____
Sunny: mean = _____ g SD = _____ n = _____
Difference in means = _____ g
✏️ Your turn: Looking at the SD values, do the two groups appear to have similar or different variance? (Does one group have much more spread than the other?)
Your observation:
The t-test assumes that data within each group are approximately normally distributed.
▶ Run this:
# Histogram per group — look for bell-shaped distribution
hist_norm_plot <- tree_df %>%
ggplot(aes(x = weight_g, fill = side)) +
geom_histogram(binwidth = 1, color = "white", alpha = 0.8) +
facet_wrap(~side, ncol = 2) +
labs(
title = "Leaf Weight Distribution by Side",
x = "Leaf Weight (g)",
y = "Count"
) +
theme_minimal() +
theme(legend.position = "none")
hist_norm_plot✏️ Your turn: Describe the shape of each histogram:
Sunny side shape (bell-shaped, skewed, flat, bimodal?):
Shady side shape:
Any obvious outliers? Y / N
✏️ Your turn: With only 10 values per group, histograms will look lumpy even if the data are normal. Does this mean we should reject normality? Why or why not?
Your answer:
A QQ (quantile-quantile) plot compares your data’s quantiles to what a perfect normal distribution would look like. Points on the diagonal line = normal.
▶ Run this:
# Q-Q plot — points should fall along the diagonal ----
qq_norm_plot <- tree_df %>%
ggplot(aes(sample = weight_g, color = side)) +
stat_qq() +
stat_qq_line(color = "black", linewidth = 0.8) +
facet_wrap(~side, scales = "free") +
labs(
title = "Normal Q-Q Plots by Side",
x = "Theoretical Quantiles",
y = "Sample Quantiles"
) +
theme_minimal() +
theme(legend.position = "none")
qq_norm_plot✏️ Your turn: Do the points for each group fall approximately along the diagonal line? What would it mean if they curved strongly away at the ends?
Sunny side — on the line? Y / mostly / no
Shady side — on the line? Y / mostly / no
What strong curvature at the ends would indicate:
The Shapiro-Wilk test formally tests whether a sample could have come from a normal distribution.
▶ Run this:
✏️ Your turn: Record the results and state your decision:
Sunny: W = _____ p = _____ Decision (normal / not normal):
Shady: W = _____ p = _____ Decision (normal / not normal):
✏️ Your turn: With n = 10 per group, the Shapiro-Wilk test has low statistical power. What does that mean in plain language for interpreting this result?
Your answer:
⚠️ Watch out! Even if Shapiro-Wilk returns p < 0.05 with very small samples, the t-test may still be appropriate — the QQ plot and histogram give important visual context. None of these checks alone is definitive.
Levene’s test checks whether the two groups have the same population variance.
▶ Run this:
✏️ Your turn: Record the result and decide:
F-statistic = _____ p = _____
Decision (variances equal / not equal):
✏️ Your turn: We are going to use Welch’s t-test (var.equal = FALSE) regardless of Levene’s result. In your own words, why is Welch’s the safe default even when Levene’s says variances are equal?
Your answer:
Now that we have checked our assumptions, we can run the test.
▶ Run this:
✏️ Your turn: Copy the full output below:
Paste or write the t.test() output here:
▶ Run this to extract individual values:
# Pull specific values from the model object ---------
cat("t-statistic:", round(leaf_ttest_model$statistic, 3), "\n")
cat("df (Welch): ", round(leaf_ttest_model$parameter, 2), "\n")
cat("p-value: ", signif(leaf_ttest_model$p.value, 3), "\n")
cat("95% CI: ", round(leaf_ttest_model$conf.int[1],2),
"to", round(leaf_ttest_model$conf.int[2], 2), "g\n")
cat("Mean shady: ", round(leaf_ttest_model$estimate[1],2), "g\n")
cat("Mean sunny: ", round(leaf_ttest_model$estimate[2],2), "g\n")✏️ Your turn: Match each piece of output to its meaning:
t-statistic = _____
→ This is large/small (circle one) because the difference in means
is large/small relative to the variability (circle one).
df = _____
→ This is a non-integer. Why? (hint: Welch-Satterthwaite equation)
p-value = _____
→ In plain language, this means: if H₀ were true, the probability
of seeing a t-statistic this extreme just by chance is _____.
95% CI = _____ to _____ g
→ This CI is for the ________________ (the true difference in means).
It does / does not include zero (circle one).
What does it mean when the CI excludes zero?
Mean shady = _____ g
Mean sunny = _____ g
▶ Run this:
# Reproduce the t-statistic manually -----------------
m_sha <- recap_df$mean_wt[recap_df$side == "shady"]
m_sun <- recap_df$mean_wt[recap_df$side == "sunny"]
s_sha <- recap_df$sd_wt[recap_df$side == "shady"]
s_sun <- recap_df$sd_wt[recap_df$side == "sunny"]
n_sha <- recap_df$n[recap_df$side == "shady"]
n_sun <- recap_df$n[recap_df$side == "sunny"]
# Welch's SE of the difference
se_diff <- sqrt((s_sha^2 / n_sha) + (s_sun^2 / n_sun))
# t-statistic
t_manual <- (m_sha - m_sun) / se_diff
cat("Difference in means:", round(m_sha - m_sun, 2), "g\n")
cat("SE of difference: ", round(se_diff, 4), "\n")
cat("Manual t: ", round(t_manual, 3), "\n")✏️ Your turn: Does your manually calculated t match the value from t.test()? (They may differ very slightly due to rounding in recap_df.)
Manual t = _____
t.test() t = _____
Match? Y / close / no
✏️ Your turn: Write the Welch’s t-test formula below and label each part:
t = _________________
Numerator means:
Denominator means:
✏️ Your turn: State your decision and conclusion. Use the formal language of hypothesis testing.
Our p-value:
Our α:
Decision (circle one): REJECT H₀ / FAIL TO REJECT H₀
In one sentence, what does this mean biologically?
💡 Key idea: We never say “H₀ is true” or “H₀ is false.” We say “we reject H₀” (evidence was strong enough) or “we fail to reject H₀” (evidence was not strong enough). Statistics gives us a decision rule, not certainty.
▶ Run this:
# Boxplot with t and p embedded in subtitle ----------
leaf_box_04_plot <- tree_df %>%
ggplot(aes(x = side, y = weight_g, fill = side)) +
geom_boxplot(alpha = 0.5, outlier.shape = NA) +
geom_point(
position = position_jitter(width = 0.15, seed = 42),
alpha = 0.6, size = 2.5
) +
labs(
title = "Leaf Weight by Tree Side",
subtitle = paste0("Welch's t-test: t = ",
round(leaf_ttest_model$statistic, 2),
", p = ",
signif(leaf_ttest_model$p.value, 2)),
x = "Side of Tree",
y = "Leaf Weight (g)"
) +
theme_minimal() +
theme(legend.position = "none")
leaf_box_04_plot✏️ Your turn: The subtitle line uses paste0() to build the label from the model object. What happens to the subtitle if you re-run this with different data? Why is this better than typing the numbers yourself?
Your answer:
▶ Run this:
✏️ Your turn: Write a complete results sentence as you would in a lab report or paper. Include:
Use this template:
"[Group] leaves were significantly [heavier / lighter] than [group] leaves
(Welch's two-sample t-test: t(___) = ___, p = ___).
Mean ± SE: [side 1] = ___ ± ___ g, [side 2] = ___ ± ___ g."
Write your sentence here:
Your results sentence:
💡 Key idea: Never write
p = 0.000. Writep < 0.001instead. A p-value is never exactly zero — it is just too small to display at three decimal places.
At this point you should be able to:
✏️ Your turn: Run your entire script from top to bottom with Ctrl/Cmd + Shift + Enter. Does it run without errors?
Ran cleanly? Y / N
If not, what error appeared:
This section is optional — work through it if you finish early or want to push deeper.
height_cm▶ Try this:
✏️ Your turn: Is leaf height also significantly different between sides? Fill in the results:
t = _____ df = _____ p = _____
Decision:
One-sentence conclusion:
By default t.test() reports a 95% CI (conf.level = 0.95). Try 99%:
▶ Try this:
✏️ Your turn: How did the 99% CI compare to the 95% CI? Why is the 99% CI wider?
95% CI was: _____ to _____
99% CI was: _____ to _____
Why wider:
✏️ Your turn: Think about what would happen if both sides had leaves with weight_g around 5.5 ± 2.0 g. Would the t-test be significant? What would change in the output?
Your prediction:
figures/ folder should containproject/
├── data/
│ └── 2026_06_25_tree_experiment_raw_data.xlsx
├── figures/
│ ├── leaf_weight_boxplot.png <- from Worksheet 03
│ ├── leaf_weight_mean_se.png <- from Worksheet 03
│ └── leaf_weight_boxplot_ttest.png <- from this worksheet
└── scripts/
└── 04_leaf_ttest.R <- your script
library(car), library(readxl), library(tidyverse)?names(tree_df)) or %>%?leveneTest not found? You need library(car) — not just car::leveneTest.t.test() formula: the grouping variable goes on the right of ~ and must have exactly two levels. Check with unique(tree_df$side).💡 Key idea: The five-step framework (hypotheses → normality → variance → test → report) is the same for nearly every parametric test you will ever run. Master it here and it transfers directly to ANOVA and regression.
End of Worksheet 04. Next: Worksheet 05 — one-way ANOVA for comparing more than two groups.