Executive Summary
RFM segments across 400 customers
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.
Analysis Overview
RFM segmentation of 400 customers from 2,990 orders.
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 Quality
Rows, customers, date range, and cleaning decisions.
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.
Segment Sizes
Customer count per RFM segment.
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.
Segment Value Breakdown
Size, share, revenue, and recency per segment.
| Segment | Customers | Share PCT | Total Value | Avg Value | Avg Recency Days |
|---|---|---|---|---|---|
| Champions | 77 | 19.2 | 192,247 | 2,497 | 4.5 |
| Potential Loyalist | 43 | 10.8 | 84,740 | 1,971 | 4.7 |
| At Risk | 67 | 16.8 | 72,631 | 1,084 | 134.7 |
| Can't Lose Them | 45 | 11.2 | 54,749 | 1,217 | 148.2 |
| Hibernating | 46 | 11.5 | 15,639 | 340 | 85.2 |
| Need Attention | 81 | 20.2 | 4,059 | 50 | 27.2 |
| New Customers | 41 | 10.2 | 1,951 | 48 | 10.5 |
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.
Recency x Frequency Grid
Customer counts across all 25 R x F score combinations.
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.
Average Customer Value by Segment
Mean per-customer monetary value in each segment.
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
Statistical methodology and diagnostics for RFM Customer Segmentation
Statistical Method
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.
- 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
- 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
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'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'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 '%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 '%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 '%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 '%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 '%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) <- NULLHeadline 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'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'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
)
}