Standard Cohort Retention
Executive Summary

Executive Summary

Retention across 18 monthly cohorts of 1,743 customers

Customers
1743
Cohorts
18
Month-1 Retention %
31.8
Best Cohort (M1)
2024-12 (41.0%)
Retention Trend
improving
Month-3 Retention %
23.6
Month-6 Retention %
16.1
Across 1,743 customers in 18 monthly cohorts (2024-01 to 2025-06), month-1 retention is 31.8% — of every 100 new customers, about 32 are still active a month after they arrive. By month 3 it is 23.6%. Retention is IMPROVING: the most recent cohorts hold 34.9% of customers at month 1 vs 30.6% for the earliest cohorts. The strongest cohort is 2024-12 (41% at month 1); the weakest is 2024-09 (16.8%).
Interpretation

Month-1 retention across all 1,743 customers averages 31.8%—roughly 32 of every 100 new customers return within a month. By month 3, this declines to 23.6%, and by month 6 to 16.1%. Retention is improving: recent cohorts (2025-01 to 2025-06) retain 34.9% at month 1 versus 30.6% for early cohorts (2024-01 to 2024-09). The strongest performer is 2024-12 at 41.0% month-1 retention; the weakest is 2024-09 at 16.8%. This 8.3-percentage-point improvement in early retention between cohort halves signals positive momentum in customer stickiness, though absolute retention levels remain modest beyond the first month.

Overview

Analysis Overview

Monthly cohort retention across 18 cohorts and 1,743 customers.

N Customers1743
N Cohorts18
Retention Horizon Months12
Interpretation

Customers are grouped by their first-activity month (cohort), and retention is measured as the percentage of each cohort still active exactly N months later. Month 0 is always 100% by definition. The analysis spans 18 monthly cohorts (2024-01 to 2025-06) containing 1,743 customers, with a 12-month observation window. Right-censoring means recent cohorts have not yet lived through their later months—those cells are excluded from calculations, not counted as zero. This prevents newer cohorts from artificially depressing the retention averages. At month 1, the earliest cohorts (2024-01 to 2024-09) retain 30.6% on average, while the most recent cohorts (2025-01 to 2025-06) retain 34.9%, indicating an improving trend in early-stage retention.

Data Preparation

Data Quality

Rows, customers, date coverage, and cohort formation.

Initial Rows5000
Final Rows5000
Rows Removed0
N Customers1743
N Cohorts18
Interpretation

All 5,000 rows were retained with no data loss: every row had a readable Order Date and a non-blank Customer ID. The dataset spans 1,743 unique customers across activity dates from 2024-01-02 to 2025-06-28. This 18-month window created exactly 18 monthly cohorts (2024-01 through 2025-06), with no rows dropped due to missing or invalid dates. The clean input ensures retention calculations reflect actual customer behaviour rather than data quality artifacts. Cohort sizes range from 75 (2025-06) to 123 (2025-05), with an average of 97 new customers per month, providing sufficient sample sizes for meaningful month-1 and month-3 retention analysis across most cohorts.

Visualization

Retention by Cohort

Percentage of each cohort still active N months after first activity (observable cells only).

Interpretation

The steepest customer loss occurs between month 0 and month 1: on average, 68.2% of a cohort does not return in month 1. Reading down any column ranks cohorts fairly at the same age; 2024-12 leads at month 1 with 41%, while 2024-09 lags at 16.8%. By month 6, the average cohort retains 16.1%—a stable loyal core that persists through month 12. Missing cells in the lower-right triangle represent right-censored months; they are not zero churn but unobserved time. The heatmap reveals high variability across cohorts: 2024-04 and 2024-08 both maintain elevated month-3 retention (30.8% and 27.3%), whereas 2024-03 and 2024-07 drop sharply (14.8% and 19.5%). This cohort-to-cohort variation suggests external or operational factors influence early stickiness.

Visualization

Average Retention Curve

Weighted average retention by months since first activity, across cohorts old enough to observe each month.

Interpretation

The average retention curve traces a typical customer lifecycle: starting at 100% in month 0, dropping sharply to 31.8% at month 1, then gradually settling into a plateau around 15–17% from month 6 onward. The curve reaches 12.8% by month 12. Each bar averages only cohorts old enough to have observed that month, so recent cohorts never artificially depress the tail. The flattening from month 6 to month 12 (ranging 16.1% to 17.5%, dipping to 12.8% at month 12) indicates a durable but small retained segment—neither continuous churn nor strong re-engagement. The near-flat tail suggests that customers who survive the first 6 months form a relatively stable, low-churn core, though absolute numbers remain modest.

Visualization

New Customers per Cohort

Acquisition volume: unique new customers by first-activity month.

Interpretation

Acquisition volume ranges from 75 (2025-06) to 123 (2025-05), averaging 97 new customers per month across the 18 cohorts. Comparing the earliest cohort (2024-01, 109 customers) to the most recent (2025-06, 75 customers) reveals a declining trend in acquisition. Notable peaks include 2024-08 (121 customers) and 2025-05 (123 customers), while troughs cluster around late 2024 and early 2025 (2024-11, 2024-12, 2025-01 each at 83 customers). Since retention percentages elsewhere in the report are relative to these cohort sizes, a small cohort with high retention contributes less to overall volume than a larger cohort with average retention. 2025-06 is too recent to have observable month-1 retention, and the shrinking intake trend warrants attention for future acquisition strategy.

Data Table

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.

CohortSizeM1 Retention PCTM3 Retention PCTM6 Retention PCT
2024-0110939.423.919.3
2024-028522.428.210.6
2024-0310834.314.816.7
2024-049125.330.823.1
2024-0510129.720.815.8
2024-061093326.616.5
2024-078733.319.516.1
2024-0812127.327.313.2
2024-0910116.825.717.8
2024-108835.222.717
2024-118330.126.513.3
2024-12834122.913.3
2025-018338.620.5
2025-0211637.119.8
2025-038929.224.7
2025-049137.4
2025-0512330.9
2025-0675
Interpretation

Side-by-side cohort metrics reveal clear performance tiers. At month 1, 2024-12 leads at 41.0% retention (83 customers), followed by 2025-01 (38.6%, 83 customers) and 2025-04 (37.4%, 91 customers). The earliest cohort, 2024-01, achieved 39.4% at month 1, but the weakest, 2024-09 (101 customers), retained only 16.8%. Reading down the month-1 column, newer cohorts cluster higher: recent performers (2024-12, 2025-01, 2025-02, 2025-04) all exceed 37%, while early-to-mid 2024 cohorts average lower. By month 3, the spread widens: 2024-04 (30.8%) and 2024-08 (27.3%) outperform, whereas 2024-03 (14.8%) and 2025-02 (19.8%) underperform. Blank cells mark months not yet observed; the improving trend is driven by substantive month-1 gains in recent cohorts, not data artifacts.

Methodology

Methodology

Statistical methodology and diagnostics for Cohort Retention Analysis

Statistical Method

Cohort Retention Analysis

Standard-library analysis: how well do you keep your customers? From raw order or activity data (one row per order/event), it builds monthly acquisition cohorts, measures the percentage of each cohort still active 1, 2, 3... months after their first activity, and shows whether newer cohorts retain better or worse than older ones. Retention heatmap, average retention curve, cohort sizes, and a cohort-by-cohort comparison table.

Data
N = 5000 observations
Assumptions
  • One row per order/event with a customer identifier and a date
  • A customer is 'retained' in month N if they have any activity N calendar months after their first activity
  • Monthly granularity is appropriate for the business cycle
Limitations
  • Recent cohorts are right-censored — their later months are unobservable, not zero
  • Activity-based retention: a paused subscriber with no events counts as churned
  • Calendar-month cohorts can blur mid-month acquisition spikes
  • Capped at the 24 most recent cohorts and 12 months of retention
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

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 size

Long 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) <- NULL

Step 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&#x27;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 data has more stories to tell. Run any analysis on your own data — 60+ validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No Signup Sign Up Free

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing