13  Comparing Multiple Groups


Comparing Two Groups split a cardinal outcome by a two-level factor and tested whether the two groups differed. This chapter extends that idea to \(G\) groups: how to describe several groups at once, and then two ways to test whether they differ, one built on means and one built on ranks.

13.1 Describing Groups

Factor Variables

So far the variables were all cardinal, where \(4-3=2-1\); many useful variables are instead labels of group membership that do not do arithmetic this way.

ImportantKey Definition

A factor variable labels which group each observation belongs to. Its possible values are called levels.

Factor variables are useful for splitting a dataset into groups that can be compared: regions, industries, treatment arms. They come in two flavors:

  • Ordered: refers to ordinal data. The difference between units means something, but not always the same thing. For example, \(4th - 3rd \neq 2nd - 1st\).
  • Unordered: refers to categorical data. The difference between units is meaningless. For example, \(D-C=?\)

One common case is if you have observations of individuals over time periods, then you may have two factor variables: an unordered factor that indicates who an individual is, and an ordered factor that indicates the time period. There are many other cases you see factor variables, including spatial ID’s in purely cross sectional data.

Be careful not to handle categorical data as if they were cardinal. E.g., generate city data with Leipzig=1, Lausanne=2, LosAngeles=3, … and then include city as if it were a cardinal number (that’s a big no-no). The same applies to ordinal data; PopulationLeipzig=2, PopulationLausanne=3, PopulationLosAngeles=1.

The built-in state.region vector labels the US Census region of each state, in the same order as the rows of USArrests, so it can be paired directly with the murder rates.

Code
# Outcome: murder arrests per 100K, one value per state
y_hat <- USArrests[, 'Murder']
# Group: the four-level region factor, in the same state order as USArrests.
# Its levels are the values the categorical variable can take.
region <- state.region
levels(region)
## [1] "Northeast"     "South"         "North Central" "West"
table(region)
## region
##     Northeast         South North Central          West 
##             9            16            12            13

Group Summaries

A factor with \(G\) levels splits a cardinal variable into \(G\) conditional distributions, one per group. The group summaries from Bivariate Distributions apply directly, and with more than two or three groups, side-by-side boxplots are usually the clearest figure, ordered here by median murder rate from lowest to highest.

Code
# Order regions by their median murder rate, lowest to highest
region_order <- names(sort(tapply(y_hat, region, median)))
region <- factor(region, levels=region_order)

# Visual summary: one boxplot per region, box width scaled to group size
boxplot(y_hat ~ region,
    col=hcl.colors(4, alpha=.45), varwidth=TRUE,
    ylab='Murder arrests (per 100,000)', xlab='Region', main=NA)
title('Murder rates by region', font.main=1)

A boxplot shows five numbers per group; varwidth=TRUE also scales each box’s width to \(\sqrt{n_g}\), so a region with more states draws a wider box. The ECDF shows every observation, and drawing one per group on the same axes compares the whole distributions at once: a curve further to the right has higher values at every quantile. The same low-to-high order carries over, so South is last in the legend, and each line’s thickness scales with its group size the same way the boxes do.

Code
# One ECDF per region, on the same axes (region is already ordered by median)
line_cols <- hcl.colors(4)
n_g <- table(region)
lwd_g <- 1.5 + 2.5 * (n_g - min(n_g)) / (max(n_g) - min(n_g))

par(mar=c(4, 4, 4.5, 1))
plot(NULL, xlim=range(y_hat), ylim=c(0, 1),
    xlab='Murder arrests (per 100,000)', ylab='Proportion of states', main=NA)
for (g in seq_along(levels(region))) {
    F_g_hat <- ecdf(y_hat[region == levels(region)[g]])
    plot(F_g_hat, add=TRUE, col=line_cols[g], lwd=lwd_g[g],
        verticals=TRUE, do.points=FALSE, col.01line=NA)
}
legend('topleft', inset=c(0, -.2), xpd=TRUE, ncol=2, bty='n',
    legend=paste0(levels(region), ' (n=', n_g, ')'), col=line_cols, lty=1, lwd=lwd_g)

The South’s curve sits to the right of the others over its whole range, so its higher center in the boxplot is not driven by a few states.

A table of group sizes and centers puts numbers on the figure. The conditional mean \(\hat{m}_{Y}(g)\) is the average of \(\hat{y}_{i}\) among the \(n_g\) observations in group \(g\), and aggregate() computes it for every group at once.

Code
# Group size, mean, and median of the murder rate by region
summary_fun <- function(y_hat) {
    out <- c(n=length(y_hat), mean=mean(y_hat), median=median(y_hat))
    return(out)
}
aggregate(y_hat ~ region, FUN=summary_fun)
##          region   y_hat.n y_hat.mean y_hat.median
## 1     Northeast  9.000000   4.700000     3.400000
## 2 North Central 12.000000   5.700000     5.150000
## 3          West 13.000000   7.030769     6.800000
## 4         South 16.000000  11.706250    12.850000

The South has the highest center and the Northeast the lowest. Whether such differences are larger than chance alone would produce is the question the rest of this chapter takes up.

13.2 Equal Means (ANOVA)

In Comparing Groups, we tested whether two groups had the same mean. We now extend this to \(G\) groups. The Analysis of Variance (ANOVA) approach asks: are the observed differences in group means larger than we would expect from random variation alone?

Decomposition

If group means differ, some of the spread in \(Y\) must come from differences between groups rather than within them. We can split each piece out.

ImportantKey Definition

Label each observation \(\hat{y}_{ig}\) for individual \(i\) in group \(g\), where group \(g=1,\dots,G\) has \(n_g\) observations with group mean \(\hat{m}_{Y}(g)\), and the grand mean across all \(n=\sum_g n_g\) observations is \(\hat{m}_{Y}\). The ANOVA decomposition splits the total sum of squares into between-group and within-group pieces: \[\begin{aligned} \underbrace{\sum_{g=1}^{G}\sum_{i=1}^{n_g}(\hat{y}_{ig}-\hat{m}_{Y})^2}_{\hat{TSS}} &= \underbrace{\sum_{g=1}^{G} n_g (\hat{m}_{Y}(g)-\hat{m}_{Y})^2}_{\hat{BSS}} \;+\; \underbrace{\sum_{g=1}^{G}\sum_{i=1}^{n_g}(\hat{y}_{ig}-\hat{m}_{Y}(g))^2}_{\hat{WSS}}, \end{aligned}\] where \(\hat{BSS}\) measures variation in group means around the grand mean and \(\hat{WSS}\) measures variation of observations around their own group mean.

The decomposition is useful as the foundation for any test of “do group means differ?”: if they do not, \(\hat{BSS}\) should be small relative to \(\hat{WSS}\). The equality holds exactly because the cross-term \(\sum_{g,i}(\hat{y}_{ig}-\hat{m}_{Y}(g))(\hat{m}_{Y}(g)-\hat{m}_{Y})\) vanishes, since the within-group deviations sum to zero around their own group mean.

In R, aov() fits the ANOVA model and summary() returns the standard ANOVA table. The ANOVA table reports \(\hat{BSS}\) (“Sum Sq” for region), \(\hat{WSS}\) (“Sum Sq” for Residuals).

Code
# Fit ANOVA
fit_aov <- aov(y_hat ~ region)
summary(fit_aov)
##             Df Sum Sq Mean Sq F value   Pr(>F)    
## region       3  391.2   130.4   11.14 1.28e-05 ***
## Residuals   46  538.3    11.7                     
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

For example, suppose we have \(G=3\) groups with the following data:

Group A Group B Group C
4 8 5
5 7 6
6 9 7

Group means: \(\hat{m}_{Y}(A) = 5\), \(\hat{m}_{Y}(B) = 8\), \(\hat{m}_{Y}(C) = 6\). Grand mean: \(\hat{m}_Y = (5+8+6)/3 = 19/3 \approx 6.33\). Each group has \(n_g = 3\).

Between-group sum of squares: \[\begin{aligned} \hat{BSS} &= 3(5-19/3)^2 + 3(8-19/3)^2 + 3(6-19/3)^2 \\ &= 3(16/9) + 3(25/9) + 3(1/9) = 48/9 + 75/9 + 3/9 = 14 \end{aligned}\] Within-group sum of squares: \[\begin{aligned} \hat{WSS} &= [(4-5)^2+(5-5)^2+(6-5)^2] + [(8-8)^2+(7-8)^2+(9-8)^2] \\ & + [(5-6)^2+(6-6)^2+(7-6)^2] = 2+2+2 = 6 \end{aligned}\]

Code
# Data
y0_hat <- c(4, 5, 6, 8, 7, 9, 5, 6, 7)
g0 <- factor(c('A', 'A', 'A', 'B', 'B', 'B', 'C', 'C', 'C'))

# Verify by hand
m0_hat <- mean(y0_hat)
m0_g_hat <- aggregate(y0_hat, list(g0), mean)$x
n0_g <- aggregate(y0_hat, list(g0), length)$x

BSS <- sum(n0_g * (m0_g_hat - m0_hat)^2)
WSS <- sum(aggregate(y0_hat, list(g0), function(y_hat) sum((y_hat - mean(y_hat))^2))$x)
cbind(BSS, WSS)
##      BSS WSS
## [1,]  14   6

The F-statistic

We have the decomposition; now we need a single number that tells us when the between-group piece is “large” relative to the within-group piece.

ImportantKey Definition

The ANOVA \(F\)-statistic is the ratio of average between-group variation to average within-group variation: \[\hat{F} = \frac{\hat{BSS}/(G-1)}{\hat{WSS}/(n-G)}.\] Writing \(Y_{ig}\) for the random outcome of individual \(i\) in group \(g\), the null is \(H_0: \mathbb{E}[Y_{i1}]=\mathbb{E}[Y_{i2}]=\dots=\mathbb{E}[Y_{iG}]\). Under the null (with the usual parametric assumptions) it follows an \(F_{G-1, n-G}\) distribution; we can also get a \(p\)-value nonparametrically by bootstrapping the labels.

The ANOVA F-statistic is useful as the global “do any means differ?” test: one number and one \(p\)-value, regardless of how many groups you have. The numerator is the average between-group variation per degree of freedom, the denominator is the average within-group variation, and a large \(\hat{F}\) means group means are more spread out than individual-level noise would predict.

Code
F_fun <- function(y_hat,g){
    m_y_hat <- mean(y_hat)
    m_g_hat <- aggregate(y_hat, list(g), mean)$x
    n_g <- aggregate(y_hat, list(g), length)$x

    BSS <- sum(n_g * (m_g_hat - m_y_hat)^2)
    WSS <- sum(aggregate(y_hat, list(g), function(y_hat) sum((y_hat - mean(y_hat))^2))$x)

    n <- length(y_hat)
    G <- nlevels(g)
    F_obs <- (BSS/(G-1)) / (WSS/(n-G))
    return(F_obs)
}
F_obs <- F_fun(y_hat=y_hat, g=region)
F_obs
## [1] 11.14389

To compute a \(p\)-value, we can use permutation or bootstrap methods to build a null distribution, just as in previous chapters.

Code
# Pairs bootstrap: resample group labels to break the Y--group pairing
n <- length(y_hat)

F_nullboot <- vector(length=999)
for( b in seq_along(F_nullboot)){
    idx <- sample(n, replace = TRUE)
    g_boot <- region[idx]
    F_b <- F_fun(y_hat=y_hat, g=g_boot)
    F_nullboot[b] <- F_b
}

# Bootstrap p-value
p_boot <- mean(F_nullboot >= F_obs)
p_boot
## [1] 0

hist(F_nullboot, breaks = 40,
    col = grey(0.5, 0.5), border = NA,
    freq = FALSE, main = NA, xlab = 'Null Bootstrap F-statistic',
    xlim = range(c(0, F_nullboot, F_obs)))
title('Null Bootstrap F-test', font.main = 1)
abline(v = F_obs, col = rgb(1, 0, 0, .8), lwd = 2)
legend('topright', legend = 'Observed F',
    col = rgb(1, 0, 0, .8), lwd = 2, bty='n')

Pairing the observed murder rates y_hat with a resampled region vector breaks the link between outcome and group: under the null, group labels are exchangeable, so this approximates the distribution of \(\hat{F}\) when no group effect exists.

Under additional parametric assumptions (independent observations, equal variances, normal errors), \(\hat{F}\) (under the null) follows an \(F_{G-1,\;n-G}\) distribution. From that theoretical null distribution, we can then compute p-values (which is the default report method in R).

Code
# Degrees of freedom for the regional data
n <- length(y_hat)
G <- nlevels(region)
p_param <- 1 - pf(F_obs, G-1, n-G)

 # Compare parametric and bootstrap p-values
cbind(F_obs, p_param, p_boot)
##         F_obs      p_param p_boot
## [1,] 11.14389 1.282093e-05      0

The parametric F-test assumes (i) independent observations, (ii) normal errors within each group, and (iii) equal variances across groups (homoscedasticity). If these assumptions are doubtful, then the nonparametric Kruskal-Wallis test (below) is a safer alternative. As a quick diagnostic, visually compare side-by-side boxplots: if the spread differs across groups or the distribution has excessive skew/kurtosis, be cautious with the parametric test.

With \(G-1=2\) and \(n-G=6\) degrees of freedom, we compare to an \(F_{2,6}\) distribution: \(p = 1 - F_{2,6}(7) \approx 0.027\).

Code
# Data (the three-group example from the decomposition above)
y0_hat <- c(4, 5, 6, 8, 7, 9, 5, 6, 7)
g0 <- factor(c('A', 'A', 'A', 'B', 'B', 'B', 'C', 'C', 'C'))

# Computation
summary(aov(y0_hat ~ g0))
##             Df Sum Sq Mean Sq F value Pr(>F)  
## g0           2     14       7       7  0.027 *
## Residuals    6      6       1                 
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

# Verify by hand
n0 <- length(y0_hat)
G0 <- nlevels(g0)
m0_hat <- mean(y0_hat)
m0_g_hat <- aggregate(y0_hat, list(g0), mean)$x
n0_g <- aggregate(y0_hat, list(g0), length)$x
BSS <- sum(n0_g * (m0_g_hat - m0_hat)^2)
WSS <- sum(aggregate(y0_hat, list(g0), function(y_hat) sum((y_hat - mean(y_hat))^2))$x)
Fstat <- (BSS/(G0-1)) / (WSS/(n0-G0))
pval  <- 1 - pf(Fstat, G0-1, n0-G0)
cbind(BSS, WSS, Fstat, pval)
##      BSS WSS Fstat  pval
## [1,]  14   6     7 0.027

13.3 Equal Distributions

ANOVA tests whether group means differ; sometimes the entire distributions differ in ways the mean would miss.

Kruskal-Wallis Test

When data are skewed, heavy-tailed, or only ordinal, a test built on means may overlook differences that show up in the rank order.

ImportantKey Definition

The Kruskal-Wallis test is a nonparametric test for equal distributions across \(G\) groups, testing \(H_0: F_1 = F_2 = \dots = F_G\) against \(H_A:\) at least one \(F_g\) differs (where \(F_g\) is the continuous distribution of group \(g=1,...G\)). It uses the average ranks within each group rather than the raw observations, so it only requires the data to be ordinal. With two groups it reduces to the Mann-Whitney U (equivalently Wilcoxon rank-sum) test.

The Kruskal-Wallis test is useful when the equal-variance normal-error assumption is implausible, as with income, response times, or other heavy-tailed outcomes. Rank-based tests need only the orderings of the data. A significant result tells us that some distribution differs, but not which one; the global rejection has to be followed up with pairwise tests.

To conduct the test, first denote individuals \(i=1,...n\) with overall ranks \(\hat{r}_1,....\hat{r}_{n}\). Each individual belongs to group \(g=1,...G\), and each group \(g\) has \(n_{g}\) individuals with average rank \(\bar{r}_{g} = \sum_{i} \hat{r}_{i} /n_{g}\). The Kruskal Wallis statistic is \[\begin{aligned} \hat{KW} &= (n-1) \frac{\sum_{g=1}^{G} n_{g}( \bar{r}_{g} - \bar{r} )^2 }{\sum_{i=1}^{n} ( \hat{r}_{i} - \bar{r} )^2}, \end{aligned}\] where \(\bar{r} = \frac{n+1}{2}\) is the grand mean rank.

In the special case with only two groups, the Kruskal Wallis test reduces to the Mann–Whitney U test (also known as the Wilcoxon rank-sum test). In this case, we can write the hypotheses in terms of individual outcomes in each group, \(Y_i\) in one group \(Y_j\) in the other; \(H_0: Prob(Y_i > Y_j)=Prob(Y_j > Y_i)\) versus \(H_A: Prob(Y_i > Y_j) \neq Prob(Y_j > Y_i)\). The corresponding test statistic is \[\begin{aligned} \hat{U} &= \min(\hat{U}_1, \hat{U}_2) \\ \hat{U}_g &= \sum_{i~\text{in}~g}\sum_{j~\text{in}~-g} \Bigl[\mathbf 1( \hat{y}_{i} > \hat{y}_{j}) + \tfrac12\mathbf 1(\hat{y}_{i} = \hat{y}_{j})\Bigr]. \end{aligned}\]

Interpretation template:

  • If the Kruskal-Wallis p-value is large, keep the global null and stop.
  • If the p-value is small, report that at least one group differs.
  • Report both statistical and practical importance (distribution plots + effect size summaries), not only p-values.
Code
kw <- kruskal.test(y_hat ~ region)
kw
## 
##  Kruskal-Wallis rank sum test
## 
## data:  y_hat by region
## Kruskal-Wallis chi-squared = 19.345, df = 3, p-value = 0.000232

A significant Kruskal-Wallis result tells us that some group differs, not which. To answer which, use a post-hoc pairwise test together with a multiple-comparisons correction. Here, pairwise.wilcox.test() runs a Mann-Whitney test on each pair of regions, and the Holm correction adjusts the \(p\)-values for the number of pairs tested.

Code
pairwise.wilcox.test(y_hat, region, p.adjust.method='holm')
## 
##  Pairwise comparisons using Wilcoxon rank sum exact test 
## 
## data:  y_hat and region 
## 
##               Northeast North Central West  
## North Central 0.6012    -             -     
## West          0.2048    0.6012        -     
## South         0.0011    0.0032        0.0082
## 
## P value adjustment method: holm

Looking back at the boxplot in Describing Groups, which region stands out?

Worked example: \(G=3\) groups with values \(A=\{1,2,3\}\), \(B=\{4,5,6\}\), \(C=\{7,8,9\}\).

Pooling and ranking gives the ranks \(1,\ldots,9\) in order. Group rank averages: \(\bar{r}_A=2\), \(\bar{r}_B=5\), \(\bar{r}_C=8\). Grand mean rank: \(\bar{r}=(n+1)/2=5\). Plug into \(\hat{KW}\) with \(n=9\), \(n_g=3\): \[ \hat{KW} = (n-1) \cdot \frac{3(2-5)^2 + 3(5-5)^2 + 3(8-5)^2}{\sum_{i=1}^{9}(\hat{r}_i - 5)^2} = 8 \cdot \frac{27 + 0 + 27}{60} = 7.2. \]

Code
y0_hat <- c(1,2,3, 4,5,6, 7,8,9)
g0 <- factor(rep(c('A','B','C'), each=3))
kruskal.test(y0_hat ~ g0)
## 
##  Kruskal-Wallis rank sum test
## 
## data:  y0_hat by g0
## Kruskal-Wallis chi-squared = 7.2, df = 2, p-value = 0.02732

For the two-group case, the Mann-Whitney statistic counts pairwise wins. Compare \(A=\{1,3,5\}\) to \(B=\{2,4,6\}\): out of \(3\times 3 = 9\) pairs, \(A\) wins three (\((3,2), (5,2), (5,4)\)), so \(\hat{U}_A=3\), \(\hat{U}_B=6\), giving \(\hat{U}=\min(3,6)=3\).

Code
a0_hat <- c(1,3,5); b0_hat <- c(2,4,6)
wilcox.test(a0_hat, b0_hat)
## 
##  Wilcoxon rank sum exact test
## 
## data:  a0_hat and b0_hat
## W = 3, p-value = 0.7
## alternative hypothesis: true location shift is not equal to 0

13.4 Exercises

  1. Comment the script you wrote for this chapter, then restart R and check that it runs from a clean session, then check the script with AI as explained in Working with AI. Write three sentences from memory on the main statistical idea of this chapter, and ask the assistant what is wrong, vague, or missing. Finish with your own questions about whatever you found hardest.

  2. Load Wages1 from the Ecdat package. Draw a boxplot of wage by sex, then add an ECDF of wage for each sex on the same axes. Compute the mean and median wage for each sex with aggregate(). Does one sex’s ECDF sit to the right of the other’s over the whole range, or do the curves cross?

  3. An ANOVA \(F\)-test rejects the null that all group means are equal. Does this tell you which group is different, or only that at least one differs?

  4. Using the USArrests dataset with state.region as the grouping variable, compute the between-group sum of squares (\(\hat{BSS}\)) and within-group sum of squares (\(\hat{WSS}\)) for Assault by hand (using aggregate()). Verify your \(\hat{F}\) statistic matches the output of summary(aov(Assault ~ state.region, data = ...)).

Further Reading

Recall

This chapter first described several groups with boxplots, ECDFs, and a table of group means, then tested whether the description reflected more than chance. ANOVA’s decomposition of variation between and within groups produced an \(F\)-statistic; the worked three-group example with \(A=\{4,5,6\}, B=\{8,7,9\}, C=\{5,6,7\}\) made the arithmetic concrete, giving \(\hat{BSS}=14\) and \(\hat{WSS}=6\) by hand, which aov(y0_hat ~ g0) reproduces exactly. The Kruskal-Wallis test replaced outcomes with ranks when the equal-variance normal-error assumptions were doubtful.