Lab 20: Kruskal-Wallis & Non-parametric Tests

BI 255 · Bethune-Cookman University · Fall 2026

Author

Dr. Rosie Stanbrook-Buyer

Published

October 12, 2026


When Parametric Assumptions Fail

Parametric tests (t-tests, ANOVA) rest on assumptions — primarily that the data are approximately normally distributed and that group variances are roughly equal. In biology, these assumptions are frequently violated: antibiotic minimum inhibitory concentrations (MICs) span several orders of magnitude, pain scores and Likert ratings are ordinal, and ecological abundance data are heavily right-skewed with many zeroes.

Non-parametric tests make no assumptions about the underlying distribution. They work by ranking the data and testing whether those ranks are distributed consistently across groups. They lose some statistical power relative to their parametric counterparts when assumptions are met, but they are valid across a much wider range of situations.

NoteLearning Objectives

By the end of this lab you will be able to:

  1. Decide when a non-parametric test is more appropriate than a parametric one
  2. Run the Wilcoxon rank-sum test (Mann-Whitney U) for two independent groups
  3. Run the Wilcoxon signed-rank test for paired data
  4. Run the Kruskal-Wallis test for three or more independent groups
  5. Apply the Dunn test as a post-hoc comparison after Kruskal-Wallis
  6. Explain what it means to test on ranks rather than raw values

Part 1: When to Use Non-parametric Tests

ImportantDecision Checklist: Parametric vs Non-parametric

Use non-parametric tests when any of these apply:

  • Sample size is small (n < 15–20) and data are clearly non-normal
  • Data are ordinal (ranked scales, pain scores, Likert ratings)
  • Data contain extreme outliers that are genuine measurements (not errors)
  • The distribution is heavily skewed and transformation does not help
  • Data are proportions or counts that cannot take negative values and cluster near a boundary

Use parametric tests (with appropriate transformations if needed) when:

  • n ≥ 30 and the CLT provides approximate normality of means
  • Data are continuous and approximately symmetric
  • The normal distribution is a biologically appropriate model

Non-parametric tests are not automatically safer — they are less powerful than parametric tests when assumptions are met.


Part 2: The Wilcoxon Rank-Sum Test (Mann-Whitney U)

The Wilcoxon rank-sum test (also called the Mann-Whitney U test — they are equivalent) compares two independent groups without assuming normality. It tests whether the two groups tend to come from the same distribution.

How It Works — Briefly

  1. Pool all observations from both groups and rank them from smallest to largest
  2. Sum the ranks within each group
  3. Test whether the rank sums are consistent with the two groups coming from the same distribution

Example: Minimum Inhibitory Concentrations (MICs)

MICs (the lowest concentration of antibiotic that inhibits bacterial growth) are reported on a doubling scale (0.25, 0.5, 1, 2, 4, 8, 16… µg/mL) — an inherently skewed, bounded distribution unsuitable for a t-test.

set.seed(42)
# MICs (ug/mL) for two bacterial isolates against ciprofloxacin
# Isolate A: susceptible  (MICs cluster at low end)
# Isolate B: resistant    (MICs elevated)
mic_A <- sample(c(0.03, 0.06, 0.125, 0.25, 0.5, 1),
                size = 20, replace = TRUE,
                prob = c(0.20, 0.30, 0.25, 0.15, 0.07, 0.03))

mic_B <- sample(c(0.5, 1, 2, 4, 8, 16, 32),
                size = 20, replace = TRUE,
                prob = c(0.10, 0.15, 0.20, 0.25, 0.15, 0.10, 0.05))

cat("Isolate A (susceptible) MICs:\n")
Isolate A (susceptible) MICs:
sort(mic_A)
 [1] 0.030 0.030 0.030 0.030 0.030 0.030 0.060 0.060 0.060 0.060 0.125 0.125
[13] 0.125 0.125 0.250 0.500 0.500 0.500 0.500 1.000
cat("\nIsolate B (resistant) MICs:\n")

Isolate B (resistant) MICs:
sort(mic_B)
 [1]  0.5  0.5  0.5  0.5  1.0  2.0  2.0  2.0  4.0  4.0  4.0  4.0  4.0  8.0  8.0
[16]  8.0 16.0 16.0 16.0 32.0
cat("Isolate A: median =", median(mic_A), "ug/mL\n")
Isolate A: median = 0.0925 ug/mL
cat("Isolate B: median =", median(mic_B), "ug/mL\n")
Isolate B: median = 4 ug/mL
# Visualise — notice the skewed, discrete nature of MIC data
library(ggplot2)
mic_df <- data.frame(
  mic     = c(mic_A, mic_B),
  isolate = rep(c("Isolate A (susceptible)", "Isolate B (resistant)"), each = 20)
)

ggplot(mic_df, aes(x = isolate, y = mic, colour = isolate)) +
  geom_jitter(width = 0.15, size = 3, alpha = 0.8) +
  stat_summary(fun = median, geom = "crossbar", width = 0.4,
               fatten = 2, colour = "black") +
  scale_y_log10(breaks = c(0.03, 0.06, 0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32)) +
  scale_colour_manual(values = c("Isolate A (susceptible)" = "#2C5F8A",
                                 "Isolate B (resistant)"   = "#C8102E")) +
  labs(title = "Ciprofloxacin MICs for Two Bacterial Isolates",
       x     = "Isolate",
       y     = "MIC (ug/mL, log scale)") +
  theme_classic(base_size = 13) +
  theme(legend.position = "none")

# Wilcoxon rank-sum test (two independent groups)
# H₀: the MIC distributions of the two isolates are identical
# H₁: one isolate tends to have higher MICs

wilcox.test(mic_B, mic_A, alternative = "greater")

    Wilcoxon rank sum exact test

data:  mic_B and mic_A
W = 387.5, p-value = 1.625e-09
alternative hypothesis: true location shift is greater than 0
NoteInterpreting wilcox.test() Output
Element Meaning
W The Wilcoxon test statistic (sum of ranks from group 1)
p-value Probability of this W or more extreme if H₀ is true
alternative hypothesis Direction of the test you specified

A significant p-value means the two groups differ in their distributions (specifically, one tends to produce larger values). The non-parametric equivalent of the mean difference is the median difference or Hodges-Lehmann estimator.


Part 3: The Wilcoxon Signed-Rank Test (Paired Non-parametric)

When data are paired and non-normal, use the Wilcoxon signed-rank test instead of a paired t-test. It ranks the absolute values of the within-pair differences and accounts for their signs.

set.seed(77)
# Pain scores (0–10 visual analogue scale) before and after acupuncture
# (n = 18 patients; ordinal data — non-parametric is appropriate)
pain_before <- c(7, 8, 6, 9, 7, 5, 8, 6, 9, 7, 8, 6, 7, 5, 8, 9, 6, 7)
pain_after  <- pain_before - sample(c(-1, 0, 1, 2, 3, 4), 18, replace = TRUE,
                                     prob = c(0.05, 0.10, 0.20, 0.30, 0.25, 0.10))
pain_after  <- pmax(0, pmin(10, pain_after))  # Clamp to 0-10 scale

cat("Before: median =", median(pain_before), "\n")
Before: median = 7 
cat("After:  median =", median(pain_after),  "\n")
After:  median = 6 
cat("Differences (after - before):", sort(pain_after - pain_before), "\n")
Differences (after - before): -4 -3 -3 -3 -3 -2 -2 -2 -1 -1 -1 -1 -1 0 0 0 1 1 
# Wilcoxon signed-rank test for paired data
# H₀: the median difference = 0 (no effect of acupuncture)
# H₁: pain scores decrease after acupuncture
wilcox.test(pain_after, pain_before,
            paired      = TRUE,
            alternative = "less")

    Wilcoxon signed rank exact test

data:  pain_after and pain_before
V = 14, p-value = 0.0009766
alternative hypothesis: true location shift is less than 0
# Visualise paired data
pain_df <- data.frame(
  score    = c(pain_before, pain_after),
  timepoint = rep(c("Before", "After"), each = 18),
  patient  = rep(1:18, 2)
)
pain_df$timepoint <- factor(pain_df$timepoint, levels = c("Before", "After"))

ggplot(pain_df, aes(x = timepoint, y = score)) +
  geom_line(aes(group = patient), colour = "grey70") +
  geom_jitter(aes(colour = timepoint), width = 0.05, size = 2.5) +
  scale_colour_manual(values = c("Before" = "#C8102E", "After" = "#2C5F8A")) +
  labs(title = "Pain Scores Before and After Acupuncture",
       x     = "Timepoint",
       y     = "Pain Score (0–10 VAS)") +
  theme_classic(base_size = 13) +
  theme(legend.position = "none")


Part 4: Kruskal-Wallis Test — Three or More Groups

The Kruskal-Wallis test is the non-parametric equivalent of one-way ANOVA. It extends the Wilcoxon rank-sum test to three or more independent groups.

Example: Antibiotic Susceptibility Across Three Species

set.seed(303)
# Zone of inhibition (mm) for three bacterial species against ampicillin
# Skewed and heterogeneous — ANOVA assumptions may not hold
species_A <- round(rlnorm(18, meanlog = 2.8, sdlog = 0.4))
species_B <- round(rlnorm(18, meanlog = 2.2, sdlog = 0.6))
species_C <- round(rlnorm(18, meanlog = 3.3, sdlog = 0.3))

bact_df <- data.frame(
  zone    = c(species_A, species_B, species_C),
  species = rep(c("Species A", "Species B", "Species C"), each = 18)
)

# Check normality
by(bact_df$zone, bact_df$species, shapiro.test)
bact_df$species: Species A

    Shapiro-Wilk normality test

data:  dd[x, ]
W = 0.92673, p-value = 0.17

------------------------------------------------------------ 
bact_df$species: Species B

    Shapiro-Wilk normality test

data:  dd[x, ]
W = 0.69211, p-value = 6.475e-05

------------------------------------------------------------ 
bact_df$species: Species C

    Shapiro-Wilk normality test

data:  dd[x, ]
W = 0.97454, p-value = 0.8774
# Visualise
ggplot(bact_df, aes(x = species, y = zone, fill = species)) +
  geom_violin(trim = FALSE, alpha = 0.5) +
  geom_jitter(aes(colour = species), width = 0.1, size = 2, alpha = 0.8) +
  scale_fill_manual(values   = c("Species A" = "#2C5F8A",
                                 "Species B" = "#5B8DB8",
                                 "Species C" = "#1A3D5C")) +
  scale_colour_manual(values = c("Species A" = "#1A3D5C",
                                 "Species B" = "#2C5F8A",
                                 "Species C" = "#0D2035")) +
  labs(title = "Ampicillin Zone of Inhibition Across Three Bacterial Species",
       x     = "Species",
       y     = "Zone of Inhibition (mm)") +
  theme_classic(base_size = 13) +
  theme(legend.position = "none")

# Kruskal-Wallis test
# H₀: all three species have the same distribution of inhibition zones
kruskal.test(zone ~ species, data = bact_df)

    Kruskal-Wallis rank sum test

data:  zone by species
Kruskal-Wallis chi-squared = 27.658, df = 2, p-value = 9.866e-07
NoteInterpreting Kruskal-Wallis
Element Meaning
Kruskal-Wallis chi-squared The test statistic (approximately chi-squared distributed)
df Degrees of freedom = k − 1 (number of groups minus 1)
p-value Probability of this statistic or larger if all groups have the same distribution

A significant Kruskal-Wallis tells you some groups differ. For pairwise comparisons, use a post-hoc procedure such as Dunn’s test.


Part 5: Dunn’s Test for Post-hoc Comparisons

After a significant Kruskal-Wallis, use Dunn’s test to identify which specific pairs of groups differ, with adjustment for multiple comparisons.

# Install if needed: install.packages("dunn.test")
# Alternative using base R: pairwise.wilcox.test()
pairwise.wilcox.test(bact_df$zone, bact_df$species,
                     p.adjust.method = "BH")   # Benjamini-Hochberg correction

    Pairwise comparisons using Wilcoxon rank sum exact test 

data:  bact_df$zone and bact_df$species 

          Species A Species B
Species B 0.00031   -        
Species C 0.00094   1.9e-06  

P value adjustment method: BH 
TipMultiple Comparison Corrections
Method When to use
"bonferroni" Conservative; use when Type I error control is paramount
"BH" (Benjamini-Hochberg) Less conservative; better power; common in biology
"holm" Step-down Bonferroni; good general choice

For most biological studies, the Benjamini-Hochberg (BH) method provides a good balance between controlling false positives and maintaining power.


Part 6: Parametric vs Non-parametric — A Side-by-Side Comparison


CHOOSING THE RIGHT TEST

Two independent groups:
  Normal data, equal variances → Student's t-test  (var.equal = TRUE)
  Normal data, unequal variances → Welch's t-test  (var.equal = FALSE)
  Non-normal or ordinal        → Wilcoxon rank-sum (wilcox.test)

Two paired groups:
  Normal differences           → Paired t-test      (paired = TRUE)
  Non-normal or ordinal        → Wilcoxon signed-rank (wilcox.test, paired = TRUE)

Three or more independent groups:
  Normal, equal variances      → One-way ANOVA     (aov)
  Normal, unequal variances    → Welch's ANOVA     (oneway.test, var.equal = FALSE)
  Non-normal or ordinal        → Kruskal-Wallis    (kruskal.test)
  Post-hoc (parametric)        → Tukey-Kramer      (TukeyHSD)
  Post-hoc (non-parametric)    → Dunn / Wilcoxon   (pairwise.wilcox.test)

3-Minute Knowledge Check

Close your notes. Answer these on your own — you have 3 minutes. We’ll go through the answers together after.

CautionKnowledge Check Questions

1. You have pain scores (0–10 ordinal scale) from 12 patients before and after a treatment. Which test is most appropriate?

a) Paired t-test    b) Wilcoxon signed-rank test    c) Kruskal-Wallis test    d) Mann-Whitney U test

2. Non-parametric tests work by:

a) Assuming the data follow an exponential distribution    b) Replacing raw values with their ranks and testing those    c) Removing outliers before analysis    d) Transforming the data to normality

3. A Kruskal-Wallis test gives chi-squared(2) = 14.3, p = 0.0008. What is your next step?

4. You apply pairwise.wilcox.test() with p.adjust.method = "BH". What does the Benjamini-Hochberg correction do?

5. True or False: Non-parametric tests are always preferable to parametric tests because they make fewer assumptions.

1. b) Wilcoxon signed-rank test — Pain scores are ordinal (0–10 scale) and the sample is small (n = 12). The data are paired (same patients before and after), so you need the paired version of a non-parametric test. The Wilcoxon signed-rank test is the non-parametric equivalent of the paired t-test.

2. b) Replacing raw values with their ranks and testing those — Non-parametric tests are rank-based. All measurements are ordered from smallest to largest and assigned ranks (1, 2, 3…). The test statistic is computed from these ranks, making it insensitive to the original scale or distribution shape.

3. Run pairwise post-hoc comparisons to find out which specific groups differ — a significant Kruskal-Wallis only tells you that at least one group differs from the rest. Use pairwise.wilcox.test() (with an appropriate multiple-comparison correction) to identify which specific pairs are different.

4. The Benjamini-Hochberg correction controls the false discovery rate (FDR) — the expected proportion of significant results that are false positives. It is less conservative than Bonferroni (which controls the probability of any false positive) but provides better power. It ranks the p-values and applies a graduated correction, allowing more true positives to survive.

5. False — Non-parametric tests have less statistical power than parametric tests when the parametric assumptions are actually met. If your data are approximately normal and variances are equal, you should prefer parametric tests because they are more likely to detect a true effect. Non-parametric tests are preferred when assumptions are violated — not as a universal default.


Lab 20 Checklist

Before you leave, make sure you can:

TipBonus Challenge

A published ecology paper reports species richness (number of species) across five habitat types: urban, suburban, agricultural, secondary forest, and primary forest. Species richness data are typically non-normal (right-skewed, bounded below by 0). Simulate realistic data for all five habitat types using rnbinom() (negative binomial distribution) with different means. Run a Kruskal-Wallis test, then pairwise Wilcoxon tests with BH correction. Produce a violin plot and write a two-sentence ecological interpretation.


Before Next Class (Wednesday)

  • Wednesday (Lab 21): Correlation and Spearman’s rank correlation
  • Quiz 5 is this Friday — covers Labs 17–20 (strip charts, violin plots, t-tests, CIs, Levene’s test, ANOVA, Tukey-Kramer, Kruskal-Wallis, and Wilcoxon tests)
  • Read R for Data Science Ch. 12