A B2B software company signs 75 new business accounts a month. Eight months of data. The board deck shows active accounts, and that line has gone up every single month.
Underneath it, nearly half of every account ever signed has already churned.
Both statements are true at the same time, and this worked example shows how. It also answers a second question the top line cannot: from month 5 the company replaced self-serve onboarding with a guided setup call for every new account. Did it work?
One row per account per month it was active. Columns:
account_id, activity_date (first of the active
month), signup_month, onboarding,
seats, mrr.
The dataset is CONSTRUCTED from two retention curves — one for the
self-serve cohorts, one for the guided-setup cohorts — with
set.seed(20260824) fixing seat counts and row order. An
account is active in tenure month t only if it was active in
every month before t, so churn is absorbing: nobody comes back
from the dead. Re-knitting reproduces data.csv
byte-for-byte.
set.seed(20260824)
cohort_size <- 75
cohorts <- sprintf("2026-%02d", 1:8)
cohort_start <- as.Date(sprintf("2026-%02d-01", 1:8))
# accounts still active at tenure 0,1,2,... out of 75
curve_self <- c(75, 47, 38, 32, 27, 24, 21, 19) # cohorts 1-4, self-serve
curve_guided <- c(75, 59, 48, 41, 36, 32, 29, 27) # cohorts 5-8, guided setup call
rows <- do.call(rbind, lapply(seq_along(cohorts), function(ci) {
guided <- ci >= 5
curve <- if (guided) curve_guided else curve_self
window <- 0:(8 - ci) # months observable before the data ends
ids <- sprintf("acct_%03d_%02d", ci, seq_len(cohort_size))
seats <- sample(3:40, cohort_size, replace = TRUE)
do.call(rbind, lapply(window, function(t) {
alive <- seq_len(curve[t + 1]) # curve is decreasing => activity is contiguous
data.frame(
account_id = ids[alive],
activity_date = seq(cohort_start[ci], by = "month", length.out = t + 1)[t + 1],
signup_month = cohorts[ci],
onboarding = if (guided) "guided_setup" else "self_serve",
seats = seats[alive],
mrr = seats[alive] * 49,
stringsAsFactors = FALSE
)
}))
}))
rows <- rows[order(rows$account_id, rows$activity_date), ]
rownames(rows) <- NULL
write.csv(rows, "data.csv", row.names = FALSE)
nrow(rows)
## [1] 1623
head(rows, 6)
| account_id | activity_date | signup_month | onboarding | seats | mrr |
|---|---|---|---|---|---|
| acct_001_01 | 2026-01-01 | 2026-01 | self_serve | 28 | 1372 |
| acct_001_01 | 2026-02-01 | 2026-01 | self_serve | 28 | 1372 |
| acct_001_01 | 2026-03-01 | 2026-01 | self_serve | 28 | 1372 |
| acct_001_01 | 2026-04-01 | 2026-01 | self_serve | 28 | 1372 |
| acct_001_01 | 2026-05-01 | 2026-01 | self_serve | 28 | 1372 |
| acct_001_01 | 2026-06-01 | 2026-01 | self_serve | 28 | 1372 |
months <- sprintf("2026-%02d", 1:8)
active <- sapply(cohort_start, function(m) sum(rows$activity_date == m))
new <- rep(cohort_size, 8)
cancels <- c(NA, head(active, -1) + cohort_size - tail(active, -1))
net <- c(NA, diff(active))
topline <- data.frame(month = months, active_accounts = active,
new_accounts = new, cancellations = cancels,
net_change = net)
knitr::kable(topline, align = "lrrrr",
col.names = c("month", "active accounts", "new", "cancelled", "net change"),
caption = "The top line rises every month. The cancellation column is why that is misleading.")
| month | active accounts | new | cancelled | net change |
|---|---|---|---|---|
| 2026-01 | 75 | 75 | NA | NA |
| 2026-02 | 122 | 75 | 28 | 47 |
| 2026-03 | 160 | 75 | 37 | 38 |
| 2026-04 | 192 | 75 | 43 | 32 |
| 2026-05 | 219 | 75 | 48 | 27 |
| 2026-06 | 255 | 75 | 39 | 36 |
| 2026-07 | 286 | 75 | 44 | 31 |
| 2026-08 | 314 | 75 | 47 | 28 |
Every month is higher than the last. And every month after the first, the company is losing accounts it already had — 48 of them in the worst month — while the top line still prints a gain, because 75 new accounts walk in the door behind them.
par(mar = c(4, 5, 3, 1))
bp <- barplot(active, names.arg = months, col = "#5fa9dd", border = NA,
ylim = c(0, 380), ylab = "active accounts",
main = "Active accounts by month, with monthly cancellations")
lines(bp, cancels, type = "b", pch = 19, col = "#F97316", lwd = 2)
text(bp, active + 16, active, cex = 0.85, font = 2)
legend("topleft", c("active accounts", "accounts cancelled that month"),
fill = c("#5fa9dd", NA), border = NA, lty = c(NA, 1), pch = c(NA, 19),
col = c(NA, "#F97316"), bty = "n")
ever <- length(unique(rows$account_id))
alive_8 <- sum(rows$activity_date == cohort_start[8])
c(accounts_ever = ever, active_month_8 = alive_8,
churned = ever - alive_8, churn_pct = round(100 * (ever - alive_8) / ever, 4))
## accounts_ever active_month_8 churned churn_pct
## 600.0000 314.0000 286.0000 47.6667
Same rows, grouped by the month an account signed rather than the calendar.
tenure <- function(d, s) {
(as.integer(format(d, "%Y")) - as.integer(substr(s, 1, 4))) * 12 +
(as.integer(format(d, "%m")) - as.integer(substr(s, 6, 7)))
}
rows$tenure <- tenure(as.Date(rows$activity_date), rows$signup_month)
tri <- matrix(NA_real_, 8, 8, dimnames = list(cohorts, paste0("m", 0:7)))
for (i in seq_along(cohorts)) for (t in 0:(8 - i)) {
n <- sum(rows$signup_month == cohorts[i] & rows$tenure == t)
tri[i, t + 1] <- 100 * n / cohort_size
}
knitr::kable(round(tri, 1), caption =
"Retention % by cohort (rows) and months since signup (columns). Read DOWN a column: same age, fair comparison.")
| m0 | m1 | m2 | m3 | m4 | m5 | m6 | m7 | |
|---|---|---|---|---|---|---|---|---|
| 2026-01 | 100 | 62.7 | 50.7 | 42.7 | 36 | 32 | 28 | 25.3 |
| 2026-02 | 100 | 62.7 | 50.7 | 42.7 | 36 | 32 | 28 | NA |
| 2026-03 | 100 | 62.7 | 50.7 | 42.7 | 36 | 32 | NA | NA |
| 2026-04 | 100 | 62.7 | 50.7 | 42.7 | 36 | NA | NA | NA |
| 2026-05 | 100 | 78.7 | 64.0 | 54.7 | NA | NA | NA | NA |
| 2026-06 | 100 | 78.7 | 64.0 | NA | NA | NA | NA | NA |
| 2026-07 | 100 | 78.7 | NA | NA | NA | NA | NA | NA |
| 2026-08 | 100 | NA | NA | NA | NA | NA | NA | NA |
Reading across a row shows how one cohort decays. Reading down a column compares cohorts at the same age — the only fair comparison, and the one that answers whether the product got better at keeping accounts.
par(mar = c(4, 5.5, 3, 1))
image(x = 0:7, y = 1:8, z = t(tri[8:1, ]), col = hcl.colors(24, "Blues", rev = TRUE),
axes = FALSE, xlab = "months since signup", ylab = "",
main = "Cohort retention heatmap")
axis(1, at = 0:7); axis(2, at = 1:8, labels = rev(cohorts), las = 1)
for (i in 1:8) for (t in 0:7) if (!is.na(tri[i, t + 1]))
text(t, 9 - i, sprintf("%.0f", tri[i, t + 1]), cex = 0.75)
box()
Column m1 is month-1 retention. Cohorts 1–4 were
self-serve; cohorts 5–8 got the guided setup call.
Cohort 8 is excluded from this comparison. It signed up in the last month of the data, so its month 1 has not happened yet — that is why the table is a triangle. Counting its 75 accounts as “not retained” would not be a small distortion: it would drag the guided-setup group from 78.7% to 59.0% and reverse the finding. Never compare a cell that exists against a cell that has not been observed.
m1 <- sapply(seq_along(cohorts), function(i)
sum(rows$signup_month == cohorts[i] & rows$tenure == 1))
observed_m1 <- which(sapply(seq_along(cohorts), function(i) (8 - i) >= 1)) # cohorts 1-7
pre_i <- intersect(1:4, observed_m1)
post_i <- intersect(5:8, observed_m1)
pre <- sum(m1[pre_i]); n_pre <- length(pre_i) * cohort_size
post <- sum(m1[post_i]); n_post <- length(post_i) * cohort_size
data.frame(group = c(sprintf("self_serve (cohorts %s)", paste(range(pre_i), collapse = "-")),
sprintf("guided_setup (cohorts %s)", paste(range(post_i), collapse = "-"))),
retained_m1 = c(pre, post), accounts = c(n_pre, n_post),
rate = sprintf("%.4f%%", 100 * c(pre / n_pre, post / n_post)))
| group | retained_m1 | accounts | rate |
|---|---|---|---|
| self_serve (cohorts 1-4) | 188 | 300 | 62.6667% |
| guided_setup (cohorts 5-7) | 177 | 225 | 78.6667% |
pt <- prop.test(c(post, pre), c(n_post, n_pre))
pt
##
## 2-sample test for equality of proportions with continuity correction
##
## data: c(post, pre) out of c(n_post, n_pre)
## X-squared = 14.788, df = 1, p-value = 0.0001203
## alternative hypothesis: two.sided
## 95 percent confidence interval:
## 0.07955379 0.24044621
## sample estimates:
## prop 1 prop 2
## 0.7866667 0.6266667
c(diff_pp = 100 * (post / n_post - pre / n_pre),
ci_lo = 100 * pt$conf.int[1], ci_hi = 100 * pt$conf.int[2])
## diff_pp ci_lo ci_hi
## 16.000000 7.955379 24.044621
chisq.test(matrix(c(post, n_post - post, pre, n_pre - pre), 2, byrow = TRUE))
##
## Pearson's Chi-squared test with Yates' continuity correction
##
## data: matrix(c(post, n_post - post, pre, n_pre - pre), 2, byrow = TRUE)
## X-squared = 14.788, df = 1, p-value = 0.0001203
fisher.test(matrix(c(post, n_post - post, pre, n_pre - pre), 2, byrow = TRUE))
##
## Fisher's Exact Test for Count Data
##
## data: matrix(c(post, n_post - post, pre, n_pre - pre), 2, byrow = TRUE)
## p-value = 8.238e-05
## alternative hypothesis: true odds ratio is not equal to 1
## 95 percent confidence interval:
## 1.455114 3.339714
## sample estimates:
## odds ratio
## 2.193568
par(mar = c(4, 5, 3, 1))
shown <- observed_m1
cols <- ifelse(shown >= 5, "#3fbf6f", "#5fa9dd")
vals <- 100 * m1[shown] / cohort_size
bp <- barplot(vals, names.arg = cohorts[shown], col = cols, border = NA,
ylim = c(0, 95), ylab = "month-1 retention (%)",
main = "Month-1 retention by cohort (green = guided setup call)")
text(bp, vals + 4, sprintf("%.1f%%", vals), cex = 0.85)
segments(bp[4] + 0.6, 0, bp[4] + 0.6, 90, lty = 2, col = "#918e98")
mtext(sprintf("cohort %s omitted: its month 1 has not happened yet", cohorts[8]),
side = 1, line = 2.6, cex = 0.8, col = "#918e98")