1696 lines
96 KiB
Markdown
1696 lines
96 KiB
Markdown
---
|
||
status: processing
|
||
type: concept
|
||
tags:
|
||
- machine-learning
|
||
- metric-methods
|
||
- knn
|
||
- classification
|
||
- regression
|
||
- ml-algorithms
|
||
created: 2026-02-26
|
||
updated: 2026-05-07
|
||
aliases:
|
||
- Метрические алгоритмы
|
||
---
|
||
# Метрические алгоритмы
|
||
|
||
## Краткая справка о библиотеке 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/)
|
||
|
||
## Связанные заметки
|
||
- [[Линейные модели]] — контраст с параметрическими моделями: kNN хранит обучающие примеры и не строит явную линейную функцию
|
||
- [[Нейронные сети]] — другой класс моделей ML; в отличие от kNN, нейросети обучают веса и строят параметрическое отображение $Y=F(X)$
|
||
- [[Расстояние между объектами]] — формулы метрик (Евклид, Манхэттен, Минковский, Махаланобис) идентичны тем, что реализованы в sklearn; Math-нота даёт строгие определения
|
||
- [[Итерационная классификация. Метод K-средних (K-means)]] — K-means тоже классифицирует по минимальному расстоянию $\min \rho_{il}$; unsupervised-аналог kNN
|
||
- [[Наивный Байесовский алгоритм]] — альтернативный метод классификации с учителем из той же обучающей выборки
|
||
- [[Функция принадлежности]] — μ_A: U → [0,1] аналогична вероятности принадлежности классу $\hat{P}_c(x)$ в kNN
|