LTV по когортам с ИИ: сравнение на равном возрасте

Как считать LTV по когортам с ИИ на равном возрасте: полный учебный журнал, окна 30 и 60 дней, зрелость, дубли и возврат. Проверка сумм по строкам.

LTV по когортам считают на равном возрасте: сравнивают только те ячейки, где каждый исходный клиент уже прожил полное окно 30 или 60 дней к дате среза. В статье — учебный протокол с полным журналом, шестью ячейками, правилами исключений и копируемым промптом для ИИ. Ниже все входы вымышлены: Учебный пример, а не клиентский кейс и не испытание модели. Ценность здесь — наблюдаемая маржинальная contribution на одного исходного клиента за окно H дней, а не полный lifetime и не прогноз.

Ответ сначала

На одном H сравнивать можно только зрелые ячейки. В учебном примере готовы три значения: январь на 30 днях — 175 ₽ на клиента, январь на 60 днях — 185 ₽, февраль на 30 днях — 120 ₽. Остальные три ячейки — null, потому что к дате среза не все их клиенты прожили окно целиком: февральская когорта не дожила до 60 дней, а мартовская не закрыла ни 30, ни 60 дней из-за клиента D с первой оплатой 20 марта.

Три готовых значения — не наблюдение полного lifetime. Это накопленная contribution за фиксированный отрезок от первой оплаты каждого клиента. Разница между 175 и 185 показывает, что произошло за следующий месяц жизни январской когорты, но не говорит, сколько она принесёт дальше и окупила ли привлечение.

Полный вход и политика

Все входы ниже — единый учебный набор. Реестр клиентов содержит пять записей с идентификатором и датой первой оплаты. Журнал транзакций содержит двенадцать строк с идентификатором строки, клиентом, датой, выручкой и переменной себестоимостью.

{
  "customers_columns": ["customer_id", "first_paid"],
  "customers": [
    ["A", "2026-01-01"],
    ["B", "2026-01-15"],
    ["E", "2026-02-01"],
    ["C", "2026-03-01"],
    ["D", "2026-03-20"]
  ],
  "ledger_columns": ["row_id", "customer_id", "date", "receipts_rub", "variable_cost_rub"],
  "ledger": [
    ["a1", "A", "2026-01-01", 200, 100],
    ["a2", "A", "2026-01-30", 100, 50],
    ["a2", "A", "2026-01-30", 100, 50],
    ["a3", "A", "2026-01-31", 100, 60],
    ["a4", "A", "2026-02-15", -20, 0],
    ["b1", "B", "2026-01-15", 300, 100],
    ["b2", "B", "2026-03-16", 200, 100],
    ["e1", "E", "2026-02-01", 200, 80],
    ["e2", "E", "2026-03-03", 100, 20],
    ["c1", "C", "2026-03-01", 500, 200],
    ["d1", "D", "2026-03-20", 500, 100],
    ["x1", "X", "2026-01-01", 100, 0]
  ],
  "rules": {
    "synthetic": true,
    "timezone": "UTC",
    "snapshot_exclusive": "2026-04-01",
    "registry": "Полная для примера авторитетная история первых оплат; даты first_paid не угадываются по ограниченному ledger.",
    "cohort": "Календарный месяц first_paid; клиент закреплён один раз.",
    "metric": "observed_contribution_per_initial_customer_Hd, не полный lifetime и не прогноз",
    "contribution": "receipts_rub-variable_cost_rub; расходы привлечения, фиксированные расходы, дисконтирование не включены. Это заданная редакционная метрика, не универсальное определение валовой прибыли.",
    "window": "Для клиента [first_paid, first_paid+Hdays), H30/60; календарные месяцы не заменяют30дней.",
    "maturity": "Ячейка когорты зрелая только если first_paid+Hdays<=snapshot для ВСЕХ исходных клиентов; иначе сумма и среднее null.",
    "denominator": "Все исходные клиенты когорты, не только повторившие покупку.",
    "refund": "a4: возврат уменьшает receipts на фактическую дату возврата; себестоимость не восстанавливается в этом примере. Это политика ledger, не универсальная финансовая политика.",
    "dedup": "Точный дубль row_id=a2 один раз; конфликт того жеID блокирует расчёт. Неизвестный X исключается отдельно, не создаёт клиента.",
    "claims": "Не сравнивать разныеH, не считать незрелое 0, не прогнозировать, не делать причинный вывод/решение о канале."
  }
}

Политика задаёт срез и границы. Срез — исключительная граница 2026-04-01 по UTC: транзакция, датированная ровно этим днём, в пример не попадает. Когорта — календарный месяц первой оплаты, клиент закрепляется один раз. Contribution — выручка минус переменная себестоимость; расходы привлечения, фиксированные расходы и дисконтирование не включены. Это заданная редакционная метрика, не универсальное определение валовой прибыли.

Окно — полуоткрытый отрезок от даты первой оплаты до неё же плюс H дней, граница начала включается, граница конца нет. Знаменатель — все исходные клиенты когорты, а не только те, кто купил повторно. Зрелость считается по худшему клиенту когорты: если хотя бы один исходный клиент не прожил окно к срезу, вся ячейка остаётся null. Себестоимость при возврате в этом примере не восстанавливается; это политика журнала, а не универсальная финансовая норма.

Сводный реестр учебного примера:

customer_id first_paid когорта
A 2026-01-01 2026-01
B 2026-01-15 2026-01
E 2026-02-01 2026-02
C 2026-03-01 2026-03
D 2026-03-20 2026-03

Расчёт и весь эталон

Дубли и неизвестные клиенты разбираются до любых сумм. Строка a2 встречается в журнале дважды с одинаковыми полями, поэтому считается один раз. Клиент X есть в журнале, но отсутствует в реестре первых оплат, поэтому строка x1 исключается отдельно и клиента не создаёт. Конфликт того же идентификатора строки с разными полями остановил бы расчёт, но в этом наборе его нет.

Граничные строки показывают, зачем нужно полуоткрытое окно. У клиента A первая оплата 1 января. Строка a3 датирована 31 января — это ровно 30 суток, и в 30-дневное окно она не входит, а в 60-дневное входит. У клиента B первая оплата 15 января, строка b2 датирована 16 марта — это ровно 60 суток, и в 60-дневное окно она не входит. Возврат a4 от 15 февраля уменьшает выручку A внутри 60-дневного окна на 20 ₽.

Ячейка за январь на 30 днях: клиенты A и B, знаменатель 2. В окно попадают строки a1, a2 (один раз) и b1. Contribution по строкам: 100, 50 и 200, итого 350 ₽, среднее 175 ₽.

Ячейка за январь на 60 днях: те же два клиента, знаменатель 2. В окно попадают a1, a2 (один раз), a3, a4 и b1. Contribution: 100, 50, 40, минус 20 и 200, итого 370 ₽, среднее 185 ₽.

Ячейка за февраль на 30 днях: клиент E, знаменатель 1. В окно попадает e1: contribution 120 ₽, значение 120 ₽.

Февраль на 60 днях: у E окно заканчивается 2 апреля, позже среза, ячейка незрелая, значения null. Март на 30 и 60 днях: у D 30-дневное окно заканчивается 19 апреля, 60-дневное — 19 мая, оба позже среза, обе ячейки незрелые, значения null.

Когорта H, дней customer_ids Знаменатель row_ids Contribution, ₽ На клиента, ₽ Статус
2026-01 30 A, B 2 a1, a2, b1 350 175 mature
2026-01 60 A, B 2 a1, a2, a3, a4, b1 370 185 mature
2026-02 30 E 1 e1 120 120 mature
2026-02 60 E 1 null null null immature
2026-03 30 C, D 2 null null null immature
2026-03 60 C, D 2 null null null immature

Исключённые строки: a2 с причиной duplicate, x1 с причиной unknown_customer. Никакого выбора только повторных покупателей в знаменателе нет: в знаменатель январской когорты входят все исходные клиенты A и B, независимо от того, повторят ли они покупку; в 30-дневное окно у A попадает a1 и a2 (один раз), у B — b1.

Копируемый промпт

Промпт ниже — не команда сверить ответ с уже известным эталоном, а самостоятельная постановка задачи. Он передаёт все входы и правила и требует собственный расчёт. Запуск кода не предполагается.

Ты проверяешь учебную наблюдаемую ценность клиента. Все данные вымышлены.
Примени ровно переданные rules.
Верни 6 ячеек cohort, H, customer_ids, denominator, row_ids, contribution_sum,
value, status; отдельный excluded с ID/причиной и not_established.
Для незрелой ячейки оставь row_ids, сумму и value null.
Покажи суммы через ID, не используй скрытый эталон.
Не сравнивай H30 с H60, не выдумывай lifetime, причинность или норму.
Если запуск кода недоступен, не утверждай, что выполнял его.

Полный вход:
{"customers_columns": ["customer_id", "first_paid"], "customers": [["A", "2026-01-01"], ["B", "2026-01-15"], ["E", "2026-02-01"], ["C", "2026-03-01"], ["D", "2026-03-20"]], "ledger_columns": ["row_id", "customer_id", "date", "receipts_rub", "variable_cost_rub"], "ledger": [["a1", "A", "2026-01-01", 200, 100], ["a2", "A", "2026-01-30", 100, 50], ["a2", "A", "2026-01-30", 100, 50], ["a3", "A", "2026-01-31", 100, 60], ["a4", "A", "2026-02-15", -20, 0], ["b1", "B", "2026-01-15", 300, 100], ["b2", "B", "2026-03-16", 200, 100], ["e1", "E", "2026-02-01", 200, 80], ["e2", "E", "2026-03-03", 100, 20], ["c1", "C", "2026-03-01", 500, 200], ["d1", "D", "2026-03-20", 500, 100], ["x1", "X", "2026-01-01", 100, 0]], "rules": {"synthetic": true, "timezone": "UTC", "snapshot_exclusive": "2026-04-01", "registry": "Полная для примера авторитетная история первых оплат; даты first_paid не угадываются по ограниченному ledger.", "cohort": "Календарный месяц first_paid; клиент закреплён один раз.", "metric": "observed_contribution_per_initial_customer_Hd, не полный lifetime и не прогноз", "contribution": "receipts_rub-variable_cost_rub; расходы привлечения, фиксированные расходы, дисконтирование не включены. Это заданная редакционная метрика, не универсальное определение валовой прибыли.", "window": "Для клиента [first_paid, first_paid+Hdays), H30/60; календарные месяцы не заменяют 30 дней.", "maturity": "Ячейка когорты зрелая только если first_paid+Hdays<=snapshot для ВСЕХ исходных клиентов; иначе сумма и среднее null.", "denominator": "Все исходные клиенты когорты, не только повторившие покупку.", "refund": "a4: возврат уменьшает receipts на фактическую дату возврата; себестоимость не восстанавливается в этом примере. Это политика ledger, не универсальная финансовая политика.", "dedup": "Точный дубль row_id=a2 один раз; конфликт того же ID блокирует расчёт. Неизвестный X исключается отдельно, не создаёт клиента.", "claims": "Не сравнивать разные H, не считать незрелое 0, не прогнозировать, не делать причинный вывод/решение о канале."}}

Что должно получиться после такого промпта, если модель поняла правила: три зрелые ячейки со значениями 175, 185 и 120 ₽, три незрелые с null, исключения a2 и x1, а в списке неустановленного — полный lifetime, прогноз, причины различий, окупаемость CAC и качество модели. Именно по этим пунктам и стоит сверять ответ, а не по форме изложения.

Пределы и следующий срез

Непроверяемый вывод из такого расчёта — полный lifetime. Окно 30 или 60 дней даёт только наблюдаемую contribution за отрезок; экстраполяция вперёд требует допущений, которых в наборе нет. Второй недопустимый вывод — причинный. Разница между январской когортой на 60 днях и на 30 днях не доказывает эффект канала, продукта или акции; причины различий здесь не установлены.

Третий предел — малые группы. Когорта из одного или двух клиентов — не бизнес-норма и не основание отключать канал. Когорты из разных продуктов, регионов или сезонов сравнивать напрямую на одном графике нельзя: разница может отражать состав, а не поведение.

Нулевую ячейку не прогнозируют. Незрелое значение остаётся null, и следующий срез берётся после того, как окно закроется у всех исходных клиентов когорты. Для февральской когорты это 2 апреля, для мартовской — 19 апреля при H 30 и 19 мая при H 60; проверку этих дат и стоит сделать следующим шагом.

Для соседних задач — когортный анализ по датам и окнам и RFM-сегментация. Первый помогает закрепить определение когорты и границы окон, второй — разложить клиентов по давности и частоте, но ни один не заменяет денежную ячейку на равном возрасте. В справке Google Analytics о когортном исследовании когорта описана как группа пользователей с общим свойством, например одинаковой датой первого посещения. Критерий включения, то есть условие добавления в когорту, и критерий возврата задаются отдельно в той же справке. Эти когорты устроены иначе, чем журнал с customer_id и денежными строками в примере выше.

Валерий Курземнек

Автор метода «Машиночитаемый маркетинг»

Что ещё почитать