Все материалы
MLпрактикумсредний

Кластеризация k-means: метод k-средних на данных пользователей

Кластерный анализ методом k-средних на данных продукта: признаки, масштабирование, выбор k по локтю и силуэту, проверка устойчивости и когда хватит правил.

КейсПрактика14 октября 2026 г.20 мин

Руководитель продукта просит: «Сегментируйте пользователей кластеризацией, хочу понять, кому что предлагать». Вы собираете признаки, запускаете 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++ — это способ выбрать начало, а не гарантия результата.

Что минимизирует k-means
J = Σ по кластерам k Σ по точкам x в кластере ‖x − μₖ‖²

μₖ — центр кластера k, среднее его точек; ‖x − μₖ‖² — квадрат евклидова расстояния. J называют внутрикластерной суммой квадратов, в scikit-learn — inertia_.

Шаги k-means и что на каждом может пойти не так
ШагЧто происходитГде риск
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 пользователей. В рабочей задаче вы выгрузите таблицу признаков напрямую, без сжатия.

Признаки за первые 28 дней, сжатые до 40 комбинаций: 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;
pythonРазворачиваем комбинации в таблицу пользователей и смотрим разброс признаков
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.

Четыре кластера без масштабирования и после него: размеры и средние
ВариантПользователейДней активностиРабочее пространствоВыгрузкаОплачено в среднем
Сырые признаки13694,420,560,210
Сырые признаки7438,200,660,250
Сырые признаки3756,140,660,1919,00
Сырые признаки2906,270,660,2431,69
Стандартизация10895,50005,09
Стандартизация7986,12106,93
Стандартизация4726,10115,13
Стандартизация4186,0010,346,74
pythonk = 4 на сырых и на стандартизованных признаках
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.

Метод локтя: сумма квадратов расстояний до центров при разном k

Учебная база симулятора SQL-аналитика, 2777 пользователей, шесть стандартизованных признаков, лучший из 50 запусков k-means++ (SciPy kmeans2). При k = 1 сумма равна 16 662.

Сумма квадратов
pythonСумма квадратов и силуэт для k от 2 до 10, лучший из 50 запусков
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

Те же запуски, что на графике локтя. Рост с k отчасти объясняется тем, что большинство признаков — флаги 0/1.

Средний силуэт

Кластеры клиентов: средние признаков и доля платящих

Кластер описывают средними исходных, а не стандартизованных признаков: «0,62 создали отчёт» читается, «+0,47 стандартного отклонения» — нет. Рядом с поведением ставим то, ради чего сегментировали, — долю платящих.

Дни активности почти одинаковы во всех четырёх кластерах: 5,5–6,1. Различаются действия. Самый большой кластер — 819 человек, которые за 28 дней не создали рабочее пространство и ничего в нём не сделали, хотя открывали приложение в среднем пять с половиной дней. Платят из них 18,8%. Самая высокая доля платящих, 28,3%, — у тех, кто создал пространство, но никого не позвал и ничего не выгружал. Во всей выборке платят 23,9%.

Имена кластерам даёт аналитик, и это самая ответственная часть: имя будет жить в презентациях дольше, чем кто-то вспомнит про силуэт. Называйте по тому, что отличает кластер от остальных, а не по тому, что хочется про него думать. «Зовут коллег» — потому что приглашение отправили все 545 человек, а рабочее пространство есть только у половины из них.

Четыре кластера на учебной базе: что их отличает и сколько из них платят
ИмяПользователейДней активностиПространствоОтчётПриглашениеВыгрузкаПлатящих
Смотрят, но не начали8195,54000018,8% (154)
Работают в одиночку7986,1210,620028,3% (226)
Выгружают результат6156,1210,670,23122,8% (140)
Зовут коллег5455,650,500,321026,6% (145)
pythonПрофили четырёх кластеров: средние признаков, доля платящих и размеры
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 с эталоном
Эталон: лучший из 508511,8819 / 798 / 615 / 5451,00
Один запуск, 20 seedвыше эталона во всех 2019 разных наборов0,22–0,87, медиана 0,43
Лучший из 10, seed 1 и 28602,2972 / 819 / 615 / 3710,87
Лучший из 10, seed 38602,41120 / 671 / 615 / 3710,64
Лучший из 10, seed 48629,2990 / 774 / 546 / 4670,55
Лучший из 50, seed 1–48511,8819 / 798 / 615 / 5451,00
Подвыборки 80%, пять штук——1,00 в четырёх, 0,65 в одной
pythonУстойчивость: одиночные запуски, лучший из 10 и из 50, подвыборки по 80%
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-аналитика.

Продолжить чтение
Вся библиотека