Семинар: расстояния, нормализация и KNN¶

На этом занятии мы будем не переписывать KNN с нуля, а исследовать его поведение:

  • вспомним расстояния $L_1$, $L_2$ и $L_\infty$;
  • посмотрим, как KNN выбирает соседей;
  • проверим, зачем нужна нормализация;
  • сравним разные значения $k$ и разные метрики;
  • нарисуем области решений при помощи matplotlib.

Полную реализацию поиска соседей и голосования оставим для домашнего контеста.

Сюжет: по запасу здоровья и магической силе определяем, кто перед нами — воин или маг.

Автор: Виктория Кулевцова. CC BY 4.0.

План занятия¶

  1. Разминка и визуализация данных — 15 минут.
  2. Три функции расстояния — 15 минут.
  3. Готовый KNN и ближайшие соседи — 15 минут.
  4. Нормализация и сравнение результатов — 20 минут.
  5. Эксперименты с $k$ и метриками — 15 минут.
  6. Области решений и выводы — 10–15 минут.
In [ ]:
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. Данные¶

Каждый персонаж описывается двумя признаками:

  1. запас здоровья;
  2. магическая сила.

Классы кодируются числами: 0 — воин, 1 — маг.

Новый персонаж имеет 700 единиц здоровья и 9 единиц магической силы. Как вы думаете, к какому классу он относится?

In [ ]:
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)
In [ ]:
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()

Обсудите в парах¶

  1. Кого бы выбрали вы: мага или воина?
  2. Какой из двух признаков сильнее повлияет на обычное евклидово расстояние?
  3. Изменится ли ответ, если здоровье измерять не в единицах, а в тысячах единиц?
  4. Может ли алгоритм сам понять, что магическая сила важнее здоровья?

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), затем запустите код.

In [ ]:
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)

Мини-эксперимент¶

Посчитаем расстояние от нового персонажа до каждого известного персонажа. Обратите внимание: во всех трёх метриках большие значения здоровья сильно влияют на результат.

In [ ]:
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 возвращает выбранных соседей и расстояния до них.

In [ ]:
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))
In [ ]:
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()

Обсудите результат¶

  1. Почему все три ближайших соседа оказались воинами?
  2. Означает ли это, что магическая сила бесполезна?
  3. Что изменится, если разделить здоровье всех персонажей на 1000?
  4. Нормально ли, что единица измерения может изменить ответ модели?

4. Min-max нормализация¶

Для каждого признака используем преобразование

$$x'=\frac{x-x_{\min}}{x_{\max}-x_{\min}}.$$

Минимум и максимум вычисляются только по обучающей выборке. Новую точку преобразуем с теми же параметрами.

In [ ]:
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))
In [ ]:
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))
In [ ]:
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.

In [ ]:
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]}"
            )

Исследовательское задание¶

По таблице выше ответьте:

  1. В каких экспериментах ответ меняется после нормализации?
  2. Всегда ли три метрики дают одинаковый ответ?
  3. При каких значениях $k$ ответ наиболее устойчив?
  4. Почему нельзя определить «лучшее» $k$ только по одной новой точке?
  5. Что может случиться, если взять $k$, равное числу всех обучающих объектов?

6. Области решений¶

Чтобы увидеть поведение модели во всём пространстве:

  1. создадим плотную сетку точек при помощи np.meshgrid;
  2. предскажем класс каждой точки сетки;
  3. закрасим фон через contourf;
  4. поверх нарисуем обучающие объекты.

Цвет фона отвечает на вопрос: какой класс KNN предскажет для точки в этой части пространства?

In [ ]:
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
In [ ]:
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.
In [ ]:
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. Итоги¶

Ответьте без запуска кода:

  1. Почему KNN чувствителен к масштабу признаков?
  2. Чем отличаются $L_1$, $L_2$ и $L_\infty$?
  3. Почему разные метрики могут выбирать разных соседей?
  4. Как маленькое и большое $k$ влияют на границу решений?
  5. Почему параметры нормализации нужно вычислять только по обучающим данным?

Мостик к домашней работе¶

Сегодня готовая библиотека выполняла за нас три операции:

  1. вычисляла расстояния до всех обучающих объектов;
  2. выбирала $k$ ближайших;
  3. проводила голосование.

В домашнем контесте эти части предстоит реализовать самостоятельно — без KNeighborsClassifier.