---
status: seed
type: concept
tags:
  - ml
created: 2026-02-26
updated: 2026-02-27
title: Метрические Алгоритмы
---

# Метрические Алгоритмы

## План Урока

1. Идея метрических алгоритмов
2. Способы измерения расстояния между объектами
3. Алгоритм kNN для классификации
4. Алгоритм kNN для регрессии
5. Преимущества и недостатки kNN
6. Модификации kNN (на самостоятельное изучение)

## Краткая Справка О Библиотеке Scikit-learn

<!-- Изображение удалено -->

> [**Scikit-learn**](https://scikit-learn.org/stable/index.html) - это библиотека машинного обучения с открытым исходным кодом для языка программирования Python. Она предоставляет простые и эффективные инструменты для предобработки данных, машинного обучения и анализа данных.

**Особенности:**

* Scikit-learn является одной из самых популярных библиотек машинного обучения в мире и используется во многих приложениях, содержащих модели машинного обучения.
* Библиотека проста в использовании и имеет хорошо документированный API.
* Scikit-learn предоставляет широкий набор алгоритмов машинного обучения, включая классификацию, регрессию, кластеризацию и снижение размерности.
* В библиотеку также включены функции для предобработки данных, необходимой перед подачей данных в модель
* Scikit-learn в основном направлен на работу именно с классическим ML, хотя в ней поддерживается и создание простейших нейронных сетей. Однако использование ее для задач глубокого обучения не рекомендуется, для этого есть специализированные инструменты (Tensorflow и PyTorch)

## Идея Метрических Алгоритмов

Идею метрических алгоритмов можно сравнить с простой философской идеей "мы - это наше окружение" или "скажи мне, кто твой друг, и я скажу, кто ты".

Бытовые примеры:

* Хотим продать квартиру, но не знаем ее цену. Для этого в интернете находим 10 похожих по характеристикам квартир, усредняем их цены и получаем примерную стоимость.

* Есть 3000 текстов разбитые по тематикам (художественная литература, спорт, политика и т.д.), хотим определить тематику текста, не читая его. Найдем 15 наиболее похожих текстов, посмотрим на их тематики и присвоим новому тексту ту тематику, которая преобладает.

* Есть 1000 старых покупателей и 10 новых, хотим предложить новым покупателям то, что покупали старые, похожие на них.

**Метрические алгоритмы машинного обучения** представляют собой класс методов, основанных на измерении расстояний между объектами в пространстве признаков.

Принцип работы метрических алгоритмов в случае обучения с учителем заключается в том, что значение таргета для объекта определяется путем анализа его ближайших по расстоянию соседей из обучающей выборки. Расстояние между объектами измеряется с использованием различных метрик.

Особенность метрических алгоритмов заключается в том, что они не строят явную модель на основе данных, а основываются на хранении и использовании обучающих примеров для принятия решений. Как такого обучения в алгоритмах не предполагается. Такой подход еще часто называется **"ленивым обучением" (lazy learning)**.

## Расстояние Между Объектами

Ключевая идея методов - использование близости между объектами для принятия решения. Как выразить близость в численном эквиваленте?

Существует множество способов измерить. Наиболее популярные метрики:

<center> <!-- Изображение удалено --> </center>

В библиотеке [scikit-learn (sklearn)](https://scikit-learn.org/stable/index.html) сборник функций для вычисления расстояний представлен в модуле [pairwise](https://scikit-learn.org/stable/modules/classes.html#module-sklearn.metrics.pairwise). Самая обобщенная из этих функций - [pairwise_distances()](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise_distances.html#sklearn.metrics.pairwise_distances). Данная функция принимает векторы или матрицы и вычисляет расстояние между ними по переданной в качестве аргумента `metric` методике.

Рассмотрим несколько наиболее часто используемых метрик:

1. Евклидово расстояние ($L_2$-норма):
$$\rho(x_1, x_2) = \sqrt{ (x_{11} - x_{21}) ^ 2 + (x_{12} - x_{22})^2+ ... + (x_{1m} - x_{2m})^2} =$$$$$$$$= \sqrt{\sum_{i=1}^m (x_{1i} - x_{2i})^2}$$

```python
import numpy as np
from sklearn.metrics.pairwise import pairwise_distances
```

```python
x = np.array([[0, 1, 1, 1]])
y = np.array([[1, 2, 1, 2]])
pairwise_distances(x, y, metric='euclidean')
```

2. Манхэттенское расстояние ($L_1$-норма):
$$\rho(x_1, x_2) = |x_{11} - x_{21}| + |x_{12} - x_{22}|+ ... + |x_{1m} - x_{2m}| = \sum_{i=1}^m |x_{1i} - x_{2i}|$$

```python
x = np.array([[0, 1, 1, 1]])
y = np.array([[1, 2, 1, 2]])
pairwise_distances(x, y, metric='manhattan')
```

3. Расстояние Минковского ($L_p$-норма):
$$\rho(x_1, x_2) = (|x_{11} - x_{21}| ^ p + |x_{12} - x_{22}|^p+ ... + |x_{1m} - x_{2m}|^p)^{\frac{1}{p}} = (\sum_{i=1}^m |x_{1i} - x_{2i}|^p)^{\frac{1}{p}}$$

```python
x = np.array([[0, 1, 1, 1]])
y = np.array([[1, 2, 1, 2]])
pairwise_distances(x, y, metric='minkowski', p=3)
```

4. Косинусное расстояние:
$$\rho(x_1, x_2) = 1- cos(\hat{(x_1, x_2)}) = 1 - \frac {{x_1} \cdot {x_2}} {||x_1|| \cdot ||x_2||}$$

```python
from sklearn.metrics.pairwise import cosine_distances

x = np.array([[0, 1, 1, 1]])
y = np.array([[1, 2, 1, 2]])
pairwise_distances(x, y, metric='cosine')
```

Все перечисленные метрики расстояния поддерживаются в метрических алгоритмах

Качество модели сильно зависит от выбора метрики. Поэтому способ измерения расстояния может использоваться в качестве внешнего параметра алгоритма (гиперпараметра), управляя которым, исследователь может модифицировать работу алгоритма и улучшать качество получаемого решения.

## Алгоритм kNN Для Классификации

В данном уроке мы будем рассматривать самого популярного представителя метрических алгоритмов - метод k-ближайших соседей (k-nearest neighbors, kNN).

Общую идею алгоритма проще всего понять на примере классификации. Поэтому с нее мы и начинаем.

### Общая Идея Алгоритма

Пусть мы решаем задачу классификации на 2 класса по двум признакам $x_1$ и $x_2$:

<center> <!-- Изображение удалено --> </center>

Хотим определить, к какому классу отнести красный объект - к желтым или фиолетовым.

Если руководствоваться идеей kNN, то нам нужно:
1. Рассчитать расстояние от нашего целевого объекта до всех объектов из выборки.
2. Затем определить k ближайших по расстоянию объектов (соседей), которые мы будем использовать для определения класса.
3. Среди выбранных посчитать количество объектов каждого из классов и определить класс большинства.

Вот и весь алгоритм.

При этом, как видно, от выбора количества соседей ($k$) будет зависеть и результат классификации.

Например, в случае, если мы возьмем $k=3$, то есть будем использовать 3 соседа, то наш целевой объект будет отнесен к фиолетовым точкам. Если же мы возьмем в качестве $k=6$, то объект будет отнесен к классу желтых точек.

### Формализация Алгоритма

Ну а теперь формализуем это в виде алгоритма.

---
Пусть дана выборка объектов и их классов:

$$\{(x_1, y_1), (x_2, y_2), ..., (x_n, y_n)\}=\{(x_i, y_i)\}_{i=1}^n$$

где
* $x_i = (x_{i1}, x_{i2}, ..., x_{im}), x_i ∈ X, X \subset \mathbb{R} ^{m}$ - множество объектов
* $y_i ∈ Y, Y=\{1,..., C\}$ - множество классов
* $m$ - количество признаков
* $n$ - размер выборки
* $C$ - количество классов

Задана функция расстояния $\rho(x_i, x_j)$, обладающая свойствами:

* Неотрицательности: $\rho(x_i, x_j) >= 0$
* Симметричности: $\rho(x_i, x_j) = \rho(x_j, x_i)$.

<!-- Изображение удалено -->

**Алгоритм:**

Чтобы определить класс $\hat{y}(x_t)$ для нового объекта $x_t$ нужно:
1. Вычислить расстояние между объектом $x_t$ и каждым объектом $x_i$ в обучающей выборке:

    $$\rho(x_t, x_i), ∀x_i ∈ X$$

2. Выбрать $k$ объектов с наименьшим расстоянием:

    $$N_k(x_t) = \\{x_i \in X | \rho(x_t, x_i) < \rho(x_t, x_j), ∀ j \neq i\\}$$

    $$j = {1, 2, ..., n}$$
    $$i={1, 2, ..., k}$$

    где $N_k(x)$ - множество $k$ ближайших соседей целевого объекта $x$.

3. Присвоить новому объекту класс, как наиболее часто встречающийся класс среди объектов из $N_k(x)$:

    $$\hat{y}(x_t) = \arg \max_{y \in Y} \sum_{i=1}^{k}\mathbb{I}[y_i = y]$$
    здесь $\mathbb{I}[y_i = y]$ - индикаторная функция, которая возвращает 1, если условие в скобках выполняется и 0 в противном случае. То есть формула обозначает подсчет количества объектов каждого класса среди соседей.

### Пример На Конкретных Цифрах

Рассмотрим следующий пример. У нас есть обучающая выборка из 10 объектов, каждый из которых имеет два признака.

| № Объекта | Признак 1 | Признак 2 | Класс |
|---|---|---|---|
| 1 | 1 | 1 | A |
| 2 | 2 | 2 | A |
| 3 | 3 | 3 | A |
| 4 | 4 | 4 | B |
| 5 | 5 | 5 | B |
| 6 | 6 | 6 | B |
| 7 | 7 | 7 | C |
| 8 | 8 | 8 | C |
| 9 | 9 | 9 | C |
| 10 | 10 | 10 | C |

Пронумеруем классы A, B и C числами 1, 2, 3, тогда множество классов $Y=\{1, 2, 3\}$.

Теперь предположим, что у нас есть новый объект $x_t$ с признаками $x_t=(x_{t1}, x_{t2}) = (5, 5)$.

Чтобы классифицировать объект $x_t$, мы должны:

1. Вычислить расстояние между новым объектом и каждым объектом в обучающей выборке:
$$\rho(x_t, x_i), ∀x_i ∈ X$$
    Считаем:
$$\rho(x_t, x_1) = \sqrt{(5-1)^2 + (5-1)^2} = 8.4853$$

$$\rho(x_t, x_2)= \sqrt{(5-2)^2 + (5-2)^2} = 5.6569$$

$$\rho(x_t, x_3) = \sqrt{(5-3)^2 + (5-3)^2} = 3.6056$$

$$\rho(x_t, x_4) = \sqrt{(5-4)^2 + (5-4)^2} = 1.4142$$

$$\rho(x_t, x_5) = \sqrt{(5-5)^2 + (5-5)^2} = 0$$

$$\rho(x_t, x_6) = \sqrt{(5-6)^2 + (5-6)^2} = 1.4142$$

$$\rho(x_t, x_7) = \sqrt{(5-7)^2 + (5-7)^2} = 3.6056$$

$$\rho(x_t, x_8) = \sqrt{(5-8)^2 + (5-8)^2} = 5.6569$$

$$\rho(x_t, x_9) = \sqrt{(5-9)^2 + (5-9)^2} = 8.4853$$

$$\rho(x_t, x_{10}) = \sqrt{(5-10)^2 + (5-10)^2} = 8.4853$$

2. Выбрать $k$ объектов с наименьшим расстоянием.
$$N_k(x_t) = \{x_i \in X | \rho(x_t, x_i) < \rho(x_t, x_j), ∀ j \neq i\}$$
В нашем случае $k=3$, тогда находим 3 ближайших объекта - это будут 4, 5 и 6 объекты:
$$N_k(x) = \{x_4, x_5, x_6\}$$

3. Присвоить новому объекту класс, который встречается чаще всего среди выбранных $k$ объектов.

    $$\hat{y_t} = a(x_t) = \arg \max_{y \in Y} \sum_{i=1}^{k}\mathbb{I}[y_i = y]$$
    Считаем сколько объектов каждого класса среди соседей. Объектам $x_4, x_5$ и $x_6$ из обучающей выборки сооветствуют свои классы $y_4$, $y_5$ и $y_6$.  </p>
    Для класса 1 (A):
    $$ \sum_{i=1}^{3}\mathbb{I}[y_i = 1] = \mathbb{I}[y_4= 1] + \mathbb{I}[y_5= 1] + \mathbb{I}[y_6= 1] = 1 + 0 + 0 = 1$$
    Для класса 2 (B):
    $$ \sum_{i=1}^{3}\mathbb{I}[y_i = 2] = \mathbb{I}[y_4= 2] + \mathbb{I}[y_5= 2] + \mathbb{I}[y_6= 2] = 0 + 1 + 1 = 2$$
    Для класса 3 (С):
    $$ \sum_{i=1}^{3}\mathbb{I}[y_i = 3] = \mathbb{I}[y_4= 3] + \mathbb{I}[y_5= 3] + \mathbb{I}[y_6= 3] = 0 + 0 + 0 = 0$$
    Тогда
    $$\hat{y_t} = \arg \max_{y \\in \\{1, 2, 3\\}}(1, 2, 0) = 2$$
    Номер 2 соответствует классу B. </p>
    
То есть для объекта $x_t=(5, 5)$ мы присваиваем класс B.

### Оценка Вероятностей Принадлежности К Классам

Легко заметить, что алгоритм kNN позволяет также оценивать не только сами классы, но и вероятности принадлежности к этим классам. Для этого достаточно просто посчитать частоты классов соседей.

Тогда вероятность того, что объект $x_t$ принадлежит классу $c$ равна:

   $$\hat{p_c}(x_t) = \frac{1} {k} \sum_{i=1}^{k}\mathbb{I}[y_i = y_c]$$

Для примера выше получим:

Для класса 1 (A):
$$ \hat{p_1}(x_t) = \frac{1} {3} \sum_{i=1}^{3}\mathbb{I}[y_i = 1] = \frac{1} {3}  (\mathbb{I}[y_4= 1] + \mathbb{I}[y_5= 1] + \mathbb{I}[y_6= 1]) = \frac{1} {3} (1 + 0 + 0) = \frac{1} {3}=0.33$$
Для класса 2 (B):
$$ \hat{p_2}(x_t) = \frac{1} {3}\sum_{i=1}^{3}\mathbb{I}[y_i = 2] = \frac{1} {3} (\mathbb{I}[y_4= 2] + \mathbb{I}[y_5= 2] + \mathbb{I}[y_6= 2]) = \frac{1} {3}(0 + 1 + 1) = \frac{2} {3}=0.66$$
Для класса 3 (С):
$$ \hat{p_3}(x_t) = \frac{1} {3} \sum_{i=1}^{3}\mathbb{I}[y_i = 3] = \frac{1} {3} (\mathbb{I}[y_4= 3] + \mathbb{I}[y_5= 3] + \mathbb{I}[y_6= 3]) = \frac{1} {3} (0 + 0 + 0) = \frac{0} {3}=0$$

То есть для объект $x_t=(5, 5)$ с вероятность $0.33$ принадлежит классу A, с вероятнотью $0.66$ - классу B и с вероятностью $0$ - классу C.

### Реализация В Python

```python
import pandas as pd
import numpy as np
import seaborn as sns
import plotly.express as px

from sklearn.datasets import make_classification
```

Создадим игрушечный датасет с помощью функции `make_classification` из 500 наблюдений, 2 признаков и 2 классов.

```python
X, y = make_classification(
    n_samples=500, # количество объектов
    n_features=2, # количество признаков
    n_informative=2, # количество информативных признаков
    n_redundant=0, # количество избыточных
    n_classes=2, # количество классов
    n_clusters_per_class=2, # количество кластеров
    flip_y=0.01, # коэффициент шума
    random_state=1, # число, фиксирующее генератор случайных чисел
    # scale=[1, 100] # масштаб признаков
    # class_sep=5, # зазор между классами
)
print(X.shape, y.shape)

# Переводим X и y в DataFrame для удобства дальнейшей работы
X = pd.DataFrame(X, columns=['x1', 'x2'])
y = pd.Series(y).astype('str')
```

```python
X.head()
```

```python
y.head()
```

Визуализируем данные на плоскости:

```python
fig = px.scatter(
    x=X.iloc[:, 0], y=X.iloc[:, 1],
    color=y, opacity=1,
    height=400, width=700
)
fig.show()
```

Нужно предсказать класс для следующего объекта:

```python
x = np.array([[0, 0.1]])
x
```

В качестве алгоритма для классификации будем использовать kNN с 5-мя соседями.

В библиотеке `sklearn` алгоритмы, основанные на использовании соседей реализованы в модуле [neighbors](https://scikit-learn.org/stable/modules/classes.html#module-sklearn.neighbors).

Метод k-ближайших соседей для классификации реализован в виде класса [KNeighborsClassifier](https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html).

**Основные параметры алгоритма:**

- `n_neighbors`: количество ближайших соседей, которое будет использоваться для классификации.
- `metric`: метрика расстояния, используемая для измерения расстояния между объектами. [Список возможных значений](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.distance_metrics.html#sklearn.metrics.pairwise.distance_metrics). Примеры:
    - `'minkowski'` - расстояние Минковского (используется по умолчанию)
    - `'cosine'` - косинусное расстояние
    - `'euclidean'` - евклидово расстояние
    - `'manhattan'` - расстояния Манхэттена

- `p`: норма, используемая для измерения расстояния между объектами (по умолчанию p=2)
- `n_jobs`: количество ядер процессора, используемых для вычислений (чтобы использовать все доступные мощности процессора нужно установить параметр в -1).

```python
from sklearn.neighbors import KNeighborsClassifier
```

Создаем объект kNN:

```python
knn_clf = KNeighborsClassifier(n_neighbors=5, metric='minkowski', p=2)
knn_clf
```

Для обучения алгоритмов в `scikit-learn` используется метод `fit()`. Для kNN как такового обучения нет, поэтому данный метод просто сохраняет обучающую выборку в атрибуты объекта kNN.

```python
knn_clf.fit(X.values, y.values)
```

Предсказать класс объекта с помощью функции `predict()`.

**Важно:** в метод передаются двумерные данные размера $n \times m$, даже если это один объект, то его нужно передать в виде матрицы с одной строкой.

```python
knn_clf.predict(x)
```

Вероятности принадлежности к классам можно определить с помощью метода `predict_proba()`

```python
knn_clf.predict_proba(x)
```

### Разделяющая Поверхность kNN

Напомним, что геометрически решить задачу классификации - это построить разделяющую поверхность в пространстве признаков.

**Разделяющая поверхность (decision bound)** - это плоскость/гиперплоскость (-и) в прострастранстве признаков, которая(-ые) отделяет классы друг от друга.

kNN также строит разделяющую поверхность. Форма разделяющей поверхности зависит от параметров алгоритма (количество соседей, метрика расстояния и т.д.)

Примеры для простейшего случая двух признаков с разным количеством соседей:

<center> <!-- Изображение удалено --> </center>

Напишем функцию для визуализации разделяющей поверхности:

```python
def plot_decision_bound_classification(X, y, model, height=400, width=700):
    xx1, xx2 = np.meshgrid(
        np.arange(X.iloc[:, 0].min() - 0.1, X.iloc[:, 0].max() +0.1, 0.1),
        np.arange(X.iloc[:, 1].min() - 0.1, X.iloc[:, 1].max() +0.1, 0.1)
    )
    X_net = np.hstack([xx1.reshape(-1, 1), xx2.reshape(-1, 1)])
    probs = model.predict_proba(X_net)[:, 0]
    probs = probs.reshape(xx1.shape)
    fig = px.scatter(
        x=X.iloc[:, 0],
        y=X.iloc[:, 1],
        color=y,
        opacity=1,
        height=height,
        width=width
    )
    fig.update_layout(
        showlegend=False,
        xaxis_title="x1",
        yaxis_title="x2"
    )
    fig.add_contour(
        x=xx1[0], y=xx2[:, 0], z=probs,
        showscale=True,
        opacity=0.5,
        colorscale='RdBu_r',
        contours=dict(showlabels=True, coloring='fill')
    )

    fig.add_contour(
        x=xx1[0], y=xx2[:, 0], z=probs,
        showscale=False,
        opacity=0.5,
        contours=dict(showlabels=True, size=30, coloring='lines')
    )
    return fig
```

```python
plot_decision_bound_classification(X, y, knn_clf)
```

В данном случае разделяющей поверхностью будет линия, где вероятность принадлежности к классу 0 равна $0.5$, проходя через эту линию цвет области меняется с оттенков красного на оттенки синего.

**Примечание:**

Кстати, в scikit-learn есть функция [DecisionBoundaryDisplay](https://scikit-learn.org/stable/modules/generated/sklearn.inspection.DecisionBoundaryDisplay.html#sklearn.inspection.DecisionBoundaryDisplay.from_estimator) для построения разделяющей поверхности. Только она не интерактивная(

### Оценка Качества Классификации

Вычислим качество полученной модели с помощью метрики `accuracy`.

Напомним, данная метрика показывает для скольких объектов класс был определен верно (в доле от общего числа данных в выборке):

$$accuracy = \frac{1}{n}\sum_{i=1}^n{\mathbb{I}[\hat {y_i} = y_i]}$$

Для вычисления качества модели можно использовать метод `score()`. По умолчанию для всех классификаторов из `sklearn` данный метод вычисляет именно метрику `accuracy`. В метод нужно передать матрицу наблюдений, для которых нужно сделать прогноз и вектор правильных ответов.

Модель предскажет классы для каждого объекта и рассчитает значение метрики:

```python
knn_clf.score(X.values, y.values)
```

Однако, как правило, метрики качества считают с помощью специализированных функций.

Все функции для вычисления метрик в `scikit-learn` представлены в модуле `metrics`.

Для вычисления метрики `accuracy` используется функция [`accuracy_score`](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.accuracy_score.html).

В данную функцию нужно передать следующие аргументы:

* `y_true` - массив с корректными метками (классами)
* `y_pred` - массив с предсказаниями модели

Рассчитаем значение метрики accuracy:

```python
from sklearn.metrics import accuracy_score

y_pred = knn_clf.predict(X.values)

print('Accuracy: {:.2f}'.format(accuracy_score(y, y_pred)))
```

### Масштабирование Признаков

Метрические алгоритмы являются **чувствительным к масштабу признаков**.

Это означает, что если один признак имеет значительно больший масштаб, чем другой, то он будет доминировать в вычислении расстояния между объектами, и алгоритм будет искать соседей только на основе доминирующего признака, не общая внимания на вклад других, что приведет к некорректной классификации.

Рассмотрим простой пример. Пусть есть датасет из 5-ти наблюдений:

|№| Признак 1 | Признак 2 | Класс |
|-|-----------------------------|------------------------------|-|
|1| 100                        | 2                            | A |
|2| 200                        | 3                            | B |
|3| 200                        | 2                            | A |
|4| 150                        | 4                            | B |
|4| 155                        | 2                            | A |

Давайте найдем ближайшего соседа для объекта $x_1$. Вычислим евклидово расстояние:

$$\rho(x_1, x_2) = \sqrt{(100 - 200)^2 + (2 - 3)^2}=\sqrt{10001}$$
$$\rho(x_1, x_3) = \sqrt{(100 - 200)^2 + (2 - 2)^2} = \sqrt{10000}$$
$$\rho(x_1, x_4) = \sqrt{(100 - 150)^2 + (2 - 4)^2} = \sqrt{2504}$$
$$\rho(x_1, x_5) = \sqrt{(100 - 155)^2 + (2 - 4)^2} = \sqrt{3027}$$

По итогу оказывается, что ближайшим соседом для $x_1$ является $x_4$. Однако, это в корне неверно, для данного объекта признак №1 отличается в 1.5 раза, а признак 2 - в 2 раза!

Однако из-за отсутствия нормировки влияние 2-ого признака (2ое слагаемое в вычислении расстояния) очень слабо учитывалось при вычислениях.

Для иллюстрации необходимости масштабирования рассмотрим следующий пример:

```python
X, y = make_classification(
    n_samples=500, # количество объектов
    n_features=2, # количество признаков
    n_informative=2, # количество информативных признаков
    n_redundant=0, # количество избыточных
    n_classes=2, # количество классов
    n_clusters_per_class=2, # количество кластеров
    flip_y=0.01, # коэффициент шума
    # class_sep=5, # зазор между классами
    random_state=1, # число, фиксирующее генератор случайных чисел

    scale=[1, 100] # изменили масштаб признаков
)
print(X.shape, y.shape)

# Переводим X и y в DataFrame для удобства дальнейшей работы
X = pd.DataFrame(X, columns=['x1', 'x2'])
y = pd.Series(y).astype('str')
```

```python
X.head()
```

```python
X.describe()
```

Обучим алгоритм, сделаем предсказание, вычислим метрику accuracy и визуализируем разделяющую поверхность:

```python
knn_clf = KNeighborsClassifier(n_neighbors=5, metric='minkowski', p=2)
knn_clf.fit(X.values, y.values)

y_pred = knn_clf.predict(X.values)

print('Accuracy: {:.2f}'.format(accuracy_score(y, y_pred)))

plot_decision_bound_classification(X, y, knn_clf)
```

Как видим, несмотря на то, что метрика $accuracy$ высока, форма разделяющей поверхности четко дает понять, что что-то тут не так.

По сути все объекты классифицируется только на основе признака $x_2$, так как он имеет наибольший вклад.

Однако, если мы нормализуем признаки, то есть приведем их к одному масштабу, то алгоритм kNN будет принимать решения на основе обоих признаков.

**Методы масштабирования:**

Пусть $x$ - это некоторый вектор-признак (столбец таблицы).

Рассмотрим наиболее популярные способы масштабирования:

* **Min-max нормализация:** </p>
Min-max нормализация — метод преобразования входных признаков, при котором значения признаков приводятся к масштабу [0,...,1]:
$$x_{scaled} = \frac{x - x_{min}}{x_{max} - x_{min}}$$
$x_{min}$ - минимальное значение признака </p>
$x_{max}$ - максимальное значение признака </p>
Например, температура в горном посёлке за день может меняться от 10 до 35 градусов. Текущая температура составляет 17 градусов. Тогда нормализованное значение будет:

$$x_{scaled} = \frac{17 - 10}{35 - 10} = 0.28$$

* **Стандартизация:** </p>
Стандартизация — метод преобразования входных признаков, при котором распределение признака меняется таким образом, чтобы среднее значение равнялось 0, а стандартное отклонение — 1:
$$x_{scaled} = \frac{x - \mu}{\sigma}$$
$\mu$ - среднее значение признака $x$ </p>
$\sigma$ - стандартное отклонение признака $x$ </p>
Например, у нас есть числовой ряд [1, 2, 3, 4, 5]. Среднее ряда: 3. Стандартное отклонение — 1.4. Стандартизируем число 4.
$$x_{scaled} = \frac{4 - 3}{1.4} = 0.7$$
Процесс стандартизации можно описать как центрирование данных с масштабированием. Сначала происходит вычитание среднего значения из всех данных — центрирование,  а затем деление на отклонение.
* **Робастное масштабирование:** </p>
$$x_{scaled} = \frac{x - Q_{0.5}}{Q_{0.75} - Q_{0.25}}=\frac{x - x_{median}}{IQR}$$
$Q_{0.5}$ - 50-ая квантиль (медиана) признака $x$</p>
$Q_{0.75}$ - 75-ая квантиль признака $x$</p>
$Q_{0.25}$ - 25-ая квантиль признака $x$</p>
$IQR= Q_{0.75} - Q_{0.25}$ - межквартильный размах</p>
Например, имеется числовой ряд [1, 2, 3, 4, 5]. Медиана ряда — 3. Межквартильный размах: 4-2=2. Мы хотим нормализовать число 4.
$$x_{scaled} = \frac{4 - 3}{2} = 0.5$$

[Подробнее про методы масштабирования, их преимущества и недостатки здесь](https://www.blog.trainindata.com/feature-scaling-in-machine-learning/)

Возвращаясь к примеру:

|№| Признак 1 | Признак 2 | Класс |
|-|-----------------------------|------------------------------|-|
|1| 100                        | 2                            | A |
|2| 200                        | 3                            | B |
|3| 200                        | 2                            | A |
|4| 150                        | 4                            | B |
|4| 155                        | 2                            | A |

Если мы масштабируем признаки с помощью min-max нормализации (приведем каждый признак к масштабу от 0 до 1). Получим:

|№| Признак 1 | Признак 2 | Класс |
|-|-----------------------------|------------------------------|-|
|1| 0                        | 0                           | A |
|2| 1                        | 0.5                            | B |
|3| 1                        | 0                            | A |
|4| 0.5                        | 1                            | B |
|4| 0.55                        | 0                            | A |

Давайте найдем ближайшего соседа для объекта $x_1$. Вычислим евклидово расстояние:

$$\rho(x_1, x_2) = \sqrt{(0 - 1)^2 + (0 - 0.5)^2}=\sqrt{1.25}$$
$$\rho(x_1, x_3) = \sqrt{(0 - 1)^2 + (0 - 0)^2} = \sqrt{1}$$
$$\rho(x_1, x_4) = \sqrt{(0 - 0.5)^2 + (0 - 1)^2} = \sqrt{1.25}$$
$$\rho(x_1, x_5) = \sqrt{(0 - 0.55)^2 + (0 - 0)^2} = \sqrt{0.3}$$

Теперь картина получается максимально противоположная: объект $x_4$ оказывается самым удаленным объектом от $x_1$, а ближайшими к $x_1$ оказываются объекты $x_3$ и $x_5$, как и должно быть.

**Важный вывод:** для корректного рассчета расстояния между объекта и, как следствие, корректной работы kNN все признаки в датасете должны быть приведены к единому масштабу.

В scikit-learn методы масштабирования реализованы в модуле `preprocessing` виде следующих классов:

* [MinMaxScaler](https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.MinMaxScaler.html) - min-max нормализация
* [StandardScaler](https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.StandardScaler.html) - стандартизация
* [RobustScaler](https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.RobustScaler.html) - робастое масштабирование

```python
from sklearn.preprocessing import MinMaxScaler, StandardScaler, RobustScaler
```

* С помощью метода `fit()` происходит рассчет параметров масштабирования.

* С помощью метода `transform()` выполняется трансформация (масштабирование) данных.

* С помощью метода `fit_transform()` выполняется и то и другое.

Добавим шаг с масштабированием в процесс предобработки данных:

```python
# создаем объект для масштабирования
scaler = MinMaxScaler()
# вычисляем необходимые параметры масштабирования
scaler.fit(X)
# выполняем масштабирование
X_sc = scaler.transform(X)
X_sc = pd.DataFrame(X_sc, columns=X.columns)

X_sc.head()
```

```python
X_sc.describe()
```

Обучим алгоритм еще раз, но уже на масштабированных данных:

```python
knn_clf = KNeighborsClassifier(n_neighbors=5, metric='minkowski', p=2)

# Обучаем на масштабированных данных
knn_clf.fit(X_sc.values, y.values)

# Предсказываем, конечно же, тоже на масштиброванных
y_pred = knn_clf.predict(X_sc.values)

print('Accuracy: {:.2f}'.format(accuracy_score(y, y_pred)))

plot_decision_bound_classification(X_sc, y, knn_clf)
```

### Кейс: Предсказание Вероятности Наличия Диабета

Рассмотрим как выполняется классификация на датасете, приближенному к реальному кейсу.

В качестве кейса возьмем простую задачу предсказания наличия диабета.

[Источник данных](https://www.kaggle.com/datasets/uciml/pima-indians-diabetes-database).

Этот датасет первоначально получен в Национальном институте диабета, болезней органов пищеварения и почек для того, чтобы на основе определённых диагностических измерений предсказать, есть ли у пациента диабет.

На выбор этих экземпляров из более крупной базы данных было наложено несколько ограничений. В частности, все пациенты здесь — женщины не моложе 21 года индейского происхождения племени Пима.

```python
import pandas as pd
import numpy as np
import plotly.express as px

from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
```

```python
diabetes_data = pd.read_csv('https://raw.githubusercontent.com/merion-networks/data-science-course/refs/heads/main/diabetes_data.csv')
diabetes_data.head()
```

**Признаки в данных:**

* Pregnancies — количество беременностей.

* Glucose — концентрация глюкозы в плазме через два часа при пероральном тесте на толерантность к глюкозе.

* BloodPressure — диастолическое артериальное давление (мм рт. ст.).

* SkinThickness — толщина кожной складки трицепса (мм).

* Insulin — двухчасовой сывороточный инсулин (ме Ед/мл).

* BMI — индекс массы тела ($\frac{кг}{м^2}$).

* DiabetesPedigreeFunction — функция родословной диабета (чем она выше, тем выше шанс наследственной заболеваемости).

* Age — возраст.

* Outcome — наличие диабета (0 — нет, 1 — да).

```python
diabetes_data.shape
```

Проверим соотношения классов в данных:

```python
px.pie(
    data_frame=diabetes_data,
    names='Outcome',
    hole=0.2,
    height=300,
    title='Распределение классов в данных'
)
```

```python
diabetes_data.info()
```

```python
diabetes_data.describe()
```

```python
diabetes_data.describe(include='object')
```

Из результатов метода describe() и базовой логики делаем следующие вывод:

> Нулевые значения в столбцах 'Glucose', 'BloodPressure', 'SkinThickness','Insulin', 'BMI' - это **скрытые пропущенные значения**.

> Признак Gender представлен только одной единственной категорией (Female), он является неинформативным

Заменим нули в этих столбцах на пропуски:

```python
diabetes_data_clf = diabetes_data.copy(deep = True)
diabetes_data_clf[
    ['Glucose','BloodPressure','SkinThickness','Insulin','BMI']
] = diabetes_data[['Glucose','BloodPressure','SkinThickness','Insulin','BMI']].replace(0, np.NaN)


diabetes_data_clf.isna().sum()
```

Избавимся от дубликатов:

```python
duplicates = diabetes_data_clf[diabetes_data_clf.duplicated()]
print('Число дубликтов: {}'.format(duplicates.shape[0]))
diabetes_data_clf = diabetes_data_clf.drop_duplicates()
print('Результирующее число записей: {}'.format(diabetes_data_clf.shape[0]))
```

Заполним пропуски в данных:

```python
diabetes_data_clf = diabetes_data_clf.fillna(
    {
        'Glucose': diabetes_data_clf['Glucose'].median(),
        'BloodPressure': diabetes_data_clf['BloodPressure'].median(),
        'SkinThickness': diabetes_data_clf['SkinThickness'].median(),
        'Insulin': diabetes_data_clf['Insulin'].median(),
        'BMI': diabetes_data_clf['BMI'].median()
    }
)
```

Удалим неинформативные признаки, не имеющие значения для построения прогноза:

```python
diabetes_data_clf = diabetes_data_clf.drop('Gender', axis=1)
```

Разделим набор данных на:

* Матрицу наблюдений X, состоящую из признаков объектов
* Вектор-столбец целевой переменной y

```python
X = diabetes_data_clf.drop('Outcome', axis=1)
y = diabetes_data_clf['Outcome']
```

В целях диагностирования наличия переобучения модели воспользуемся простейшим методом валидации.

Разделим всю выборку на тренировочную и тестовую в соотношении 75/25.

На тренировочной выборке будем обучать модели, а на тестовой проверять их качество.

Для этого воспользуемся функцией `train_test_split` из модуля `model_selection` библиотеки `scikit-learn`.

```python
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.25, # размер тестовой выборки
    random_state=42, # число отвечающее за генерацию случайных чисел
    stratify=y # стратификация по y
)

print('Train shape: {}'.format(X_train.shape))
print('Test shape: {}'.format(X_test.shape))
```

Проверим, что соотношение классов в обеих выборках одинаково и равно изначальному:

```python
y.value_counts(normalize=True)
```

```python
y_train.value_counts(normalize=True)
```

```python
y_test.value_counts(normalize=True)
```

Масштабируем данные.

**Важно:** для предотвращения учетки данных параметры масштабирования всегда подбираются на обучающей выборке, и эти параметры применяются как для масштабирования обучающей, так и тестовой выборок.

То есть:
* fit_transform() - на train
* transform() - на test

```python
# создаем объект для масштабирования
scaler = MinMaxScaler()

# вычисляем параметры масштабирования на train и применяем их
X_train_sc =  scaler.fit_transform(X_train)
# применяем те же параметры для масшабирования test
X_test_sc =  scaler.transform(X_test)

# Преобразуем результаты в DataFrame для наглядности
X_train_sc = pd.DataFrame(X_train_sc, columns=X_train.columns)
X_test_sc = pd.DataFrame(X_test_sc, columns=X_test.columns)

X_train_sc.head()
```

```python
X_train_sc.describe()
```

Обучим модель kNN на обучающей выборке.

Сделаем предсказание на обучающей и тестовой выборке и проверим полученное качество модели по метрике `accuracy`:

```python
knn_clf = KNeighborsClassifier(n_neighbors=5, metric='minkowski', p=2)
knn_clf.fit(X_train_sc, y_train)

y_train_hat = knn_clf.predict(X_train_sc)
y_test_hat = knn_clf.predict(X_test_sc)

print('Train accuracy score: {:.2f}'.format(accuracy_score(y_train, y_train_hat)))
print('Test accuracy score: {:.2f}'.format(accuracy_score(y_test, y_test_hat)))
```

```python
y_test_hat
```

Если нам необходимо выводить вероятности наличия диабета, то можно рассчитать и их:

```python
y_test_proba_hat = knn_clf.predict_proba(X_test_sc)
y_test_proba_hat.shape
```

Выполним подбор гиперпараметра модели - количества соседей.

Для этого воспользуемся простейшим способом подбора параметров на тестовой выборке.

Для этого:

1. обучим алгоритм с разным количеством соседей на обучающей выборке
2. рассчитаем метрику accuracy на обучающей и тестовой выборках
3. выберем то количество соседей, при которой качество на тестовой выборке наилучшее

```python
np.arange(2, 25)
```

```python
# создаем массив с количеством соседей, которое будет подбирать
n_neighbors = np.arange(2, 25)

# инициализируем списки со значениями метрик на train и test выборках
train_scores = []
test_scores = []

# создаем цикл по соседям
for k in n_neighbors:
    # обучаем алгоритм kNN с заданным количеством соседей
    knn_clf = KNeighborsClassifier(n_neighbors=k, metric='minkowski', p=2)
    knn_clf.fit(X_train_sc, y_train)
    # производим классификацию на обучающей и тестовой выборках
    y_train_hat = knn_clf.predict(X_train_sc)
    y_test_hat = knn_clf.predict(X_test_sc)
    # рассчитываем значения метрик и добавляем их в списки
    train_scores.append(accuracy_score(y_train, y_train_hat))
    test_scores.append(accuracy_score(y_test, y_test_hat))

# создаем DataFrame из результатов
scores_df = pd.DataFrame({
    'k': n_neighbors,
    'train_score': train_scores,
    'test_score': test_scores
})

# display(scores_df)

# визуализируем результаты в виде линейного графика
fig = px.line(
    data_frame=scores_df,
    x='k',
    y=['train_score', 'test_score'],
    height=300
)
fig.show()
```

Из графика видим, что наилучшим выбором будет $k=5$.

Обучим kNN с 5-ью соседями:

```python
knn_clf = KNeighborsClassifier(n_neighbors=5, metric='minkowski', p=2)
knn_clf.fit(X_train_sc, y_train)

y_train_hat = knn_clf.predict(X_train_sc)
y_test_hat = knn_clf.predict(X_test_sc)

print('Train accuracy score: {:.2f}'.format(accuracy_score(y_train, y_train_hat)))
print('Test accuracy score: {:.2f}'.format(accuracy_score(y_test, y_test_hat)))
```

Итоговую обученную модель можно сохранить в виде pickle-файла для дальнейшего использования модели в приложениях.

За сохранение (сериализацию модели) отвечает метод `dump()`, в него нужно передать python-объект (обученную модель) и объект-файл, в который будет сохранена модель.

```python
import pickle

# открываем файл на запись
with open('diabet_knn_classifier.pkl', 'wb+') as f:
    # сохраняем обученную модель kNN
    pickle.dump(knn_clf, f)
```

Импортировать модель из файла можно с помощью функции `load`:

```python
# открываем файл на чтение
with open('diabet_knn_classifier.pkl', 'rb+') as f:
    # сохраняем обученную модель kNN
    loaded_clf = pickle.load(f)

loaded_clf
```

```python
loaded_clf.predict(X_test_sc)
```

## Алгоритм kNN Для Регрессии

### Общая Идея Алгоритма

Для того, чтобы адаптировать метод для решения регрессии нам нужно лишь ввести парочку модификаций.

Если в случае задачи классификации мы определяли класс объекта путем мажоритарного голосования среди его k-ближайших соседей, то в случае регрессии - нам нужно предсказать не класс, а число.

Идея! Давайте просто вычислять среднее (ну или медиану) по таргету всех ближайших соседей и выдавать результат в качестве предсказания модели.

<center> <!-- Изображение удалено --> </center>

### Формализация Алгоритма

Ну а теперь формализуем это в виде алгоритма.

---
Пусть дана выборка объектов и их ответов к ним:

$$\{(x_1, y_1), (x_2, y_2), ..., (x_n, y_n)\}=\{(x_i, y_i)\}_{i=1}^n$$

где
* $x_i = (x_{i1}, x_{i2}, ..., x_{im}), x_i ∈ X, X \subset \mathbb{R} ^{m}$ - множество объектов
* $y_i ∈ Y, Y \subset \mathbb{R} ^{m}$ - множество целевой переменной
* $m$ - количество признаков
* $n$ - размер выборки

Задана функция расстояния $\rho(x_i, x_j)$, обладающая свойствами:

* Неотрицательности: $\rho(x_i, x_j) >= 0$
* Симметричности: $\rho(x_i, x_j) = \rho(x_j, x_i)$.

Чтобы определить таргет $\hat{y}(x_t)$ для нового объекта $x_t$ нужно:
1. Вычислить расстояние между объектом $x_t$ и каждым объектом $x_i$ в обучающей выборке:

    $$\rho(x_t, x_i), ∀x_i ∈ X$$

2. Выбрать $k$ объектов с наименьшим расстоянием:

    $$N_k(x_t) = \\{x_i \in X | \rho(x_t, x_i) < \rho(x_t, x_j), ∀ j \neq i\\}$$

    $$j = {1, 2, ..., n}$$
    $$i={1, 2, ..., k}$$

    где $N_k(x)$ - множество $k$ ближайших соседей целевого объекта $x$.

3. Вычислить прогноз как среднее значение таргета среди найденных соседей$:

    $$\hat{y_t}(x_t) = \frac{1}{k} \sum_{i=1}^k y_i$$

<center> <!-- Изображение удалено --> <!-- Изображение удалено --> </center>

### Пример На Конкретных Цифрах

| Объект | Признак 1 | Признак 2 | Целевая переменная |
|---|---|---|---|
| 1 | 1 | 1 | 2 |
| 2 | 2 | 2 | 3 |
| 3 | 3 | 3 | 4 |
| 4 | 4 | 4 | 5 |
| 5 | 5 | 5 | 6 |
| 6 | 6 | 6 | 7 |
| 7 | 7 | 7 | 8 |
| 8 | 8 | 8 | 9 |
| 9 | 9 | 9 | 10 |
| 10 | 10 | 10 | 11 |

Теперь предположим, что у нас есть новый объект $x_t$ с признаками $x_t=(x_{t1}, x_{t2}) = (5, 5)$.

Чтобы построить прогноз целевой переменной для объекта $x_t$, мы:
1. Вычисляем расстояние между новым объектом и каждым объектом в обучающей выборке:
$$\rho(x_t, x_i), ∀x_i ∈ X$$
    Считаем:
$$\rho(x_t, x_1) = \sqrt{(5 - 1)^2 + (5 - 1)^2} = 6.32$$
$$\rho(x_t, x_2) = \sqrt{(5 - 2)^2 + (5 - 2)^2} = 4.24$$
$$\rho(x_t, x_3) = \sqrt{(5 - 3)^2 + (5 - 3)^2} = 2.83$$
$$\rho(x_t, x_4) = \sqrt{(5 - 4)^2 + (5 - 4)^2} = 1.41$$
$$\rho(x_t, x_5) = \sqrt{(5 - 5)^2 + (5 - 5)^2} = 0$$
$$\rho(x_t, x_6) = \sqrt{(5 - 6)^2 + (5 - 6)^2} = 1.41$$
$$\rho(x_t, x_7) = \sqrt{(5 - 7)^2 + (5 - 7)^2} = 2.83$$
$$\rho(x_t, x_8) = \sqrt{(5 - 8)^2 + (5 - 8)^2} = 4.24$$
$$\rho(x_t, x_9) = \sqrt{(5 - 9)^2 + (5 - 9)^2} = 6.32$$
$$\rho(x_t, x_{10}) = \sqrt{(5 - 10)^2 + (5 - 10)^2} = 7.07$$

2. Выбираем $k$ объектов с наименьшим расстоянием.
$$N_k(x_t) = \{x_i \in X | \rho(x_t, x_i) < \rho(x_t, x_j), ∀ j \neq i\}$$
В нашем случае $k=5$, тогда находим 5 ближайших объекта - это будут объекты под номерами 2, 3, 4, 5 и 6:
$$N_k(x) = \{x_2, x_3, x_4, x_5, x_6\}$$

2. Вычисляем предсказание для объекта среднее по таргету k-ближайших соседей:
$$\hat{y_t}= f(x_t) = \frac{1}{k} \sum_{i=1}^k y_i$$

$$\hat{y_t} = \frac{6 + 5 + 4 + 3 + 2}{5} = 4$$

Итак, предсказанное значение целевой переменной для объекта $x_t=(5, 5)$ равно 4.

### Реализация В Python

```python
import pandas as pd
import numpy as np
import seaborn as sns
import plotly.express as px

from sklearn.datasets import make_regression
```

Для иллюстрации реализации алгоритма создадим игрушечный датасет с помощью функции `make_regression` из 500 наблюдений, 2 признаков и 2 классов.

```python
# Генерируем данные
X, y = make_regression(n_samples=500, n_features=1, noise=15, random_state=1)

# Переводим X и y в DataFrame для удобства дальнейшей работы
X = pd.DataFrame(X, columns=['x'])
y = pd.Series(y)
```

```python
X.head()
```

```python
y.head()
```

Визуализируем данные:

```python
fig = px.scatter(
    x=X.iloc[:, 0], y=y,
    height=400, width=700
)
fig.show()
```

Хотим предсказать значение таргета для следующего объекта:

```python
x = np.array([[1.5]])
```

Метод k-ближайших соседей для регрессии реализован в виде класса [KNeighborsRegressor](https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsRegressor.html).

Основные параметры алгоритма:

- `n_neighbors`: количество ближайших соседей, которое будет использоваться для регрессии.
- `metric`: метрика расстояния, используемая для измерения расстояния между объектами. [Список возможных значений](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.distance_metrics.html#sklearn.metrics.pairwise.distance_metrics). Примеры:
    - `'minkowski'` - расстояние Минковского (используется по умолчанию)
    - `'cosine'` - косинусное расстояние
    - `'euclidean'` - евклидово расстояние
    - `'manhattan'` - расстояния Манхэттена

- `p`: норма, используемая для измерения расстояния между объектами (по умолчанию p=2)
- `n_jobs`: количество ядер процессора, используемых для вычислений (чтобы использовать все доступные мощности процессора нужно установить параметр в -1).

```python
from sklearn.neighbors import KNeighborsRegressor
```

Обучим модель kNN c 10-ью соседями:

```python
knn_reg = KNeighborsRegressor(n_neighbors=10, metric='minkowski', p=2)
knn_reg.fit(X.values, y.values)
```

Предскажем значение таргета для нового объекта:

```python
y_pred = knn_reg.predict(x)
y_pred
```

Построим аналог разделяющей поверхности для регрессии - регрессионная кривая.

Для этого сгенерируем новые данные от минимума до максимума в обущей выборке, сделаем предсказание для этих значений $x$ и визуализируем результат в виде кривой.

Проследим за тем, как меняется кривая в зависимости от количества соседей.

```python
def plot_decision_bound_regression(X, y, model, height=400, width=700):
    x_grid = np.arange(X.iloc[:, 0].min(), X.iloc[:, 0].max(), 0.001)
    y_pred = model.predict(x_grid.reshape(-1, 1))
    fig = px.scatter(x=X.iloc[:, 0], y=y, opacity=0.7, height=height, width=width)
    fig.add_scatter(x=x_grid, y=y_pred, name='prediction')
    fig.show()

knn_reg = KNeighborsRegressor(n_neighbors=10, metric='minkowski', p=2)
knn_reg.fit(X.values, y.values)

plot_decision_bound_regression(X, y, knn_reg)
```

### Оценка Качества Регрессии

Вычислим качество нашей регрессионной модели.

По умолчанию метод `score` для объектов регрессии в `scikit-learn` рассчитывает метрику $R^2$ (коэффициент детерминации):

$$R^2 = 1 - \frac{{\sum_{i=1}^n (y_i - \hat{y_i})^2}}{\sum_{i=1}^n (y_i - y_{mean})^2}$$
где </p>
* ${y_i}$ - истинное значение таргета для $i$-ого объекта </p>
* $\hat{y_i}$ - прогноз для $i$-ого объекта </p>
* $y_{mean}$ - среднее значение по таргету

Коэффициент детерминации показывает, насколько наша модель лучше, чем если бы все предсказания были средним по правильным ответам.

Если на математическом, то данная метрика измеряет, какую долю дисперсии (вариативности) целевой переменной смогла уловить наша модель.

Данная метрика измеряется от $-∞$ до 1.

Удовлетворительным $R^2$ считается показатель выше 0.5: чем ближе к 1, тем лучше. Отрицательные значения  говорят о том, что построенная модель настолько плоха, что лучше было бы присвоить всем ответам среднее значение.

```python
print('R^2-score: {:.2f}'.format(knn_reg.score(X.values, y.values)))
```

Как правило, метрики рассчитываются с помощью специализированных функций из модуля `metrics`. Поступим так же.

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

```python
y_pred = knn_reg.predict(X.values)
```

Рассчитаем коэффициент детерминации ($R^2$) с помощью функции `r2_score` из модуля `metrics`:

```python
from sklearn.metrics import r2_score
```

```python
print('R^2-score: {:.2f}'.format(r2_score(y, y_pred)))
```

Коэффициент детерминации полезная метрика, однако она обладает непонятной для бизнеса интерпретации и ничего не говорит о том, какова точность нашего прогноза.

Гораздо большей интерпретацией обладают метрики $MAE$ и $MAPE$

* **Средняя абсолютная ошибка — MAE (Mean Absolute Error)** </p>

    Данная метрика показывает, насколько в среднем наша модель ошибается и вычисляется как среднее арифметическое модуля отклонения предсказанного значения от реального:
    $$MAE = \frac{1}{n} \sum_{i=1}^n |y_i - \hat{y_i}|$$
    
    Чем меньше значение метрики, тем лучше.

    Однако, это метрика не дает понимания о том, насколько наша ошибка велика. Например, в результате построения модели по предсказанию цены недвижимости MAE составила 3.5 тыс. долларов. Много ли это?

    Без эксперта-оценщика недвижимости будет сложно дать ответ. Однако можно попробовать посчитать ошибку в процентах, ведь в процентах всё воспринимается легче, и для этого нам пригодится следующая метрика — $MAPE$.

* **Средняя абсолютная ошибка в процентах — Map (Mean Absolute Percent Error)** </p>

    Данная метрика показывает, на сколько процентов в среднем наше предсказание отклоняется от реального значения:
    $$MAPE = \frac{100 \\%}{n} \sum_{i=1}^n \frac{|y_i - \hat{y_i}|}{|y_i|}$$
    
    Чем меньше значение метрики, тем лучше.

    Примечание: в `sklearn` данная метрика считается не в процентах, а в долях, то есть результат еще дополнительно нужно умножить на $100\\%$.

Рассчитаем метрики MAE и Map:

```python
from sklearn.metrics import mean_absolute_error, mean_absolute_percentage_error
```

```python
print('MAE: {:.2f}'.format(mean_absolute_error(y, y_pred)))
print('MAPE: {:.2f}'.format(mean_absolute_percentage_error(y, y_pred) * 100))
```

### Заполнение Пропусков В Данных С Помощью kNN

Широкое применение регрессионный kNN нашел в задачах восстановления пропущенных данных.

Рассмотрим задачу заполнения пропусков на примере датасета о диабете:

```python
diabetes_data = pd.read_csv('https://raw.githubusercontent.com/merion-networks/data-science-course/refs/heads/main/diabetes_data.csv')
diabetes_data.head()
```

Предобработаем данные вновь:

```python
diabetes_data[
    ['Glucose','BloodPressure','SkinThickness','Insulin','BMI']
] = diabetes_data[['Glucose','BloodPressure','SkinThickness','Insulin','BMI']].replace(0, np.NaN)

diabetes_data = diabetes_data.drop('Gender', axis=1)

diabetes_data.isna().sum()
```

**Идея:**

> Давайте рассматривать признаки с пропусками как таргеты, для которых нужно построить прогноз.
> Будем строить модель kNN для регрессии и предсказывать неизвестные значения в столбцах на основе похожих объектов из выборки.

Можно даже написать функцию, которая принимает на вход датасет и возвращать новый датасет без пропусков.

Однако, все это лишнее изобретение велосипеда, так в `scikit-learn` уже реализован подход к заполнению пропусков с помощью kNN.

Как и другие методы заполнения пропусков в данных, данный метод находится в модуле `impute` и реализован в виде класса [`KNNImputer`](https://scikit-learn.org/stable/modules/generated/sklearn.impute.KNNImputer.html).

Данный класс позволяет выполнять трансформацию данных по заполнению пропусков, причем недостающие значения для каждой выборки вычисляются с использованием среднего значения из k ближайших соседей, найденных в обучающем наборе данных.

Аргументы данного класса:

* missing_values - какое значение считается пропуском, по умолчанию параметр равен `nan`, но можно указать любое другое значение (например, 0 как это было в нашем примере)
* n_neighbors - количество соседей, используемых для заполнения
* metric - метрика расстояния, по умолчанию используется метрика `nan_euclidean`, она позволяет вычислять расстояние Евклида не обращая внимания на пропуски в столбцах. Рекомендуется не менять ее значение.

Основные методы:

* fit() - подгонка (по сути ничего не делает кроме сохранения выборки в память)
* transform() - заполняет пропущенные значения в данных и возвращает результат в виде numpy-массива
* fit_transform() - fit + transform

Пример из документации:

```python
from sklearn.impute import KNNImputer

X = [[1, 2, np.nan], [3, 4, 3], [np.nan, 6, 5], [8, 8, 7]]

imputer = KNNImputer(n_neighbors=2)
imputer.fit_transform(X)
```

Посмотрим на то, как выглядит реализация заполнения пропусков с помощью KNNImputer на примере датасета о диабете.

Обратите внимание, что для заполнения нам даже не нужно разделять таблицу на фичи и таргет, KNNImputer сам понимает, что является таргетом - это все будут все столбцы в таблице, содержащие пропуски.

Однако, не забудем предварительно масштабировать данные, чтобы результат получился как можно точнее:

```python
X_nan = diabetes_data.copy()

# создаем объект для масштабирования
scaler = MinMaxScaler()
# масштабируем данные
X_nan_sc =  scaler.fit_transform(X_nan)
# Преобразуем результаты в DataFrame для наглядности
X_nan_sc = pd.DataFrame(X_nan_sc, columns=X_nan.columns)
X_nan_sc.head()
```

```python
# создаем объект для заполнения
imputer = KNNImputer(n_neighbors=5)

# выполняем подгонку + трансформацию на исходных данных
# результат - numpy массив с заполненными данными
X_inputed_sc = imputer.fit_transform(X_nan_sc)

# преобразуем результат в DataFrame
X_inputed_sc = pd.DataFrame(X_inputed_sc, columns=X_nan_sc.columns)
X_inputed_sc.head()
```

Чтобы вернуться к исходным масштабам признаков, можно воспользоваться методом `inverse_transform`:

```python
X_inputed = scaler.inverse_transform(X_inputed_sc)
diabetes_data_inputed = pd.DataFrame(X_inputed, columns=diabetes_data.columns)
diabetes_data_inputed.head()
```

Проверяем наличие пропусков в данных:

```python
diabetes_data_inputed.isna().sum()
```

Уже на этих предобработанных данных можно решать другие задачи, например, можно проклассифицировать пациентов еще раз и проверить, улучшилось ли качество.

Выполним предобработку данных аналогично тому как мы делали это ранее:

```python
# выделяем матрицу наблюдений X и вектор ответов y
X = diabetes_data_inputed.drop('Outcome', axis=1)
y = diabetes_data_inputed['Outcome']

# Делим выборку на тренировочную и тестовую
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.25, # размер тестовой выборки
    random_state=42, # число отвечающее за генерацию случайных чисел
    stratify=y # стратификация по y
)

print('Train shape: {}'.format(X_train.shape))
print('Test shape: {}'.format(X_test.shape))

# создаем объект для масштабирования
scaler = MinMaxScaler()

# вычисляем параметры масштабирования на train и применяем их
X_train_sc =  scaler.fit_transform(X_train)
# применяем те же параметры для масшабирования test
X_test_sc =  scaler.transform(X_test)

# Преобразуем результаты в DataFrame для наглядности
X_train_sc = pd.DataFrame(X_train_sc, columns=X_train.columns)
X_test_sc = pd.DataFrame(X_test_sc, columns=X_test.columns)

X_train_sc.head()
```

Подберем значение параметра $k$ аналогично тому как мы делали это в прошлый раз.

```python
# создаем массив с количеством соседей, которое будет подбирать
n_neighbors = np.arange(2, 25)

# инициализируем списки со значениями метрик на train и test выборках
train_scores = []
test_scores = []

# создаем цикл по соседям
for k in n_neighbors:
    # обучаем алгоритм kNN с заданным количеством соседей
    knn_clf = KNeighborsClassifier(n_neighbors=k, metric='minkowski', p=2)
    knn_clf.fit(X_train_sc, y_train)
    # производим классификацию на обучающей и тестовой выборках
    y_train_hat = knn_clf.predict(X_train_sc)
    y_test_hat = knn_clf.predict(X_test_sc)
    # рассчитываем значения метрик и добавляем их в списки
    train_scores.append(accuracy_score(y_train, y_train_hat))
    test_scores.append(accuracy_score(y_test, y_test_hat))

# создаем DataFrame из результатов
scores_df = pd.DataFrame({
    'k': n_neighbors,
    'train_score': train_scores,
    'test_score': test_scores
})

# display(scores_df)

# визуализируем результаты в виде линейного графика
fig = px.line(
    data_frame=scores_df,
    x='k',
    y=['train_score', 'test_score'],
    height=300
)
fig.show()
```

Из графика видим, что при $k=9$ у нас наблюдается наилучшее качество на тестовой выборке.

```python
knn_clf = KNeighborsClassifier(n_neighbors=9, metric='minkowski', p=2)
knn_clf.fit(X_train_sc, y_train)

y_train_hat = knn_clf.predict(X_train_sc)
y_test_hat = knn_clf.predict(X_test_sc)

print('Train accuracy score: {:.2f}'.format(accuracy_score(y_train, y_train_hat)))
print('Test accuracy score: {:.2f}'.format(accuracy_score(y_test, y_test_hat)))
```

Итого благодаря замене способа заполнения пропуска с топорного (заполнения через медиану) на более продвинутый (заполнение через kNN) нам удалось повысить качество решения задачи на 2%! Что очень даже неплохо!

Сохраняем модель:

```python
# открываем файл на запись
with open('diabet_knn_classifier_2.pkl', 'wb+') as f:
    # сохраняем обученную модель kNN
    pickle.dump(knn_clf, f)
```

### Преимущества И Недостатки kNN

**Преимущества:**

* Простота: kNN является простым и легко понимаемым алгоритмом. Он не требует глубокого понимания математики или статистики.
* Интерпретируемость: kNN не является черным ящиком, всегда можно проследить весь путь алгоритма и понять как был получен результат его работы
* Эффективность: kNN может показывать неплохое базовое качество для небольших и простых наборов данных.
* Универсальность: kNN может использоваться для решения различных задач машинного обучения, включая классификацию, регрессию и кластеризацию.
* Нелинейность: kNN может моделировать нелинейные отношения между признаками и целевой переменной.

**Недостатки:**

* Чувствительность к гиперпараметрам: результаты работы kNN сильно зависят от гиперпараметров алгоритма, в особенности от количества соседей, поэтому алгоритм требует подбора этих гиперпараметров.
* Чувствительность к масштабу: kNN требует обязательного масштабирования данных.
* Необходимость хранения всего набора данных: kNN требует хранения всего набора данных в памяти, что может быть проблематично для больших наборов данных (на миллионы и десятки миллионов строк).
* Низкое качество на сложных данных: kNN уступает в качестве более сложным и продвинутым алгоритмам

Сегодня kNN используется исследователями только в качестве бейзлайна, то есть нижней планки качества, с которой можно сравнивать другие более сложные алгоритмы. Также kNN находит активное применение в заполнении пропущенных значений в данных.

## Модификации kNN*

### Взвешенный kNN

У оригинального алгоритма есть один большой недостаток: он никак не учитывает расстояния до соседних объектов, хотя эта информация может быть полезной.

Давайте попробуем придумать, как исправить этот недостаток. Нам нужно каким-то образом увеличивать вклад близких объектов и уменьшать вклад далёких. Можно заметить, что все индикаторы в формуле
 учитываются в сумме с одинаковыми коэффициентами. Возникает идея — назначить этим индикаторам веса, которые тем больше, чем ближе объект к целевому. Таким образом, получаем следующую формулу:

Для классификации будем иметь:

 $$\hat{y}(x_t) = \arg \max_{y \in Y} \sum_{i=1}^{k} \omega_i \mathbb{I}[y_i = y]$$

Для регрессии:

$$\hat{y_t} = a(x_t) = \frac{1}{k} \sum_{i=1}^k \omega_i y_i$$

Такой алгоритм называется взвешенным KNN (weighted KNN).

**Как выбрать веса?**

Самый простой способ - учитывать номер ближайшего соседа $i$. Например, мы нашли 5 ближайших соседей, тогда отсортируем их по расстоянию и дадим им номера от 1 до 5.

Тогда вес для i-ого соседа можно определять линейно:

$$\omega_i = \frac{k-i + 1}{k}$$

Тогда, если мы используем 3 соседа, то вес 1-ого соседа будет $\omega_1=\frac{3-1+1}{3}=1$, 2-ого - $\omega_2=\frac{3-2+1}{3}=0.66$, 3-ого - $\omega_3=\frac{3-3+1}{3}=0.33$.

Или нелинейно как геометрическую прогрессию с $q$ от 0 до 1, например $q=0.5$:

$$\omega_i = q^i, 0 < q < 1$$

Тогда вес 1-ого соседа будет $\omega_1=0.5^1=0.5$, 2-ого - $\omega_2=0.5^2=0.25$, 3-ого - $\omega_3=0.5^3=0.125$ и т.д.

Однако, встает вопрос - а зачем использовать порядковый номер соседа, если можно воспользовать расстоянием. Чем меньше расстояние, тем больше вес объекта в предсказании.

Тогда вес соседа будет вычисляться по формуле:

$$\omega_i = \frac{1}{\rho}$$

Вычисление весов на основе расстояния реализовано в sklearn как для классификации, так и для регрессии.

Указать способ инициализации весов можно с помощью параметра `weights`.

* По умолчанию значение параметра выставлено как 'uniform', что сооветствует равномерному распределению весов соседей, то есть все соседи имеют одинаковый  и прогноз алгоритма выполняется путем мажоритарного голосования (в случае классификации) или усреднения таргета (для регрессии)

* Если выставить значение данного параметра как 'distance', то веса распределяются обратно пропорционально расстоянию (по формуле $\omega_i = \frac{1}{\rho}$)

Ниже представлены примеры того как выглядят графики разделяющих поверхностей и регрессионных моделей при использовании обоих вариантов параметров.

**Для классификации** ([код примера](https://scikit-learn.org/stable/auto_examples/neighbors/plot_classification.html#sphx-glr-auto-examples-neighbors-plot-classification-py)):
<center> <!-- Изображение удалено --> </center>

**Для регрессии** ([код примера](https://scikit-learn.org/stable/auto_examples/neighbors/plot_regression.html#sphx-glr-auto-examples-neighbors-plot-regression-py)):

<center> <!-- Изображение удалено --> </center>

Однако, есть и более продвинутые способы определять веса объектов на основе расстояния. Можно задавать веса на основе некоторой функции, зависящей от расстояния между объектами.

**Ядерная функция (kernel function) $K(\rho)$** - это функция, которая определяет вес объекта в предсказании в зависимости от его расстояния до целевого объекта.

**Виды ядер:**

* **Единообразное ядро (uniform kernel):**

$$K(\rho) = \begin{cases}
1, & \text{если } \rho \leq 1 \\
0, & \text{иначе}
\end{cases}$$

Допустим, у нас есть 3 соседа с расстояниями до целевого объекта $\rho_1 = 0.5$, $\rho_2 = 1.2$, $\rho_3 = 1.8$. Тогда их веса будут:

* $w_1 = K(\rho_1) = 1$
* $w_2 = K(\rho_2) = 0$
* $w_3 = K(\rho_3) = 0$

* **Треугольное ядро (triangular kernel):**

$$K(\rho) = \begin{cases}
1 - \rho, & \text{если } \rho \leq 1 \\
0, & \text{иначе}
\end{cases}$$

Допустим, у нас есть 3 соседа с расстояниями до целевого объекта $\rho_1 = 0.5$, $\rho_2 = 1.2$, $\rho_3 = 1.8$. Тогда их веса будут:

* $w_1 = K(\rho_1) = 1 - 0.5 = 0.5$
* $w_2 = K(\rho_2) = 1 - 1.2 = -0.2$
* $w_3 = K(\rho_3) = 1 - 1.8 = -0.8$

* **Эпанечникова ядро (Epanechnikov kernel):**

$$K(\rho) = \begin{cases}
\frac{3}{4}(1 - \rho^2), & \text{если } \rho \leq 1 \\
0, & \text{иначе}
\end{cases}$$

Допустим, у нас есть 3 соседа с расстояниями до целевого объекта $\rho_1 = 0.5$, $\rho_2 = 1.2$, $\rho_3 = 1.8$. Тогда их веса будут:

* $w_1 = K(\rho_1) = \frac{3}{4}(1 - 0.5^2) = 0.5625$
* $w_2 = K(\rho_2) = \frac{3}{4}(1 - 1.2^2) = -0.1875$
* $w_3 = K(\rho_3) = \frac{3}{4}(1 - 1.8^2) = -0.75$

* **Гауссово ядро (Gaussian kernel):**

$$K(\rho) = \frac{1}{\sqrt{2\pi}}e^{-\frac{\rho^2}{2}}$$

Допустим, у нас есть 3 соседа с расстояниями до целевого объекта $\rho_1 = 0.5$, $\rho_2 = 1.2$, $\rho_3 = 1.8$. Тогда их веса будут:

* $w_1 = K(\rho_1) = \frac{1}{\sqrt{2\pi}}e^{-\frac{0.5^2}{2}} = 0.3989$
* $w_2 = K(\rho_2) = \frac{1}{\sqrt{2\pi}}e^{-\frac{1.2^2}{2}} = 0.1814$
* $w_3 = K(\rho_3) = \frac{1}{\sqrt{2\pi}}e^{-\frac{1.8^2}{2}} = 0.0443$



Тогда с учетом ядерной функции, используемой для вычисления весов конечный вид формулы рассчета предсказания по найденным $k$ соседям будет иметь вид:

* **Для классификации:**
$$\hat{y}(x_t) = \arg \max_{y \in Y} \sum_{i=1}^{k} K(\frac{\rho_i}{h}) \mathbb{I}[y_i = y]$$
* **Для регрессии:**

$$\hat{y_t}(x_t) = \frac{1}{\sum_{i=1}^k K\frac{\rho_i}{h}} \sum_{i=1}^k K(\frac{\rho_i}{h}) y_i$$

где:

* $K(\rho_i)$ - ядерная функция для i-ого соседа
* $\mathbb{I}[y_i = y]$ - индикатор, который равен 1, если целевая переменная i-ого соседа равна y, и 0 в противном случае
* $\rho_i$ - расстояние от целевого объекта до i-ого соседа
* $y_i$ - целевая переменная i-ого соседа
* $h$ - некоторое положительное число, называемое **шириной окна**


От выбора ядра зависит гладкость аппроксимации, но на её качество этот выбор почти не влияет.

На практике чаще всего используют либо единообразное (uniform) для простоты, либо гауссовское (gaussian), в случае когда важна гладкость модели (а она важна в случаерегрессии).



Ширина окна же $h$ существенно влияет на качество модели и может быть использована в качестве гиперпараметра алгоритма и ее можно подбирать, также как и количество соседей.

При слишком маленькой ширине модель сильно подстраивается под обучающую выборку,теряет свою обобщающую способность и рисует переобучиться. При слишком большой ширине, наблюдается обратная ситуация - модель рискует недообучиться. Универсальной ширины окна не существует, поэтому для каждой задачи её приходится подбирать отдельно.


На рисунке ниже приведены [примеры](https://education.yandex.ru/handbook/ml/article/metricheskiye-metody) того как ядерная функция и ширина окна влияют на результаты регрессии:

<center> <!-- Изображение удалено --> </center>


<center> <!-- Изображение удалено --> </center>

<center> <!-- Изображение удалено --> </center>


В `scikit-learn` подход с использованием ядерной функции не реализован в самой библиотеке. Однако, библиотека дает возможность пользователю самостоятельно задать функцию для вычисления весов объектов.

Для этого в качестве параметра `weights` нужно передать функцию, которая принимает в качестве аргумента расстояния между объектами и возвращает веса соседей.



Например, гаусовское ядро с шириной окна $h$ можно задать следующий образом:

```python
import numpy as np
from sklearn.neighbors import KNeighborsClassifier
from sklearn.neighbors import KNeighborsRegressor

# Определяем Функцию Для Вычисления Весов Объекта С Гаусовским Ядром И Шириной Окна H
def gaussian_kernel(distances, h=1):
    return 1/np.sqrt(2 * np.pi)  * np.exp(-0.5 * (distances / h) ** 2)

# Создаем Экземпляр Классификатора KNN С Кастомной Функцией Для Вычисления Весов
knn_clf = KNeighborsClassifier(n_neighbors=5, weights=gaussian_kernel)

# Создаем Экземпляр Регрессора KNN С Кастомной Функцией Для Вычисления Весов
knn_reg = KNeighborsRegressor(n_neighbors=5, weights=gaussian_kernel)
```

### Ускорение Поиска Соседей

Вторая модификация классического алгоритма kNN связана с необходимостью оптимизации поиска соседей.

В классическом алгоритме предлагается предполагается, что для того, чтобы найти ближайших соседей для объекта $x_t$ нам нужно вычислить расстояние между этим объектом и всеми объектами из обучающей выборки $(x_i)_{i=1}^n$.

То есть нам нужно перебрать $n$ объектов. Такой "топорный" перебор называется методом грубой силы (brute force).

А если в датасете миллионы строк? Десятки миллионов? Это получается, что для каждого объекта из обучающей выборки нам нужно будет выполнить цикл как минимум с миллионом итераций! [Сложность](https://bimlibik.github.io/posts/complexity-of-algorithms/) такого алгоритма $O(n)$.

Но тут еще стоит учесть, что каждый объект из выборки - это вектор с $m$ координатами. То есть цикл будет вложенный - сначала по объектам, потом по их признакам. Тогда, если в датасете миллион объектов и каждый из них описывается 100 признаками, то нам понадобится 100 миллионов операций на поиск соседей только для одного нового объекта. Сложность такого алгоритма будет $O(nm)$.

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

Таким образом, возникает необходимость в более быстрых методах поиска ближайших соседей, чем простой перебор.

Для этого были разработаны методы для ускорения поиска ближайших соседей. Это методы:

* K-d-деревья (k-dimensional tree)
* Ball-деревья (ball tree)
* [Random projection trees](https://erikbern.com/2015/10/01/nearest-neighbors-and-vector-models-part-2-how-to-search-in-high-dimensional-spaces.html)
* [Locality-sensitive hashing (LSH)](https://randorithms.com/2019/09/19/Visual-LSH.html)
* [Proximity graphs & Hierarchical navigable small world (HNSW)](https://arxiv.org/abs/1603.09320)


В реализации kNN в библиотеке scikit-learn реализованы несколько подходов к поиску соседей. Способ поиска определяется параметром algorithm. Его возможные значения:

* `'brute'` - поиск полным перебором всей обучающей выборки
* `'kd_tree'` - поиск с помощью k-d-деревьев
* `'ball_tree'` - поиск с помощью ball-деревьев
* '`auto'` - автоматическое определение алгоритма поиска на основе датасета.

По умолчанию параметр algorithm установлен в значение `'auto'`, то есть модель сама выбирает, какой метод поиска соседей ей использовать для данного набора данных. В [документации scikit-learn](https://scikit-learn.org/stable/modules/neighbors.html#choice-of-nearest-neighbors-algorithm) по kNN приведены условия, когда используется тот иной алгоритм поиска, приводить их здесь не имеет смысла.

Мы не будем рассматривать принципы работы каждого из алгоритмов поиска соседей, так как, алгоритм поиска как таковой не влияет на качество модели, а только на скорость ее работы, и, как правило, дата-сайентисты используют алгоритм поиска по умолчанию (на выбор scikit-learn), не задумываясь над тем как именно происходит поиск под капотом.

Желающим детальнее ознакомиться с каждым из алгоритмов предлагаю воспользоваться доп. источниками:
* [Документация scikit-learn по kNN](https://scikit-learn.org/stable/modules/neighbors.html#nearest-neighbor-algorithms)
* [Учебник по машинному обучению от Яндекса](https://education.yandex.ru/handbook/ml/article/metricheskiye-metody)
* [Статья с примерами применения K-d- и Ball- деревьев](https://www.geeksforgeeks.org/ball-tree-and-kd-tree-algorithms/)
