Standard Rfm
Executive Summary

Executive Summary

RFM segments across 400 customers

Customers
400
Orders
2990
Segments
7
Biggest Segment
Need Attention
Top Segment Revenue %
45.1
At-Risk Revenue
127380.21
Across 400 customers and 2,990 orders, the biggest segment is Need Attention (20.2% of customers). Revenue is concentrated in Champions, which accounts for 45.1% of total Order Value. Champions are 77 customers (19.2%) holding 45.1% of revenue. 127,380 of revenue (29.9%) sits with At Risk / Can't Lose Them customers — historically valuable buyers who have gone quiet; that is the revenue at stake if they are not won back.
Interpretation

Your largest segment is Need Attention, comprising 81 customers (20.2% of the base), yet it holds only 4,059 in total value. Revenue is heavily concentrated in Champions—77 customers (19.2%) account for 192,247, or 45.1% of all revenue. The critical at-risk opportunity lies with 112 customers (At Risk and Can't Lose Them combined) who hold 127,380 in revenue (29.9% of total) but show high recency drift (avg 134.7 and 148.2 days, respectively). Reactivating these historically valuable segments offers the highest revenue recovery potential.

Overview

Analysis Overview

RFM segmentation of 400 customers from 2,990 orders.

N Customers400
N Orders2990
N Segments7
Reference Date2026-07-01
Interpretation

RFM segmentation maps your 400 customers across three behavioral dimensions: Recency (days since last order, measured against the reference date 2026-07-01), Frequency (order count), and Monetary (total net spend, including refunds). Each customer receives quintile scores (1–5) on all three dimensions; Recency and Frequency scores jointly determine segment membership. The analysis identifies 7 active segments from 2,990 orders. This framework enables targeted retention and reactivation strategies by isolating customer cohorts with distinct purchase patterns and engagement levels.

Data Preparation

Data Quality

Rows, customers, date range, and cleaning decisions.

Initial Rows3000
Final Rows2990
Rows Removed10
N Customers400
Unparseable Dates10
Negative Orders25
Interpretation

Data quality controls yielded 2,990 usable orders from 400 customers. Of 3,000 initial rows, 10 rows were excluded due to unparseable Order Dates, remaining below the 5% threshold. The order date range spans 2025-07-01 to 2026-07-01. Critically, 25 orders with negative Order Values (totalling -3,409.88) were retained as refunds, so each customer's Monetary score reflects net spending after reversals. This treatment ensures that Monetary values are realistic and account for customer churn signals embedded in return behavior.

Visualization

Segment Sizes

Customer count per RFM segment.

Interpretation

Your customer base shows a bifurcated structure: Need Attention dominates at 81 customers, followed closely by Champions at 77. Together, these two segments account for 39.4% of your customer base. At Risk (67), Can't Lose Them (45), and Hibernating (46) represent 28.5% of customers, flagging significant lapse risk. New Customers (41) and Potential Loyalist (43) comprise the growth and development pipeline at 21%. This distribution suggests a mature base with churn pressure; the concentration of customers in low-engagement segments indicates that retention and reactivation should precede acquisition-heavy strategies.

Data Table

Segment Value Breakdown

Size, share, revenue, and recency per segment.

SegmentCustomersShare PCTTotal ValueAvg ValueAvg Recency Days
Champions7719.2192,2472,4974.5
Potential Loyalist4310.884,7401,9714.7
At Risk6716.872,6311,084134.7
Can't Lose Them4511.254,7491,217148.2
Hibernating4611.515,63934085.2
Need Attention8120.24,0595027.2
New Customers4110.21,9514810.5
Interpretation

Champions deliver the highest total value (192,247) and average per-customer value (2,497), with minimal recency drift (4.5 days). Potential Loyalist (43 customers, avg 1,971 per customer, 4.7 days recency) mirrors this health. The revenue-at-risk segments diverge sharply: At Risk (67 customers, avg 1,084 each, 134.7 days recency) and Can't Lose Them (45 customers, avg 1,217 each, 148.2 days recency) retain strong individual value but show acute recency gaps. Hibernating (46 customers, avg 340 each, 85.2 days) and Need Attention (81 customers, avg 50 each, 27.2 days) are either deeply lapsed or low-value. Win-back ROI is highest in At Risk and Can't Lose Them.

Visualization

Recency x Frequency Grid

Customer counts across all 25 R x F score combinations.

Interpretation

The R×F grid reveals a sparse, concentrated structure: 18 of 25 cells are occupied. The densest cell is R3×F1 (81 customers)—moderate recency, low frequency—representing your largest single cohort. The top-right region (R5×F5: 26 customers; R5×F4: 26 customers; R5×F3: 32 customers) holds your Champions core. The left column (low R scores, high F) houses 96 customers (R1–R2, F3–F5) who are frequent but lapsed; these are your primary win-back targets. The grid underscores that your customer base clusters into either low-frequency recent buyers or high-frequency lapsed buyers—few occupy the active middle.

Visualization

Average Customer Value by Segment

Mean per-customer monetary value in each segment.

Interpretation

Average per-customer value varies dramatically across segments. Champions lead at 2,496.72, followed by Potential Loyalist at 1,970.7. The lapsed-but-valuable segments—Can't Lose Them (1,216.65) and At Risk (1,084.04)—retain individual worth 22–25× that of New Customers (47.59) and Need Attention (50.11). This disparity is actionable: recovering a single Can't Lose Them customer returns 25.6× the value of acquiring a new customer. Hibernating (339.98) sits between, suggesting partial decay. The concentration of value in Champions and the lapsed high-value segments underscores that retention and reactivation campaigns will yield far higher per-customer ROI than new-customer acquisition.

Methodology

Methodology

Statistical methodology and diagnostics for RFM Customer Segmentation

Statistical Method

RFM Customer Segmentation

Standard-library analysis: segment your customers by Recency, Frequency, and Monetary value straight from a raw transactions table (one row per order). Every customer gets quintile R/F/M scores and lands in a standard industry segment — Champions, Loyal, At Risk, Can't Lose Them, Hibernating and more — with per-segment size, revenue, and recency stats so you know exactly who to reward, who to win back, and how much revenue is at stake.

Data
N = 2990 observations · 10 excluded
Assumptions
  • One row per order (transaction-level data, not pre-aggregated per customer)
  • The most recent order date in the data is a reasonable 'today' reference
  • Quintile scoring is relative to this customer base, not an external benchmark
Limitations
  • Segments describe past behavior — they are a targeting map, not a churn prediction model
  • With few customers or heavily tied frequencies, quintile scores collapse and segments blur together
  • Monetary uses order value as given (net of refunds if negatives are present); margin and costs are not considered
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

RFM Customer Segmentation — Who Are Your Best Customers?

Segments customers by Recency, Frequency, and Monetary value computed from a raw transactions table (one row per order): quintile R/F/M scores, the standard industry segment map (Champions, At Risk, ...), and per-segment size, value, and recency statistics.

Why This Method?

RFM is the workhorse of customer-base analysis: three behavioral signals every business already records (when, how often, how much), scored against the customer base itself, so it needs no model fitting and works on any transaction log. The segment names carry the action: reward Champions, win back At Risk, welcome New Customers.

What This Analysis Covers

  • Per-customer Recency / Frequency / Monetary + quintile R/F/M scores
  • Standard segment map from R and F scores
  • Segment sizes, revenue concentration, and at-risk revenue
  • The full Recency x Frequency grid

Standard Library

Platform standard-library module (LAT-1441): runs on ANY transaction dataset via the semantic mapping {customer_id, order_date, order_value}. 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

Multi-format date parser

Tries each candidate format across the whole vector, keeps the format that parses the most values, then back-fills stragglers per value from the remaining formats. Returns a Date vector (NA where unparseable).

parse_dates_multi <- function(x) {
  if (inherits(x, "Date")) return(x)
  if (inherits(x, "POSIXct") || inherits(x, "POSIXlt")) return(as.Date(x))
  s <- trimws(as.character(x))
  s[is.na(s) | s == ""] <- NA_character_
  fmts <- c("%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%m-%d-%Y",
            "%d-%m-%Y", "%d.%m.%Y", "%b %d, %Y", "%B %d, %Y",
            "%d %b %Y", "%d %B %Y")
  parsed <- lapply(fmts, function(f) suppressWarnings(as.Date(s, format = f)))
  counts <- vapply(parsed, function(p) sum(!is.na(p)), integer(1))
  best <- parsed[[which.max(counts)]]   # counts is never all-NA (integer counts)
  for (p in parsed[order(-counts)]) {
    fill <- is.na(best) & !is.na(p)
    if (any(fill)) best[fill] <- p[fill]
  }
  best
}

Quintile scorer (5 = best)

Primary path: quantile breaks with type = 1. When ties or too few distinct values make the breaks collapse, fall back to ntile-style ranking on average ranks so tied customers always share a score; a single distinct value collapses gracefully to the neutral score 3.

score_quintile <- function(x, higher_is_better = TRUE) {
  n <- length(x)
  ux <- unique(x)
  if (length(ux) <= 1) return(rep(3L, n))
  sc <- NULL
  if (length(ux) >= 5) {
    br <- quantile(x, probs = seq(0, 1, 0.2), type = 1, na.rm = TRUE)
    if (length(unique(br)) == 6) {
      sc <- as.integer(cut(x, breaks = br, include.lowest = TRUE, labels = FALSE))
    }
  }
  if (is.null(sc)) {
    r <- rank(x, ties.method = "average")
    sc <- as.integer(ceiling(r * 5 / n))
  }
  sc <- pmin(pmax(sc, 1L), 5L)
  if (!higher_is_better) sc <- 6L - sc
  sc
}

Segment map (standard industry names), from R and F scores.

Assigned lowest-priority first so later, more specific rules overwrite: "Can't Lose Them" (R=1, F>=4) takes precedence over the broader "At Risk" (R<=2, F>=3); Champions wins over Loyal.

assign_segments <- function(R, F_) {
  seg <- rep("Need Attention", length(R))
  seg[R <= 2 & F_ <= 2] <- "Hibernating"
  seg[R <= 2 & F_ >= 3] <- "At Risk"
  seg[R == 1 & F_ >= 4] <- "Can&#x27;t Lose Them"
  seg[R >= 4 & F_ == 1] <- "New Customers"
  seg[R >= 4 & F_ %in% 2:3] <- "Potential Loyalist"
  seg[R >= 3 & F_ >= 4] <- "Loyal"
  seg[R >= 4 & F_ >= 4] <- "Champions"
  seg
}

SEGMENT_ORDER <- c("Champions", "Loyal", "Potential Loyalist", "New Customers",
                   "At Risk", "Can&#x27;t Lose Them", "Need Attention", "Hibernating")

compute_shared <- function(df, params, col_map = list()) {
  # === SHARED EXPORTS ===
  #   initial_rows/final_rows/rows_removed   $ row accounting
  #   n_missing_id / n_bad_dates / n_missing_value / n_negative  $ preprocessing counts
  #   negative_total    $ numeric — summed value of negative (refund) orders
  #   id_name/date_name/value_name  $ humanized user column names
  #   ref_date / date_min / date_max $ Date — reference + observed range
  #   customer_df       $ data.frame(customer, recency_days, frequency,
  #                       monetary, recency_score, frequency_score,
  #                       monetary_score, segment) — one row per customer
  #   seg_stats         $ data.frame(segment, customers, share_pct,
  #                       total_value, avg_value, avg_recency_days)
  #   segment_sizes_df / segment_value_df / rf_grid_df / monetary_by_segment_df
  #   n_customers       $ integer
  #   total_revenue     $ numeric — sum of all order values
  #   biggest_segment / biggest_share          $ by customer count
  #   top_rev_segment / top_rev_share          $ revenue concentration
  #   at_risk_revenue / at_risk_rev_share      $ At Risk + Can't Lose Them
  #   metrics / json_output
  # === /SHARED EXPORTS ===

Step 1: Required semantic columns + humanized names

initial_rows <- nrow(df)
  id_name    <- humanize_semantic("customer_id", col_map)[1]
  date_name  <- humanize_semantic("order_date", col_map)[1]
  value_name <- humanize_semantic("order_value", col_map)[1]
  for (need in c("customer_id", "order_date", "order_value")) {
    if (!need %in% names(df)) {
      stop(sprintf("Required column &#x27;%s' is not mapped.",
                   humanize_semantic(need, col_map)[1]))
    }
  }

Step 2: Customer id — character, drop blank/missing

cid <- trimws(as.character(df$customer_id))
  keep_id <- !is.na(cid) & cid != ""
  n_missing_id <- sum(!keep_id)
  df <- df[keep_id, , drop = FALSE]
  cid <- cid[keep_id]

Step 3: Dates — multi-format parse; stop (humanized) if >5% unparseable

dts <- parse_dates_multi(df$order_date)
  n_bad_dates <- sum(is.na(dts))
  if (nrow(df) > 0 && n_bad_dates / nrow(df) > 0.05) {
    stop(sprintf(
      paste0("Could not read %s of %s values in &#x27;%s' as dates (%.1f%%). ",
             "More than 5%% of the order dates are unparseable — please ",
             "check the date format of that column."),
      format(n_bad_dates, big.mark = ","), format(nrow(df), big.mark = ","),
      date_name, 100 * n_bad_dates / nrow(df)))
  }
  keep_dt <- !is.na(dts)
  df <- df[keep_dt, , drop = FALSE]
  cid <- cid[keep_dt]
  dts <- dts[keep_dt]

Step 4: Order value — 95% numeric coercion rule; drop NA values;

KEEP negatives (refunds) but count them for the narrative.

val <- df$order_value
  if (!is.numeric(val)) {
    conv <- suppressWarnings(as.numeric(as.character(val)))
    n_orig <- sum(!is.na(val) & trimws(as.character(val)) != "")
    if (n_orig == 0 || sum(!is.na(conv)) < 0.95 * n_orig) {
      stop(sprintf(
        "Column &#x27;%s' is not numeric enough to use as an order amount — fewer than 95%% of its values could be read as numbers.",
        value_name))
    }
    val <- conv
  }
  keep_val <- !is.na(val)
  n_missing_value <- sum(!keep_val)
  cid <- cid[keep_val]
  dts <- dts[keep_val]
  val <- val[keep_val]
  n_negative <- sum(val < 0)
  negative_total <- if (n_negative > 0) sum(val[val < 0]) else 0

  final_rows <- length(val)
  rows_removed <- initial_rows - final_rows
  if (final_rows < 30) {
    stop(sprintf(
      "Only %d usable orders remain after cleaning &#x27;%s', '%s', and '%s' — need at least 30.",
      final_rows, id_name, date_name, value_name))
  }

Step 5: Reference date + per-customer R/F/M

ref_date <- max(dts)
  date_min <- min(dts)
  date_max <- ref_date

  tx <- data.frame(customer = cid, order_date = dts, order_value = val,
                   stringsAsFactors = FALSE)
  customer_df <- tx %>%
    group_by(customer) %>%
    summarise(
      recency_days = as.numeric(ref_date - max(order_date)),
      frequency    = n(),
      monetary     = sum(order_value),
      .groups = "drop"
    ) %>%
    as.data.frame(stringsAsFactors = FALSE)

  n_customers <- nrow(customer_df)
  if (n_customers < 5) {
    stop(sprintf(
      "Only %d distinct customers found in &#x27;%s' — RFM segmentation needs at least 5.",
      n_customers, id_name))
  }

Step 6: Quintile scores (5 = best) + segment map

customer_df$recency_score   <- score_quintile(customer_df$recency_days,
                                                higher_is_better = FALSE)
  customer_df$frequency_score <- score_quintile(customer_df$frequency)
  customer_df$monetary_score  <- score_quintile(customer_df$monetary)
  customer_df$segment <- assign_segments(customer_df$recency_score,
                                         customer_df$frequency_score)

Step 7: Segment statistics

total_revenue <- sum(customer_df$monetary)
  seg_stats <- customer_df %>%
    group_by(segment) %>%
    summarise(
      customers        = n(),
      total_value      = round(sum(monetary), 2),
      avg_value        = round(mean(monetary), 2),
      avg_recency_days = round(mean(recency_days), 1),
      .groups = "drop"
    ) %>%
    mutate(share_pct = round(100 * customers / n_customers, 1)) %>%
    as.data.frame(stringsAsFactors = FALSE)
  seg_stats$segment <- factor(seg_stats$segment, levels = SEGMENT_ORDER)
  seg_stats <- seg_stats[order(seg_stats$segment), , drop = FALSE]
  seg_stats$segment <- as.character(seg_stats$segment)
  rownames(seg_stats) <- NULL

Headline facts (all computed; guard against all-NA/max on empty)

by_size <- seg_stats[order(-seg_stats$customers), , drop = FALSE]
  biggest_segment <- by_size$segment[1]
  biggest_share   <- by_size$share_pct[1]
  by_rev <- seg_stats[order(-seg_stats$total_value), , drop = FALSE]
  top_rev_segment <- by_rev$segment[1]
  top_rev_share   <- if (total_revenue != 0)
    round(100 * by_rev$total_value[1] / total_revenue, 1) else NA_real_
  at_risk_segments <- c("At Risk", "Can&#x27;t Lose Them")
  at_risk_revenue  <- round(sum(seg_stats$total_value[
    seg_stats$segment %in% at_risk_segments]), 2)
  at_risk_rev_share <- if (total_revenue != 0)
    round(100 * at_risk_revenue / total_revenue, 1) else NA_real_

Format value columns as plain rounded strings — large numerics would otherwise serialize in scientific notation (e.g. 1.922e+05) in the table.

segment_value_df$total_value <- format(round(segment_value_df$total_value, 0),
                                         big.mark = ",", scientific = FALSE,
                                         trim = TRUE)
  segment_value_df$avg_value <- format(round(segment_value_df$avg_value, 0),
                                       big.mark = ",", scientific = FALSE,
                                       trim = TRUE)
  rownames(segment_value_df) <- NULL

  grid <- expand.grid(r = 1:5, f = 1:5)
  rf_grid_df <- data.frame(
    recency_score   = paste0("R", grid$r),
    frequency_score = paste0("F", grid$f),
    customers = mapply(function(r, f) {
      sum(customer_df$recency_score == r & customer_df$frequency_score == f)
    }, grid$r, grid$f),
    stringsAsFactors = FALSE
  )

  monetary_by_segment_df <- seg_stats[, c("segment", "avg_value")]
  names(monetary_by_segment_df) <- c("segment", "avg_customer_value")
  monetary_by_segment_df <- monetary_by_segment_df[
    order(-monetary_by_segment_df$avg_customer_value), , drop = FALSE]
  rownames(monetary_by_segment_df) <- NULL

  metrics <- list(
    `Customers`             = n_customers,
    `Orders`                = final_rows,
    `Segments`              = nrow(seg_stats),
    `Biggest Segment`       = biggest_segment,
    `Top Segment Revenue %` = top_rev_share,
    `At-Risk Revenue`       = at_risk_revenue
  )

  json_output <- list(
    answer = paste0(
      "RFM segmentation of ", format(n_customers, big.mark = ","),
      " customers across ", format(final_rows, big.mark = ","),
      " orders(", format(date_min), " to ", format(date_max), "): ",
      "the biggest segment is ", biggest_segment, " (", biggest_share,
      "% of customers); ", top_rev_segment, " concentrates ", top_rev_share,
      "% of total revenue; ",
      format(round(at_risk_revenue), big.mark = ","), " (",
      at_risk_rev_share, "% of revenue) sits with At Risk / Can&#x27;t Lose Them ",
      "customers and is the immediate win-back opportunity."
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "segment_sizes",
        "segment_value", "rf_grid", "monetary_by_segment"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows,
    rows_removed = rows_removed,
    n_missing_id = n_missing_id, n_bad_dates = n_bad_dates,
    n_missing_value = n_missing_value,
    n_negative = n_negative, negative_total = negative_total,
    id_name = id_name, date_name = date_name, value_name = value_name,
    ref_date = ref_date, date_min = date_min, date_max = date_max,
    customer_df = customer_df, seg_stats = seg_stats,
    segment_sizes_df = segment_sizes_df,
    segment_value_df = segment_value_df,
    rf_grid_df = rf_grid_df,
    monetary_by_segment_df = monetary_by_segment_df,
    n_customers = n_customers, total_revenue = total_revenue,
    biggest_segment = biggest_segment, biggest_share = biggest_share,
    top_rev_segment = top_rev_segment, top_rev_share = top_rev_share,
    at_risk_revenue = at_risk_revenue, at_risk_rev_share = at_risk_rev_share,
    metrics = metrics, json_output = json_output
  )
}
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