A B2B software company runs its support desk as four regional teams. The quarterly operations review reports one number per team — average resolution time — and this quarter two of the four teams reported effectively the same average.
Are those two teams performing the same, and if not, what should we do about it?
The mean says yes. This review shows the mean is answering a narrower question than the one operations asked, and that the number which actually decides the intervention is the one the report does not carry: the spread.
One row per resolved ticket.
| column | meaning | unit |
|---|---|---|
ticket_id |
support ticket reference | id |
team |
which regional team resolved it | factor, 4 levels |
opened_date |
date the ticket was opened | date |
resolution_hours |
hours from open to resolved | hours |
The dataset is constructed so that each team’s
sample lands on an exact target mean and standard deviation: a draw is
standardised and then placed (round(mean + sd * z, 1)),
with two teams drawn from a bimodal mixture because that is what a split
workload actually looks like. Generation is deterministic
(set.seed(4242)); re-knitting reproduces
data.csv byte-for-byte. All figures below are computed from
the written-out, rounded file — the same one the platform tool
consumes.
set.seed(4242)
make_team <- function(n, target_mean, target_sd, bimodal = FALSE) {
z <- if (bimodal) c(rnorm(ceiling(n / 2), -1, 0.55), rnorm(floor(n / 2), 1, 0.55))
else rnorm(n)
z <- (z - mean(z)) / sd(z)
round(target_mean + target_sd * z, 1)
}
n <- 40
teams <- list(
team_north = make_team(n, 18.1, 2.6), # steady
team_south = make_team(n, 17.9, 8.4, bimodal = TRUE), # same average, split workload
team_east = make_team(n, 14.8, 3.0), # fast and steady
team_west = make_team(n, 21.5, 9.2, bimodal = TRUE) # slow and erratic
)
d <- do.call(rbind, lapply(names(teams), function(t) {
data.frame(team = t, resolution_hours = teams[[t]])
}))
d <- d[order(runif(nrow(d))), ]
d$ticket_id <- sprintf("TCK-%04d", seq_len(nrow(d)))
d$opened_date <- as.character(as.Date("2026-04-06") + (seq_len(nrow(d)) %% 63))
d <- d[, c("ticket_id", "team", "opened_date", "resolution_hours")]
write.csv(d, "data.csv", row.names = FALSE)
nrow(d)
## [1] 160
by_team <- do.call(rbind, lapply(names(teams), function(t) {
v <- d$resolution_hours[d$team == t]
data.frame(team = t, tickets = length(v),
mean_hours = round(mean(v), 2), sd_hours = round(sd(v), 2),
median_hours = round(median(v), 2),
fastest = min(v), slowest = max(v),
pct_over_30h = round(100 * mean(v > 30), 1))
}))
knitr::kable(by_team, caption = "The quarterly table, with the column the report omits (sd_hours)")
| team | tickets | mean_hours | sd_hours | median_hours | fastest | slowest | pct_over_30h |
|---|---|---|---|---|---|---|---|
| team_north | 40 | 18.09 | 2.60 | 17.95 | 14.2 | 24.6 | 0.0 |
| team_south | 40 | 17.90 | 8.40 | 18.55 | 4.8 | 32.8 | 5.0 |
| team_east | 40 | 14.80 | 2.99 | 14.50 | 8.0 | 20.7 | 0.0 |
| team_west | 40 | 21.50 | 9.20 | 21.25 | 2.4 | 38.0 | 22.5 |
team_north and team_south resolve tickets
in 18.09 and 17.90 hours on average.
On that column they are the same team.
w_test <- t.test(teams$team_north, teams$team_south) # Welch, unequal variances
w_test
##
## Welch Two Sample t-test
##
## data: teams$team_north and teams$team_south
## t = 0.14023, df = 46.42, p-value = 0.8891
## alternative hypothesis: true difference in means is not equal to 0
## 95 percent confidence interval:
## -2.603319 2.993319
## sample estimates:
## mean of x mean of y
## 18.0925 17.8975
p = 0.8891. On averages, north and south are indistinguishable, and a review that stops at the mean closes the item.
op <- par(mar = c(4, 7, 3, 2))
cols <- c(team_north = "#5fa9dd", team_south = "#F97316",
team_east = "#3fbf6f", team_west = "#c0392b")
plot(NA, xlim = c(0, 40), ylim = c(0.5, 4.5), yaxt = "n", ylab = "",
xlab = "resolution time (hours)", main = "Every ticket, by team")
for (i in seq_along(teams)) {
t <- names(teams)[i]
v <- teams[[t]]
points(v, jitter(rep(i, length(v)), amount = 0.13), pch = 19,
col = adjustcolor(cols[[t]], 0.75))
segments(mean(v), i - 0.28, mean(v), i + 0.28, lwd = 3, col = "#111")
}
axis(2, 1:4, gsub("_", " ", names(teams)), las = 2)
abline(v = 30, lty = 3, col = "#888")
text(30, 4.45, " 30h SLA", col = "#888", pos = 4, cex = 0.8)
par(op)
The black bars are the means — north and south sit on top of each other. The tickets do not. North’s tickets cluster within a few hours of the average; south’s run from 4.8 to 32.8 hours. 5% of south’s tickets breach the 30-hour SLA. North breaches 0%.
Eyeballing is not a test — small samples throw off lopsided spreads by luck routinely. The spread deserves the same discipline the mean gets.
library(car)
lev_all <- leveneTest(resolution_hours ~ factor(team), data = d, center = median)
lev_all
| Df | F value | Pr(>F) | |
|---|---|---|---|
| group | 3 | 39.91448 | 0 |
| 156 | NA | NA |
lev_pair <- leveneTest(resolution_hours ~ factor(team),
data = d[d$team %in% c("team_north", "team_south"), ],
center = median)
lev_pair
| Df | F value | Pr(>F) | |
|---|---|---|---|
| group | 1 | 63.27509 | 0 |
| 78 | NA | NA |
leveneTest(..., center = median) is the Brown-Forsythe
form: it asks whether tickets sit further from their own team’s centre
in one team than another, and centring on the median keeps a handful of
extreme tickets from deciding the answer.
f_test <- var.test(teams$team_south, teams$team_north) # ratio of variances
f_test
##
## F test to compare two variances
##
## data: teams$team_south and teams$team_north
## F = 10.416, num df = 39, denom df = 39, p-value = 3.348e-11
## alternative hypothesis: true ratio of variances is not equal to 1
## 95 percent confidence interval:
## 5.508962 19.693539
## sample estimates:
## ratio of variances
## 10.4159
The classic variance-ratio F test agrees emphatically — south’s variance is 10.4× north’s — but it assumes normality and these distributions are bimodal, so it is the sensitivity check and Levene leads.
knitr::kable(data.frame(
question = c("Do north and south differ on the MEAN?",
"Do north and south differ on the SPREAD?",
"Do all four differ on the SPREAD?"),
test = c("Welch two-sample t", "Levene (median-centred)", "Levene (median-centred)"),
statistic = c(sprintf("t = %.4f", w_test$statistic),
sprintf("F = %.4f", lev_pair[1, "F value"]),
sprintf("F = %.4f", lev_all[1, "F value"])),
p = c(sprintf("%.4f", w_test$p.value),
sprintf("%.4g", lev_pair[1, "Pr(>F)"]),
sprintf("%.4g", lev_all[1, "Pr(>F)"])),
verdict = c("no difference", "emphatic difference", "emphatic difference")
), caption = "Same two teams, two questions, opposite answers")
| question | test | statistic | p | verdict |
|---|---|---|---|---|
| Do north and south differ on the MEAN? | Welch two-sample t | t = 0.1402 | 0.8891 | no difference |
| Do north and south differ on the SPREAD? | Levene (median-centred) | F = 63.2751 | 1.152e-11 | emphatic difference |
| Do all four differ on the SPREAD? | Levene (median-centred) | F = 39.9145 | 3.367e-19 | emphatic difference |
fit <- aov(resolution_hours ~ factor(team), data = d)
a <- summary(fit)[[1]]
a
| Df | Sum Sq | Mean Sq | F value | Pr(>F) | |
|---|---|---|---|---|---|
| factor(team) | 3 | 899.5215 | 299.84050 | 7.015541 | 0.0001863 |
| Residuals | 156 | 6667.3575 | 42.73947 | NA | NA |
eta2 <- a[1, "Sum Sq"] / sum(a[, "Sum Sq"])
sprintf("eta-squared = %.4f", eta2)
## [1] "eta-squared = 0.1189"
Widened to four teams the means do separate (p = 0.0001863), and eta-squared says which team a ticket lands with explains about 12% of all variation in resolution time. The other 88% sits inside the teams — between one ticket and the next.
plot(by_team$mean_hours, by_team$sd_hours, pch = 19, cex = 2,
col = cols[by_team$team], xlim = c(13, 23), ylim = c(0, 11),
xlab = "mean resolution time (hours)", ylab = "standard deviation (hours)",
main = "Two dials, not one")
text(by_team$mean_hours, by_team$sd_hours - 0.8, gsub("_", " ", by_team$team), cex = 0.85)
abline(v = mean(by_team$mean_hours), h = mean(by_team$sd_hours), col = "#ddd", lty = 3)
knitr::kable(data.frame(
measure = c("north mean / sd", "south mean / sd", "variance ratio south:north",
"Welch t p (means, north vs south)", "Levene p (spread, north vs south)",
"Levene p (spread, all four)", "ANOVA p (means, all four)", "eta-squared"),
value = c(sprintf("%.2f h / %.2f h", by_team$mean_hours[1], by_team$sd_hours[1]),
sprintf("%.2f h / %.2f h", by_team$mean_hours[2], by_team$sd_hours[2]),
sprintf("%.2f", var(teams$team_south) / var(teams$team_north)),
sprintf("%.4f", w_test$p.value),
sprintf("%.4g", lev_pair[1, "Pr(>F)"]),
sprintf("%.4g", lev_all[1, "Pr(>F)"]),
sprintf("%.4g", a[1, "Pr(>F)"]),
sprintf("%.4f", eta2))
), caption = "Every quotable figure in this review")
| measure | value |
|---|---|
| north mean / sd | 18.09 h / 2.60 h |
| south mean / sd | 17.90 h / 8.40 h |
| variance ratio south:north | 10.42 |
| Welch t p (means, north vs south) | 0.8891 |
| Levene p (spread, north vs south) | 1.152e-11 |
| Levene p (spread, all four) | 3.367e-19 |
| ANOVA p (means, all four) | 0.0001863 |
| eta-squared | 0.1189 |
standard_group_comparison needs one row per
ticket with two mapped columns:
column_mapping {group: team, outcome: resolution_hours} —
the group label and the numeric outcome. Minimum 10 rows.
The constraint that decides everything here: a spread cannot be recovered from a mean. If the support platform exports one average per team per week, this analysis is impossible — the 30-hour breaches, the bimodal shape, the whole finding is already destroyed by the time the export is written. Keep ticket-level rows.