Activity: Non-Parametric T-Tests

Rank-based tests

Hands-on activity: testing assumptions, data transformations, Welch’s t-test, Mann-Whitney, and permutation tests on the lake trout dataset.
Author

Bill Perry

Worksheet: Non-Parametric T-Tests

How to use this worksheet

Work through each part in order, at your own pace. Type every line of code yourself into a plain R script — do not copy-paste. Blocks marked ▶ Run this are code you should type and execute. Blocks marked ✏️ Your turn ask you to write, modify, or answer something. Boxes marked 🚀 If you finish early are optional bonus material that goes a bit further than what we covered in lecture.

I This worksheet demonstrates statistical analysis of lake trout mass data from Island Lake and NE 12, focusing on: testing assumptions for parametric tests, transforming data when assumptions aren’t met, running several different tests (standard t-test, log-transformed t-test, Welch’s t-test, Mann-Whitney Wilcoxon, permutation test), and interpreting/reporting results properly.


Part 1 · Setup and the Single-Sample Question

Last time we covered assumptions of parametric tests, α and β errors, power, and mean ± SE plots. Let’s start by exploring only lake NE 12, as if doing a single-sample t-test, then compare NE 12 to Island Lake.

We want to test if the mass of lake trout in NE 12 differs from a mean of 500g.

✏️ Your turn: Write out the hypotheses:

H0: mu = _____ (the mean mass of lake trout in NE 12 is _____ g)
H1: mu ≠ _____ (the mean mass of lake trout in NE 12 is not _____ g)

▶ Run this in your Script:

library(tidyverse)  # For data manipulation and visualization
library(car)         # For statistical tests
library(patchwork)   # For combining plots
library(perm)         # For permutation tests

lt_df <- read_csv("data/lake_trout.csv")
head(lt_df)

✏️ Your turn: How many lakes are in this dataset? ________________________

▶ Run this — calculate the mode (R has no built-in mode() for this):

lt_df %>%
  filter(!is.na(mass_g)) %>%
  group_by(lake, mass_g) %>%
  summarise(count = n(), .groups = "drop_last") %>%
  arrange(desc(count)) %>%
  slice(1) %>%
  select(-count) %>%
  rename(mode_mass = mass_g)

▶ Run this — create a data frame with just NE 12:

ne12_df <- lt_df %>%
  filter(lake == "NE 12") %>%
  filter(!is.na(mass_g))  # Remove any NA values
Tip

🚀 If you finish early: Compute the mean, SD, n, and SE for length_mm across all lakes at once with group_by(lake) %>% summarize(...).

# Write your code here:

Part 2 · Testing Assumptions on NE 12

Before conducting our t-test, we need to verify our data meets the necessary assumptions. Methods to test normality: visual (QQ plots, histograms) and statistical (Shapiro-Wilk test).

▶ Run this — histogram:

ne12_histo_plot <- ggplot(ne12_df, aes(x = mass_g)) +
  geom_histogram(binwidth = 200) +
  labs(x = "mass (g)", y = "Frequency")
ne12_histo_plot

▶ Run this — dotplot, boxplot, and QQ plot:

ne12_dot_plot <- ggplot(ne12_df, aes(x = mass_g, y = "")) +
  geom_dotplot(binwidth = 60, stackdir = "center", fill = "darkblue", dotsize = 0.5) +
  labs(title = "Dotplot", x = "Mass (g)", y = "")
ne12_dot_plot

ne12_box_plot <- ggplot(ne12_df, aes(y = mass_g)) +
  geom_boxplot(fill = "darkblue") +
  labs(y = "Mass (g)", x = "") +
  coord_flip()
ne12_box_plot

ne12_qq_plot <- ggplot(ne12_df, aes(sample = mass_g)) +
  stat_qq(color = "darkblue") +
  stat_qq_line() +
  labs(title = "QQ Plot", x = "Theoretical Quantiles", y = "Sample Quantiles") +
  theme_minimal() +
  theme(plot.title = element_text(hjust = 0.5))
ne12_qq_plot

▶ Run this — combine all four with patchwork:

combined_stats_plot <- (ne12_histo_plot + ne12_dot_plot) / (ne12_box_plot + ne12_qq_plot) +
  plot_annotation(
    theme = theme(plot.title = element_text(hjust = 0.5),
                  plot.subtitle = element_text(hjust = 0.5))
  )
combined_stats_plot

▶ Run this — the formal test (note: really you want to do this on residuals, but here we test the raw values directly):

shapiro_test <- shapiro.test(ne12_df$mass_g)
print(shapiro_test)

✏️ Your turn: Does the QQ plot agree with the Shapiro-Wilk result? Is NE 12’s mass normally distributed? ________________________

Tip

🚀 If you finish early: Rerun the Shapiro-Wilk test on length_mm instead of mass_g for NE 12. Does normality hold for both variables?

# Write your code here:

Part 3 · Comparing Two Lakes — NE 12 vs. Island Lake

Now that we’ve shown NE 12’s mass fails (or passes) normality, let’s explore a comparison of NE 12 and Island Lake mass_g.

▶ Run this — build the two-lake data frame:

in_df <- lt_df %>%
  filter(lake %in% c("NE 12", "Island Lake")) %>%
  filter(!is.na(mass_g))
head(in_df)

▶ Run this — summary stats by lake:

summary_by_lake <- in_df %>%
  group_by(lake) %>%
  summarise(
    n = n(),
    mean_mass = mean(mass_g),
    sd_mass = sd(mass_g),
    se_mass = sd_mass / sqrt(n),
    min_mass = min(mass_g),
    max_mass = max(mass_g)
  )
summary_by_lake

▶ Run this — histograms by lake:

hist_plot <- in_df %>%
  ggplot(aes(x = mass_g, fill = lake)) +
  geom_histogram(bins = 20, alpha = 0.7) +
  labs(x = "Mass (g)", y = "Count") +
  theme_minimal() +
  facet_wrap(~lake, scales = "free_y")
hist_plot

▶ Run this — QQ plots by lake (in a QQ plot, points that follow the line indicate normally distributed data; deviations suggest non-normality):

qq_plot <- in_df %>%
  ggplot(aes(sample = mass_g, color = lake)) +
  stat_qq() +
  stat_qq_line() +
  labs(title = "QQ Plot for Normality Check",
       x = "Theoretical Quantiles", y = "Sample Quantiles") +
  theme_minimal() +
  facet_wrap(~lake)
qq_plot

▶ Run this — the formal test, done for each lake separately (note: Island Lake can look non-normal in the QQ plot but come out close to normal in the Shapiro-Wilk test):

normality_results <- in_df %>%
  group_by(lake) %>%
  summarize(
    shapiro_stat = shapiro.test(mass_g)$statistic,
    shapiro_p_value = shapiro.test(mass_g)$p.value,
    normal_distribution = if_else(shapiro_p_value > 0.05, "Normal", "Non-normal")
  )
print(normality_results)

▶ Run this — Levene’s test for equal variances (H₀: variances are equal across groups; we want this non-significant):

levene_result <- leveneTest(mass_g ~ lake, data = in_df)
print(levene_result)

✏️ Your turn: Based on your normality and Levene’s test results, do these two lakes meet the assumptions for a standard t-test? ________________________

Tip

🚀 If you finish early: Rerun the Levene’s test on length_mm ~ lake instead of mass_g ~ lake. Same conclusion?

# Write your code here:

Part 4 · Data Transformations

A log₁₀ transformation commonly helps right-skewed data like this pass the normality assumption.

▶ Run this:

in_df <- in_df %>%
  mutate(log_mass = log10(mass_g))
head(in_df)

▶ Run this — check if it worked, with a histogram:

log_hist_plot <- in_df %>%
  ggplot(aes(x = log_mass, fill = lake)) +
  geom_histogram(bins = 20, alpha = 0.7) +
  labs(title = "Distribution of Log-Transformed Lake Trout Mass",
       x = "Log10 Mass", y = "Count") +
  theme_minimal() +
  facet_wrap(~lake, scales = "free_y")
log_hist_plot

▶ Run this — a QQ plot of the transformed data (we’ll skip Shapiro-Wilk for a moment):

log_qq_plot <- in_df %>%
  ggplot(aes(sample = log_mass, color = lake)) +
  stat_qq() +
  stat_qq_line() +
  labs(title = "QQ Plot for Log-Transformed Data",
       x = "Theoretical Quantiles", y = "Sample Quantiles") +
  theme_minimal() +
  facet_wrap(~lake)
log_qq_plot

▶ Run this — now the formal tests on the transformed data:

log_normality_results <- in_df %>%
  group_by(lake) %>%
  summarize(
    shapiro_stat = shapiro.test(log10(mass_g))$statistic,
    shapiro_p_value = shapiro.test(log10(mass_g))$p.value,
    normal_distribution = if_else(shapiro_p_value > 0.05, "Normal", "Non-normal")
  )
print(log_normality_results)

levene_log_result <- leveneTest(log_mass ~ lake, data = in_df)
print(levene_log_result)

✏️ Your turn: Did the log transformation fix normality, equal variance, both, or neither? ________________________

Tip

🚀 If you finish early: Try a square-root transformation (sqrt(mass_g)) instead of log₁₀. Build a histogram and rerun the Shapiro-Wilk test — does it do better or worse than the log transform?

# Write your code here:

Part 5 · Running the Tests

Even if the transformation doesn’t fully fix things, let’s run the full set of tests so we can compare them side by side.

▶ Run this — standard two-sample t-test on the raw data:

t_test_result <- t.test(
  mass_g ~ lake,
  data = in_df,
  var.equal = TRUE,
  alternative = "two.sided"
)
print(t_test_result)

▶ Run this — the same t-test on the log-transformed data:

log_t_test_result <- t.test(
  log_mass ~ lake,
  data = in_df,
  var.equal = TRUE,
  alternative = "two.sided"
)
print(log_t_test_result)
Note

When analyzing log-transformed data:

  1. The mean of log-transformed data, back-transformed, gives the geometric mean (not the arithmetic mean).
  2. The back-transformed confidence intervals represent the CI for the geometric mean, and must be calculated carefully.
  3. Report results like: “The geometric mean mass of lake trout in NE 12 was X g (95% CI: Y–Z).”
  4. You can’t just take 10^SE to get the standard error — instead, back-transform mean - se and mean + se separately.

▶ Run this — back-transform to the geometric mean:

back_transformed <- in_df %>%
  group_by(lake) %>%
  summarise(
    n = n(),
    mean_log = mean(log_mass),
    sd_log = sd(log_mass),
    se_log = sd_log / sqrt(n),
    geometric_mean = 10^mean_log,
    lower_se = 10^(mean_log - se_log),
    upper_se = 10^(mean_log + se_log),
    arithmetic_mean = mean(mass_g)
  )

back_transformed %>%
  select(lake, mean_log, geometric_mean, arithmetic_mean)

▶ Run this — plot the back-transformed geometric means (note the error bars are not symmetrical!):

geo_mean_plot <- back_transformed %>%
  ggplot(aes(x = lake, y = geometric_mean, fill = lake)) +
  geom_bar(stat = "identity", width = 0.5, alpha = 0.7) +
  geom_errorbar(aes(ymin = lower_se, ymax = upper_se), width = 0.2, linewidth = 1) +
  labs(title = "Geometric Mean Lake Trout Mass with Standard Error",
       subtitle = "Back-transformed from log10 scale",
       x = "Lake", y = "Geometric Mean Mass (g)") +
  theme_minimal() +
  theme(legend.position = "none")
geo_mean_plot

▶ Run this — Welch’s t-test (doesn’t assume equal variances):

welch_test_result <- t.test(
  mass_g ~ lake,
  data = in_df,
  var.equal = FALSE,
  alternative = "two.sided"
)
print(welch_test_result)

💡 Welch’s t-test is preferred when group variances are unequal (per Levene’s test), sample sizes differ between groups, or you just want a more robust default.

▶ Run this — the Mann-Whitney Wilcoxon test (non-parametric):

wilcox_test_result <- wilcox.test(
  mass_g ~ lake,
  data = in_df,
  alternative = "two.sided"
)
print(wilcox_test_result)

💡 Mann-Whitney is preferred when data isn’t normal even after transformation, you’re comparing medians rather than means, or outliers might distort a t-test — it compares ranks, not raw values.

▶ Run this — a permutation test (balance the sample sizes first):

set.seed(123)  # For reproducibility

island_size <- sum(in_df$lake == "Island Lake")

ne12_sample <- in_df %>%
  filter(lake == "NE 12") %>%
  slice_sample(n = island_size)

balanced_df <- bind_rows(
  ne12_sample,
  in_df %>% filter(lake == "Island Lake")
)

ne12_mass   <- balanced_df %>% filter(lake == "NE 12") %>% pull(mass_g)
island_mass <- balanced_df %>% filter(lake == "Island Lake") %>% pull(mass_g)

perm_test_result <- permTS(
  x = ne12_mass,
  y = island_mass,
  alternative = "two.sided",
  method = "exact.mc",
  control = permControl(nmc = 10000)
)
print(perm_test_result)

💡 Permutation tests are useful for small samples, data that doesn’t meet parametric assumptions, or when you want a test that makes minimal distributional assumptions — and they can test statistics other than the mean.

Tip

🚀 If you finish early: Rerun the permutation test with nmc = 1000 instead of 10000. Does the p-value change meaningfully? What’s the tradeoff of using fewer Monte Carlo replications?

# Write your code here:

Part 6 · Comparing All Results

▶ Run this — build a comparison table:

test_results <- data.frame(
  Test = c("Standard t-test",
           "Log-transformed t-test",
           "Welch's t-test",
           "Mann-Whitney Wilcoxon test"),
  Statistic = c(paste("t =", round(t_test_result$statistic, 2)),
               paste("t =", round(log_t_test_result$statistic, 2)),
               paste("t =", round(welch_test_result$statistic, 2)),
               paste("W =", wilcox_test_result$statistic)),
  p_value = c(t_test_result$p.value,
             log_t_test_result$p.value,
             welch_test_result$p.value,
             wilcox_test_result$p.value),
  Significant = c(t_test_result$p.value < 0.05,
                 log_t_test_result$p.value < 0.05,
                 welch_test_result$p.value < 0.05,
                 wilcox_test_result$p.value < 0.05)
)
test_results

▶ Run this — visualize the raw comparison:

combined_plot <- in_df %>%
  ggplot(aes(x = lake, y = mass_g, fill = lake)) +
  geom_boxplot(alpha = 0.7, outlier.shape = NA) +
  geom_jitter(width = 0.2, alpha = 0.5, size = 2) +
  labs(x = "Lake", y = "Mass (g)") +
  theme_minimal() +
  theme(legend.position = "none")
combined_plot

✏️ Your turn: Do all four tests agree on whether the difference is significant? If any disagree, what does that tell you about how sensitive the conclusion is to test choice? ________________________

When reporting results, include:

  • Standard t-test: “Lake trout from NE 12 had significantly different mass (M = [mean], SD = [SD]) compared to Island Lake (M = [mean], SD = [SD]), t([df]) = [t-value], p = [p-value].”
  • Log-transformed t-test: “After log transformation to meet normality assumptions, lake trout from NE 12 had significantly different mass (geometric mean = [value], 95% CI [lower–upper]) compared to Island Lake (geometric mean = [value], 95% CI [lower–upper]), t([df]) = [t-value], p = [p-value].”
  • Welch’s t-test: “Assuming unequal variances, lake trout from NE 12 had significantly different mass (M = [mean], SD = [SD]) compared to Island Lake (M = [mean], SD = [SD]), Welch’s t([df]) = [t-value], p = [p-value].”
  • Mann-Whitney Wilcoxon test: “Lake trout mass differed significantly between NE 12 (Mdn = [median]) and Island Lake (Mdn = [median]), W = [W-value], p = [p-value].”
  • Permutation test: “Permutation testing (10,000 iterations) revealed significant differences in lake trout mass between NE 12 and Island Lake, p = [p-value].”

✏️ Your turn: Write the standard t-test and Mann-Whitney reporting sentences using your own numbers from Parts 5–6.

________________________________________________________
________________________________________________________
Tip

🚀 If you finish early: Add the permutation test’s p-value as a fifth row to test_results. (Hint: perm_test_result$p.value.)

# Write your code here:

Conclusion

This analysis demonstrates several approaches to comparing mass between lake trout populations. The choice of statistical test depends on whether your data meets the assumptions of parametric tests. When assumptions are violated:

  1. Try transforming the data (e.g., log transformation)
  2. Use Welch’s t-test if variances are unequal
  3. Use non-parametric tests (Mann-Whitney or permutation tests) if data remains non-normal

All methods have strengths and limitations, and consistency of results across methods can strengthen your conclusions.

Note

When to use each test

  • Standard t-test: data is normally distributed with equal variances
  • Log-transformed t-test: raw data is skewed but log-transformation achieves normality
  • Welch’s t-test: variances are unequal
  • Mann-Whitney Wilcoxon test: data is not normal and cannot be transformed to normality
  • Permutation test: sample sizes are small, or assumptions cannot be met

Review and checkpoint

At this point you can:

Note

📤 What to turn in before next class

Upload both of these to the course management system:

  1. Your code — the scripts/ folder (or just 08_nonparametric_t_tests.R)
  2. This worksheet, with your written answers

Part 7 · Take-Home Extension — Crayfish Claw Strength

Due Wednesday, September 30 — before the Study Design & Sampling class.

This time the test itself is the question. You now know five ways to compare two groups — standard t-test, log-transformed t-test, Welch’s t-test, Mann-Whitney, and permutation test. Given a new dataset, you decide which one is appropriate and defend your choice.

Background

Graham & Angilletta (2022) asked whether claw strength in crayfish honestly signals fighting ability. They compared maximum claw strength between stream-dwelling crayfish (which fight often and may use claws to signal) and burrowing crayfish (docile, claws used mainly for digging).

▶ Run this — load the data and collapse to one claw-strength value per individual. Each crayfish has up to two claws measured (left and right) — you already know from the pine needle data why those can’t be treated as independent observations:

claw_df <- read_csv("data/graham_and_angilletta_claw_data.csv")

crayfish_df <- claw_df %>%
  group_by(crayfish.id, lifestyle) %>%
  summarize(maxclawstr_N = mean(maxclawstr.N, na.rm = TRUE), .groups = "drop")

head(crayfish_df)

Your Task

Question: Does maximum claw strength differ between stream-dwelling and burrowing crayfish?

✏️ Your turn: State your hypotheses. Explore the data yourself — normality, variance, sample size, outliers, whatever you need to check — and decide which test from this unit is the right one for this data. Justify your choice, then run it, interpret the result, and write it up as a proper results sentence.

H0 =
________________________________________________________
Ha =
________________________________________________________
Test I will use and why:
________________________________________________________
# Write your code here:
Interpretation:
________________________________________________________
________________________________________________________

Results sentence:
________________________________________________________

Final Figure

Produce one publication-quality figure comparing claw strength between the two lifestyles — proper axis labels, no default ggplot grey background — and export it with ggsave().

# Write your code here:
Note

📤 What to turn in — due Sept 30

Your write-up should include:


Getting unstuck

When code breaks — and it will, that is normal:

  1. Read the error message out loud. R usually names the line and the problem.
  2. Check the usual suspects: did you run library(tidyverse), library(car), library(patchwork), and library(perm)? Spelling? A missing ) or %>% at the start of a line?
  3. ?function_name opens the built-in help page.
  4. Bring the exact error (copy-paste it) to class or office hours.

💡 Key idea: Every working scientist googles error messages daily. Getting stuck is not failing — it is the job.