Статья Автор: Деникина Н.В., Деникин А.В.

k-NN: пишем код

k-NN: пишем код
Сначала с нуля, чтобы понять. Потом через sklearn, чтобы пользоваться.

01 Реализация с нуля

Лучший способ убедиться, что ты понимаешь алгоритм — написать его руками. k-NN для этого идеален: на чистом Python он умещается в десяток строк. Никаких хитрых формул, никакого градиентного спуска — только сортировка и подсчёт голосов.

Начнём с маленькой выборки. Пусть у нас есть точки на плоскости с метками классов 0 и 1:

import math
from collections import Counter

# Обучающая выборка: каждая точка — (x, y, класс)
train = [
    (1, 2, 0), (2, 1, 0), (2, 3, 0), (3, 2, 0),
    (6, 5, 1), (7, 6, 1), (7, 4, 1), (8, 5, 1),
]

# Новая точка, класс которой нужно предсказать
new_point = (5, 4)

Восемь точек: четыре в левом нижнем углу с классом 0, четыре в правом верхнем с классом 1. Новая точка где-то между ними. Куда её отнести?

Шаг 1: функция расстояния

def euclidean(a, b):
    """Евклидово расстояние между двумя точками."""
    return math.sqrt((a[0] - b[0])**2 + (a[1] - b[1])**2)

Прямо по теореме Пифагора. Берём разности по координатам, возводим в квадрат, складываем, извлекаем корень.

Шаг 2: сам алгоритм k-NN

def knn_predict(train, new_point, k):
    # 1. Считаем расстояние от новой точки до каждой обучающей
    distances = []
    for point in train:
        d = euclidean(new_point, point[:2])  # берём только x, y
        distances.append((d, point[2]))   # сохраняем (расстояние, класс)

    # 2. Сортируем по расстоянию и берём k ближайших
    distances.sort()
    k_nearest = distances[:k]

    # 3. Голосуем большинством
    classes = [cls for _, cls in k_nearest]
    most_common = Counter(classes).most_common(1)
    return most_common[0][0]

Вот и весь алгоритм. Давай разберём построчно, потому что здесь важна каждая деталь.

  • Список distances — туда складываем пары: расстояние от новой точки до обучающей и класс этой обучающей точки. Класс нужно сохранить, чтобы потом проголосовать.
  • point[:2] — берём только координаты, без метки класса. Иначе расстояние посчиталось бы по трём числам.
  • distances.sort() — Python сортирует список кортежей по первому элементу, то есть по расстоянию. Это именно то, что нам нужно.
  • distances[:k] — срез: первые k элементов отсортированного списка. Это и есть «k ближайших соседей».
  • Counter(classes).most_common(1) — считает, сколько раз встречается каждый класс, возвращает самый частый. Это голосование большинством одной строкой.

Запускаем

for k in [1, 3, 5]:
    pred = knn_predict(train, new_point, k)
    print(f"k={k}: предсказанный класс = {pred}")
вывод
k=1: предсказанный класс = 1 k=3: предсказанный класс = 1 k=5: предсказанный класс = 1

На этой выборке все три значения k дают одинаковый ответ — точка ближе к синим. Если хочешь увидеть, как ответ меняется с k, добавь в обучающую выборку выброс — например, синюю точку (4, 3, 1) прямо посреди красного облака. Тогда при k=1 новая точка может «поймать» этот выброс, а при k=5 он уже не повлияет.

Это и есть весь k-NN
Двадцать строк кода. Никакого «обучения», никаких параметров, которые надо подбирать градиентным спуском. Вся «модель» — это исходные данные плюс функция расстояния. Поэтому k-NN и называют ленивым алгоритмом.

02 То же самое через sklearn

Писать k-NN с нуля полезно для понимания. Но в реальной работе ты будешь брать готовую реализацию из библиотеки. Она быстрее (внутри использует структуры данных типа KD-дерева), отлажена и работает с массивами numpy.

from sklearn.neighbors import KNeighborsClassifier
import numpy as np

# Признаки и метки отдельно — так требует sklearn
X_train = np.array([
    [1, 2], [2, 1], [2, 3], [3, 2],
    [6, 5], [7, 6], [7, 4], [8, 5],
])
y_train = np.array([0, 0, 0, 0, 1, 1, 1, 1])

# Создаём модель и "обучаем" (на самом деле просто запоминаем)
model = KNeighborsClassifier(n_neighbors=3)
model.fit(X_train, y_train)

# Предсказываем
new_point = np.array([[5, 4]])
print("Класс:", model.predict(new_point)[0])
print("Вероятности:", model.predict_proba(new_point)[0])
вывод
Класс: 1 Вероятности: [0. 1.]

Несколько важных моментов про API sklearn:

  • Признаки и метки разделены. X_train — двумерный массив (по строке на каждый объект, по столбцу на каждый признак). y_train — одномерный массив с метками. Это стандарт для всего sklearn, не только для k-NN.
  • fit(X, y) у k-NN не «обучает» в обычном смысле — он просто запоминает данные внутри объекта модели. Метод называется так для единообразия со всеми другими алгоритмами sklearn.
  • predict ожидает двумерный массив, даже если ты предсказываешь одну точку. Поэтому [[5, 4]] с двойными скобками, а не [5, 4].
  • predict_proba возвращает «вероятности» классов — на самом деле просто доли голосов соседей. Если из 3 соседей все 3 — класс 1, то вероятность [0, 1]. Это та самая грубая оценка, о которой мы говорили в теории.

03 Реальный пример: важность масштабирования

Возьмём настоящую задачу и покажем, почему нельзя забывать про масштабирование признаков. Классифицируем людей на «спортсмены» и «не спортсмены» по двум признакам: пульс в покое (40–90 ударов в минуту) и годовой доход (от 30 000 до 5 000 000 рублей).

Заметь подвох: пульс и доход — это совершенно разные шкалы. Доход в десятки тысяч раз больше по абсолютному значению.

from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
import numpy as np

# Генерируем синтетические данные
np.random.seed(42)
n = 200

# Спортсмены: низкий пульс, доход случайный
pulse_sport = np.random.normal(55, 5, n)
income_sport = np.random.normal(800000, 300000, n)

# Не спортсмены: высокий пульс, доход тоже случайный
pulse_other = np.random.normal(75, 5, n)
income_other = np.random.normal(800000, 300000, n)

X = np.vstack([
    np.column_stack([pulse_sport, income_sport]),
    np.column_stack([pulse_other, income_other]),
])
y = np.array([1] * n + [0] * n)

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

Обрати внимание: классы различаются только пульсом. Доход у обеих групп одинаковый — это «информационный шум». Идеальная модель должна научиться смотреть на пульс и игнорировать доход.

Без масштабирования

model_raw = KNeighborsClassifier(n_neighbors=5)
model_raw.fit(X_train, y_train)
acc_raw = model_raw.score(X_test, y_test)
print(f"Без масштабирования: {acc_raw:.1%}")
вывод
Без масштабирования: 51.7%

Около 52% — это почти как монетка. Алгоритм провалился. Почему? Потому что пульс отличается на единицы, а доход — на сотни тысяч. В формуле расстояния разница в доходе полностью «съедает» вклад пульса. K-NN, по сути, считает расстояние только по доходу — а доход не несёт никакой информации о классе.

С масштабированием

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

model_scaled = KNeighborsClassifier(n_neighbors=5)
model_scaled.fit(X_train_scaled, y_train)
acc_scaled = model_scaled.score(X_test_scaled, y_test)
print(f"С масштабированием: {acc_scaled:.1%}")
вывод
С масштабированием: 97.5%

97.5% против 52%. Один и тот же алгоритм, одни и те же данные — разница только в том, что мы сделали StandardScaler перед обучением.

Тонкий момент: fit_transform vs transform
На обучающих данных мы вызываем fit_transform — он считает среднее и стандартное отклонение по этим данным и сразу преобразует. На тестовых данных — только transform, потому что параметры (среднее, отклонение) уже подсчитаны на обучающей выборке. Если на тесте вызвать fit_transform, получится утечка данных — модель «увидит» статистику теста, и оценка точности будет нечестной. Это правило работает для всех преобразований в sklearn, не только для k-NN.

04 Подбираем оптимальное k

Мы говорили в теории: правильное значение k нужно подбирать. Маленькое k — переобучение, большое — недообучение, где-то посередине оптимум. Покажем это на коде.

from sklearn.model_selection import cross_val_score

k_values = range(1, 31, 2)  # нечётные от 1 до 29
scores = []

for k in k_values:
    model = KNeighborsClassifier(n_neighbors=k)
    # Кросс-валидация: 5 разбиений, среднее по точности
    cv_score = cross_val_score(model, X_train_scaled, y_train, cv=5).mean()
    scores.append(cv_score)
    print(f"k={k:2d}: {cv_score:.3f}")

best_k = k_values[np.argmax(scores)]
print(f"\nЛучшее k: {best_k}")
вывод (фрагмент)
k= 1: 0.961 k= 3: 0.971 k= 5: 0.975 k= 7: 0.979 k= 9: 0.979 k=11: 0.982 ... k=29: 0.971 Лучшее k: 11

Видно характерную картину: на маленьких k точность ниже (модель переобучается на отдельных точках), потом она растёт, достигает максимума где-то в районе 11–15, и при дальнейшем увеличении k начинает медленно падать (модель переусредняется). Это та самая U-образная зависимость из теории — только перевёрнутая, потому что мы смотрим на точность, а не на ошибку.

Что такое cross_val_score

Это удобная функция для кросс-валидации. Что она делает под капотом:

  • Разбивает обучающую выборку на 5 равных частей (потому что мы указали cv=5).
  • Тренирует модель на 4 частях, проверяет на пятой. Записывает точность.
  • Повторяет это 5 раз — каждая часть один раз побывает в роли тестовой.
  • Возвращает массив из 5 точностей. Мы берём их среднее через .mean().

Зачем так сложно? Чтобы оценка была устойчивой. Если просто разбить данные на train/test один раз, можно случайно попасть на удобное (или неудобное) разбиение и получить обманчивую цифру. Кросс-валидация усредняет и даёт более честный результат.

Что запомнить из этого урока
Минимальный шаблон для k-NN на реальных данных всегда выглядит одинаково: (1) разбили на train и test, (2) отмасштабировали через StandardScaler — обязательно fit_transform на трейне и transform на тесте, (3) подобрали k через кросс-валидацию, (4) обучили финальную модель с лучшим k, (5) измерили точность на тесте. Этот шаблон работает не только для k-NN — для большинства методов классификации в sklearn он почти такой же.
Печать