Кластеризация k-means: метод k-средних на данных пользователей
Кластерный анализ методом k-средних на данных продукта: признаки, масштабирование, выбор k по локтю и силуэту, проверка устойчивости и когда хватит правил.
Содержание статьи
Руководитель продукта просит: «Сегментируйте пользователей кластеризацией, хочу понять, кому что предлагать». Вы собираете признаки, запускаете k-means, через пять минут у вас четыре группы с красивыми средними. Через неделю коллега перезапускает тот же код с другим зерном генератора — и группы другого размера, а часть людей переехала. Ниже — как устроен метод k-средних, что он на самом деле оптимизирует и как проверить, что найденные кластеры не случайность. Всё на учебной базе продукта: SQL собирает признаки, Python ищет кластеры.
Коротко
Кластеризация — это разбиение объектов на группы похожих без заранее известных правильных ответов: обучение без учителя. Метод k-средних (k-means) делит точки на k групп так, чтобы сумма квадратов расстояний от каждой точки до центра её группы была как можно меньше. Число k задаёте вы.
- Кластер находит алгоритм по расстоянию между признаками, сегмент по правилу задаёте вы. Если группу можно описать одной фразой с порогом, правило проще и надёжнее.
- Признаки в разных единицах нужно масштабировать. На учебной базе сумма оплат без стандартизации даёт 95,4% общего разброса и одна решает, кто с кем окажется.
- Метод локтя и силуэт подсказывают k, но не выбирают его за вас. На поведенческих данных силуэт около 0,3 — обычное дело и означает слабую структуру.
- Один запуск k-means — лотерея: из 20 одиночных запусков ни один не нашёл лучшее разбиение. Нужны десятки стартов и проверка на подвыборках.
- Кластер описывает сходство, а не причину. Разница в конверсии между кластерами не говорит, что поведение кластера её создало.
Что такое кластеризация и чем кластер отличается от сегмента
В задаче с учителем у каждого примера есть ответ: заплатил пользователь или нет, ушёл или остался. Модель учится его предсказывать, а качество проверяют по отложенным ответам. В кластеризации ответа нет. Есть только признаки, и алгоритм ищет в них группы точек, которые лежат ближе друг к другу, чем к остальным. Проверить «правильность» кластеров не по чему, поэтому проверяют другое: насколько они плотные, устойчивые и полезные.
Сегмент по правилу устроен наоборот: границу проводит человек. «Платил за последние 90 дней», «создал отчёт в первую неделю», RFM-баллы по порогам. Такой сегмент одинаков при каждом пересчёте, его можно объяснить продажам одной фразой и поставить в фильтр BI. Кластер появляется из данных, и его смысл приходится восстанавливать по средним признаков внутри.
Кластеризация полезна, когда признаков много и вы не знаете, по какому из них резать. Она показывает, какие сочетания поведения встречаются вместе. Но итоговый сегмент для работы часто всё равно переписывают в правило: «кластер 2» превращается в «создал рабочее пространство и отчёт, но никого не позвал».
| Сегмент по правилу | Кластер k-means | Классификация | |
|---|---|---|---|
| Кто проводит границу | аналитик или бизнес | алгоритм по расстоянию между признаками | модель по размеченным примерам |
| Нужен ли правильный ответ | нет | нет, обучение без учителя | да: метка у каждого примера |
| Как объяснить группу | одной фразой с порогом | через средние признаков в кластере | через вклад признаков в прогноз |
| При пересчёте | та же граница | может сдвинуться от запуска и от данных | меняется с выборкой обучения |
| Пример | RFM, «платил за 90 дней» | группы по сочетаниям действий | прогноз оплаты логистической регрессией |
Как работает метод k-средних: алгоритм по шагам
Каждый пользователь — точка в пространстве признаков: дни активности, отчёты, оплаты. У каждого кластера есть центр — точка, координаты которой равны средним признаков его участников. Отсюда и название: k средних.
Алгоритм чередует два шага. Сначала каждую точку относят к ближайшему центру по обычному евклидову расстоянию. Потом каждый центр переносят в среднее точек, которые к нему попали. Оба шага не увеличивают сумму квадратов расстояний, поэтому процесс сходится: назначения перестают меняться. Эту схему называют алгоритмом Ллойда: Стюарт Ллойд описал её для квантования сигнала, журнальная статья вышла в 1982 году. Сам термин «k-means» ввёл Джеймс Маккуин в 1967-м.
Сходится алгоритм к локальному минимуму, а не к лучшему из возможных. Куда он придёт, зависит от начальных центров. Поэтому важен способ старта. k-means++ Дэвида Артура и Сергея Васильвицкого (2007) выбирает первый центр случайно, а каждый следующий — с вероятностью, пропорциональной квадрату расстояния от точки до ближайшего уже выбранного центра. Центры расходятся по разным областям данных, и плохие старты случаются реже. Но не исчезают: k-means++ — это способ выбрать начало, а не гарантия результата.
J = Σ по кластерам k Σ по точкам x в кластере ‖x − μₖ‖²μₖ — центр кластера k, среднее его точек; ‖x − μₖ‖² — квадрат евклидова расстояния. J называют внутрикластерной суммой квадратов, в scikit-learn — inertia_.
| Шаг | Что происходит | Где риск |
|---|---|---|
| 1. Выбрать k | число кластеров задаёт аналитик | алгоритм не скажет, что групп в данных нет |
| 2. Начальные центры | k-means++: следующий центр выбирается с вероятностью ∝ квадрату расстояния | неудачный старт ведёт в плохой локальный минимум |
| 3. Назначение | каждая точка — к ближайшему центру | признак с большим разбросом решает за всех |
| 4. Пересчёт центров | центр = среднее точек кластера | выбросы тянут среднее к себе |
| 5. Повтор 3–4 | пока назначения меняются | сходимость к локальному, а не глобальному минимуму |
| 6. Перезапуски | несколько стартов, остаётся решение с меньшей суммой квадратов | одного запуска мало, это видно ниже |
Признаки на пользователя: собираем таблицу в SQL
В учебной базе симулятора SQL-аналитика — данные SaaS-продукта «Маяк»: регистрации с 1 июня по 29 августа 2026 года, события и оплаты, выгрузка закрыта 30 августа. Строка будущей таблицы — один пользователь. Признаки считаем за одинаковое окно — первые 28 дней после регистрации. Иначе июньский пользователь будет «активнее» августовского просто потому, что дольше живёт в данных. Окно целиком помещается в данные у тех, кто зарегистрировался до 2 августа включительно, — это 2777 человек.
Шесть признаков: open_days — в сколько разных дней человек открывал приложение; workspace, report, invite, exported — создал ли рабочее пространство, отчёт, отправил ли приглашение, сделал ли выгрузку; paid — сколько заплатил за окно. В этой базе каждое из четырёх действий встречается у пользователя не больше одного раза и в первые два дня после регистрации, так что эти признаки — флаги 0/1. За 28 дней успевает пройти только первая оплата, поэтому paid принимает значения 0, 19, 29 или 39 — цену тарифа.
Таблицу из 2777 строк песочница целиком не покажет, поэтому запрос сжимает её: для каждой комбинации флагов и суммы — сколько пользователей открывали приложение 1, 2, 3… дней. Получается 40 строк, и Python-блок ниже разворачивает их обратно в те же 2777 пользователей. В рабочей задаче вы выгрузите таблицу признаков напрямую, без сжатия.
with mature as (
select user_id, signup_date
from users
where signup_date + 28 <= date '2026-08-30'
),
act as (
select
m.user_id,
count(distinct case when e.event_name = 'app_open'
then cast(e.event_time as date) end) as open_days,
count(*) filter (where e.event_name = 'workspace_created') as workspace,
count(*) filter (where e.event_name = 'report_created') as report,
count(*) filter (where e.event_name = 'invite_sent') as invite,
count(*) filter (where e.event_name = 'export_completed') as exported
from mature as m
left join events as e
on e.user_id = m.user_id
and cast(e.event_time as date) < m.signup_date + 28
group by m.user_id
),
pay as (
select m.user_id, cast(coalesce(sum(p.amount), 0) as integer) as paid
from mature as m
left join payments as p
on p.user_id = m.user_id
and p.paid_at < m.signup_date + 28
group by m.user_id
),
features as (
select a.*, p.paid
from act as a
join pay as p on p.user_id = a.user_id
),
cells as (
select workspace, report, invite, exported, paid, open_days, count(*) as n
from features
group by workspace, report, invite, exported, paid, open_days
)
select
workspace, report, invite, exported, paid,
sum(n) as users,
string_agg(cast(open_days as varchar) || ':' || cast(n as varchar), ' '
order by open_days) as open_days_hist
from cells
group by workspace, report, invite, exported, paid
order by workspace, report, invite, exported, paid;import tempfile
from pathlib import Path
import pandas as pd
# Результат SQL-запроса выше: флаги workspace, report, invite, exported, сумма оплат
# и после черты — сколько пользователей открывали приложение 1, 2, 3… дней
RAW = """
0 0 0 0 0 | 1:14 2:45 3:80 4:123 5:105 6:100 7:83 8:51 9:34 10:21 11:7 12:1 13:1
0 0 0 0 19 | 2:3 3:10 4:8 5:10 6:11 7:15 8:11 9:9 10:8 11:1 13:1
0 0 0 0 29 | 2:1 3:3 4:4 5:8 6:9 7:9 8:6 9:3 10:1 11:1
0 0 0 0 39 | 3:1 4:4 5:7 6:5 7:3 8:1 11:1
0 0 1 0 0 | 1:4 2:17 3:21 4:27 5:43 6:30 7:25 8:16 9:10 10:3 11:2
0 0 1 0 19 | 2:4 3:3 4:7 5:9 6:5 7:6 8:4 9:1 10:2
0 0 1 0 29 | 3:2 4:6 5:7 6:2 7:5 8:2 10:1 11:1
0 0 1 0 39 | 4:1 5:1 6:1 7:1 12:1
1 0 0 0 0 | 1:5 2:10 3:14 4:25 5:27 6:38 7:30 8:33 9:11 10:12 11:8 12:1
1 0 0 0 19 | 1:1 2:5 3:2 4:5 5:10 6:12 7:13 8:2 9:4 10:3 12:2
1 0 0 0 29 | 2:1 3:2 4:1 6:4 7:3 8:3 9:1 10:2 11:1 14:1
1 0 0 0 39 | 2:1 4:2 5:2 6:2 7:2
1 0 0 1 0 | 1:2 2:5 3:8 4:17 5:22 6:25 7:18 8:12 9:10 10:5 11:1
1 0 0 1 19 | 1:1 3:1 5:7 6:1 7:1 8:1 9:3
1 0 0 1 29 | 3:1 4:2 6:2 7:3 8:1 9:1
1 0 0 1 39 | 6:1 7:2
1 0 1 0 0 | 1:1 2:4 3:5 4:13 5:13 6:6 7:13 8:5 9:5 10:6
1 0 1 0 19 | 2:1 3:2 4:2 5:6 6:4 7:1 8:3 9:1 10:2
1 0 1 0 29 | 5:1 6:3 7:1 10:1 11:1
1 0 1 0 39 | 7:1
1 0 1 1 0 | 2:1 3:5 4:4 5:6 6:10 8:4 9:2 10:2 11:1
1 0 1 1 19 | 2:1 4:1 6:1 7:2 8:2 9:2
1 0 1 1 29 | 4:1 5:2 6:1 7:1
1 0 1 1 39 | 5:1
1 1 0 0 0 | 1:4 2:8 3:28 4:52 5:61 6:70 7:53 8:35 9:28 10:12 11:1 12:6
1 1 0 0 19 | 2:1 3:4 4:14 5:12 6:11 7:6 8:11 9:4 10:6 11:3
1 1 0 0 29 | 2:1 3:4 4:3 5:10 6:6 7:9 8:6 9:4 10:3 11:1
1 1 0 0 39 | 4:3 5:4 6:6 7:3 8:1 10:2 11:1
1 1 0 1 0 | 1:2 2:6 3:16 4:37 5:43 6:46 7:38 8:30 9:12 10:12 11:4 12:1 13:1
1 1 0 1 19 | 1:1 2:2 3:2 4:2 5:3 6:11 7:6 8:6 9:3 10:1 11:1
1 1 0 1 29 | 4:3 5:4 6:4 7:5 8:7 9:3 10:1 11:1
1 1 0 1 39 | 5:1 6:1 8:2 9:1
1 1 1 0 0 | 2:5 3:11 4:25 5:19 6:25 7:15 8:14 9:11 10:5 11:1
1 1 1 0 19 | 1:1 3:1 4:2 5:4 6:7 7:1 8:7
1 1 1 0 29 | 4:1 5:6 6:2 7:2 9:1 10:2
1 1 1 0 39 | 3:1 4:2 6:1 7:1 8:1
1 1 1 1 0 | 2:2 3:5 4:11 5:9 6:9 7:8 8:7 9:8 10:4 11:1 12:3
1 1 1 1 19 | 4:2 5:1 6:1 7:3 8:2
1 1 1 1 29 | 3:2 4:1 5:3 6:3 7:1 8:1
1 1 1 1 39 | 5:1 6:1 7:2 8:2
"""
rows = []
for line in RAW.strip().splitlines():
head, hist = line.split('|')
flags = [int(v) for v in head.split()]
for cell in hist.split():
days, n = map(int, cell.split(':'))
rows += [[days, *flags]] * n
cols = ['open_days', 'workspace', 'report', 'invite', 'exported', 'paid']
df = pd.DataFrame(rows, columns=cols)
df.to_csv(Path(tempfile.gettempdir()) / 'mayak_features.csv', index=False)
print(len(df), 'пользователей')
print(df.agg(['mean', 'std']).round(2))
var = df.var()
print((var / var.sum()).round(3)) # доля признака в общем разбросеЗачем масштабировать признаки перед k-means
k-means складывает квадраты разностей по всем признакам. Разница в оплате 19 долларов против 0 весит 361, разница в одном флаге — 1, а разница в три дня активности — 9. Признак с самым большим разбросом становится главным просто из-за единиц измерения. Первый Python-блок это и показал: стандартное отклонение paid — 11,01, у флагов — 0,42–0,49, у дней — 2,22. На оплаты приходится 95,4% общего разброса, на дни активности — 3,9%, на четыре флага вместе — меньше процента.
Запустим k-means с k = 4 дважды: на сырых признаках и после стандартизации, когда из каждого признака вычли среднее и разделили на стандартное отклонение. Это та же z-оценка, только для каждого столбца: после неё у всех признаков одинаковый разброс.
Без масштабирования алгоритм разрезал людей по сумме оплаты и по дням активности. Неплатящие разошлись на 1369 человек с 1–6 днями и 743 с 7–13 днями, платящие — на тариф за 19 и тарифы за 29 и 39. Флаги во всех четырёх кластерах почти одинаковые: рабочее пространство есть у 56–66%. То есть действия пользователей в разбиении не участвовали вовсе. После стандартизации картина обратная: кластеры отличаются именно действиями, а средняя сумма оплат в них близка.
z = (x − среднее признака) / стандартное отклонение признакаСчитается по каждому столбцу отдельно. В блоке — numpy std с делителем n, как в StandardScaler из scikit-learn.
| Вариант | Пользователей | Дней активности | Рабочее пространство | Выгрузка | Оплачено в среднем |
|---|---|---|---|---|---|
| Сырые признаки | 1369 | 4,42 | 0,56 | 0,21 | 0 |
| Сырые признаки | 743 | 8,20 | 0,66 | 0,25 | 0 |
| Сырые признаки | 375 | 6,14 | 0,66 | 0,19 | 19,00 |
| Сырые признаки | 290 | 6,27 | 0,66 | 0,24 | 31,69 |
| Стандартизация | 1089 | 5,50 | 0 | 0 | 5,09 |
| Стандартизация | 798 | 6,12 | 1 | 0 | 6,93 |
| Стандартизация | 472 | 6,10 | 1 | 1 | 5,13 |
| Стандартизация | 418 | 6,00 | 1 | 0,34 | 6,74 |
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.cluster.vq import kmeans2
df = pd.read_csv(Path(tempfile.gettempdir()) / 'mayak_features.csv')
X = df.to_numpy(dtype=float)
Z = (X - X.mean(axis=0)) / X.std(axis=0) # z-оценка каждого признака
for name, data in [('Без масштабирования', X), ('После стандартизации', Z)]:
centers, labels = kmeans2(data, 4, iter=100, minit='++',
rng=np.random.default_rng(7))
profile = df.groupby(labels).mean().round(2)
profile['users'] = np.bincount(labels)
print(name)
print(profile.sort_values('users', ascending=False))
print()После неё каждый признак весит одинаково. Если четыре из шести признаков — про действия в продукте, действия получат две трети веса. Решите заранее, что важно для задачи: можно убрать лишние признаки или умножить нужный на коэффициент. Масштабирование не делает выбор признаков нейтральным.
Сколько кластеров выбрать: метод локтя
Сумма квадратов падает с каждым новым кластером: при k, равном числу точек, она станет нулём. Поэтому минимум J ничего не говорит о правильном k. Метод локтя смотрит на то, сколько даёт каждый следующий кластер. Если до какого-то k сумма падает круто, а дальше полого, на графике виден излом — «локоть», и его берут за ответ.
Для стандартизованных данных при одном кластере сумма квадратов равна 2777 × 6 = 16 662. Второй кластер снимает 4546, третий — 2217, четвёртый — 1387, пятый — 1051, дальше по 400–800. Четыре кластера описывают 48,9% разброса. Излом есть, но размытый: между k = 3 и k = 5 нет резкого перехода. Так выглядит большинство реальных данных, учебный пример с тремя чёткими облаками — исключение.
В блоке каждое k запускается 50 раз с k-means++, остаётся решение с наименьшей суммой квадратов. Зачем столько — видно в разделе об устойчивости. У kmeans2 из SciPy нет своего параметра для повторных запусков, поэтому цикл написан руками; в scikit-learn за это отвечает n_init. И ещё деталь SciPy: iter — это ровно столько итераций, проверки сходимости там нет, поэтому берём 100.
Учебная база симулятора SQL-аналитика, 2777 пользователей, шесть стандартизованных признаков, лучший из 50 запусков k-means++ (SciPy kmeans2). При k = 1 сумма равна 16 662.
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.cluster.vq import kmeans2
from scipy.spatial.distance import cdist
df = pd.read_csv(Path(tempfile.gettempdir()) / 'mayak_features.csv')
X = df.to_numpy(dtype=float)
Z = (X - X.mean(axis=0)) / X.std(axis=0)
def best_kmeans(Z, k, n_init=50, seed=0):
"""n_init запусков с k-means++; остаётся тот, где сумма квадратов меньше."""
rng = np.random.default_rng(seed)
best = None
for _ in range(n_init):
centers, labels = kmeans2(Z, k, iter=100, minit='++', rng=rng)
inertia = ((Z - centers[labels]) ** 2).sum()
if best is None or inertia < best[2]:
best = (centers, labels, inertia)
return best
def silhouette(D, labels):
"""Средний силуэт по готовой матрице попарных расстояний D."""
_, lab = np.unique(labels, return_inverse=True)
sizes = np.bincount(lab)
sums = np.column_stack([D[:, lab == j].sum(axis=1) for j in range(len(sizes))])
idx = np.arange(len(lab))
a = sums[idx, lab] / np.maximum(sizes[lab] - 1, 1) # до своих, без самой точки
mean_other = sums / sizes
mean_other[idx, lab] = np.inf
b = mean_other.min(axis=1) # до ближайшего чужого кластера
s = (b - a) / np.maximum(a, b)
s[sizes[lab] == 1] = 0
return s.mean()
D = cdist(Z, Z) # 2777 × 2777; на сотнях тысяч строк берите подвыборку
for k in range(2, 11):
_, labels, inertia = best_kmeans(Z, k)
print(f'k={k:>2} сумма квадратов={inertia:8.1f} силуэт={silhouette(D, labels):.3f} '
f'размеры={sorted(np.bincount(labels).tolist(), reverse=True)}')Силуэт: насколько кластеры отделены друг от друга
Силуэт предложил Питер Руссеу в 1987 году. Для каждой точки берут a — среднее расстояние до остальных точек своего кластера — и b — среднее расстояние до точек ближайшего чужого кластера. Если точка сидит глубоко в своём кластере, a много меньше b и силуэт близок к 1. Около 0 — точка на границе, отрицательный — ей ближе чужой кластер. Средний силуэт по всем точкам оценивает разбиение целиком.
В scikit-learn есть готовая silhouette_score, но она не нужна: в блоке выше силуэт посчитан через матрицу попарных расстояний cdist. Матрица на 2777 точек занимает около 60 МБ. На сотнях тысяч пользователей она не поместится в память — тогда силуэт считают на случайной подвыборке в несколько тысяч точек.
На учебной базе силуэт при k от 2 до 10 — от 0,29 до 0,43. Такие значения обычно описывают как слабую структуру: группы есть, но границы между ними размыты, и точек «на краю» много. Растёт силуэт с k не потому, что десять кластеров лучше четырёх. Четыре признака из шести — флаги, и все пользователи лежат на 40 сочетаниях флагов и суммы. Чем больше k, тем точнее кластеры совпадают с отдельными сочетаниями и тем они плотнее. На таких данных силуэт подталкивает дробить до бесконечности, и выбирать по максимуму силуэта нельзя.
Итог по выбору k: локоть указывает на 3–5, силуэт однозначного ответа не даёт. Берём k = 4 по другой причине — четыре группы различаются понятными действиями, и на каждую можно придумать отдельное действие продукта. Это нормальный критерий: число кластеров — решение аналитика, а метрики лишь отсекают явно плохие варианты.
s = (b − a) / max(a, b)a — среднее расстояние до точек своего кластера, b — до точек ближайшего другого кластера. Значения от −1 до 1; средний силуэт по всем точкам — оценка разбиения.
Те же запуски, что на графике локтя. Рост с k отчасти объясняется тем, что большинство признаков — флаги 0/1.
Кластеры клиентов: средние признаков и доля платящих
Кластер описывают средними исходных, а не стандартизованных признаков: «0,62 создали отчёт» читается, «+0,47 стандартного отклонения» — нет. Рядом с поведением ставим то, ради чего сегментировали, — долю платящих.
Дни активности почти одинаковы во всех четырёх кластерах: 5,5–6,1. Различаются действия. Самый большой кластер — 819 человек, которые за 28 дней не создали рабочее пространство и ничего в нём не сделали, хотя открывали приложение в среднем пять с половиной дней. Платят из них 18,8%. Самая высокая доля платящих, 28,3%, — у тех, кто создал пространство, но никого не позвал и ничего не выгружал. Во всей выборке платят 23,9%.
Имена кластерам даёт аналитик, и это самая ответственная часть: имя будет жить в презентациях дольше, чем кто-то вспомнит про силуэт. Называйте по тому, что отличает кластер от остальных, а не по тому, что хочется про него думать. «Зовут коллег» — потому что приглашение отправили все 545 человек, а рабочее пространство есть только у половины из них.
| Имя | Пользователей | Дней активности | Пространство | Отчёт | Приглашение | Выгрузка | Платящих |
|---|---|---|---|---|---|---|---|
| Смотрят, но не начали | 819 | 5,54 | 0 | 0 | 0 | 0 | 18,8% (154) |
| Работают в одиночку | 798 | 6,12 | 1 | 0,62 | 0 | 0 | 28,3% (226) |
| Выгружают результат | 615 | 6,12 | 1 | 0,67 | 0,23 | 1 | 22,8% (140) |
| Зовут коллег | 545 | 5,65 | 0,50 | 0,32 | 1 | 0 | 26,6% (145) |
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.cluster.vq import kmeans2
df = pd.read_csv(Path(tempfile.gettempdir()) / 'mayak_features.csv')
X = df.to_numpy(dtype=float)
Z = (X - X.mean(axis=0)) / X.std(axis=0)
def best_kmeans(Z, k, n_init=50, seed=0):
rng = np.random.default_rng(seed)
best = None
for _ in range(n_init):
centers, labels = kmeans2(Z, k, iter=100, minit='++', rng=rng)
inertia = ((Z - centers[labels]) ** 2).sum()
if best is None or inertia < best[2]:
best = (centers, labels, inertia)
return best
_, labels, _ = best_kmeans(Z, 4)
profile = df.assign(payer=df['paid'] > 0).groupby(labels).mean().round(2)
profile['users'] = np.bincount(labels)
print(profile.sort_values('users', ascending=False))
print(f"Платящих во всей выборке: {(df['paid'] > 0).mean():.1%}")Разница 18,8% и 28,3% не значит, что рабочее пространство приводит к оплате. Его могут создавать те, кто пришёл уже с намерением платить. Кластеризация показывает, какие сочетания поведения встречаются вместе; чтобы узнать, что изменится, если подтолкнуть людей к действию, нужен эксперимент. И помните, что 18,8% против 23,9% — это разница в несколько десятков оплат, а не новый закон о пользователях.
Насколько устойчивы кластеры: перезапуск с другим seed
Кластеры, которые меняются от зерна генератора случайных чисел (seed), нельзя отдавать в CRM. Проверим три вещи: что даёт один запуск, хватает ли десяти, и повторяется ли разбиение на другой части пользователей. Сходство двух разбиений меряем скорректированным индексом Рэнда (ARI): 1 — люди разложены одинаково, около 0 — совпадение не лучше случайного. Номера кластеров индекс не учитывает, только то, кто с кем оказался вместе.
Двадцать одиночных запусков k-means++ с разными seed ни разу не нашли лучшее разбиение. Получилось 19 разных наборов размеров, ARI с эталоном от 0,22, медиана 0,43. Лучший из 10 запусков тоже нестабилен: при четырёх разных seed — три разных ответа с суммой квадратов 8602–8629 против лучших 8511,8. Разница по сумме квадратов — 1–1,4%, а люди разложены заметно иначе: ARI 0,55–0,87. Лучший из 50 при тех же seed каждый раз приходит к одному решению.
На подвыборках по 80% пользователей разбиение повторилось четыре раза из пяти, в пятом ARI 0,65. Вывод: структура в данных слабая, несколько разных разбиений почти одинаково хороши по сумме квадратов, и какое найдётся, зависит от старта и от состава выборки. Кластер «Выгружают результат» (о названиях — в разделе выше) находится почти в том же составе во всех вариантах, «Смотрят, но не начали» — в большинстве, а сильнее всего гуляет граница между «одиночками» и «зовущими коллег».
Практическое следствие для scikit-learn: в KMeans с версии 1.4 по умолчанию n_init='auto', что для k-means++ означает один запуск. На данных вроде этих задавайте число запусков явно, например KMeans(n_clusters=4, n_init=50, random_state=0), и фиксируйте random_state, чтобы коллега получил те же кластеры.
| Проверка | Сумма квадратов | Размеры кластеров | ARI с эталоном |
|---|---|---|---|
| Эталон: лучший из 50 | 8511,8 | 819 / 798 / 615 / 545 | 1,00 |
| Один запуск, 20 seed | выше эталона во всех 20 | 19 разных наборов | 0,22–0,87, медиана 0,43 |
| Лучший из 10, seed 1 и 2 | 8602,2 | 972 / 819 / 615 / 371 | 0,87 |
| Лучший из 10, seed 3 | 8602,4 | 1120 / 671 / 615 / 371 | 0,64 |
| Лучший из 10, seed 4 | 8629,2 | 990 / 774 / 546 / 467 | 0,55 |
| Лучший из 50, seed 1–4 | 8511,8 | 819 / 798 / 615 / 545 | 1,00 |
| Подвыборки 80%, пять штук | — | — | 1,00 в четырёх, 0,65 в одной |
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.cluster.vq import kmeans2
from scipy.special import comb
df = pd.read_csv(Path(tempfile.gettempdir()) / 'mayak_features.csv')
X = df.to_numpy(dtype=float)
Z = (X - X.mean(axis=0)) / X.std(axis=0)
def best_kmeans(Z, k, n_init=50, seed=0):
rng = np.random.default_rng(seed)
best = None
for _ in range(n_init):
centers, labels = kmeans2(Z, k, iter=100, minit='++', rng=rng)
inertia = ((Z - centers[labels]) ** 2).sum()
if best is None or inertia < best[2]:
best = (centers, labels, inertia)
return best
def ari(a, b):
"""Скорректированный индекс Рэнда: 1 — разбиения совпали, около 0 — как случайные."""
t = pd.crosstab(a, b).to_numpy()
pairs = comb(t, 2).sum()
rows, cols, total = comb(t.sum(1), 2).sum(), comb(t.sum(0), 2).sum(), comb(t.sum(), 2)
expected = rows * cols / total
return (pairs - expected) / ((rows + cols) / 2 - expected)
_, ref, ref_inertia = best_kmeans(Z, 4)
single = [kmeans2(Z, 4, iter=100, minit='++', rng=np.random.default_rng(s)) for s in range(20)]
hits = sum(np.isclose(((Z - c[l]) ** 2).sum(), ref_inertia) for c, l in single)
scores = [ari(ref, l) for _, l in single]
print(f'Один запуск, 20 разных seed: лучшее разбиение найдено {hits} раз, '
f'наборов размеров {len({tuple(sorted(np.bincount(l))) for _, l in single})}, '
f'ARI с эталоном от {min(scores):.2f}, медиана {np.median(scores):.2f}')
for seed in range(1, 5):
for n_init in (10, 50):
_, labels, inertia = best_kmeans(Z, 4, n_init=n_init, seed=seed)
print(f'seed={seed}, лучший из {n_init}: сумма квадратов {inertia:.1f}, '
f'размеры {sorted(np.bincount(labels).tolist(), reverse=True)}, '
f'ARI {ari(ref, labels):.2f}')
rng = np.random.default_rng(2026)
for i in range(5):
part = rng.choice(len(Z), size=int(0.8 * len(Z)), replace=False)
_, labels, _ = best_kmeans(Z[part], 4, seed=i)
print(f'80% пользователей, выборка {i + 1}: ARI на тех же людях {ari(ref[part], labels):.2f}')Ограничения k-means: где метод k-средних ошибается
У k-means есть встроенные допущения, и данные о пользователях нарушают их почти всегда. Перед тем как нести кластеры в отчёт, проверьте, какие из них задевают вашу задачу.
- Кластеры шарообразные и одного размаха. Точка уходит к ближайшему центру, поэтому вытянутые, изогнутые или вложенные группы k-means режет поперёк. Для них есть иерархическая кластеризация и методы плотности, например DBSCAN.
- Выбросы тянут центры. Центр — среднее, и один клиент с оплатой в сто раз выше обычной может получить собственный кластер или сдвинуть чужой. Длинные хвосты сначала логарифмируют или обрезают по перцентилю.
- Результат определяется выбором признаков. Добавили три признака про оплаты — получили кластеры по деньгам; убрали — по действиям. Сильно коррелирующие признаки фактически считаются дважды.
- Категориальные признаки плохо ложатся в расстояние. Канал или страна, превращённые в столбцы 0/1, создают решётку, как флаги в нашем примере, и среднее «0,5 канала» ничего не значит. Для категорий есть k-modes и расстояние Гауэра.
- Алгоритм всегда возвращает k групп. Даже на однородных данных без всякой структуры вы получите ровно столько кластеров, сколько попросили, с аккуратными средними.
- Кластер — не причина и не прогноз. Если задача — предсказать оплату или отток, это классификация, и её качество меряют совсем другими метриками.
Когда хватит правил: RFM, пороги и простые сегменты
Если бизнес-вопрос уже называет признак, кластеризация лишняя. «Кто давно не платил» — это Recency, RFM-сегменты по порогам ответят точнее и будут одинаковы в каждом пересчёте. «Кто не дошёл до первого отчёта» — один флаг. На учебной базе k-means после стандартизации по сути нашёл границы, которые можно записать правилами: нет рабочего пространства; есть пространство без выгрузки и приглашений; есть выгрузка; есть приглашение. Правило объясняется одной строкой SQL, кластер — таблицей средних и оговорками об устойчивости.
Кластеризация оправдана, когда признаков десяток и больше, связи между ними неочевидны, а задача — разведка: увидеть, какие сочетания поведения вообще встречаются. Найденные кластеры становятся гипотезами для правил, интервью с пользователями и экспериментов, а не готовыми сегментами для рассылки.
| Ситуация | Подход |
|---|---|
| Вопрос называет признак и порог («не платил 60 дней») | сегмент по правилу |
| Нужна классика удержания и повторных покупок | RFM-анализ по порогам или квантилям |
| Нужно предсказать событие (оплату, отток) | классификация, проверка на отложенных данных |
| Признаков много, связи неизвестны, нужна разведка | кластеризация, затем перевод кластеров в правила |
| Группы вытянутые или разной плотности | не k-means: иерархическая кластеризация, DBSCAN |
Частые вопросы
Чем кластеризация отличается от классификации?
В классификации у каждого примера есть правильный ответ, и модель учится его предсказывать. В кластеризации ответов нет: алгоритм группирует объекты по сходству признаков, а смысл групп вы восстанавливаете сами.
Нужно ли масштабировать признаки для k-means?
Да, если они в разных единицах. Иначе признак с самым большим разбросом определит кластеры один: на учебной базе сумма оплат без стандартизации давала 95,4% разброса, и действия пользователей в разбиение не попали.
Как выбрать число кластеров?
Посмотрите на локоть суммы квадратов и на силуэт, отбросьте явно плохие k, а из оставшихся выберите то, при котором кластеры различаются понятными признаками и на каждый есть своё действие. Потом проверьте устойчивость перезапусками и подвыборками.
Можно ли сделать k-means в SQL?
Технически — итерациями в рекурсивном запросе, но это неудобно и медленно. Обычно признаки собирают в SQL, где живут данные, а кластеризуют в Python: SciPy kmeans2 или KMeans из scikit-learn.
Почему кластеры меняются при каждом запуске?
Старт случайный, и алгоритм сходится к ближайшему локальному минимуму. Задайте много запусков, зафиксируйте seed и проверьте, повторяется ли разбиение на подвыборках. Если нет — структура в данных слабая, и это тоже результат.
Итог
Метод k-средних быстро делит пользователей на группы по сходству признаков, но решения за аналитика не принимает. Признаки и окно выбираете вы, масштаб признаков решает, кто с кем окажется, число кластеров — компромисс между метриками и смыслом, а устойчивость нужно проверять отдельно. На учебной базе четыре кластера описали половину разброса, различаются действиями, а не активностью, а доля платящих в них — от 19% до 28%. Это хороший материал для гипотез и плохой — для выводов о причинах.
Попробуйте сами: соберите признаки запросом из статьи в песочнице и замените окно 28 дней на 14 или добавьте канал привлечения — посмотрите, как изменятся кластеры. Метрики продукта на той же базе разбираются в Симуляторе SQL-аналитика.
Материалы по теме

Логистическая регрессия: сигмоида, отношение шансов и пример на Python
Логистическая регрессия простыми словами: сигмоида и логит, шансы и отношение шансов, интерпретация коэффициентов, лог-лосс. Модель оплаты на Python с ROC AUC и калибровкой.

DuckDB для аналитика: SQL по CSV и Parquet без сервера
Что такое DuckDB и как выполнять SQL по CSV, Parquet и pandas DataFrame без сервера: примеры в Python, задача от выгрузки до ответа, отличия от PostgreSQL и ограничения.

SQL или Python: что учить аналитику первым и где граница между ними
SQL и Python в работе аналитика данных: что учить первым, какие задачи решать запросом, а какие в pandas. Одна задача двумя способами на учебной базе и порядок изучения.