96 KiB
status, type, tags, created, updated, title
| status | type | tags | created | updated | title | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| processing | concept |
|
2026-02-26 | 2026-05-07 | Метрические алгоритмы |
Метрические алгоритмы
Краткая справка о библиотеке Scikit-learn
Scikit-learn - это библиотека машинного обучения с открытым исходным кодом для языка программирования Python. Она предоставляет простые и эффективные инструменты для предобработки данных, машинного обучения и анализа данных.
Особенности:
- Scikit-learn является одной из самых популярных библиотек машинного обучения в мире и используется во многих приложениях, содержащих модели машинного обучения.
- Библиотека проста в использовании и имеет хорошо документированный API.
- Scikit-learn предоставляет широкий набор алгоритмов машинного обучения, включая классификацию, регрессию, кластеризацию и снижение размерности.
- В библиотеку также включены функции для предобработки данных, необходимой перед подачей данных в модель
- Scikit-learn в основном направлен на работу именно с классическим ML, хотя в ней поддерживается и создание простейших нейронных сетей. Однако использование ее для задач глубокого обучения не рекомендуется, для этого есть специализированные инструменты (Tensorflow и PyTorch)
Идея метрических алгоритмов
Идею метрических алгоритмов можно сравнить с простой философской идеей "мы - это наше окружение" или "скажи мне, кто твой друг, и я скажу, кто ты".
Бытовые примеры:
-
Хотим продать квартиру, но не знаем ее цену. Для этого в интернете находим 10 похожих по характеристикам квартир, усредняем их цены и получаем примерную стоимость.
-
Есть 3000 текстов разбитые по тематикам (художественная литература, спорт, политика и т.д.), хотим определить тематику текста, не читая его. Найдем 15 наиболее похожих текстов, посмотрим на их тематики и присвоим новому тексту ту тематику, которая преобладает.
-
Есть 1000 старых покупателей и 10 новых, хотим предложить новым покупателям то, что покупали старые, похожие на них.
Метрические алгоритмы машинного обучения представляют собой класс методов, основанных на измерении расстояний между объектами в пространстве признаков.
Принцип работы метрических алгоритмов в случае обучения с учителем заключается в том, что значение таргета для объекта определяется путем анализа его ближайших по расстоянию соседей из обучающей выборки. Расстояние между объектами измеряется с использованием различных метрик.
Особенность метрических алгоритмов заключается в том, что они не строят явную модель на основе данных, а основываются на хранении и использовании обучающих примеров для принятия решений. Как такого обучения в алгоритмах не предполагается. Такой подход еще часто называется "ленивым обучением" (lazy learning).
Расстояние между объектами
Ключевая идея методов - использование близости между объектами для принятия решения. Как выразить близость в численном эквиваленте?
Существует множество способов измерить. Наиболее популярные метрики:
В библиотеке scikit-learn (sklearn) сборник функций для вычисления расстояний представлен в модуле pairwise. Самая обобщенная из этих функций - pairwise_distances(). Данная функция принимает векторы или матрицы и вычисляет расстояние между ними по переданной в качестве аргумента metric методике.
Рассмотрим несколько наиболее часто используемых метрик:
- Евклидово расстояние ($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}
import numpy as np
from sklearn.metrics.pairwise import pairwise_distances
x = np.array([[0, 1, 1, 1]])
y = np.array([[1, 2, 1, 2]])
pairwise_distances(x, y, metric='euclidean')
- Манхэттенское расстояние ($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}|
x = np.array([[0, 1, 1, 1]])
y = np.array([[1, 2, 1, 2]])
pairwise_distances(x, y, metric='manhattan')
- Расстояние Минковского ($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}}
x = np.array([[0, 1, 1, 1]])
y = np.array([[1, 2, 1, 2]])
pairwise_distances(x, y, metric='minkowski', p=3)
- Косинусное расстояние:
\rho(x_1, x_2) = 1- cos(\hat{(x_1, x_2)}) = 1 - \frac {{x_1} \cdot {x_2}} {||x_1|| \cdot ||x_2||}
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:
Хотим определить, к какому классу отнести красный объект - к желтым или фиолетовым.
Если руководствоваться идеей kNN, то нам нужно:
- Рассчитать расстояние от нашего целевого объекта до всех объектов из выборки.
- Затем определить k ближайших по расстоянию объектов (соседей), которые мы будем использовать для определения класса.
- Среди выбранных посчитать количество объектов каждого из классов и определить класс большинства.
Вот и весь алгоритм.
При этом, как видно, от выбора количества соседей (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 нужно:
-
Вычислить расстояние между объектом
x_tи каждым объектомx_iв обучающей выборке:\rho(x_t, x_i), ∀x_i ∈ X -
Выбрать
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. -
Присвоить новому объекту класс, как наиболее часто встречающийся класс среди объектов из
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, мы должны:
- Вычислить расстояние между новым объектом и каждым объектом в обучающей выборке:
\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
- Выбрать
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\}
-
Присвоить новому объекту класс, который встречается чаще всего среди выбранных
kобъектов.\hat{y_t} = a(x_t) = \arg \max_{y \in Y} \sum_{i=1}^{k}\mathbb{I}[y_i = y]Считаем сколько объектов каждого класса среди соседей. Объектам
Для класса 1 (A):x_4, x_5иx_6из обучающей выборки сооветствуют свои классыy_4,y_5иy_6.\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.
То есть для объекта 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
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 классов.
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')
X.head()
y.head()
Визуализируем данные на плоскости:
fig = px.scatter(
x=X.iloc[:, 0], y=X.iloc[:, 1],
color=y, opacity=1,
height=400, width=700
)
fig.show()
Нужно предсказать класс для следующего объекта:
x = np.array([[0, 0.1]])
x
В качестве алгоритма для классификации будем использовать kNN с 5-мя соседями.
В библиотеке sklearn алгоритмы, основанные на использовании соседей реализованы в модуле neighbors.
Метод k-ближайших соседей для классификации реализован в виде класса KNeighborsClassifier.
Основные параметры алгоритма:
-
n_neighbors: количество ближайших соседей, которое будет использоваться для классификации. -
metric: метрика расстояния, используемая для измерения расстояния между объектами. Список возможных значений. Примеры:'minkowski'- расстояние Минковского (используется по умолчанию)'cosine'- косинусное расстояние'euclidean'- евклидово расстояние'manhattan'- расстояния Манхэттена
-
p: норма, используемая для измерения расстояния между объектами (по умолчанию p=2) -
n_jobs: количество ядер процессора, используемых для вычислений (чтобы использовать все доступные мощности процессора нужно установить параметр в -1).
from sklearn.neighbors import KNeighborsClassifier
Создаем объект kNN:
knn_clf = KNeighborsClassifier(n_neighbors=5, metric='minkowski', p=2)
knn_clf
Для обучения алгоритмов в scikit-learn используется метод fit(). Для kNN как такового обучения нет, поэтому данный метод просто сохраняет обучающую выборку в атрибуты объекта kNN.
knn_clf.fit(X.values, y.values)
Предсказать класс объекта с помощью функции predict().
Важно: в метод передаются двумерные данные размера n \times m, даже если это один объект, то его нужно передать в виде матрицы с одной строкой.
knn_clf.predict(x)
Вероятности принадлежности к классам можно определить с помощью метода predict_proba()
knn_clf.predict_proba(x)
Разделяющая Поверхность kNN
Напомним, что геометрически решить задачу классификации - это построить разделяющую поверхность в пространстве признаков.
Разделяющая поверхность (decision bound) - это плоскость/гиперплоскость (-и) в прострастранстве признаков, которая(-ые) отделяет классы друг от друга.
kNN также строит разделяющую поверхность. Форма разделяющей поверхности зависит от параметров алгоритма (количество соседей, метрика расстояния и т.д.)
Примеры для простейшего случая двух признаков с разным количеством соседей:
Напишем функцию для визуализации разделяющей поверхности:
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
plot_decision_bound_classification(X, y, knn_clf)
В данном случае разделяющей поверхностью будет линия, где вероятность принадлежности к классу 0 равна 0.5, проходя через эту линию цвет области меняется с оттенков красного на оттенки синего.
Примечание:
Кстати, в scikit-learn есть функция DecisionBoundaryDisplay для построения разделяющей поверхности. Только она не интерактивная(
Оценка Качества Классификации
Вычислим качество полученной модели с помощью метрики accuracy.
Напомним, данная метрика показывает для скольких объектов класс был определен верно (в доле от общего числа данных в выборке):
accuracy = \frac{1}{n}\sum_{i=1}^n{\mathbb{I}[\hat {y_i} = y_i]}
Для вычисления качества модели можно использовать метод score(). По умолчанию для всех классификаторов из sklearn данный метод вычисляет именно метрику accuracy. В метод нужно передать матрицу наблюдений, для которых нужно сделать прогноз и вектор правильных ответов.
Модель предскажет классы для каждого объекта и рассчитает значение метрики:
knn_clf.score(X.values, y.values)
Однако, как правило, метрики качества считают с помощью специализированных функций.
Все функции для вычисления метрик в scikit-learn представлены в модуле metrics.
Для вычисления метрики accuracy используется функция accuracy_score.
В данную функцию нужно передать следующие аргументы:
y_true- массив с корректными метками (классами)y_pred- массив с предсказаниями модели
Рассчитаем значение метрики accuracy:
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ое слагаемое в вычислении расстояния) очень слабо учитывалось при вычислениях.
Для иллюстрации необходимости масштабирования рассмотрим следующий пример:
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')
X.head()
X.describe()
Обучим алгоритм, сделаем предсказание, вычислим метрику accuracy и визуализируем разделяющую поверхность:
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 нормализация: Min-max нормализация — метод преобразования входных признаков, при котором значения признаков приводятся к масштабу [0,...,1]:
x_{scaled} = \frac{x - x_{min}}{x_{max} - x_{min}}
x_{min} - минимальное значение признака
x_{max} - максимальное значение признака
Например, температура в горном посёлке за день может меняться от 10 до 35 градусов. Текущая температура составляет 17 градусов. Тогда нормализованное значение будет:
x_{scaled} = \frac{17 - 10}{35 - 10} = 0.28
- Стандартизация: Стандартизация — метод преобразования входных признаков, при котором распределение признака меняется таким образом, чтобы среднее значение равнялось 0, а стандартное отклонение — 1:
x_{scaled} = \frac{x - \mu}{\sigma}
\mu - среднее значение признака x
\sigma - стандартное отклонение признака x
Например, у нас есть числовой ряд [1, 2, 3, 4, 5]. Среднее ряда: 3. Стандартное отклонение — 1.4. Стандартизируем число 4.
x_{scaled} = \frac{4 - 3}{1.4} = 0.7
Процесс стандартизации можно описать как центрирование данных с масштабированием. Сначала происходит вычитание среднего значения из всех данных — центрирование, а затем деление на отклонение.
- Робастное масштабирование:
x_{scaled} = \frac{x - Q_{0.5}}{Q_{0.75} - Q_{0.25}}=\frac{x - x_{median}}{IQR}
Q_{0.5} - 50-ая квантиль (медиана) признака $x$
Q_{0.75} - 75-ая квантиль признака $x$
Q_{0.25} - 25-ая квантиль признака $x$
IQR= Q_{0.75} - Q_{0.25} - межквартильный размах
Например, имеется числовой ряд [1, 2, 3, 4, 5]. Медиана ряда — 3. Межквартильный размах: 4-2=2. Мы хотим нормализовать число 4.
x_{scaled} = \frac{4 - 3}{2} = 0.5
Подробнее про методы масштабирования, их преимущества и недостатки здесь
Возвращаясь к примеру:
| № | Признак 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 - min-max нормализация
- StandardScaler - стандартизация
- RobustScaler - робастое масштабирование
from sklearn.preprocessing import MinMaxScaler, StandardScaler, RobustScaler
-
С помощью метода
fit()происходит расчёт параметров масштабирования. -
С помощью метода
transform()выполняется трансформация (масштабирование) данных. -
С помощью метода
fit_transform()выполняется и то и другое.
Добавим шаг с масштабированием в процесс предобработки данных:
# создаем объект для масштабирования
scaler = MinMaxScaler()
# вычисляем необходимые параметры масштабирования
scaler.fit(X)
# выполняем масштабирование
X_sc = scaler.transform(X)
X_sc = pd.DataFrame(X_sc, columns=X.columns)
X_sc.head()
X_sc.describe()
Обучим алгоритм еще раз, но уже на масштабированных данных:
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)
Кейс: Предсказание Вероятности Наличия Диабета
Рассмотрим как выполняется классификация на датасете, приближенному к реальному кейсу.
В качестве кейса возьмем простую задачу предсказания наличия диабета.
Этот датасет первоначально получен в Национальном институте диабета, болезней органов пищеварения и почек для того, чтобы на основе определённых диагностических измерений предсказать, есть ли у пациента диабет.
На выбор этих экземпляров из более крупной базы данных было наложено несколько ограничений. В частности, все пациенты здесь — женщины не моложе 21 года индейского происхождения племени Пима.
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
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 — да).
diabetes_data.shape
Проверим соотношения классов в данных:
px.pie(
data_frame=diabetes_data,
names='Outcome',
hole=0.2,
height=300,
title='Распределение классов в данных'
)
diabetes_data.info()
diabetes_data.describe()
diabetes_data.describe(include='object')
Из результатов метода describe() и базовой логики делаем следующие вывод:
Нулевые значения в столбцах 'Glucose', 'BloodPressure', 'SkinThickness','Insulin', 'BMI' - это скрытые пропущенные значения.
Признак Gender представлен только одной единственной категорией (Female), он является неинформативным
Заменим нули в этих столбцах на пропуски:
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()
Избавимся от дубликатов:
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]))
Заполним пропуски в данных:
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()
}
)
Удалим неинформативные признаки, не имеющие значения для построения прогноза:
diabetes_data_clf = diabetes_data_clf.drop('Gender', axis=1)
Разделим набор данных на:
- Матрицу наблюдений X, состоящую из признаков объектов
- Вектор-столбец целевой переменной y
X = diabetes_data_clf.drop('Outcome', axis=1)
y = diabetes_data_clf['Outcome']
В целях диагностирования наличия переобучения модели воспользуемся простейшим методом валидации.
Разделим всю выборку на тренировочную и тестовую в соотношении 75/25.
На тренировочной выборке будем обучать модели, а на тестовой проверять их качество.
Для этого воспользуемся функцией train_test_split из модуля model_selection библиотеки scikit-learn.
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))
Проверим, что соотношение классов в обеих выборках одинаково и равно изначальному:
y.value_counts(normalize=True)
y_train.value_counts(normalize=True)
y_test.value_counts(normalize=True)
Масштабируем данные.
Важно: для предотвращения учетки данных параметры масштабирования всегда подбираются на обучающей выборке, и эти параметры применяются как для масштабирования обучающей, так и тестовой выборок.
То есть:
- fit_transform() - на train
- transform() - на test
# создаем объект для масштабирования
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()
X_train_sc.describe()
Обучим модель kNN на обучающей выборке.
Сделаем предсказание на обучающей и тестовой выборке и проверим полученное качество модели по метрике accuracy:
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)))
y_test_hat
Если нам необходимо выводить вероятности наличия диабета, то можно рассчитать и их:
y_test_proba_hat = knn_clf.predict_proba(X_test_sc)
y_test_proba_hat.shape
Выполним подбор гиперпараметра модели - количества соседей.
Для этого воспользуемся простейшим способом подбора параметров на тестовой выборке.
Для этого:
- обучим алгоритм с разным количеством соседей на обучающей выборке
- рассчитаем метрику accuracy на обучающей и тестовой выборках
- выберем то количество соседей, при которой качество на тестовой выборке наилучшее
np.arange(2, 25)
# создаем массив с количеством соседей, которое будет подбирать
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-ью соседями:
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-объект (обученную модель) и объект-файл, в который будет сохранена модель.
import pickle
# открываем файл на запись
with open('diabet_knn_classifier.pkl', 'wb+') as f:
# сохраняем обученную модель kNN
pickle.dump(knn_clf, f)
Импортировать модель из файла можно с помощью функции load:
# открываем файл на чтение
with open('diabet_knn_classifier.pkl', 'rb+') as f:
# сохраняем обученную модель kNN
loaded_clf = pickle.load(f)
loaded_clf
loaded_clf.predict(X_test_sc)
Алгоритм kNN Для Регрессии
Общая Идея Алгоритма
Для того, чтобы адаптировать метод для решения регрессии нам нужно лишь ввести парочку модификаций.
Если в случае задачи классификации мы определяли класс объекта путем мажоритарного голосования среди его k-ближайших соседей, то в случае регрессии - нам нужно предсказать не класс, а число.
Идея! Давайте просто вычислять среднее (ну или медиану) по таргету всех ближайших соседей и выдавать результат в качестве предсказания модели.
Формализация Алгоритма
Ну а теперь формализуем это в виде алгоритма.
Пусть дана выборка объектов и их ответов к ним:
\{(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 нужно:
-
Вычислить расстояние между объектом
x_tи каждым объектомx_iв обучающей выборке:\rho(x_t, x_i), ∀x_i ∈ X -
Выбрать
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. -
Вычислить прогноз как среднее значение таргета среди найденных соседей$:
\hat{y_t}(x_t) = \frac{1}{k} \sum_{i=1}^k y_i
Пример На Конкретных Цифрах
| Объект | Признак 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, мы:
- Вычисляем расстояние между новым объектом и каждым объектом в обучающей выборке:
\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
- Выбираем
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\}
- Вычисляем предсказание для объекта среднее по таргету 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
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 классов.
# Генерируем данные
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)
X.head()
y.head()
Визуализируем данные:
fig = px.scatter(
x=X.iloc[:, 0], y=y,
height=400, width=700
)
fig.show()
Хотим предсказать значение таргета для следующего объекта:
x = np.array([[1.5]])
Метод k-ближайших соседей для регрессии реализован в виде класса KNeighborsRegressor.
Основные параметры алгоритма:
-
n_neighbors: количество ближайших соседей, которое будет использоваться для регрессии. -
metric: метрика расстояния, используемая для измерения расстояния между объектами. Список возможных значений. Примеры:'minkowski'- расстояние Минковского (используется по умолчанию)'cosine'- косинусное расстояние'euclidean'- евклидово расстояние'manhattan'- расстояния Манхэттена
-
p: норма, используемая для измерения расстояния между объектами (по умолчанию p=2) -
n_jobs: количество ядер процессора, используемых для вычислений (чтобы использовать все доступные мощности процессора нужно установить параметр в -1).
from sklearn.neighbors import KNeighborsRegressor
Обучим модель kNN c 10-ью соседями:
knn_reg = KNeighborsRegressor(n_neighbors=10, metric='minkowski', p=2)
knn_reg.fit(X.values, y.values)
Предскажем значение таргета для нового объекта:
y_pred = knn_reg.predict(x)
y_pred
Построим аналог разделяющей поверхности для регрессии - регрессионная кривая.
Для этого сгенерируем новые данные от минимума до максимума в обущей выборке, сделаем предсказание для этих значений x и визуализируем результат в виде кривой.
Проследим за тем, как меняется кривая в зависимости от количества соседей.
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}
где
{y_i}- истинное значение таргета для $i$-ого объекта\hat{y_i}- прогноз для $i$-ого объектаy_{mean}- среднее значение по таргету
Коэффициент детерминации показывает, насколько наша модель лучше, чем если бы все предсказания были средним по правильным ответам.
Если на математическом, то данная метрика измеряет, какую долю дисперсии (вариативности) целевой переменной смогла уловить наша модель.
Данная метрика измеряется от -∞ до 1.
Удовлетворительным R^2 считается показатель выше 0.5: чем ближе к 1, тем лучше. Отрицательные значения говорят о том, что построенная модель настолько плоха, что лучше было бы присвоить всем ответам среднее значение.
print('R^2-score: {:.2f}'.format(knn_reg.score(X.values, y.values)))
Как правило, метрики рассчитываются с помощью специализированных функций из модуля metrics. Поступим так же.
Сделаем предсказание для всего датасета:
y_pred = knn_reg.predict(X.values)
Рассчитаем коэффициент детерминации (R^2) с помощью функции r2_score из модуля metrics:
from sklearn.metrics import r2_score
print('R^2-score: {:.2f}'.format(r2_score(y, y_pred)))
Коэффициент детерминации полезная метрика, однако она обладает непонятной для бизнеса интерпретации и ничего не говорит о том, какова точность нашего прогноза.
Гораздо большей интерпретацией обладают метрики MAE и MAPE
-
Средняя абсолютная ошибка — MAE (Mean Absolute Error)
Данная метрика показывает, насколько в среднем наша модель ошибается и вычисляется как среднее арифметическое модуля отклонения предсказанного значения от реального:
MAE = \frac{1}{n} \sum_{i=1}^n |y_i - \hat{y_i}|Чем меньше значение метрики, тем лучше.
Однако, это метрика не дает понимания о том, насколько наша ошибка велика. Например, в результате построения модели по предсказанию цены недвижимости MAE составила 3.5 тыс. долларов. Много ли это?
Без эксперта-оценщика недвижимости будет сложно дать ответ. Однако можно попробовать посчитать ошибку в процентах, ведь в процентах всё воспринимается легче, и для этого нам пригодится следующая метрика —
MAPE. -
Средняя абсолютная ошибка в процентах — Map (Mean Absolute Percent Error)
Данная метрика показывает, на сколько процентов в среднем наше предсказание отклоняется от реального значения:
MAPE = \frac{100 \\%}{n} \sum_{i=1}^n \frac{|y_i - \hat{y_i}|}{|y_i|}Чем меньше значение метрики, тем лучше.
Примечание: в
sklearnданная метрика считается не в процентах, а в долях, то есть результат еще дополнительно нужно умножить на100\\%.
Рассчитаем метрики MAE и Map:
from sklearn.metrics import mean_absolute_error, mean_absolute_percentage_error
print('MAE: {:.2f}'.format(mean_absolute_error(y, y_pred)))
print('MAPE: {:.2f}'.format(mean_absolute_percentage_error(y, y_pred) * 100))
Заполнение Пропусков В Данных С Помощью kNN
Широкое применение регрессионный kNN нашел в задачах восстановления пропущенных данных.
Рассмотрим задачу заполнения пропусков на примере датасета о диабете:
diabetes_data = pd.read_csv('https://raw.githubusercontent.com/merion-networks/data-science-course/refs/heads/main/diabetes_data.csv')
diabetes_data.head()
Предобработаем данные вновь:
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.
Данный класс позволяет выполнять трансформацию данных по заполнению пропусков, причем недостающие значения для каждой выборки вычисляются с использованием среднего значения из k ближайших соседей, найденных в обучающем наборе данных.
Аргументы данного класса:
- missing_values - какое значение считается пропуском, по умолчанию параметр равен
nan, но можно указать любое другое значение (например, 0 как это было в нашем примере) - n_neighbors - количество соседей, используемых для заполнения
- metric - метрика расстояния, по умолчанию используется метрика
nan_euclidean, она позволяет вычислять расстояние Евклида не обращая внимания на пропуски в столбцах. Рекомендуется не менять ее значение.
Основные методы:
- fit() - подгонка (по сути ничего не делает кроме сохранения выборки в память)
- transform() - заполняет пропущенные значения в данных и возвращает результат в виде numpy-массива
- fit_transform() - fit + transform
Пример из документации:
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 сам понимает, что является таргетом - это все будут все столбцы в таблице, содержащие пропуски.
Однако, не забудем предварительно масштабировать данные, чтобы результат получился как можно точнее:
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()
# создаем объект для заполнения
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:
X_inputed = scaler.inverse_transform(X_inputed_sc)
diabetes_data_inputed = pd.DataFrame(X_inputed, columns=diabetes_data.columns)
diabetes_data_inputed.head()
Проверяем наличие пропусков в данных:
diabetes_data_inputed.isna().sum()
Уже на этих предобработанных данных можно решать другие задачи, например, можно проклассифицировать пациентов еще раз и проверить, улучшилось ли качество.
Выполним предобработку данных аналогично тому как мы делали это ранее:
# выделяем матрицу наблюдений 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 аналогично тому как мы делали это в прошлый раз.
# создаем массив с количеством соседей, которое будет подбирать
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 у нас наблюдается наилучшее качество на тестовой выборке.
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%! Что очень даже неплохо!
Сохраняем модель:
# открываем файл на запись
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})
Ниже представлены примеры того как выглядят графики разделяющих поверхностей и регрессионных моделей при использовании обоих вариантов параметров.
Для классификации (код примера):
Для регрессии (код примера):
Однако, есть и более продвинутые способы определять веса объектов на основе расстояния. Можно задавать веса на основе некоторой функции, зависящей от расстояния между объектами.
Ядерная функция (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.3989w_2 = K(\rho_2) = \frac{1}{\sqrt{2\pi}}e^{-\frac{1.2^2}{2}} = 0.1814w_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 существенно влияет на качество модели и может быть использована в качестве гиперпараметра алгоритма и ее можно подбирать, также как и количество соседей.
При слишком маленькой ширине модель сильно подстраивается под обучающую выборку, теряет свою обобщающую способность и рискует переобучиться. При слишком большой ширине наблюдается обратная ситуация - модель рискует недообучиться. Универсальной ширины окна не существует, поэтому для каждой задачи её приходится подбирать отдельно.
На рисунке ниже приведены примеры того как ядерная функция и ширина окна влияют на результаты регрессии:
В scikit-learn подход с использованием ядерной функции не реализован в самой библиотеке. Однако, библиотека дает возможность пользователю самостоятельно задать функцию для вычисления весов объектов.
Для этого в качестве параметра weights нужно передать функцию, которая принимает в качестве аргумента расстояния между объектами и возвращает веса соседей.
Например, гауссовское ядро с шириной окна h можно задать следующим образом:
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).
А если в датасете миллионы строк? Десятки миллионов? Это получается, что для каждого объекта из обучающей выборки нам нужно будет выполнить цикл как минимум с миллионом итераций! Сложность такого алгоритма O(n).
Но тут еще стоит учесть, что каждый объект из выборки - это вектор с m координатами. То есть цикл будет вложенный - сначала по объектам, потом по их признакам. Тогда, если в датасете миллион объектов и каждый из них описывается 100 признаками, то нам понадобится 100 миллионов операций на поиск соседей только для одного нового объекта. Сложность такого алгоритма будет O(nm).
Проблема осложняется ещё и тем, что данный поиск необходимо выполнять на этапе применения модели, который должен быть быстрым. Пользователь вашего приложения по оценке недвижимости не будет ждать пока вы в своей базе данных вы найдете похожие дома и посчитаете прогноз.
Таким образом, возникает необходимость в более быстрых методах поиска ближайших соседей, чем простой перебор.
Для этого были разработаны методы для ускорения поиска ближайших соседей. Это методы:
- K-d-деревья (k-dimensional tree)
- Ball-деревья (ball tree)
- Random projection trees
- Locality-sensitive hashing (LSH)
- Proximity graphs & Hierarchical navigable small world (HNSW)
В реализации kNN в библиотеке scikit-learn реализованы несколько подходов к поиску соседей. Способ поиска определяется параметром algorithm. Его возможные значения:
'brute'- поиск полным перебором всей обучающей выборки'kd_tree'- поиск с помощью k-d-деревьев'ball_tree'- поиск с помощью ball-деревьев- '
auto'- автоматическое определение алгоритма поиска на основе датасета.
По умолчанию параметр algorithm установлен в значение 'auto', то есть модель сама выбирает, какой метод поиска соседей ей использовать для данного набора данных. В документации scikit-learn по kNN приведены условия, когда используется тот иной алгоритм поиска, приводить их здесь не имеет смысла.
Мы не будем рассматривать принципы работы каждого из алгоритмов поиска соседей, так как, алгоритм поиска как таковой не влияет на качество модели, а только на скорость ее работы, и, как правило, дата-сайентисты используют алгоритм поиска по умолчанию (на выбор scikit-learn), не задумываясь над тем как именно происходит поиск под капотом.
Желающим детальнее ознакомиться с каждым из алгоритмов предлагаю воспользоваться доп. источниками:
- Документация scikit-learn по kNN
- Учебник по машинному обучению от Яндекса
- Статья с примерами применения K-d- и Ball- деревьев
Связанные заметки
- Линейные модели — контраст с параметрическими моделями: 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