ЛР2
Свой kMeans

Раздел 5 · 4 балла

Свой kMeans

Пишем kMeans сами в три шага: функция «к какому центру ближе», цикл «передвинуть центры», и всё вместе с условием остановки. Проверяем на игрушечных точках, где правильный ответ известен.

Игрушечные данные

Ноутбук сам генерирует 3 облака по 50 точек на плоскости:

ОблакоЦентрРазброс
p1(0, 0)1
p2(5, 5)2 (самое размазанное)
p3(3, 3) − (5, −4) = (−2, 7)0.6 (самое плотное)
np.random.normal(loc, scale, size)
Случайные точки вокруг loc с разбросом scale, как выстрелы вокруг центра мишени.
Три облака точек, слева раскрашены, справа одним цветом
Слева мы знаем, где какое облако. Справа так их видит алгоритм: все точки одного цвета. Его задача найти три группы самому.

Попробуй руками

Тот же алгоритм прямо здесь. Нажимай «Шаг» и смотри, как красные крестики (центры) едут к облакам. Иногда при неудачном старте два облака склеиваются в один кластер, тогда нажми «Новый старт».

Старт: 3 центра поставлены случайно.

Подпункт 1: kmeans_predict

Идея: для каждой точки посчитать расстояние до каждого центра и выбрать ближайший.

import scipy
from scipy.spatial.distance import cdist


def kmeans_predict(X, clusters):
    # расстояния от каждой точки до каждого центра: таблица (число точек) x (число центров)
    distances_to_centers = cdist(X, clusters)
    # номер ближайшего центра для каждой точки
    labels = distances_to_centers.argmin(axis=1)
    return labels
  1. cdist(X, clusters) таблица 150 × 3: строка = точка, столбец = центр, в клетке расстояние.
  2. .argmin(axis=1) в каждой строке номер самого маленького числа, то есть номер ближайшего центра (0, 1 или 2).
  3. return labels 150 номеров. Метка (label) = номер кластера точки.

Пример: точка (1, 1), центры (0, 0), (5, 5), (−2, 4).

до (0, 0): √(1² + 1²) = √2 ≈ 1.41 ← меньше всех до (5, 5): √(4² + 4²) = √32 ≈ 5.66 до (−2, 4): √(3² + 3²) = √18 ≈ 4.24 argmin → 0, точка в кластере 0
Точки раскрашены по ближайшему из трёх случайных центров
Центры (крестики) поставлены случайно, каждая точка покрашена в цвет ближайшего. Пока это ещё не кластеры, а просто «кто к кому ближе».

Подпункт 2: обновление центров

Идея: каждый центр переносим в среднее своих точек и так 7 раз, каждый раз запоминая центры для картинки.

for i in range(iters):
    labels = kmeans_predict(X, centroids)
    new_centroids = centroids.copy()
    for j in range(k):
        if np.any(labels == j):  # если в кластер не попало ни одной точки, центр не двигаем
            new_centroids[j] = X[labels == j].mean(axis=0)
    centroids = new_centroids
    centroids_history.append(centroids)
  1. labels = kmeans_predict(...) каждая точка выбирает ближайший центр.
  2. centroids.copy() копия, чтобы не испортить старые центры: они уже лежат в истории.
  3. for j in range(k) по очереди для каждого из 3 кластеров.
  4. labels == j маска «точки кластера j».
  5. np.any(...) есть ли в кластере хоть одна точка. Среднее пустого множества посчитать нельзя.
  6. X[labels == j].mean(axis=0) среднее точек кластера: отдельно по x и по y (axis=0 = вниз по столбцам). Это новый центр.
  7. centroids_history.append(...) сохраняем кадр.
новый центр = (сумма точек кластера) / (их количество) пример: точки (0, 0), (2, 0), (1, 3) → центр ((0+2+1)/3, (0+0+3)/3) = (1, 1)
8 кадров: центры едут к облакам
Step 1 это случайный старт, дальше 7 обновлений. Уже к Step 4-5 центры встали в середину трёх облаков и дальше почти не двигаются.

Подпункт 3: kmeans_fit_predict

ПараметрСмысл
kСколько кластеров (у нас 3)
max_iterМаксимум повторов (100), чтобы цикл точно закончился
tolПорог: если центры суммарно сдвинулись меньше 0.001, хватит
low, highВ каком диапазоне ставить случайные стартовые центры

loss здесь означает «на сколько суммарно сдвинулись центры за шаг». Пока центры едут, loss большой; когда встали, loss = 0.

loss = |сдвиг центра 1| + |сдвиг центра 2| + |сдвиг центра 3| сдвиг центра из (0, 0) в (3, 4) = √(3² + 4²) = 5
def kmeans_fit_predict(x, k=8, max_iter=100, tol=0.001, low=0.0, high=1.0, print_progress=False):
    n_features = x.shape[1]
    clusters = np.random.uniform(low, high, size=(k, n_features))  # случайные стартовые центры
    loss_history = []

    for i in range(max_iter):
        labels = cdist(x, clusters).argmin(axis=1)

        new_clusters = np.empty_like(clusters)
        for j in range(k):
            points = x[labels == j]
            if len(points) > 0:
                new_clusters[j] = points.mean(axis=0)
            else:
                new_clusters[j] = np.random.uniform(low, high, size=n_features)

        loss = np.linalg.norm(new_clusters - clusters, axis=1).sum()
        loss_history.append(loss)
        if print_progress:
            print(f'Итерация = {i}, loss = {loss}')

        clusters = new_clusters
        if loss < tol:  # центры почти не двигаются: алгоритм сошёлся
            break

    labels = cdist(x, clusters).argmin(axis=1)  # метки для финальных центров
    return clusters, labels, loss_history
  1. x.shape[1] сколько координат у точки (у нас 2).
  2. np.random.uniform(low, high, size=(k, 2)) k случайных центров, любое число от low до high одинаково вероятно.
  3. cdist(...).argmin(axis=1) то же, что kmeans_predict: каждая точка к ближайшему центру.
  4. np.empty_like(clusters) заготовка под новые центры такой же формы.
  5. if len(points) > 0 ... else ... если кластер пуст, по условию даём ему новый случайный центр, чтобы он снова «поймал» точки.
  6. new_clusters - clusters на сколько сдвинулся каждый центр по x и y.
  7. np.linalg.norm(..., axis=1) длина сдвига каждого центра (тот же Пифагор). .sum() складываем по всем центрам.
  8. if loss < tol: break центры встали: выходим из цикла раньше.
  9. После цикла ещё раз считаем метки, чтобы они точно соответствовали финальным центрам.

Пример вывода (у тебя числа будут другие, старт случайный):

Итерация = 0, loss = 5.265
Итерация = 1, loss = 5.256
Итерация = 2, loss = 2.128
Итерация = 3, loss = 1.059
Итерация = 4, loss = 0.187
Итерация = 5, loss = 0.0
Удачный запуск: три облака найдены верно, loss падает до нуля
Удачный запуск: три кластера по 48, 50 и 52 точки, то есть почти ровно наши облака по 50. Справа loss упал до 0 за 6 итераций.
Неудачный запуск: два облака склеены в один кластер
Неудачный запуск: кластер из 100 точек склеил два облака, а третье облако разрезано пополам (27 и 23). loss тоже дошёл до 0: алгоритм «сошёлся», но в плохое решение. Это и есть локальный минимум.
Ловушка. loss = 0 значит «центры перестали двигаться», а не «разбиение правильное». Если на защите у тебя получилась склейка, это не ошибка кода: перезапусти ячейку и объясни, что старт случайный.

Вопросы на защите

Почему алгоритм обязательно остановится?

Каждый шаг не увеличивает сумму квадратов расстояний, а вариантов разбиения конечное число, поэтому центры рано или поздно встают (loss < tol). На всякий случай есть ещё max_iter.

Почему каждый запуск даёт разный результат?

Стартовые центры случайные (np.random.uniform), от старта зависит, куда придёт алгоритм.

Чем твой kMeans хуже sklearn?

Один запуск со случайного старта. В sklearn умный старт k-means++, 10 запусков (n_init) с выбором лучшего, и код быстрее.

Что делать с пустым кластером?

В подпункте 2 центр оставляем на месте, в подпункте 3 по условию задания даём ему новый случайный центр.

Что такое tol?

Порог остановки (0.001). Если центры суммарно сдвинулись меньше него, считаем, что алгоритм сошёлся.

Одна фразаkMeans повторяет два шага: каждая точка идёт к ближайшему центру (cdist + argmin), центр переезжает в среднее своих точек; остановка, когда сдвиг центров (loss) меньше tol или кончились итерации.