A B2B software company wants one number for its board: how long does an account last? The finance team already has an answer — average the tenure of every account that has churned. That number is wrong, and it is wrong in a predictable direction.
The reason is censoring. On the reporting date a large share of accounts are still active. Their contracts have not ended, so their lifetimes are not known — but they are not missing. An account that has been live for 900 days and has not churned tells you something exact: its lifetime is at least 900 days. Averaging only the churned accounts throws every one of those facts away, and every fact it throws away is good news. The estimate can only come out short.
The second question is the actionable one: does completing onboarding change how long an account lasts? Onboarding costs money, so it has to earn its place.
One row per account, as it would come out of a subscription billing system.
| column | meaning |
|---|---|
account_id |
the account |
segment |
SMB / Mid-Market / Enterprise |
seats |
licensed seats |
mrr_usd |
monthly recurring revenue, US dollars |
onboarding |
completed / not_completed — did the
account finish guided onboarding |
started_at |
contract start date |
churned_at |
churn date, or empty if the account is still active |
tenure_days |
days from start to churn, or to the reporting date if still active |
churned |
1 = churned (event observed), 0 = censored (still active) |
Accounts start on staggered dates, so each has a different length of
observation. The reporting date is fixed at 2026-06-30. Generation is
seeded, so re-knitting reproduces data.csv
byte-for-byte.
set.seed(20260824)
n <- 320
reporting_date <- as.Date("2026-06-30")
segment <- sample(c("SMB", "Mid-Market", "Enterprise"), n, replace = TRUE,
prob = c(0.55, 0.32, 0.13))
seats <- ifelse(segment == "SMB", sample(3:25, n, replace = TRUE),
ifelse(segment == "Mid-Market", sample(25:120, n, replace = TRUE),
sample(120:600, n, replace = TRUE)))
mrr_usd <- round(seats * runif(n, 28, 46), 2)
onboarding <- ifelse(runif(n) < 0.62, "completed", "not_completed")
# staggered entry: every account has a different observation window
follow_up_days <- sample(180:1500, n, replace = TRUE)
started_at <- reporting_date - follow_up_days
# true lifetimes: Weibull, shorter for accounts that never finished onboarding
shape <- 1.15
scale_days <- ifelse(onboarding == "completed", 32 * 30.44, 14 * 30.44)
true_life <- round(rweibull(n, shape = shape, scale = scale_days))
churned <- as.integer(true_life <= follow_up_days)
tenure_days <- pmin(true_life, follow_up_days)
churned_at <- as.Date(ifelse(churned == 1, as.character(started_at + tenure_days), NA))
accounts <- data.frame(
account_id = sprintf("ACC-%04d", seq_len(n)),
segment = segment,
seats = seats,
mrr_usd = mrr_usd,
onboarding = onboarding,
started_at = started_at,
churned_at = churned_at,
tenure_days = tenure_days,
churned = churned,
stringsAsFactors = FALSE
)
accounts <- accounts[order(accounts$account_id), ]
rownames(accounts) <- NULL
write.csv(accounts, "data.csv", row.names = FALSE)
n_events <- sum(accounts$churned)
n_censored <- sum(accounts$churned == 0)
data.frame(accounts = n, churned = n_events, still_active = n_censored,
censored_pct = round(100 * n_censored / n, 2))
## accounts churned still_active censored_pct
## 1 320 210 110 34.38
kable(head(accounts[, c("account_id","segment","seats","mrr_usd","onboarding",
"started_at","churned_at","tenure_days","churned")], 8),
caption = "First eight rows as the billing export delivers them")
| account_id | segment | seats | mrr_usd | onboarding | started_at | churned_at | tenure_days | churned |
|---|---|---|---|---|---|---|---|---|
| ACC-0001 | SMB | 14 | 638.10 | completed | 2023-07-03 | NA | 1093 | 0 |
| ACC-0002 | Mid-Market | 75 | 2536.53 | completed | 2024-10-15 | 2026-01-13 | 455 | 1 |
| ACC-0003 | SMB | 8 | 325.64 | not_completed | 2023-12-08 | 2024-06-28 | 203 | 1 |
| ACC-0004 | SMB | 20 | 674.87 | completed | 2022-06-26 | 2025-12-13 | 1266 | 1 |
| ACC-0005 | Enterprise | 387 | 17307.17 | completed | 2025-11-13 | NA | 229 | 0 |
| ACC-0006 | Mid-Market | 73 | 2074.86 | completed | 2023-12-14 | NA | 929 | 0 |
| ACC-0007 | SMB | 3 | 136.41 | not_completed | 2023-03-18 | 2024-08-29 | 530 | 1 |
| ACC-0008 | Mid-Market | 94 | 4067.74 | completed | 2023-10-20 | NA | 984 | 0 |
churned_only <- accounts$tenure_days[accounts$churned == 1]
naive_mean <- mean(churned_only)
naive_median <- median(churned_only)
c(naive_mean_days = round(naive_mean, 2), naive_median_days = round(naive_median, 2),
naive_mean_months = round(naive_mean / 30.44, 2))
## naive_mean_days naive_median_days naive_mean_months
## 380.30 301.00 12.49
This is the finance team’s number: it answers “how long did the accounts that already left last?” — a question about the dead, not about the book of business.
fit_all <- survfit(Surv(tenure_days, churned) ~ 1, data = accounts)
km_med <- summary(fit_all)$table
km_med[c("records", "events", "median", "0.95LCL", "0.95UCL")]
## records events median 0.95LCL 0.95UCL
## 320 210 540 451 637
anchors <- summary(fit_all, times = c(365, 730, 1095))
data.frame(day = anchors$time,
n_at_risk = anchors$n.risk,
survival = round(anchors$surv, 4),
lower = round(anchors$lower, 4),
upper = round(anchors$upper, 4))
## day n_at_risk survival lower upper
## 1 365 175 0.6137 0.5618 0.6703
## 2 730 73 0.3671 0.3125 0.4313
## 3 1095 24 0.2305 0.1788 0.2973
km_median_days <- unname(km_med["median"])
gap_pct <- 100 * (km_median_days - naive_median) / km_median_days
c(naive_median_days = naive_median, km_median_days = km_median_days,
naive_understates_pct = round(gap_pct, 2))
## naive_median_days km_median_days naive_understates_pct
## 301.00 540.00 44.26
The median answers “half the book outlasts this.” If you need an average instead, the honest one is the restricted mean — the area under the survival curve out to a stated horizon. The horizon is part of the number; quoting it without the horizon is meaningless.
rmst <- function(fit, horizon) {
t <- c(0, fit$time); s <- c(1, fit$surv)
keep <- t <= horizon
t <- c(t[keep], horizon); s <- c(s[keep], tail(s[keep], 1))
sum(diff(t) * head(s, -1)) # left-continuous step integral
}
rmst_all <- rmst(fit_all, 730)
c(rmst_730_days = round(rmst_all, 2), rmst_730_months = round(rmst_all / 30.44, 2))
## rmst_730_days rmst_730_months
## 470.03 15.44
fit_grp <- survfit(Surv(tenure_days, churned) ~ onboarding, data = accounts)
grp_tab <- summary(fit_grp)$table
kable(round(grp_tab[, c("records","events","median","0.95LCL","0.95UCL")], 2),
caption = "Median account lifetime in days, by onboarding status")
| records | events | median | 0.95LCL | 0.95UCL | |
|---|---|---|---|---|---|
| onboarding=completed | 217 | 123 | 703 | 599 | 800 |
| onboarding=not_completed | 103 | 87 | 257 | 190 | 374 |
lr <- survdiff(Surv(tenure_days, churned) ~ onboarding, data = accounts)
lr
## Call:
## survdiff(formula = Surv(tenure_days, churned) ~ onboarding, data = accounts)
##
## N Observed Expected (O-E)^2/E (O-E)^2/V
## onboarding=completed 217 123 164.4 10.4 49.7
## onboarding=not_completed 103 87 45.6 37.7 49.7
##
## Chisq= 49.7 on 1 degrees of freedom, p= 2e-12
lr_p <- 1 - pchisq(lr$chisq, df = length(lr$n) - 1)
signif(lr_p, 6)
## [1] 1.83409e-12
cx <- coxph(Surv(tenure_days, churned) ~ onboarding, data = accounts)
summary(cx)
## Call:
## coxph(formula = Surv(tenure_days, churned) ~ onboarding, data = accounts)
##
## n= 320, number of events= 210
##
## coef exp(coef) se(coef) z Pr(>|z|)
## onboardingnot_completed 0.9775 2.6578 0.1440 6.79 1.12e-11 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## exp(coef) exp(-coef) lower .95 upper .95
## onboardingnot_completed 2.658 0.3763 2.004 3.524
##
## Concordance= 0.614 (se = 0.017 )
## Likelihood ratio test= 42.75 on 1 df, p=6e-11
## Wald test = 46.1 on 1 df, p=1e-11
## Score (logrank) test = 49.65 on 1 df, p=2e-12
hr <- unname(exp(coef(cx)))
hr_ci <- unname(exp(confint(cx)))
c(hazard_ratio_notcompleted_vs_completed = round(hr, 4),
ci_lo = round(hr_ci[1], 4), ci_hi = round(hr_ci[2], 4))
## hazard_ratio_notcompleted_vs_completed ci_lo
## 2.6578 2.0044
## ci_hi
## 3.5242
fits <- survfit(Surv(tenure_days, churned) ~ onboarding, data = accounts)
strata_names <- names(fits$strata)
idx <- rep(seq_along(fits$strata), fits$strata)
rmst_by <- sapply(seq_along(strata_names), function(k) {
sub <- list(time = fits$time[idx == k], surv = fits$surv[idx == k])
rmst(sub, 730)
})
names(rmst_by) <- strata_names
round(rmst_by, 2)
## onboarding=completed onboarding=not_completed
## 532.60 338.25
plot(fit_grp, col = c("#F97316", "#5fa9dd"), lwd = 2.4, mark.time = TRUE,
xlab = "days since contract start", ylab = "share of accounts still active",
main = "Kaplan-Meier: account survival by onboarding status",
xlim = c(0, 1200), yaxs = "i", ylim = c(0, 1.02))
abline(h = 0.5, lty = 3, col = "#888888")
legend("topright", legend = c("onboarding completed", "onboarding not completed"),
col = c("#F97316", "#5fa9dd"), lwd = 2.4, bty = "n")
mtext("vertical ticks = censored accounts (still active at the reporting date)",
side = 3, line = 0.2, cex = 0.8, col = "#666666")
vals <- c(`churned accounts only\n(the naive answer)` = naive_median,
`Kaplan-Meier median\n(every account counted)` = km_median_days)
bp <- barplot(vals, col = c("#5fa9dd", "#F97316"), ylim = c(0, max(vals) * 1.25),
ylab = "median account lifetime (days)",
main = "Dropping the still-active accounts shortens the answer")
text(bp, vals + max(vals) * 0.05, sprintf("%.0f days", vals), font = 2)
Your turn
Bring your own data and the question you actually need answered.
CympleData Scientist Send me your data and question, I’ll send you the analytics. ds@mcpanalytics.ai