Brief

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.

The data

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)")
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.

The mean test says there is nothing here

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.

Look at the tickets

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%.

Testing the spread

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")
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

Across all four teams

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)

Results

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")
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

What the data has to look like

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.

Decision

  1. North and south are not the same team, and the quarterly report’s mean column cannot see the difference. South’s customers get a coin flip: fast, or an SLA breach.
  2. The spread chooses the intervention. North’s steady distribution moves as a group, so a process change lifts everyone. South needs the split diagnosed first — a bimodal resolution time usually means two queues wearing one name (routing, escalation, or a skills gap), and the fix is triage, not a general efficiency push.
  3. West needs both: the slowest average and the widest spread.
  4. Add the standard deviation and the SLA-breach rate to the quarterly report. Reporting a mean alone hid a real operational failure for a full quarter.