Семинар: расстояния, нормализация и KNN¶
На этом занятии мы будем не переписывать KNN с нуля, а исследовать его поведение:
- вспомним расстояния $L_1$, $L_2$ и $L_\infty$;
- посмотрим, как KNN выбирает соседей;
- проверим, зачем нужна нормализация;
- сравним разные значения $k$ и разные метрики;
- нарисуем области решений при помощи
matplotlib.
Полную реализацию поиска соседей и голосования оставим для домашнего контеста.
Сюжет: по запасу здоровья и магической силе определяем, кто перед нами — воин или маг.
Автор: Виктория Кулевцова. CC BY 4.0.
План занятия¶
- Разминка и визуализация данных — 15 минут.
- Три функции расстояния — 15 минут.
- Готовый KNN и ближайшие соседи — 15 минут.
- Нормализация и сравнение результатов — 20 минут.
- Эксперименты с $k$ и метриками — 15 минут.
- Области решений и выводы — 10–15 минут.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import MinMaxScaler
plt.rcParams["figure.figsize"] = (8, 5)
plt.rcParams["font.size"] = 12
print("Библиотеки загружены")
1. Данные¶
Каждый персонаж описывается двумя признаками:
- запас здоровья;
- магическая сила.
Классы кодируются числами: 0 — воин, 1 — маг.
Новый персонаж имеет 700 единиц здоровья и 9 единиц магической силы. Как вы думаете, к какому классу он относится?
X = np.array([
[900, 2],
[850, 3],
[780, 2],
[820, 4],
[740, 3],
[880, 5],
[350, 9],
[420, 8],
[300, 10],
[450, 9],
[500, 7],
[380, 6],
], dtype=float)
y = np.array([0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1])
query = np.array([700, 9], dtype=float)
class_names = {0: "Воин", 1: "Маг"}
class_colors = {0: "tomato", 1: "royalblue"}
print("Размер X:", X.shape)
print("Новый персонаж:", query)
def plot_dataset(ax, X_plot, y_plot, query_plot=None, title="Персонажи"):
for class_id in np.unique(y_plot):
mask = y_plot == class_id
ax.scatter(
X_plot[mask, 0],
X_plot[mask, 1],
color=class_colors[class_id],
edgecolor="black",
s=90,
label=class_names[class_id],
)
if query_plot is not None:
ax.scatter(
query_plot[0],
query_plot[1],
marker="*",
color="gold",
edgecolor="black",
linewidth=1.2,
s=320,
label="Новый персонаж",
zorder=5,
)
ax.set_xlabel("Здоровье")
ax.set_ylabel("Магическая сила")
ax.set_title(title)
ax.grid(alpha=0.25)
ax.legend()
fig, ax = plt.subplots()
plot_dataset(ax, X, y, query, "Кто этот новый персонаж?")
plt.show()
Обсудите в парах¶
- Кого бы выбрали вы: мага или воина?
- Какой из двух признаков сильнее повлияет на обычное евклидово расстояние?
- Изменится ли ответ, если здоровье измерять не в единицах, а в тысячах единиц?
- Может ли алгоритм сам понять, что магическая сила важнее здоровья?
2. Три функции расстояния¶
Для точек $a=(a_1, \ldots, a_d)$ и $b=(b_1, \ldots, b_d)$:
$$L_1(a,b)=\sum_i |a_i-b_i|$$
$$L_2(a,b)=\sqrt{\sum_i(a_i-b_i)^2}$$
$$L_\infty(a,b)=\max_i |a_i-b_i|$$
Сначала предскажите ответы для точек a = (1, 2) и b = (4, 6), затем запустите код.
def l1_distance(a, b):
return np.sum(np.abs(a - b))
def l2_distance(a, b):
return np.sqrt(np.sum((a - b) ** 2))
def linf_distance(a, b):
return np.max(np.abs(a - b))
a = np.array([1, 2])
b = np.array([4, 6])
print("L1: ", l1_distance(a, b))
print("L2: ", l2_distance(a, b))
print("Linf:", linf_distance(a, b))
assert np.isclose(l1_distance(a, b), 7)
assert np.isclose(l2_distance(a, b), 5)
assert np.isclose(linf_distance(a, b), 4)
Мини-эксперимент¶
Посчитаем расстояние от нового персонажа до каждого известного персонажа. Обратите внимание: во всех трёх метриках большие значения здоровья сильно влияют на результат.
distance_functions = {
"L1": l1_distance,
"L2": l2_distance,
"Linf": linf_distance,
}
for metric_name, metric_function in distance_functions.items():
distances = np.array([metric_function(point, query) for point in X])
nearest_index = np.argmin(distances)
print(
f"{metric_name}: ближайший объект {X[nearest_index]}, "
f"класс — {class_names[y[nearest_index]]}, "
f"расстояние — {distances[nearest_index]:.2f}"
)
3. Готовый KNN¶
На семинаре используем реализацию из scikit-learn. Это позволит сосредоточиться на поведении алгоритма.
Пока работаем с исходными, ненормализованными признаками и $k=3$. Метод kneighbors возвращает выбранных соседей и расстояния до них.
knn_raw = KNeighborsClassifier(
n_neighbors=3,
metric="euclidean",
)
knn_raw.fit(X, y)
prediction_raw = knn_raw.predict(query.reshape(1, -1))[0]
distances_raw, indices_raw = knn_raw.kneighbors(query.reshape(1, -1))
indices_raw = indices_raw[0]
distances_raw = distances_raw[0]
print("Предсказание:", class_names[prediction_raw])
print("Индексы соседей:", indices_raw)
print("Классы соседей:", [class_names[label] for label in y[indices_raw]])
print("Расстояния:", np.round(distances_raw, 2))
def plot_neighbors(
ax,
X_plot,
y_plot,
query_plot,
neighbor_indices,
prediction,
title,
):
plot_dataset(ax, X_plot, y_plot, query_plot, title)
ax.scatter(
X_plot[neighbor_indices, 0],
X_plot[neighbor_indices, 1],
s=250,
facecolors="none",
edgecolors="black",
linewidths=2.2,
zorder=4,
)
for index in neighbor_indices:
ax.plot(
[query_plot[0], X_plot[index, 0]],
[query_plot[1], X_plot[index, 1]],
color="dimgray",
linestyle="--",
alpha=0.75,
zorder=1,
)
ax.set_title(f"{title}\nПредсказание: {class_names[prediction]}")
fig, ax = plt.subplots()
plot_neighbors(
ax, X, y, query, indices_raw, prediction_raw,
"Без нормализации, k=3, L2",
)
plt.show()
Обсудите результат¶
- Почему все три ближайших соседа оказались воинами?
- Означает ли это, что магическая сила бесполезна?
- Что изменится, если разделить здоровье всех персонажей на 1000?
- Нормально ли, что единица измерения может изменить ответ модели?
4. Min-max нормализация¶
Для каждого признака используем преобразование
$$x'=\frac{x-x_{\min}}{x_{\max}-x_{\min}}.$$
Минимум и максимум вычисляются только по обучающей выборке. Новую точку преобразуем с теми же параметрами.
scaler = MinMaxScaler()
X_scaled = scaler.fit_transform(X)
query_scaled = scaler.transform(query.reshape(1, -1))
print("Минимумы обучающих данных:", scaler.data_min_)
print("Максимумы обучающих данных:", scaler.data_max_)
print("Новая точка до нормализации: ", query)
print("Новая точка после нормализации:", np.round(query_scaled[0], 3))
knn_scaled = KNeighborsClassifier(
n_neighbors=3,
metric="euclidean",
)
knn_scaled.fit(X_scaled, y)
prediction_scaled = knn_scaled.predict(query_scaled)[0]
distances_scaled, indices_scaled = knn_scaled.kneighbors(query_scaled)
indices_scaled = indices_scaled[0]
distances_scaled = distances_scaled[0]
print("Предсказание без нормализации:", class_names[prediction_raw])
print("Предсказание с нормализацией: ", class_names[prediction_scaled])
print("Новые соседи:", indices_scaled)
print("Классы соседей:", [class_names[label] for label in y[indices_scaled]])
print("Расстояния в новом масштабе:", np.round(distances_scaled, 3))
fig, axes = plt.subplots(1, 2, figsize=(15, 5), sharex=True, sharey=True)
plot_neighbors(
axes[0], X, y, query, indices_raw, prediction_raw,
"Без нормализации",
)
# Рисуем точки в исходных координатах, но показываем соседей,
# выбранных моделью в нормализованном пространстве.
plot_neighbors(
axes[1], X, y, query, indices_scaled, prediction_scaled,
"После min-max нормализации",
)
plt.tight_layout()
plt.show()
Зафиксируйте вывод¶
Дополните фразы:
- До нормализации на расстояние сильнее всего влиял признак
__________. - После нормализации оба признака оказались примерно в диапазоне
__________. - Список соседей изменился, потому что
__________________________________. - Нормализация меняет не сами объекты, а
______________________________.
5. Эксперименты с $k$ и метрикой¶
Соответствие названий метрик формулам:
В scikit-learn |
Формула |
|---|---|
manhattan |
$L_1$ |
euclidean |
$L_2$ |
chebyshev |
$L_\infty$ |
Посмотрим, как меняется ответ для разных настроек. Для безопасной совместной работы нормализацию и KNN объединим в Pipeline.
metric_names = {
"L1": "manhattan",
"L2": "euclidean",
"Linf": "chebyshev",
}
print(f"{'Нормализация':<17} {'k':<3} {'Метрика':<8} Ответ")
print("-" * 44)
for use_scaling in [False, True]:
for k in [1, 3, 5, 7]:
for metric_label, sklearn_metric in metric_names.items():
if use_scaling:
model = make_pipeline(
MinMaxScaler(),
KNeighborsClassifier(n_neighbors=k, metric=sklearn_metric),
)
else:
model = KNeighborsClassifier(
n_neighbors=k,
metric=sklearn_metric,
)
model.fit(X, y)
prediction = model.predict(query.reshape(1, -1))[0]
scaling_label = "да" if use_scaling else "нет"
print(
f"{scaling_label:<17} {k:<3} {metric_label:<8} "
f"{class_names[prediction]}"
)
Исследовательское задание¶
По таблице выше ответьте:
- В каких экспериментах ответ меняется после нормализации?
- Всегда ли три метрики дают одинаковый ответ?
- При каких значениях $k$ ответ наиболее устойчив?
- Почему нельзя определить «лучшее» $k$ только по одной новой точке?
- Что может случиться, если взять $k$, равное числу всех обучающих объектов?
6. Области решений¶
Чтобы увидеть поведение модели во всём пространстве:
- создадим плотную сетку точек при помощи
np.meshgrid; - предскажем класс каждой точки сетки;
- закрасим фон через
contourf; - поверх нарисуем обучающие объекты.
Цвет фона отвечает на вопрос: какой класс KNN предскажет для точки в этой части пространства?
def plot_decision_regions(
ax,
X_train,
y_train,
k=3,
metric="euclidean",
use_scaling=True,
):
if use_scaling:
model = make_pipeline(
MinMaxScaler(),
KNeighborsClassifier(n_neighbors=k, metric=metric),
)
else:
model = KNeighborsClassifier(n_neighbors=k, metric=metric)
model.fit(X_train, y_train)
x_min, x_max = X_train[:, 0].min() - 80, X_train[:, 0].max() + 80
y_min, y_max = X_train[:, 1].min() - 1, X_train[:, 1].max() + 1
xx, yy = np.meshgrid(
np.linspace(x_min, x_max, 240),
np.linspace(y_min, y_max, 180),
)
grid = np.column_stack([xx.ravel(), yy.ravel()])
grid_prediction = model.predict(grid).reshape(xx.shape)
ax.contourf(
xx,
yy,
grid_prediction,
levels=[-0.5, 0.5, 1.5],
colors=["mistyrose", "lightblue"],
alpha=0.8,
)
for class_id in np.unique(y_train):
mask = y_train == class_id
ax.scatter(
X_train[mask, 0],
X_train[mask, 1],
color=class_colors[class_id],
edgecolor="black",
s=70,
label=class_names[class_id],
)
scaling_title = "с нормализацией" if use_scaling else "без нормализации"
ax.set_title(f"k={k}, {metric}, {scaling_title}")
ax.set_xlabel("Здоровье")
ax.set_ylabel("Магическая сила")
ax.grid(alpha=0.15)
return model
fig, axes = plt.subplots(2, 2, figsize=(14, 10), sharex=True, sharey=True)
plot_decision_regions(axes[0, 0], X, y, k=1, use_scaling=False)
plot_decision_regions(axes[0, 1], X, y, k=1, use_scaling=True)
plot_decision_regions(axes[1, 0], X, y, k=7, use_scaling=False)
plot_decision_regions(axes[1, 1], X, y, k=7, use_scaling=True)
handles, labels = axes[0, 0].get_legend_handles_labels()
fig.legend(handles, labels, loc="upper center", ncol=2)
fig.suptitle("Как k и нормализация меняют области решений", fontsize=16, y=1.02)
plt.tight_layout()
plt.show()
Что видно на рисунке?¶
- При маленьком $k$ модель сильнее реагирует на отдельные обучающие объекты.
- При большом $k$ граница обычно становится более гладкой.
- Без нормализации изменение здоровья влияет на решение гораздо сильнее.
- После нормализации геометрия пространства меняется, поэтому меняются соседи и граница.
7. Самостоятельный эксперимент¶
Выберите настройки, запустите следующую ячейку и объясните получившуюся картинку соседу.
Допустимые значения:
K:1,3,5,7,9;METRIC:"manhattan","euclidean","chebyshev";USE_SCALING:TrueилиFalse.
K = 3
METRIC = "euclidean"
USE_SCALING = True
fig, ax = plt.subplots(figsize=(9, 6))
experiment_model = plot_decision_regions(
ax,
X,
y,
k=K,
metric=METRIC,
use_scaling=USE_SCALING,
)
experiment_prediction = experiment_model.predict(query.reshape(1, -1))[0]
ax.scatter(
query[0], query[1],
marker="*", color="gold", edgecolor="black", s=320,
)
ax.set_title(
ax.get_title() + f"\nНовый персонаж: {class_names[experiment_prediction]}"
)
ax.legend()
plt.show()
8. Итоги¶
Ответьте без запуска кода:
- Почему KNN чувствителен к масштабу признаков?
- Чем отличаются $L_1$, $L_2$ и $L_\infty$?
- Почему разные метрики могут выбирать разных соседей?
- Как маленькое и большое $k$ влияют на границу решений?
- Почему параметры нормализации нужно вычислять только по обучающим данным?
Мостик к домашней работе¶
Сегодня готовая библиотека выполняла за нас три операции:
- вычисляла расстояния до всех обучающих объектов;
- выбирала $k$ ближайших;
- проводила голосование.
В домашнем контесте эти части предстоит реализовать самостоятельно — без KNeighborsClassifier.