Executive Summary
Retention across 18 monthly cohorts of 1,805 customers
The short answer
Of 1,805 customers, 43.5% return in month 1 after their first order, and 31% by month 3. Newer cohorts are retaining better than older ones: recent cohorts hold 47% at month 1 versus 42.6% for early cohorts. The strongest performer is the 2025-01 cohort at 56.3% month-1 retention.
The detail
Month-1 retention: 43.5%. Month-3 retention: 31%. Month-6 retention: 22.8%. Retention trend: improving. The 18 cohorts span 2024-01 to 2025-06. The most recent cohorts (2024-12 to 2025-06) average 47% retention at month 1 versus 42.6% for the earliest cohorts (2024-01 to 2024-07). Best cohort: 2025-01 at 56.3% month-1 retention. Weakest cohort: 2024-09 at 28.2%.
What this can't tell you
The improvement in newer cohorts could reflect changes in acquisition, product, or marketing mix rather than a true shift in customer behavior. Stratifying by acquisition source or customer segment would help isolate which cohort characteristics drive the trend.
Analysis Overview
Monthly cohort retention across 18 cohorts and 1,805 customers.
The short answer
This analysis tracks 1,805 customers across 18 monthly cohorts from January 2024 to June 2025, measuring what percentage of each cohort remains active in each month after their first order. Month 0 is always 100% by definition. Recent cohorts show incomplete data for later months—not churn, but right-censoring: those months haven't occurred yet.
The detail
Each customer is assigned to the calendar month of their first Order Date. Retention is the share of that cohort still active exactly N months later, capped at a 12-month horizon. The analysis covers 6,714 activity rows with no missing Order Dates or Customer IDs. Cohorts range from 2024-01 through 2025-06. The trend compares the earliest half (2024-01 to 2024-07) against the most recent half (2024-12 to 2025-06) at month 1: retention is improving.
What this can't tell you
Right-censoring means recent cohorts lack observations for months not yet elapsed. This is not a limitation but a structural feature of cohort analysis on live data; the missing cells do not imply zero retention. Longer observation windows would sharpen estimates of month 6–12 retention, which currently rest on 12 older cohorts only.
Data Quality
Rows, customers, date coverage, and cohort formation.
The short answer
All 6,714 rows loaded successfully with valid Order Dates and Customer IDs—no data was dropped. The activity spans 18 months (January 2024 through June 2025) and covers 1,805 unique customers, forming 18 monthly cohorts.
The detail
Initial rows: 6,714. Final rows used: 6,714. Rows removed: 0. All rows had a readable Order Date and a non-blank Customer ID. Activity date range: 2024-01-02 to 2025-06-28. Number of unique customers: 1,805. Number of cohorts formed: 18 (one per month from 2024-01 to 2025-06).
What this can't tell you
The data contains activity rows but does not distinguish between purchase events and other activity types. A transaction-level export would clarify whether "active" means any order or a repeat purchase, sharpening interpretation of retention as repurchase behavior.
Retention by Cohort
Percentage of each cohort still active N months after first activity (observable cells only).
The short answer
Customers drop sharply in month 1—on average 56.5% do not return—then stabilize into a durable core around 20–25% by month 6. The 2025-01 cohort leads at month 1 (56.3%), while 2024-09 lags at 28.2%. Blank cells in the lower right are cohorts too recent to observe those months, not zero retention.
The detail
Each row is a cohort; each column is months since first activity. M0 is 100% by definition. The steepest loss occurs month 0 to month 1: the average cohort retains 43.5% at M1. By month 6, the average cohort holds 22.8% of its customers. Reading down any column compares cohorts at the same age: 2025-01 leads at M1 with 56.3%; 2024-09 trails at 28.2%. From month 6 onward, retention curves flatten, indicating a loyal core that does not erode further.
What this can't tell you
Month-to-month volatility (e.g., 2024-01 jumps from 20.8% at M8 to 30% at M11) may reflect seasonal activity, data quality variance, or small cohort sizes. Cohort-level sample sizes range from 80 to 120 customers, so individual month cells can be noisy; focus on within-cohort trends and between-cohort comparisons at the same age.
Average Retention Curve
Weighted average retention by months since first activity, across cohorts old enough to observe each month.
The short answer
The average customer lifecycle drops from 100% at month 0 to 43.5% at month 1, then slides to 31% by month 3 and 22.8% by month 6. After month 6, the curve flattens around 19–24%, indicating a stable loyal core that does not churn further.
The detail
Each bar averages only cohorts old enough to observe that month, so recent cohorts do not artificially depress later-month figures. M0: 100%. M1: 43.5%. M2: 36.6%. M3: 31%. M4: 24.4%. M5: 26.7%. M6: 22.8%. M7: 22.7%. M8: 22.4%. M9: 23.4%. M10: 20.5%. M11: 24%. M12: 19.3%. The largest single drop is M0 to M1 (56.5 percentage points). The curve stabilizes between month 6 and month 12, hovering near 20–24%.
What this can't tell you
The slight uptick at M5 (26.7%) and M11 (24%) relative to neighboring months may reflect seasonal or promotional activity, but the sample of cohorts old enough to reach those months is smaller. A longer observation window would clarify whether the month-6-onward plateau is true stability or temporary noise.
New Customers per Cohort
Acquisition volume: unique new customers by first-activity month.
The short answer
New customer intake ranges from 80 (2025-06) to 120 (2024-01 and 2024-08), averaging 100 per month. Acquisition has shrunk slightly from the earliest to most recent cohorts (120 to 80), but the variation is modest—no single month is a major outlier.
The detail
Largest cohorts: 2024-01 (120), 2024-08 (120), 2025-02 (120). Smallest cohorts: 2025-06 (80), 2024-07 (83), 2024-12 (83). Mean cohort size: 100 customers. Comparing first cohort (2024-01: 120) to last (2025-06: 80) shows a 40-customer decline. Retention percentages elsewhere in this report are relative to these sizes: a small cohort with high retention can still represent fewer total retained customers than a large cohort with average retention. 2025-06 is too recent to have observable month-1 retention.
What this can't tell you
Cohort size variation (80–120) is small enough that it does not substantially skew the average retention curve, but it does mean that recent cohorts contribute fewer customers to the long-tail retention estimates (months 6–12). A larger or more evenly distributed intake would sharpen precision on those later months.
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 |
|---|---|---|---|---|
| 2024-01 | 120 | 46.7 | 31.7 | 21.7 |
| 2024-02 | 101 | 42.6 | 30.7 | 18.8 |
| 2024-03 | 111 | 41.4 | 21.6 | 22.5 |
| 2024-04 | 106 | 36.8 | 36.8 | 32.1 |
| 2024-05 | 101 | 44.6 | 25.7 | 17.8 |
| 2024-06 | 115 | 44.3 | 37.4 | 24.3 |
| 2024-07 | 83 | 43.4 | 31.3 | 20.5 |
| 2024-08 | 120 | 40.8 | 35 | 20.8 |
| 2024-09 | 103 | 28.2 | 29.1 | 26.2 |
| 2024-10 | 96 | 45.8 | 29.2 | 27.1 |
| 2024-11 | 87 | 39.1 | 28.7 | 21.8 |
| 2024-12 | 83 | 55.4 | 30.1 | 19.3 |
| 2025-01 | 87 | 56.3 | 32.2 | — |
| 2025-02 | 120 | 45 | 33.3 | — |
| 2025-03 | 89 | 43.8 | 30.3 | — |
| 2025-04 | 87 | 48.3 | — | — |
| 2025-05 | 116 | 42.2 | — | — |
| 2025-06 | 80 | — | — | — |
The short answer
Month-1 retention varies from 28.2% (2024-09) to 56.3% (2025-01). Newer cohorts consistently outperform older ones: the most recent cohorts average 47% at month 1 versus 42.6% for the earliest cohorts. By month 3, the spread narrows; by month 6, most cohorts settle around 20–27%.
The detail
Of 18 cohorts, 17 have reached month 1, 15 have reached month 3, and 12 have reached month 6. Blank cells indicate the cohort has not yet lived that many months, not zero retention. At M1: 2025-01 leads (56.3%), 2024-09 trails (28.2%). At M3: 2024-06 leads (37.4%), 2024-03 trails (21.6%). At M6: 2024-04 leads (32.1%), 2024-05 trails (17.8%). Reading down the M1 column, newer cohorts (2025-01 through 2025-05) average 47% versus older cohorts (2024-01 through 2024-07) at 42.6%, confirming the improvement trend.
What this can't tell you
Cohort-level retention at month 6 rests on only 12 cohorts (2024-01 through 2024-12), so estimates for later months carry less precision. The 2025 cohorts cannot yet be observed at month 6. Stratifying by acquisition channel, customer segment, or product category would reveal whether the improvement is broad-based or driven by a specific subgroup.
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: Check 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)
)
}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