Executive Summary
Retention across 13 monthly cohorts of 260 customers
The short answer
About 30 of every 100 new customers return in the month after their first purchase (29.8% month-1 retention), and by month 3 that drops to 27.4%. Newer cohorts are retaining worse than older ones: your most recent cohorts hold 26.6% at month 1 versus 29.1% for your earliest cohorts. The strongest performer is the November 2011 cohort (46.7% month-1 retention); the weakest is July 2011 (10%).
The detail
Across 260 customers in 13 monthly cohorts (2010-12 to 2011-12), month-1 retention averages 29.8%, month-3 reaches 27.4%, and month-6 settles at 23.8%. The trend is declining: comparing the first six cohorts (2010-12 to 2011-05) at month 1 yields 29.1%, while the most recent observable cohorts (2011-07 to 2011-11) average 26.6%. Cohort 2011-11 is the outlier at 46.7%; cohort 2011-07 is the lowest at 10%.
What this can't tell you
The November 2011 cohort's strong retention may reflect seasonal holiday purchasing or a small-sample artifact (15 customers); its persistence should be monitored across future months. July 2011's weakness (10 customers) is similarly thin and may not generalize.
Analysis Overview
Monthly cohort retention across 13 cohorts and 260 customers.
The short answer
This analysis tracks 260 customers across 13 acquisition cohorts, measuring what fraction return in each subsequent month. By definition, every cohort starts at 100% in month 0 (first purchase). The method groups customers by their first-activity month and observes how many remain active at fixed intervals—a standard cohort retention analysis. Recent cohorts are excluded from later months because those months haven't occurred yet, not because they churned.
The detail
The dataset spans 13 cohorts with a 12-month observation window. Each cohort's retention is expressed as a percentage of its initial size. Month 0 is always 100%; the analysis measures the share of each cohort still active N months after first InvoiceDate. Right-censoring is handled by omitting unobserved future months from both the heatmap and the average retention curve, not by counting them as zero. The trend verdict compares month 1 retention between the earliest half and most recent half of cohorts: retention is declining.
What this can't tell you
The method does not distinguish between customers who never returned and those whose data is simply not yet recorded. Cohorts acquired near the end of the observation window have fewer observable months by design, limiting the precision of long-term retention estimates for recent acquisitions.
Data Quality
Rows, customers, date coverage, and cohort formation.
The short answer
No data was lost in cleaning. All 21,608 rows had valid invoice dates and customer IDs; zero rows were removed. Your dataset spans the full 13-month period from December 2010 to December 2011 and captures 260 unique customers forming 13 monthly acquisition cohorts.
The detail
Initial rows: 21,608; final rows: 21,608; rows removed: 0. Date coverage: 2010-12-01 to 2011-12-09. All activity rows were retained because every row carried a readable InvoiceDate and a non-blank CustomerID. This clean handoff means no systematic bias was introduced by missing or malformed data.
What this can't tell you
The export does not indicate whether rows represent individual orders or aggregated customer activity. A transaction-level export (one row per order, not per customer) would clarify whether the retention metric is based on order frequency or customer presence, which can move differently.
Retention by Cohort
Percentage of each cohort still active N months after first activity (observable cells only).
The short answer
Month-to-month retention drops sharply after first purchase: on average, 70.2% of customers do not return in month 1, leaving about 30% of each cohort active. By month 6, the average cohort has stabilized around a loyal core of 23.8%. The 2010-12 cohort shows the most resilience early on, while 2011-11 leads at month 1 with 46.7%—though cohort size varies and affects interpretation.
The detail
The retention matrix shows each row as an acquisition cohort and each column as months elapsed since first activity. M0 is 100% by definition. The steepest loss occurs between month 0 and month 1: on average 70.2% do not return. By month 6, the average cohort retains 23.8% of customers. Comparing rows at the same column (e.g., month 1) ranks cohorts fairly: 2011-11 leads at 46.7%, while 2011-07 trails at 10%. The lower-right triangle is blank because recent cohorts have not yet reached those months—this is right-censoring, not zero retention. After month 1, decay patterns vary: some cohorts stabilize, others continue declining.
What this can't tell you
Small cohort sizes introduce noise into individual retention percentages. For example, 2011-07 has only 10 customers, making its 10% month 1 retention (1 customer) subject to high variance. A transaction-level export would help separate true churn from seasonal or sporadic purchase patterns.
Average Retention Curve
Weighted average retention by months since first activity, across cohorts old enough to observe each month.
The short answer
The typical customer lifecycle is front-loaded: a steep drop from 100% to 29.8% in month 1, then a gentle slide to a stable core around 23–28% by month 6. The curve does not flatten dramatically, suggesting continuous low-level churn rather than a durable long-term base. Month 5 and months 9–11 show upticks (35–36%), but the overall pattern is downward.
The detail
Each bar averages only the cohorts old enough to observe that month, so recent cohorts never artificially depress the tail. M0 is 100% by definition; M1 drops to 29.8% (the largest single-month loss). M2–M4 drift slightly downward to 26.5% by M4. M5 rebounds to 35.9%, then M6 falls to 23.8% (the lowest observed). Months 7–12 oscillate between 23.8% and 36.4%, with M9 (36.4%), M10 (33.9%), and M11 (35.1%) slightly elevated and M12 returning to 28.1%.
What this can't tell you
The oscillations in months 5–12 lack confidence intervals in the supplied data, so it is unclear whether they reflect true seasonal variation or sampling noise. A month-by-month statistical test would be needed to confirm whether these mid-to-late-year bumps are real or artifact.
New Customers per Cohort
Acquisition volume: unique new customers by first-activity month.
The short answer
Your acquisition volume is shrinking: the oldest cohort (December 2010) brought 57 new customers, while the most recent (December 2011) brought only 2. The average is 20 new customers per month. This shrinking feed matters because retention percentages elsewhere are relative to these sizes—a small cohort with high retention contributes less volume than a large one with average retention.
The detail
Cohort sizes range from 57 (2010-12) to 2 (2011-12). The first half averages 25 customers per cohort (2010-12 through 2011-06: 57, 37, 21, 25, 17, 12, 16); the second half averages 14 (2011-07 through 2011-12: 10, 9, 22, 17, 15, 2). December 2011 is too recent to have observable month-1 retention yet; its size of 2 means any future retention rate will be highly volatile.
What this can't tell you
The sharp drop in recent months (December 2011: 2 customers) could indicate the data export was truncated mid-month, a true decline in acquisition, or a seasonal dip. A note on the export date relative to December 2011 would clarify whether this is a data artifact or a real business signal.
Cohort Comparison
Every cohort's size and month-1/3/6 retention side by side; blank cells are months the cohort has not reached yet.
| Cohort | Size | M1 Retention PCT | M3 Retention PCT | M6 Retention PCT |
|---|---|---|---|---|
| 2010-12 | 57 | 42.1 | 33.3 | 33.3 |
| 2011-01 | 37 | 27 | 24.3 | 13.5 |
| 2011-02 | 21 | 14.3 | 23.8 | 33.3 |
| 2011-03 | 25 | 20 | 16 | 24 |
| 2011-04 | 17 | 29.4 | 23.5 | 11.8 |
| 2011-05 | 12 | 41.7 | 33.3 | 33.3 |
| 2011-06 | 16 | 31.2 | 25 | 6.2 |
| 2011-07 | 10 | 10 | 20 | — |
| 2011-08 | 9 | 22.2 | 44.4 | — |
| 2011-09 | 22 | 31.8 | 31.8 | — |
| 2011-10 | 17 | 17.6 | — | — |
| 2011-11 | 15 | 46.7 | — | — |
| 2011-12 | 2 | — | — | — |
The short answer
Month 1 retention varies widely across cohorts: 2011-11 retains 46.7% of customers, while 2011-07 retains only 10%. When comparing early cohorts (2010-12 through 2011-05) against recent ones (2011-06 through 2011-10), the recent cohorts average 26.6% month 1 retention versus 29.1% for early cohorts—a modest downward trend. By month 6, only 7 cohorts have data, and retention ranges from 6.2% to 33.3%.
The detail
Of 13 cohorts, 12 have reached month 1, 10 have reached month 3, and 7 have reached month 6; blank cells indicate months not yet observed. At month 1, 2011-11 leads at 46.7% and 2011-07 trails at 10%. The month 1 column shows early cohorts averaging 29.1% retention and recent cohorts averaging 26.6% retention. At month 3, performance clusters between 16% and 44.4%, with 2011-08 at 44.4% (9 customers). At month 6, observable cohorts range from 6.2% to 33.3%; 2011-02 and 2011-05 both reach 33.3%. Cohort size ranges from 2 to 57 customers.
What this can't tell you
Small cohorts (e.g., 2011-07 with 10 customers, 2011-12 with 2) produce volatile retention percentages that may not generalize. The trend toward lower month 1 retention in recent cohorts is real but modest; a longer observation window would clarify whether this reflects a genuine shift in customer quality or natural sampling variation.
Cohort Retention Analysis — How Well Do You Keep Your Customers?
Builds monthly acquisition cohorts from raw activity/order data (one row per order/event), computes each cohort's month-by-month retention, and determines whether newer cohorts retain better or worse than older ones.
Why This Method?
Cohort retention separates growth from stickiness: total actives can rise while every cohort leaks. Grouping customers by first-activity month and tracking each group's survival is the standard, right-censoring-aware way to see whether the product actually keeps the customers it acquires.
What This Analysis Covers
- Retention heatmap: every cohort x months-since-acquisition cell
- Average retention curve (only cohorts old enough to observe each month)
- Cohort sizes (acquisition volume per month)
- Cohort comparison table with month-1/3/6 retention and a trend verdict
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {customer_id, activity_date}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# cust_h / date_h $ humanized user column names
# n_customers $ unique customers analysed
# cohort_labels $ character — "YYYY-MM" per cohort (chronological)
# cohort_sizes_vec $ integer — new customers per cohort
# n_excluded_cohorts $ cohorts older than the 24-cohort cap
# date_min / date_max $ Date — observed activity range
# matrix_df $ data.frame(cohort, month_number, retention_pct) — observable cells only
# curve_df $ data.frame(month_label, avg_retention_pct) — right-censoring-aware
# sizes_df $ data.frame(cohort, new_customers)
# comparison_df $ data.frame(cohort, size, m1/m3/m6_retention_pct) — NA when unobservable
# m1_overall/m3_overall/m6_overall $ weighted avg retention (NA if unobservable)
# m1_by_cohort $ numeric — per-cohort M1 retention (NA if unobservable)
# best_cohort/worst_cohort + _m1 $ best/worst cohort at month 1
# trend_word/trend_first/trend_second/trend_n $ first-half vs second-half M1 verdict
# metrics / json_output
# === /SHARED EXPORTS ===
MAX_MONTHS <- 12L # retention horizon: M0..M12
MAX_COHORTS <- 24L # keep the most recent 24 cohorts
initial_rows <- nrow(df)
cust_h <- humanize_semantic("customer_id", col_map)
date_h <- humanize_semantic("activity_date", col_map)Step 1: Validate mapping and parse dates
if (!("customer_id" %in% names(df)) || !("activity_date" %in% names(df))) {
stop(sprintf("Cohort retention needs both a customer column(%s) and an activity date column(%s) mapped.",
cust_h, date_h))
}
cust <- trimws(as.character(df$customer_id))
raw_dates <- df$activity_date
dts <- parse_activity_dates(raw_dates)
n_nonblank <- sum(!is.na(raw_dates) & trimws(as.character(raw_dates)) != "")
if (n_nonblank == 0 || sum(!is.na(dts)) < 0.95 * n_nonblank) {
stop(sprintf("The %s column could not be read as dates — expected values like 2024-01-31 or 1/31/2024.",
date_h))
}
keep <- !is.na(dts) & !is.na(cust) & cust != ""
cust <- cust[keep]
dts <- dts[keep]
rows_bad <- initial_rows - sum(keep)
if (length(dts) < 2) {
stop(sprintf("Too few usable rows after cleaning — check %s and %s for blanks.", cust_h, date_h))
}Step 2: Assign cohorts (first-activity month per customer)
act_idx <- month_index(dts)
cohort_by_cust <- tapply(act_idx, cust, min) # named: customer -> cohort month index
all_cohorts <- sort(unique(as.integer(cohort_by_cust)))Cap at the MAX_COHORTS most recent cohorts
n_excluded_cohorts <- 0L
if (length(all_cohorts) > MAX_COHORTS) {
n_excluded_cohorts <- length(all_cohorts) - MAX_COHORTS
all_cohorts <- tail(all_cohorts, MAX_COHORTS)
}
cust_cohort <- as.integer(cohort_by_cust[cust]) # per-row cohort of the row's customer
in_scope <- cust_cohort %in% all_cohorts
cust <- cust[in_scope]; dts <- dts[in_scope]
act_idx <- act_idx[in_scope]; cust_cohort <- cust_cohort[in_scope]
final_rows <- length(cust)
rows_removed <- initial_rows - final_rows
max_idx <- max(act_idx)
date_min <- min(dts); date_max <- max(dts)Step 3: Guards — need at least 2 cohorts and 2 observable months
if (length(all_cohorts) < 2) {
stop(sprintf("Cohort retention needs customers arriving in at least two different calendar months — every %s in this data first appears in %s. A longer %s range is required.",
cust_h, month_label(all_cohorts[1]), date_h))
}
if (max_idx - min(all_cohorts) < 1) {
stop(sprintf("The %s column spans a single calendar month — at least two months of activity are needed to measure retention.",
date_h))
}
cohort_labels <- month_label(all_cohorts)Cohort sizes: distinct customers per cohort (recomputed on in-scope rows — a customer belongs to exactly one cohort, so filtering is customer-complete)
cust_first <- tapply(act_idx, cust, min)
cohort_sizes_vec <- as.integer(sapply(all_cohorts, function(cc)
sum(as.integer(cust_first) == cc)))
n_customers <- length(cust_first)Step 4: Retention matrix — distinct customers active per cohort x month
month_number <- act_idx - cust_cohort
um <- unique(data.frame(cohort = cust_cohort, m = month_number, id = cust,
stringsAsFactors = FALSE))
k <- length(all_cohorts)
active_mat <- matrix(0L, nrow = k, ncol = MAX_MONTHS + 1L) # counts, cols = M0..M12
obs_mat <- matrix(FALSE, nrow = k, ncol = MAX_MONTHS + 1L)
for (i in seq_len(k)) {
cc <- all_cohorts[i]
max_obs_m <- min(MAX_MONTHS, max_idx - cc) # right-censoring boundary
for (m in 0:max_obs_m) {
obs_mat[i, m + 1L] <- TRUE
active_mat[i, m + 1L] <- sum(um$cohort == cc & um$m == m)
}
}
ret_mat <- 100 * sweep(active_mat, 1, cohort_sizes_vec, "/") # row-wise: cell / cohort sizeLong format, observable cells only (no future NAs)
cells <- which(obs_mat, arr.ind = TRUE)
cells <- cells[order(cells[, 1], cells[, 2]), , drop = FALSE]
matrix_df <- data.frame(
cohort = cohort_labels[cells[, 1]],
month_number = paste0("M", cells[, 2] - 1L),
retention_pct = round(ret_mat[cells], 1),
stringsAsFactors = FALSE
)
rownames(matrix_df) <- NULLStep 5: Aggregate curve — weighted avg over cohorts old enough (right-censoring aware)
max_m <- min(MAX_MONTHS, max_idx - min(all_cohorts))
curve_vals <- sapply(0:max_m, function(m) {
elig <- which(obs_mat[, m + 1L]) # cohorts that have lived m months
100 * sum(active_mat[elig, m + 1L]) / sum(cohort_sizes_vec[elig])
})
curve_df <- data.frame(
month_label = paste0("M", 0:max_m),
avg_retention_pct = round(curve_vals, 1),
stringsAsFactors = FALSE
)
sizes_df <- data.frame(cohort = cohort_labels,
new_customers = cohort_sizes_vec,
stringsAsFactors = FALSE)Step 6: Headline metrics — M1/M3/M6, best/worst cohort, trend
ret_at <- function(m) if (max_m >= m) round(curve_vals[m + 1L], 1) else NA_real_
m1_overall <- ret_at(1); m3_overall <- ret_at(3); m6_overall <- ret_at(6)
cohort_ret_at <- function(m) {
v <- rep(NA_real_, k)
obs <- obs_mat[, m + 1L]
v[obs] <- round(ret_mat[obs, m + 1L], 1)
v
}
m1_by_cohort <- cohort_ret_at(1)
m3_by_cohort <- cohort_ret_at(3)
m6_by_cohort <- cohort_ret_at(6)NEVER which.max over possibly-all-NA vectors — filter NA indices first
obs1 <- which(!is.na(m1_by_cohort))
best_cohort <- worst_cohort <- NA_character_
best_cohort_m1 <- worst_cohort_m1 <- NA_real_
if (length(obs1) > 0) {
bi <- obs1[which.max(m1_by_cohort[obs1])]
wi <- obs1[which.min(m1_by_cohort[obs1])]
best_cohort <- cohort_labels[bi]; best_cohort_m1 <- m1_by_cohort[bi]
worst_cohort <- cohort_labels[wi]; worst_cohort_m1 <- m1_by_cohort[wi]
}Trend: first-half vs second-half cohorts' month-1 retention
trend_word <- "not assessable"
trend_first <- trend_second <- NA_real_
trend_n <- length(obs1)
if (trend_n >= 4) {
m1s <- m1_by_cohort[obs1] # chronological (cohorts sorted)
half <- floor(trend_n / 2)
trend_first <- round(mean(head(m1s, half)), 1)
trend_second <- round(mean(tail(m1s, half)), 1)
d <- trend_second - trend_first
trend_word <- if (d >= 2) "improving" else if (d <= -2) "declining" else "stable"
}
comparison_df <- data.frame(
cohort = cohort_labels,
size = cohort_sizes_vec,
m1_retention_pct = m1_by_cohort,
m3_retention_pct = m3_by_cohort,
m6_retention_pct = m6_by_cohort,
stringsAsFactors = FALSE
)
metrics <- list(
`Customers` = n_customers,
`Cohorts` = k,
`Month-1 Retention %` = m1_overall,
`Best Cohort(M1)` = if (!is.na(best_cohort)) sprintf("%s(%.1f%%)", best_cohort, best_cohort_m1) else "n/a",
`Retention Trend` = trend_word
)
if (!is.na(m3_overall)) metrics$`Month-3 Retention %` <- m3_overall
if (!is.na(m6_overall)) metrics$`Month-6 Retention %` <- m6_overall
trend_phrase <- switch(trend_word,
improving = sprintf("newer cohorts are retaining BETTER(month-1: %.1f%% recent vs %.1f%% early)", trend_second, trend_first),
declining = sprintf("newer cohorts are retaining WORSE(month-1: %.1f%% recent vs %.1f%% early)", trend_second, trend_first),
stable = sprintf("retention is stable across cohorts(month-1: %.1f%% recent vs %.1f%% early)", trend_second, trend_first),
"too few cohorts to assess a trend")
json_output <- list(
answer = paste0(
"Monthly cohort retention on ", format(n_customers, big.mark = ","),
" customers across ", k, " cohorts(", cohort_labels[1], " to ",
cohort_labels[k], "): month-1 retention is ",
if (!is.na(m1_overall)) paste0(m1_overall, "%") else "unobservable",
if (!is.na(m3_overall)) paste0(", month-3 is ", m3_overall, "%") else "",
"; ", trend_phrase, ". Best cohort at month 1: ",
if (!is.na(best_cohort)) paste0(best_cohort, " (", best_cohort_m1, "%)") else "n/a", "."
),
cards = lapply(
c("tldr", "overview", "preprocessing", "retention_heatmap",
"retention_curve", "cohort_sizes", "cohort_comparison"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows,
rows_removed = rows_removed, rows_bad = rows_bad,
cust_h = cust_h, date_h = date_h,
n_customers = n_customers,
cohort_labels = cohort_labels, cohort_sizes_vec = cohort_sizes_vec,
n_excluded_cohorts = n_excluded_cohorts,
date_min = date_min, date_max = date_max, max_m = max_m,
matrix_df = matrix_df, curve_df = curve_df, sizes_df = sizes_df,
comparison_df = comparison_df,
m1_overall = m1_overall, m3_overall = m3_overall, m6_overall = m6_overall,
m1_by_cohort = m1_by_cohort,
best_cohort = best_cohort, best_cohort_m1 = best_cohort_m1,
worst_cohort = worst_cohort, worst_cohort_m1 = worst_cohort_m1,
trend_word = trend_word, trend_first = trend_first,
trend_second = trend_second, trend_n = trend_n,
metrics = metrics, json_output = json_output
)
}Only cohorts with NO observable month-1 cell may be called "too recent" — never a cohort that has reached month 1.
no_m1 <- shared$cohort_labels[is.na(shared$m1_by_cohort)]
censor_note <- if (length(no_m1) > 0) {
paste0(" ", paste(no_m1, collapse = ", "),
if (length(no_m1) == 1) " is" else " are",
" too recent to have observable month-1 retention yet.")
} else ""
list(
title = "New Customers per Cohort",
description = "Acquisition volume: unique new customers by first-activity month.",
text = paste0(
"Cohort intake ranges from ", format(min(sz), big.mark = ","), " (",
shared$cohort_labels[si], ") to ", format(max(sz), big.mark = ","), " (",
shared$cohort_labels[bi], "), averaging ", format(round(mean(sz)), big.mark = ","),
" new customers per month. Comparing the first and last cohorts(",
format(first_v, big.mark = ","), " vs ", format(last_v, big.mark = ","),
"), acquisition is ", acq_word, ". Retention percentages elsewhere in this report are relative ",
"to these sizes — a small cohort with high retention can still matter less than a large one ",
"with average retention.", censor_note
),
chart_labels = list(
cohort = "Acquisition cohort(first-activity month)",
new_customers = "New customers"
),
data = list(cohort_sizes = shared$sizes_df)
)
}
# Card: cohort_comparison (table)
card_cohort_comparison <- function(shared, df, params) {
n_m3 <- sum(!is.na(shared$comparison_df$m3_retention_pct))
n_m6 <- sum(!is.na(shared$comparison_df$m6_retention_pct))
list(
title = "Cohort Comparison",
description = "Every cohort's size and month-1/3/6 retention side by side; blank cells are months the cohort has not reached yet.",
text = paste0(
"Of ", length(shared$cohort_labels), " cohorts, ", sum(!is.na(shared$m1_by_cohort)),
" have reached month 1, ", n_m3, " month 3, and ", n_m6, " month 6 — blank cells mean the cohort ",
"is too recent to observe that month, not that retention is zero. ",
if (!is.na(shared$best_cohort)) paste0("At month 1, ", shared$best_cohort, " leads(",
shared$best_cohort_m1, "%) and ", shared$worst_cohort, " trails(", shared$worst_cohort_m1,
"%). ") else "",
switch(shared$trend_word,
improving = paste0("Reading down the month-1 column, newer cohorts are clearly retaining better(",
shared$trend_second, "% recent vs ", shared$trend_first, "% early)."),
declining = paste0("Reading down the month-1 column, newer cohorts are retaining worse(",
shared$trend_second, "% recent vs ", shared$trend_first, "% early)."),
stable = "Month-1 retention is broadly stable from the earliest to the latest cohorts.",
"Too few cohorts have observable month-1 retention to compare halves.")
),
data = list(cohort_comparison = shared$comparison_df)
)
}