Skip to main content

13  Comparing Multiple Groups


Comparing Two Groups split a cardinal outcome by a two-level factor and tested whether the two groups differed. We now extend that idea to \(G\) groups.

This chapter asks three questions.

13.1 Describing Groups

A cardinal variable can be grouped into a factor with \(G\) levels. We pair these groups with a cardinal outcome.

Code
## MAIN EXAMPLE
# Load the data: 3294 US workers
data(Wages1, package='Ecdat')
# Outcome: hourly wage in 1980 dollars, one value per worker
y_hat <- Wages1[, 'wage']
# Group: years of schooling, cut into three levels (G=3).
# Its levels are the values the categorical variable can take.
# Here they are fewer than 12 years, exactly 12 years, and more than 12 years.
school_g <- cut(Wages1[, 'school'], breaks=c(0, 11, 12, 16),
    labels=c('<12', '12', '>12'))
levels(school_g)
## [1] "<12" "12"  ">12"
table(school_g)
## school_g
##  <12   12  >12 
## 1353 1188  753

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. 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. Each line’s thickness scales with its group size.

Code
## MAIN EXAMPLE
# One ECDF per schooling group, on the same axes
cols <- c(rgb(.8, 0, 0, .5), rgb(0, .8, 0, .5), rgb(0, 0, .8, .5))
n_g <- table(school_g)
lwd_g <- 1.5 + 2.5 * (n_g - min(n_g)) / (max(n_g) - min(n_g))

# The axis stops just past 20 dollars, so the 16 largest wages are not drawn
plot(NULL, xlim=c(0, 20), ylim=c(0, 1),
    xlab='Hourly wage (1980 dollars)', ylab='Proportion of workers', main=NA)
for (g in seq_along(levels(school_g))) {
    F_g_hat <- ecdf(y_hat[school_g == levels(school_g)[g]])
    plot(F_g_hat, add=TRUE, col=cols[g], lwd=lwd_g[g],
        verticals=TRUE, do.points=FALSE, col.01line=NA)
}
legend('bottomright', bty='n',
    legend=paste0(levels(school_g), ' years (n=', n_g, ')'), col=cols, lty=1, lwd=lwd_g)

Code
# The curve for more than 12 years sits to the right of the other two.
# That holds over the whole range shown.
# So its higher wages are not driven by a few workers.

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
## MAIN EXAMPLE
# Group size, mean, and median of the hourly wage by schooling group
summary_fun <- function(y_hat) {
    out <- c(n=length(y_hat), mean=mean(y_hat), median=median(y_hat))
    return(out)
}
aggregate(y_hat ~ school_g, FUN=summary_fun)
##   school_g     y_hat.n  y_hat.mean y_hat.median
## 1      <12 1353.000000    4.990937     4.593337
## 2       12 1188.000000    5.668354     5.166288
## 3      >12  753.000000    7.275887     6.736894
# Workers with more than 12 years of schooling have the highest center.
# Those with fewer than 12 years have the lowest.

Are the differences between group centers larger than we would expect by chance?

Here is another example, with murder arrest rates across the four census regions, an unordered factor with \(G=4\) levels. To make the bootstrap intervals in the later callouts meaningful, pretend we observed only a random sample of \(35\) states, as we did with workers in Sampling.

Code
# USArrests covers all 50 states, so it is the whole population of states.
# Draw one random sample of 35 of the 50 states, and use only it from here on
set.seed(111)
state_id <- sample(nrow(USArrests), 35)

# Outcome: murder arrests per 100K, one value per sampled state
murder_hat <- USArrests[state_id, 'Murder']
# Group: the four-level region factor, for the same sampled states.
# Its levels are the values the categorical variable can take.
# The built-in state.region lists them in the same state order as USArrests.
# So it can be paired directly with the murder rates.
region <- state.region[state_id]
levels(region)
## [1] "Northeast"     "South"         "North Central" "West"
table(region)
## region
##     Northeast         South North Central          West 
##             5            14             8             8

The regions are ordered by median murder rate from lowest to highest, and each line’s thickness scales with its group size.

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

# One ECDF per region, on the same axes
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(murder_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(murder_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)

Code
# The South has the highest median, so it is last in the legend.
# Its curve sits to the right of the others over its whole range.
# So its higher values are not driven by a few states.

A table of group sizes and centers puts numbers on the figure.

Code
# Group size, mean, and median of the murder rate by region
aggregate(murder_hat ~ region, FUN=summary_fun)
##          region murder_hat.n murder_hat.mean murder_hat.median
## 1     Northeast      5.00000         3.08000           3.30000
## 2 North Central      8.00000         5.48750           4.95000
## 3          West      8.00000         7.15000           7.35000
## 4         South     14.00000        11.58571          12.85000
# The South has the highest center and the Northeast the lowest.

13.2 Equal Medians

In Comparing Two Groups, we bootstrapped a confidence interval for the difference between two medians; with \(G\) groups, we can instead bootstrap one interval per group and compare them all on one figure.

ImportantKey Definition

A notched boxplot draws one box per group \(g=1,\dots,G\) from the group’s lower to upper quartile. Each box has a line at the group median \(\tilde{m}_{Y}(g)\). A notch cut into each side of the box spans a confidence interval for that median (McGill et al. 1978). With a \(95\%\) bootstrap percentile interval, the notch for group \(g\) spans the \(2.5\)th to \(97.5\)th percentiles of the \(B\) bootstrap medians \(\tilde{m}_{Y}^{(b)}(g)\). Each bootstrap median is the median of a resample of the \(n_{g}\) observations in group \(g\).

Notched boxplots are useful for comparing medians across many groups at a glance: if two notches do not overlap, that is strong evidence the two medians differ. Overlapping notches do not show that the medians are equal, because comparing two separate \(95\%\) intervals is stricter than a \(95\%\) test of their difference. Unlike R’s built-in notches, the bootstrap interval needs no normal-theory formula.1

Code
## MAIN EXAMPLE
# Bootstrap the median within each schooling group, keeping each group's size
n_boot <- 999
boot_medians <- matrix(NA, n_boot, nlevels(school_g))
colnames(boot_medians) <- levels(school_g)
for (b in seq(n_boot)) {
    for (g in levels(school_g)) {
        y_boot <- sample(y_hat[school_g == g], replace=TRUE)
        boot_medians[b, g] <- median(y_boot)
    }
}

# 95% bootstrap percentile intervals: one column per schooling group
med_ci <- apply(boot_medians, 2, quantile, probs=c(.025, .975))
round(med_ci, 2)
##        <12   12  >12
## 2.5%  4.39 4.97 6.37
## 97.5% 4.68 5.37 7.10

# Boxplot statistics, with the bootstrap intervals as the notches
bx <- boxplot(y_hat ~ school_g, plot=FALSE)
bx[['conf']] <- med_ci

# One notched box per schooling group, box width scaled to group size
# The axis stops just past 20 dollars, so the 16 largest wages are not drawn
med_cols <- adjustcolor(cols, alpha.f=2, red.f=.5, green.f=.5, blue.f=.5) # darker shades, for the medians
bxp(bx, notch=TRUE, varwidth=TRUE,
    boxfill=cols, border=NA, medcol=med_cols, ylim=c(0, 20),
    whisklty=0, staplelty=0, outpch=NA,
    ylab='Hourly wage (1980 dollars)', xlab='Years of schooling', main=NA)
title('Wages by years of schooling', font.main=1)
# Raw Observations, in place of whiskers and outliers
stripchart(y_hat ~ school_g,
    pch='-', col=grey(0, .2), cex=1,
    vert=TRUE, add=TRUE)

Code
# With 753 to 1353 workers per group, every interval is narrow.
# Each one stays well inside its box.
# No two notches overlap.
# That is strong evidence that all three medians differ.

Each box shows the median and the two quartiles, and each dash is one observation. varwidth=TRUE also scales each box’s width to \(\sqrt{n_{g}}\), so a group with more observations draws a wider box.

Researchers use the same displays (Tukey 1977) to compare treatments in experiments.

Here are the notched boxplots for the sampled states from Describing Groups.

Code
# Bootstrap the median within each region, keeping each region's size
n_boot <- 999
boot_medians <- matrix(NA, n_boot, nlevels(region))
colnames(boot_medians) <- levels(region)
for (b in seq(n_boot)) {
    for (g in levels(region)) {
        y_boot <- sample(murder_hat[region == g], replace=TRUE)
        boot_medians[b, g] <- median(y_boot)
    }
}

# 95% bootstrap percentile intervals: one column per region
med_ci <- apply(boot_medians, 2, quantile, probs=c(.025, .975))
round(med_ci, 2)
##       Northeast North Central West South
## 2.5%        2.1           2.2    4  8.65
## 97.5%       4.4           9.0    9 14.06

# Boxplot statistics, with the bootstrap intervals as the notches
bx <- boxplot(murder_hat ~ region, plot=FALSE)
bx[['conf']] <- med_ci

# One notched box per region, box width scaled to group size
cols2 <- hcl.colors(4, alpha=.45)
med_cols2 <- adjustcolor(cols2, alpha.f=2, red.f=.5, green.f=.5, blue.f=.5) # darker shades, for the medians
bxp(bx, notch=TRUE, varwidth=TRUE,
    boxfill=cols2, border=NA, medcol=med_cols2,
    whisklty=0, staplelty=0, outpch=NA,
    ylab='Murder arrests (per 100,000)', xlab='Region', main=NA)
title('Murder rates by region', font.main=1)
# Raw Observations, in place of whiskers and outliers
stripchart(murder_hat ~ region,
    pch='-', col=grey(0, .5), cex=2,
    vert=TRUE, add=TRUE)

Code
# Each dash is one state's murder rate.
# There are only 5 to 14 sampled states per region.
# So three of the four intervals are wider than the box itself.
# Only the South, with the most states, has an interval inside its box.
# The South's notch lies above the Northeast's, so the South's median is higher.

When an interval reaches past a quartile, the notch folds back outside the box. Besides the South and the Northeast, do any other pairs of regions have notches that do not overlap?

Here is another example, with monthly earnings by years of education.

Code
# Monthly earnings of 935 US working men in 1980
data(wage2, package='wooldridge')
wage_hat <- wage2[, 'wage']
educ <- wage2[, 'educ']
# Group years of schooling: dropout, high school, some college, BA, graduate.
educ_g <- cut(educ, breaks=c(0, 11, 12, 15, 16, 18),
    labels=c('<12', '12', '13-15', '16', '17-18'))
n_g <- table(educ_g)
n_g
## educ_g
##   <12    12 13-15    16 17-18 
##    88   393   207   150    97

# Bootstrap the median within each education group, keeping each group's size.
n_boot <- 999
boot_med_g <- matrix(NA, n_boot, nlevels(educ_g))
colnames(boot_med_g) <- levels(educ_g)
for (b in seq(n_boot)) {
    for (g in levels(educ_g)) {
        wage_boot <- sample(wage_hat[educ_g == g], replace=TRUE)
        boot_med_g[b, g] <- median(wage_boot)
    }
}
# 95% bootstrap percentile intervals: one column per education group.
med_ci <- apply(boot_med_g, 2, quantile, probs=c(.025, .975))
round(med_ci)
##       <12  12 13-15   16 17-18
## 2.5%  625 800   900  981  1000
## 97.5% 800 881  1000 1133  1304
Code
educ_cols <- hcl.colors(nlevels(educ_g))
lwd_g <- 1.5 + 2.5 * (n_g - min(n_g)) / (max(n_g) - min(n_g))
educ_par <- par(mfrow=c(1, 2), mar=c(4.5, 4.2, 1, 1))

### One ECDF per education group on the same axes, line width scaled to group size
plot(NULL, xlim=range(wage_hat), ylim=c(0, 1), main=NA,
    xlab='Monthly earnings (1980 dollars)', ylab='Proportion of workers')
for (g in seq_along(levels(educ_g))) {
    plot(ecdf(wage_hat[educ_g == levels(educ_g)[g]]), add=TRUE,
        col=educ_cols[g], lwd=lwd_g[g],
        verticals=TRUE, do.points=FALSE, col.01line=NA)
}
legend('bottomright', legend=paste0(levels(educ_g), ' years (n=', n_g, ')'),
    col=educ_cols, lty=1, lwd=lwd_g, bty='n', cex=.8)

### Boxplots with the bootstrap intervals as notches, box width scaled to group size
bx <- boxplot(wage_hat ~ educ_g, plot=FALSE)
bx[['conf']] <- med_ci
bxp(bx, notch=TRUE, varwidth=TRUE, boxfill=hcl.colors(nlevels(educ_g), alpha=.45),
    border=NA, medcol=adjustcolor(educ_cols, red.f=.5, green.f=.5, blue.f=.5), # darker shades, for the medians
    whisklty=0, staplelty=0, outpch=NA, main=NA,
    xlab='Years of education', ylab='Monthly earnings (1980 dollars)')
# Raw observations, in place of whiskers and outliers
stripchart(wage_hat ~ educ_g, pch='-', col=grey(0, .2), cex=1, vert=TRUE, add=TRUE)

13.3 Equal Means (ANOVA)

In Comparing Two 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 \(\hat{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{\text{TSS}}} &= \underbrace{\sum_{g=1}^{G} n_{g} (\hat{m}_{Y}(g)-\hat{m}_{Y})^2}_{\hat{\text{BSS}}} \;+\; \underbrace{\sum_{g=1}^{G}\sum_{i=1}^{n_{g}}(\hat{y}_{ig}-\hat{m}_{Y}(g))^2}_{\hat{\text{WSS}}}, \end{aligned}\] where \(\hat{\text{BSS}}\) measures variation in group means around the grand mean and \(\hat{\text{WSS}}\) measures variation of observations around their own group mean.

The decomposition is useful for testing whether group means differ. If they do not, \(\hat{\text{BSS}}\) should be small relative to \(\hat{\text{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{\text{BSS}}\) (“Sum Sq” for the group factor) and \(\hat{\text{WSS}}\) (“Sum Sq” for Residuals).

Code
# Fit ANOVA
reg_aov <- aov(y_hat ~ school_g)
summary(reg_aov)
##               Df Sum Sq Mean Sq F value Pr(>F)    
## school_g       2   2541  1270.3     128 <2e-16 ***
## Residuals   3291  32654     9.9                   
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

Data for Three Groups
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{\text{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{\text{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{\text{BSS}}/(G-1)}{\hat{\text{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}]\).

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. A large \(\hat{F}\) means the group means are more spread out than individual-level noise would predict.

Code
## MAIN EXAMPLE
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=school_g)
F_obs
## [1] 128.0237

To compute a \(p\)-value, we use a permutation test: shuffle the group labels, which keeps each group’s number of observations fixed, and recompute \(\hat{F}\) each time. Only large values of \(\hat{F}\) count against the null. As with KS and CVM in Comparing Two Groups, we reject at the \(5\%\) level when \(\hat{F}\) exceeds the \(95\)th percentile of the permutation distribution.

Code
## MAIN EXAMPLE
# Null distribution: shuffle schooling labels to break the Y--group pairing
F_perm <- vector(length=999)
for( b in seq_along(F_perm)){
    g_perm <- sample(school_g, replace=FALSE)
    F_perm[b] <- F_fun(y_hat=y_hat, g=g_perm)
}

# Permutation p-value
p_perm <- mean(F_perm >= F_obs)
p_perm
## [1] 0

# One-sided critical value: 95th percentile of the permutation distribution
F_crit <- quantile(F_perm, probs=.95)

hist(F_perm, breaks = 40,
    border = NA,
    freq = FALSE, main = NA, xlab = 'Null Permutation F-statistic',
    xlim = range(c(0, F_perm, F_obs)))
title('Null Permutation F-test', font.main = 1)
abline(v = F_crit, col = rgb(0, 0, 0, .8), lwd = 2)
abline(v = F_obs, col = rgb(1, 0, 0, .8), lwd = 2)
legend('top', legend = c('Observed F', '95th percentile'),
    col = c(rgb(1, 0, 0, .8), rgb(0, 0, 0, .8)), lwd = 2, bty='n')

Code
# No shuffle comes close to the observed F of about 128.
# So the permutation p-value is zero.

Pairing the observed outcomes y_hat with a shuffled vector of group labels 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 the null and additional parametric assumptions, \(\hat{F}\) follows an \(F_{G-1,\;n-G}\) distribution. The parametric \(F_{G-1,\;n-G}\) null distribution requires three assumptions: independent observations, normal errors within each group, and equal variances across groups (homoscedasticity). R reports \(p\)-values from this theoretical distribution by default.

Here is the same test for the sampled states from Describing Groups.

Code
# Fit ANOVA
summary(aov(murder_hat ~ region))
##             Df Sum Sq Mean Sq F value   Pr(>F)    
## region       3  357.3  119.09   10.07 8.64e-05 ***
## Residuals   31  366.5   11.82                     
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

# F-statistic
F_obs <- F_fun(y_hat=murder_hat, g=region)
F_obs
## [1] 10.0743

# Null distribution: shuffle region labels to break the Y--group pairing
F_perm <- vector(length=999)
for( b in seq_along(F_perm)){
    g_perm <- sample(region, replace=FALSE)
    F_perm[b] <- F_fun(y_hat=murder_hat, g=g_perm)
}

# Permutation p-value
p_perm <- mean(F_perm >= F_obs)
p_perm
## [1] 0

# One-sided critical value: 95th percentile of the permutation distribution
F_crit <- quantile(F_perm, probs=.95)

hist(F_perm, breaks = 40,
    border = NA,
    freq = FALSE, main = NA, xlab = 'Null Permutation F-statistic',
    xlim = range(c(0, F_perm, F_obs)))
title('Null Permutation F-test', font.main = 1)
abline(v = F_crit, col = rgb(0, 0, 0, .8), lwd = 2)
abline(v = F_obs, col = rgb(1, 0, 0, .8), lwd = 2)
legend('top', legend = c('Observed F', '95th percentile'),
    col = c(rgb(1, 0, 0, .8), rgb(0, 0, 0, .8)), lwd = 2, bty='n')

The figure below draws the theoretical \(F_{G-1,\;n-G}\) density over the permutation distribution for the sampled states, from the callout above.

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

# Compare parametric and permutation p-values
cbind(F_obs, p_param, p_perm)
##        F_obs      p_param p_perm
## [1,] 10.0743 8.639652e-05      0

# Overlay: permutation histogram vs. theoretical F density
hist(F_perm, breaks = 40,
    border = NA,
    freq = FALSE, main = NA, xlab = 'Null F-statistic',
    xlim = range(c(0, F_perm, F_obs)))
title('Permutation vs. Theoretical Null Distribution', font.main = 1)
F_grid <- seq(0, max(c(F_perm, F_obs)), length = 200)
lines(F_grid, df(F_grid, G-1, n-G), col = rgb(0, 0, 1, .8), lwd = 1, lty = 2)
abline(v = F_obs, col = rgb(1, 0, 0, .8), lwd = 2)
legend('top', legend = c('Null permutation', paste0('F(', G-1, ', ', n-G, ')'), 'Observed F'),
    fill = c(grey(.83), NA, NA), border = NA, # grey(.83) matches the default histogram fill
    lwd = c(NA, 1, 2), lty = c(NA, 2, 1), col = c(NA, rgb(0, 0, 1, .8), rgb(1, 0, 0, .8)), bty = 'n')

If these assumptions are doubtful, the Kruskal-Wallis test below is a safer alternative. As a quick check, compare side-by-side boxplots: if the spread differs across groups or the distributions show strong skewness or kurtosis, be cautious with the parametric test.

For the three-group example above, \(\hat{F}=(14/2)/(6/6)=7\). With \(G-1=2\) and \(n-G=6\) degrees of freedom, we compare it 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.4 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 (Kruskal and Wallis 1952) 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 (Wilcoxon 1945; Mann and Whitney 1947).

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{\text{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 and \(Y_{j}\) in the other: \(H_{0}: \text{Prob}(Y_{i} > Y_{j})=\text{Prob}(Y_{j} > Y_{i})\) versus \(H_{A}: \text{Prob}(Y_{i} > Y_{j}) \neq \text{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
## MAIN EXAMPLE
kw <- kruskal.test(y_hat ~ school_g)
kw
## 
##  Kruskal-Wallis rank sum test
## 
## data:  y_hat by school_g
## Kruskal-Wallis chi-squared = 259.3, df = 2, p-value < 2.2e-16

The test above does not tell us which groups differ. 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 groups, and the Holm correction (Holm 1979) adjusts the \(p\)-values for the number of pairs tested.

Code
pairwise.wilcox.test(y_hat, school_g, p.adjust.method='holm')
## 
##  Pairwise comparisons using Wilcoxon rank sum test with continuity correction 
## 
## data:  y_hat and school_g 
## 
##     <12     12     
## 12  2.6e-10 -      
## >12 < 2e-16 < 2e-16
## 
## P value adjustment method: holm
# All three adjusted p-values are tiny, so every pair of groups differs.

Looking back at the boxplot in Equal Medians, which two groups are closest?

Here are the same tests for the sampled states from Describing Groups.

Code
kw <- kruskal.test(murder_hat ~ region)
kw
## 
##  Kruskal-Wallis rank sum test
## 
## data:  murder_hat by region
## Kruskal-Wallis chi-squared = 16.394, df = 3, p-value = 0.0009415

pairwise.wilcox.test(murder_hat, region, p.adjust.method='holm')
## 
##  Pairwise comparisons using Wilcoxon rank sum exact test 
## 
## data:  murder_hat and region 
## 
##               Northeast North Central West 
## North Central 0.582     -             -    
## West          0.075     0.582         -    
## South         0.001     0.031         0.075
## 
## P value adjustment method: holm

Looking back at the boxplot in the Equal Medians callout, 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{\text{KW}}\) with \(n=9\), \(n_{g}=3\): \[ \hat{\text{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

The Mann-Whitney test compares medians when the two groups have the same shape, so that one distribution is a shifted copy of the other. Otherwise, it tests whether a draw from one group is as likely to exceed a draw from the other as the reverse, and it can reject even when the two medians are equal.

Same shape. Suppose hourly wages in two labor markets have the same right-skewed shape, but every wage in the second market is \(2\) dollars higher. The population medians are \(15\) and \(17\). Consider the probability that a worker drawn from the second market earns more than a worker drawn from the first. Its sample analog is the share of pairs that the second group wins, \(\hat{U}_{2}/(n_{1}n_{2})\) in the notation above. With conf.int=TRUE, wilcox.test also reports a shift estimate, the median of all differences \(\hat{y}_{i}-\hat{y}_{j}\) between a worker \(i\) in the second group and a worker \(j\) in the first. Because the shapes match, the shift estimate and the difference in sample medians both estimate the \(2\) dollar gap. So here, a Mann-Whitney rejection is evidence that the medians differ.

Code
# Same right-skewed shape, with every wage in market 2 shifted up by 2 dollars
n_g <- 2000
y1_sim <- 15 + 3 * (rexp(n_g) - log(2)) # population median 15
y2_sim <- 17 + 3 * (rexp(n_g) - log(2)) # population median 17

# Difference in sample medians
median(y2_sim) - median(y1_sim)
## [1] 1.934255

# Share of pairs that market 2 wins
mean(outer(y2_sim, y1_sim, '>'))
## [1] 0.7364205
# A market 2 worker earns more than a market 1 worker about 74% of the time.

# Mann-Whitney test and its shift estimate
mw <- wilcox.test(y2_sim, y1_sim, conf.int=TRUE)
c(shift=unname(mw[['estimate']]), p_value=mw[['p.value']])
##    shift  p_value 
## 1.930319 0.000000

# The market 2 ECDF is the market 1 ECDF moved right by 2
cols <- c(rgb(.8, 0, 0, .5), rgb(0, 0, .8, .5))
F1_hat <- ecdf(y1_sim)
F2_hat <- ecdf(y2_sim)
plot(F1_hat, col=cols[1], lwd=2, verticals=TRUE, do.points=FALSE, col.01line=NA,
    xlim=c(5, 35), main=NA,
    xlab='Hourly wage', ylab='Proportion of workers')
plot(F2_hat, add=TRUE, col=cols[2], lwd=2, verticals=TRUE, do.points=FALSE, col.01line=NA)
abline(h=.5, col=grey(.5), lty=3) # each curve crosses this line at its median
legend('bottomright', legend=c('Market 1', 'Market 2'),
    lty=1, lwd=2, col=cols, bty='n')

Different shapes. Now suppose wages in the first market are symmetric around \(15\), while the second market keeps the right-skewed shape from above, also with median \(15\). The medians are equal, but the second market has no wages below about \(12.92\), while the first market has a long left tail. When both workers earn less than \(15\), the worker from the second market earns more about \(70\%\) of the time. Overall, the second market wins more than half of all pairs. So the Mann-Whitney test can reject, with a shift estimate above zero, even though the medians are equal.

Code
# Same population median of 15, different shapes
y1_sim <- 15 + 3 * rnorm(n_g)             # symmetric
y2_sim <- 15 + 3 * (rexp(n_g) - log(2))   # right-skewed, no wages below 12.92

# Difference in sample medians is close to zero
median(y2_sim) - median(y1_sim)
## [1] -0.1661474

# Yet market 2 wins more than half of the pairs
mean(outer(y2_sim, y1_sim, '>'))
## [1] 0.5432795
# The sample share is about 54%, and the population share is about 56%.

# So the Mann-Whitney test rejects
mw <- wilcox.test(y2_sim, y1_sim, conf.int=TRUE)
c(shift=unname(mw[['estimate']]), p_value=mw[['p.value']])
##        shift      p_value 
## 4.178188e-01 2.132699e-06
# The shift estimate is about 0.42, and the population shift is about 0.55.
# Both are well above zero, even though the medians are equal.

# The ECDFs cross at the median but differ below it
F1_hat <- ecdf(y1_sim)
F2_hat <- ecdf(y2_sim)
plot(F1_hat, col=cols[1], lwd=2, verticals=TRUE, do.points=FALSE, col.01line=NA,
    xlim=c(5, 35), main=NA,
    xlab='Hourly wage', ylab='Proportion of workers')
plot(F2_hat, add=TRUE, col=cols[2], lwd=2, verticals=TRUE, do.points=FALSE, col.01line=NA)
abline(h=.5, col=grey(.5), lty=3) # both curves cross this line at 15
legend('bottomright', legend=c('Market 1', 'Market 2'),
    lty=1, lwd=2, col=cols, bty='n')

Categorical ANOVA

When the outcome is a factor, we can still ask whether the groups spread their observations across the categories in the same proportions. Let \(\hat{p}(y)\) be the proportion of all \(n\) observations in category \(y\), and \(\hat{p}(y \mid g)\) the proportion of the \(n_{g}\) observations in group \(g\). The categorical ANOVA measures variation with one minus the Herfindahl index, and splits it into between-group and within-group pieces: \[\begin{aligned} \underbrace{\frac{n}{2}\left(1-\hat{H}\right)}_{\hat{\text{TSS}}} &= \underbrace{\frac{1}{2}\sum_{g=1}^{G} n_{g} \sum_{y}\left(\hat{p}(y \mid g)-\hat{p}(y)\right)^2}_{\hat{\text{BSS}}} \;+\; \underbrace{\sum_{g=1}^{G} \frac{n_{g}}{2}\left(1-\hat{H}(g)\right)}_{\hat{\text{WSS}}}, \end{aligned}\] where \(\hat{H}=\sum_{y}\hat{p}(y)^2\) is the Herfindahl index of the pooled data and \(\hat{H}(g)=\sum_{y}\hat{p}(y \mid g)^2\) is the Herfindahl index within group \(g\).

The categorical ANOVA is useful when the outcome is an unordered factor, such as occupation, brand, or vote choice, where there are no means to compare and no ranks to sort. Recall that \(1-\hat{H}\) is zero when every observation falls in one category and grows as the categories become equally common, so it plays the role of the variance. The pieces are also ordinary ANOVA sums of squares. Make one 0/1 indicator for each category and compute its \(\hat{\text{TSS}}\), \(\hat{\text{BSS}}\), and \(\hat{\text{WSS}}\) as in the decomposition above. Adding each piece up across the categories and halving it gives the three pieces here. With a binary outcome, the two indicators have the same sums of squares, so the categorical ANOVA is exactly the ANOVA of a single 0/1 variable. The share \(\hat{\text{BSS}}/\hat{\text{TSS}}\) reports the fraction of the variation in \(\hat{y}\) that lies between groups.2

For example, cut a cardinal outcome into brackets and sort each group’s observations into them. The brackets are an ordered factor, but the categorical ANOVA ignores that order, so it treats them as it would any unordered factor.

Code
## MAIN EXAMPLE
# Outcome: hourly wage in four brackets, an ordered factor
# The brackets are up to 4 dollars an hour, 4 to 6, 6 to 8, and above 8.
wage_bracket <- cut(y_hat, breaks=c(0, 4, 6, 8, Inf), ordered_result=TRUE)
tab <- table(school_g, wage_bracket)
tab
##         wage_bracket
## school_g (0,4] (4,6] (6,8] (8,Inf]
##      <12   542   430   236     145
##      12    365   375   251     197
##      >12   126   177   194     256

# Proportions within each schooling group: each bar sums to 1
p_bracket_school <- prop.table(tab, 1)
cols <- hcl.colors(4, alpha=.45)
par(mar=c(4, 4, 3.5, 1))
barplot(t(p_bracket_school), col=cols, border=NA,
    xlab='Years of schooling', ylab='Proportion of workers', main=NA)
legend('top', inset=c(0, -.15), xpd=TRUE, ncol=4, bty='n',
    fill=cols, border=NA, legend=levels(wage_bracket))

Code
# Workers with more schooling are less often in the lowest bracket.
# They are more often in the highest.

The function below computes the three pieces and the between-group share.

Code
## MAIN EXAMPLE
# Categorical ANOVA: variation is measured by one minus the Herfindahl index
cat_anova <- function(y_hat, g) {
    # Total variation, from the pooled proportions
    n <- length(y_hat)
    p_hat <- table(y_hat) / n
    TSS <- n / 2 * (1 - sum(p_hat^2))
    # Within-group variation, from each group's own proportions
    WSS_g <- tapply(y_hat, g, function(y_g) {
        p_g_hat <- table(y_g) / length(y_g)
        return(length(y_g) / 2 * (1 - sum(p_g_hat^2)))
    })
    WSS <- sum(WSS_g)
    # Between-group variation, from the decomposition
    BSS <- TSS - WSS
    return(c(BSS=BSS, WSS=WSS, TSS=TSS, share=BSS / TSS))
}
cat_anova(wage_bracket, school_g)
##          BSS          WSS          TSS        share 
## 3.017363e+01 1.183800e+03 1.213974e+03 2.485526e-02
# About 2.5% of the variation in wage brackets lies between schooling groups.

Suppose we record how \(n=8\) workers commute, \(n_{g}=4\) from each of two neighborhoods. Neighborhood \(A\) has car, car, car, bus, and neighborhood \(B\) has car, bus, bike, bike.

Pooled, the proportions are \(\hat{p}(\text{car})=4/8\), \(\hat{p}(\text{bus})=2/8\), and \(\hat{p}(\text{bike})=2/8\), so \(\hat{H}=1/4+1/16+1/16=3/8\) and \(\hat{\text{TSS}}=\frac{8}{2}(1-3/8)=5/2\). Within \(A\), \(\hat{H}(A)=(3/4)^2+(1/4)^2=5/8\), and within \(B\), \(\hat{H}(B)=(1/4)^2+(1/4)^2+(2/4)^2=3/8\). So \(\hat{\text{WSS}}=\frac{4}{2}(1-5/8)+\frac{4}{2}(1-3/8)=3/4+5/4=2\). For \(\hat{\text{BSS}}\), each neighborhood’s proportions differ from the pooled ones by \(1/4\) in two categories and by \(0\) in the third, so \(\hat{\text{BSS}}=\frac{1}{2}[4(1/16+0+1/16)+4(1/16+0+1/16)]=1/2\). The pieces add up, \(5/2=1/2+2\), and the between-group share is \(\hat{\text{BSS}}/\hat{\text{TSS}}=1/5\).

Code
# Commute mode of 8 workers, 4 from each neighborhood
y0_hat <- factor(c('car', 'car', 'car', 'bus', 'car', 'bus', 'bike', 'bike'))
g0 <- factor(c('A', 'A', 'A', 'A', 'B', 'B', 'B', 'B'))
table(g0, y0_hat)
##    y0_hat
## g0  bike bus car
##   A    0   1   3
##   B    2   1   1
cat_anova(y0_hat, g0)
##   BSS   WSS   TSS share 
##   0.5   2.0   2.5   0.2

As with the ANOVA \(F\)-statistic, we get a \(p\)-value by shuffling the group labels and recomputing the share each time.

Code
# Observed between-group share
share_obs <- cat_anova(wage_bracket, school_g)[['share']]

# Null distribution: shuffle schooling labels to break the outcome--group pairing
share_perm <- rep(NA, 999)
for (b in seq_along(share_perm)) {
    g_perm <- sample(school_g, replace=FALSE)
    share_perm[b] <- cat_anova(wage_bracket, g_perm)[['share']]
}

# Permutation p-value
p_perm <- mean(share_perm >= share_obs)
p_perm
## [1] 0

# One-sided critical value: 95th percentile of the permutation distribution
share_crit <- quantile(share_perm, probs=.95)

hist(share_perm, breaks=40,
    border=NA,
    freq=FALSE, main=NA, xlab='Null Permutation Between-Group Share',
    xlim=range(c(0, share_perm, share_obs)))
title('Null Permutation Categorical ANOVA', font.main=1)
abline(v=share_crit, col=rgb(0, 0, 0, .8), lwd=2)
abline(v=share_obs, col=rgb(1, 0, 0, .8), lwd=2)
legend('top', legend=c('Observed share', '95th percentile'),
    col=c(rgb(1, 0, 0, .8), rgb(0, 0, 0, .8)), lwd=2, bty='n')

Because the brackets are ordered, the Kruskal-Wallis test also applies.

Code
# Kruskal-Wallis ranks the brackets, so it uses their order
kruskal.test(wage_bracket ~ school_g)
## 
##  Kruskal-Wallis rank sum test
## 
## data:  wage_bracket by school_g
## Kruskal-Wallis chi-squared = 241.42, df = 2, p-value < 2.2e-16
# Both this test and the categorical ANOVA reject.
# The schooling groups differ along one direction, from low to high brackets.

Ranks concentrate on differences along one direction, from low to high brackets. The categorical ANOVA spreads its attention over every way the proportions could differ, so a rank test is usually more powerful when the categories are ordered. The categorical ANOVA can still detect differences that ranks miss, such as one group concentrated in the middle brackets and another split between the two extremes.

Here is the same analysis for the sampled states from Describing Groups. The urban population bands from Ordered Factors sort each region’s states into mostly rural, mixed, and mostly urban.

Code
# Outcome: urban population in three bands, an ordered factor, for the sampled states
urban_band <- cut(USArrests[state_id, 'UrbanPop'], breaks=c(0, 50, 75, 100),
    labels=c('mostly rural', 'mixed', 'mostly urban'), ordered_result=TRUE)
tab <- table(region, urban_band)
tab
##                urban_band
## region          mostly rural mixed mostly urban
##   Northeast                1     1            3
##   North Central            1     7            0
##   West                     0     3            5
##   South                    5     7            2

# Proportions within each region: each bar sums to 1
p_band_region <- prop.table(tab, 1)
cols <- c(rgb(.8, 0, 0, .5), rgb(0, .8, 0, .5), rgb(0, 0, .8, .5))
par(mar=c(4, 4, 3.5, 1))
barplot(t(p_band_region), col=cols, border=NA,
    xlab='Region', ylab='Proportion of states', main=NA)
legend('top', inset=c(0, -.15), xpd=TRUE, ncol=3, bty='n',
    fill=cols, border=NA, legend=levels(urban_band))

Code
# The South and North Central lean rural.
# The Northeast and West lean urban.

The function from above computes the three pieces and the between-group share.

Code
cat_anova(urban_band, region)
##        BSS        WSS        TSS      share 
##  2.3785714  8.3642857 10.7428571  0.2214096
# About 22% of the variation in urban bands lies between regions.

As above, we get a \(p\)-value by shuffling the group labels and recomputing the share each time.

Code
# Observed between-group share
share_obs <- cat_anova(urban_band, region)[['share']]

# Null distribution: shuffle region labels to break the outcome--group pairing
share_perm <- rep(NA, 999)
for (b in seq_along(share_perm)) {
    g_perm <- sample(region, replace=FALSE)
    share_perm[b] <- cat_anova(urban_band, g_perm)[['share']]
}

# Permutation p-value
p_perm <- mean(share_perm >= share_obs)
p_perm
## [1] 0.01801802

# One-sided critical value: 95th percentile of the permutation distribution
share_crit <- quantile(share_perm, probs=.95)

hist(share_perm, breaks=40,
    border=NA,
    freq=FALSE, main=NA, xlab='Null Permutation Between-Group Share',
    xlim=range(c(0, share_perm, share_obs)))
title('Null Permutation Categorical ANOVA', font.main=1)
abline(v=share_crit, col=rgb(0, 0, 0, .8), lwd=2)
abline(v=share_obs, col=rgb(1, 0, 0, .8), lwd=2)
legend('topright', legend=c('Observed share', '95th percentile'),
    col=c(rgb(1, 0, 0, .8), rgb(0, 0, 0, .8)), lwd=2, bty='n')

Because the bands are ordered, the Kruskal-Wallis test also applies.

Code
# Kruskal-Wallis ranks the bands, so it uses their order
kruskal.test(urban_band ~ region)
## 
##  Kruskal-Wallis rank sum test
## 
## data:  urban_band by region
## Kruskal-Wallis chi-squared = 9.284, df = 3, p-value = 0.02574
# The categorical ANOVA gives the smaller p-value, about 0.018 against 0.026.
# The regions differ partly along one direction, from rural to urban.
# Ranks concentrate on that direction.
# In this sample, 7 of North Central's 8 states are in the middle band.
# That pattern contributes more to the categorical ANOVA than any other region.
# It contributes the least to the Kruskal-Wallis statistic.

For the sampled states in the callout above, make a binary outcome that marks the mostly urban states. Check that the categorical ANOVA of this outcome gives the same \(\hat{\text{BSS}}\) and \(\hat{\text{WSS}}\) as the ordinary ANOVA of the 0/1 variable.

Code
# 1 for mostly urban states, 0 otherwise
d_hat <- as.numeric(urban_band == 'mostly urban')
cat_anova(factor(d_hat), region)
##      BSS      WSS      TSS    share 
## 2.353571 4.789286 7.142857 0.329500
summary(aov(d_hat ~ region))
##             Df Sum Sq Mean Sq F value  Pr(>F)   
## region       3  2.354  0.7845   5.078 0.00563 **
## Residuals   31  4.789  0.1545                   
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

13.5 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{\text{BSS}}\)) and within-group sum of squares (\(\hat{\text{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 described several groups with ECDFs and a table of group means. We compared their medians with bootstrap notches on boxplots. We then tested whether the differences reflected more than chance. ANOVA splits variation into between-group and within-group pieces and combines them into an \(F\)-statistic. A permutation test got its \(p\)-value by shuffling the group labels. In the three-group example with \(A=\{4,5,6\}, B=\{8,7,9\}, C=\{5,6,7\}\), we found \(\hat{\text{BSS}}=14\) and \(\hat{\text{WSS}}=6\) by hand. The function aov(y0_hat ~ g0) gives the same numbers. The Kruskal-Wallis test replaced outcomes with ranks when the equal-variance normal-error assumptions were doubtful. The categorical ANOVA measured the variation of a factor outcome with one minus the Herfindahl index; for the commutes in two neighborhoods it gave \(\hat{\text{TSS}}=5/2\), \(\hat{\text{BSS}}=1/2\), and \(\hat{\text{WSS}}=2\) by hand.

Holm, Sture. 1979. “A Simple Sequentially Rejective Multiple Test Procedure.” Scandinavian Journal of Statistics 6: 65–70. https://www.jstor.org/stable/4615733.
Kruskal, William H., and W. Allen Wallis. 1952. “Use of Ranks in One-Criterion Variance Analysis.” Journal of the American Statistical Association 47 (260): 583–621. https://doi.org/10.1080/01621459.1952.10483441.
Mann, H. B., and D. R. Whitney. 1947. “On a Test of Whether One of Two Random Variables Is Stochastically Larger Than the Other.” The Annals of Mathematical Statistics 18 (1): 50–60. https://doi.org/10.1214/aoms/1177730491.
McGill, Robert, John W. Tukey, and Wayne A. Larsen. 1978. “Variations of Box Plots.” The American Statistician 32 (1): 12–16. https://doi.org/10.1080/00031305.1978.10479236.
Tukey, John Wilder. 1977. Exploratory Data Analysis. Vol. 2. Springer.
Wilcoxon, Frank. 1945. “Individual Comparisons by Ranking Methods.” Biometrics Bulletin 1 (6): 80–83. https://doi.org/10.2307/3001968.

  1. R’s built-in notch=TRUE draws \(\pm 1.58 \hat{\text{IQR}}/\sqrt{n_{g}}\) around each median, a rule of thumb from normal theory (see ?boxplot.stats).↩︎

  2. See Light, R. J., and B. H. Margolin (1971), “An Analysis of Variance for Categorical Data,” Journal of the American Statistical Association, 66(335), 534–544, https://doi.org/10.1080/01621459.1971.10482297.↩︎