Привет, Хабр!

Сегодня мы рассмотрим, как реализовать RFM‑модель в чистом SQL на примере магазина котиков.

RFM — это:

  • Recency: когда последний раз юзер что‑то купил;

  • Frequency: сколько покупок в целом;

  • Monetary: сколько потрачено денег.

Цель — хорошо различать клиентов: активные и ценные, лояльных, забытых, и работать с каждой группой по‑своему. У нас же это магазин игрушек для котиков.

Подготовка схемы

Представим simple таблицы:

-- список покупателей
CREATE TABLE customers (
  customer_id   INT PRIMARY KEY,
  name          TEXT
);

-- покупки
CREATE TABLE orders (
  order_id      INT PRIMARY KEY,
  customer_id   INT REFERENCES customers(customer_id),
  order_date    DATE,
  amount        NUMERIC(10,2)
);

И пара строк для тестирования:

INSERT INTO customers VALUES
 (1,'Мурзик'), (2,'Барсик'), (3,'Кеша');

INSERT INTO orders VALUES
 (10, 1,'2025-07-20', 300),
 (11, 1,'2025-07-10', 150),
 (12, 2,'2025-01-01', 200),
 (13, 3,'2024-12-01', 100),
 (14, 3,'2024-07-01', 50);

Готовим базу: собираем Recency, Frequency, Monetary

В RFM‑модели три ключевых признака пользователя: когда он покупал в последний раз (recency), как часто покупает (frequency) и сколько денег приносит (monetary). Все три — это характеристики поведения, которые можно извлечь из сырых заказов. Суть в том, чтобы превратить простой лог покупок в сжатую, но выразительную цифровую модель клиента.

Создаём CTE, где сгруппируем заказы по каждому пользователю и посчитаем нужные значения. Заодно используем LEFT JOIN, чтобы не потерять пользователей с нулём заказов (да, и такие бывают — например, кто‑то только зарегистрировался или просто оформил заказ, но не оплатил).

Вот так это выглядит:

WITH rfm AS (
  SELECT
    c.customer_id,
    MAX(o.order_date)::date AS last_order,
    COUNT(o.order_id) AS freq,
    COALESCE(SUM(o.amount), 0) AS monetary
  FROM customers c
  LEFT JOIN orders o ON c.customer_id = o.customer_id
  GROUP BY c.customer_id
)
SELECT * FROM rfm;

Bзвлекаем три ключевые метрики поведения клиента: MAX(o.order_date) определяет дату последней покупки, COUNT(o.order_id) показывает, сколько заказов он совершил, а SUM(o.amount) — сколько денег потратил (с COALESCE на случай отсутствия покупок). Используем LEFT JOIN, чтобы сохранить в выборке даже тех клиентов, у которых не было заказов вовсе — это важно для полной картины. В результате получаем базовую таблицу, где каждый клиент представлен как набором сырых чисел, так и потенциальной моделью поведения, пригодной для дальнейшей сегментации.

Расчёт Recency в днях

Просто дата последней покупки — это не метрика. С ней нельзя делать арифметику. Мы хотим понимать, сколько дней назад был последний заказ. Это и будет recency, от которого отталкивается большинство стратегий: пушить новых, возвращать уснувших, удерживать лояльных и т. д.

Добавляем колонку recency_days как разницу между CURRENT_DATE и MAX(order_date).

WITH rfm AS (
  SELECT
    c.customer_id,
    MAX(o.order_date)::date AS last_order,
    CURRENT_DATE - MAX(o.order_date)::date AS recency_days,
    COUNT(o.order_id) AS freq,
    COALESCE(SUM(o.amount), 0) AS monetary
  FROM customers c
  LEFT JOIN orders o ON c.customer_id = o.customer_id
  GROUP BY c.customer_id
)
SELECT * FROM rfm;

Используем CURRENT_DATE, а не NOW(), поскольку он возвращает только дату без времени — это делает расчёты более стабильными и предсказуемыми, особенно при вычитании дат. Если у клиента нет заказов, MAX(order_date) даст NULL, а значит recency_days тоже станет NULL — с этим мы разберёмся позже при сегментации. Зато теперь появляется выразительная и удобная для анализа метрика: сколько дней прошло с последней покупки.

Нормируем данные: присваиваем RFM-баллы

Абсолютные значения — это хорошо, но мы хотим сравнивать клиентов между собой. Один потратил 5000, другой 10 000 — как их сравнивать? Мы можем использовать квантильную нормализацию: разбиваем клиентов на группы, например, на 3 (терцилями), и каждому даём балл от 1 до 3.

Используем NTILE(3) — встроенную функцию оконной агрегации, которая делит отсортированный список на равные части и присваивает номер группы.

WITH base AS (
  SELECT
    c.customer_id,
    CURRENT_DATE - MAX(o.order_date)::date AS recency_days,
    COUNT(o.order_id) AS freq,
    COALESCE(SUM(o.amount), 0) AS monetary
  FROM customers c
  LEFT JOIN orders o USING(customer_id)
  GROUP BY c.customer_id
),
scored AS (
  SELECT
    customer_id,
    recency_days, freq, monetary,
    NTILE(3) OVER (ORDER BY recency_days ASC)  AS r_score,
    NTILE(3) OVER (ORDER BY freq DESC)         AS f_score,
    NTILE(3) OVER (ORDER BY monetary DESC)     AS m_score
  FROM base
)
SELECT * FROM scored;

Нормализуем метрики через NTILE(3): для recency_days сортируем по возрастанию, чтобы клиенты с недавними покупками получили высокий балл (то есть ближе к 1), а для freq и monetary — по убыванию, ведь большее значение считается лучше.

Формируем итоговый RFM-код и сегмент

Когда у нас есть отдельные баллы R, F, M — удобно их объединить в строку: например, 111, 123, 321. Такой RFM‑код — краткая характеристика клиента. На этом этапе можно строить любые сегменты, под которые затачивать акции, рекомендации, ретаргетинг и всё, что угодно.

Создаём итоговый rfm_code и назначаем сегмент.

WITH base AS (...),
scored AS (...),
combined AS (
  SELECT *,
    r_score * 100 + f_score * 10 + m_score AS rfm_code,
    CONCAT(r_score, f_score, m_score) AS rfm_str
  FROM scored
)
SELECT
  customer_id, recency_days, freq, monetary,
  r_score, f_score, m_score, rfm_str,
  CASE
    WHEN r_score = 1 AND f_score = 1 AND m_score = 1 THEN 'Champions'
    WHEN r_score <= 2 AND f_score <= 2 AND m_score <= 2 THEN 'Potential'
    WHEN r_score = 3 THEN 'At Risk'
    ELSE 'Others'
  END AS segment
FROM combined
ORDER BY customer_id;

Собираем RFM‑баллы в единый код: rfm_code удобен как числовой ключ для быстрого поиска. Сегментация через CASE это лишь верхушка, по идее можно отдельно вычленить редких, но щедрых покупателей и таргетировать их подпиской или премиальными предложениями.

Мтериализуем и оптимизируем

Если запускать эти запросы каждый раз — будет не очень. На больших объёмах NTILE, оконные функции и агрегации не самые лёгкие. Мы хотим, чтобы BI‑аналитик мог в любой момент открыть дашборд и мгновенно увидеть RFM‑сегменты. Поэтому сохраняем всё как materialized view, который можно обновлять раз в день или по крону.

CREATE MATERIALIZED VIEW rfm_summary AS
WITH base AS (...),
scored AS (...),
combined AS (
  SELECT *,
    r_score * 100 + f_score * 10 + m_score AS rfm_code,
    CONCAT(r_score, f_score, m_score) AS rfm_str
  FROM scored
)
SELECT
  customer_id, recency_days, freq, monetary,
  r_score, f_score, m_score, rfm_str,
  CASE
    WHEN r_score = 1 AND f_score = 1 AND m_score = 1 THEN 'Champions'
    WHEN r_score <= 2 AND f_score <= 2 AND m_score <= 2 THEN 'Potential'
    WHEN r_score = 3 THEN 'At Risk'
    ELSE 'Others'
  END AS segment
FROM combined;

И не забываем:

REFRESH MATERIALIZED VIEW rfm_summary;

Можно настроить через pg_cron или планировщик вне базы. Индексируем segment, rfm_code, customer_id для ускорения BI‑запросов.


Итоги

Если вы уже применяете RFM в своих проектах — делитесь, как вы это делаете. Какие баллы используете, как сегментируете, что работает лучше?

Что можно докрутить в нашем примере? Например, добавить временные окна — последние 3/6 месяцев, если бизнес сезонный. Вместо NTILE(3) — использовать бизнесовые пороги custom ranges, завязанные на абсолютные значения. Можно посчитать retention после применения сегментации, использовать веса для каждого из R/F/M (если, например, деньги важнее частоты), связать с рекомендациями или фидить это всё в кластеризацию или ML‑модель. Возможностей — масса. Всё зависит от того, как глубоко вы хотите погрузиться и что именно для вас важно.

Если вы работаете с требованиями, моделируете процессы или взаимодействуете с командами разработки, обратите внимание на серию открытых уроков курса «Системный аналитик. Advanced». Это возможность последовательно разобрать прикладные аспекты работы аналитика на практике:

30 июля в 20:00 — Data Governance: бюрократия или суперспособность? Обсудим, как сделать управление данными не формальностью, а рабочим инструментом аналитика.

31 июля в 18:00 — Пользовательские сценарии (Use Cases). Рассмотрим, как превращать бизнес‑требования заказчика в понятные задачи для команды разработки.

13 августа в 20:00 — События в BPMN 2.0. Подробно разберёмся, как с ними работать и как не допустить типичных ошибок в диаграммах.

20 августа в 20:00 — Разработка тест‑кейсов на основе требований. Поговорим о том, как формировать проверяемые сценарии уже на этапе описания требований.

Также вы можете пройти вступительное тестирование — оно поможет понять, насколько вам подойдёт продвинутый уровень программы и на какие темы стоит обратить внимание в первую очередь.

Комментарии (1)


  1. Akina
    28.07.2025 17:18

    Если кто‑то только зарегистрировался или просто оформил заказ, но не оплатил, то у него не будет не только ни одного amount, но и ни одного order_date. Соответственно MAX(o.order_date)::date вернёт NULL, и вся ваша аналитика превращается в тыкву.