Раздел 3 · 2 балла
Предобработка и kNN
У 24 клиентов не указан доход. Мы заполним его средним доходом трёх самых похожих на них клиентов. Для этого надо превратить клиента в набор чисел и уметь мерить, насколько два клиента похожи.
- kNN
- k Nearest Neighbours, «k ближайших соседей»: ответ для объекта берём у k самых похожих объектов. У нас k = 3.
- Признак
- Одно свойство клиента, один столбец таблицы.
- Вектор
- Клиент, записанный как список чисел: [год рождения, дети, траты на вино, ...].
- Евклидово расстояние
- Обычное расстояние «по линейке» между двумя такими списками.
Как мерить расстояние
Идея из школьной геометрии: на плоскости расстояние между точками считают по теореме Пифагора. Разница по одной оси и разница по другой оси это катеты, а расстояние это гипотенуза. Для 30 признаков всё то же самое, только катетов 30.
Пример на двух признаках, клиенты 5524 и 2174 (дети и дни с последней покупки):
Видно проблему: разница в детях дала 1, а разница в днях дала 400. Дни «перекричали» детей только потому, что они измеряются большими числами. Поэтому данные сначала приводят к одному масштабу.
Предобработка: 4 шага
| Шаг | Что делаем | Зачем |
|---|---|---|
1. Удалить id | Выбрасываем номер клиента | Номер ничего не говорит о человеке. Иначе клиенты №5 и №6 считались бы похожими |
| 2. One-hot | education и marital_status превращаем в столбцы из 0 и 1 | Расстояние считается только по числам. Номера 0, 1, 2 дали бы ложный порядок |
| 3. Дата → min-max | dt_customer → число дней от первой даты → отрезок [0, 1] | Дату-строку нельзя вычитать, а число дней можно |
| 4. z-scaling | Все столбцы, кроме income, приводим к среднему 0 и разбросу 1 | Чтобы большие числа не заглушали маленькие |
One-hot на примере
Было: один столбец education со словами. Стало: пять столбцов, в каждой строке ровно одна единица.
| education | _2n Cycle | _Basic | _Graduation | _Master | _PhD |
|---|---|---|---|---|---|
| PhD | 0 | 0 | 0 | 0 | 1 |
| Graduation | 0 | 0 | 1 | 0 | 0 |
Если бы мы закодировали Basic = 0, ..., PhD = 4, то PhD оказался бы «в 4 раза дальше» от Basic, чем Graduation. One-hot делает все категории одинаково далёкими друг от друга.
Min-max на примере
Идея: самую раннюю дату делаем 0, самую позднюю 1, остальные пропорционально между ними.
z-scaling на примере
Идея: показываем, насколько клиент выше или ниже среднего, в «типичных отклонениях». 0 = ровно средний, +1 = выше среднего на одно типичное отклонение.
- Стандартное отклонение
- Насколько в среднем значения отходят от среднего. Мера разброса.
from sklearn.preprocessing import MinMaxScaler, StandardScaler
df = df.drop(columns='id')
df = pd.get_dummies(df, columns=['education', 'marital_status'], dtype=int)
dates = pd.to_datetime(df['dt_customer'], format='%d-%m-%Y')
df['dt_customer'] = (dates - dates.min()).dt.days
df[['dt_customer']] = MinMaxScaler().fit_transform(df[['dt_customer']])
feature_cols = df.columns.drop('income')
df[feature_cols] = StandardScaler().fit_transform(df[feature_cols])
dfэто уже склеенная таблица из всех трёх (её склеил сам ноутбук ячейкой выше).df.drop(columns='id')удаляем столбец номера.pd.get_dummies(..., dtype=int)one-hot;dtype=intчтобы были 0/1, а не True/False.pd.to_datetime(..., format='%d-%m-%Y')превращаем текст «04-09-2012» в дату: день-месяц-год.(dates - dates.min()).dt.daysсколько дней прошло от самой ранней даты.MinMaxScaler().fit_transform(...)fitзапоминает min и max,transformпересчитывает по формуле. Двойные скобки[['dt_customer']]потому, что sklearn ждёт таблицу, а не один столбец.df.columns.drop('income')все столбцы, кроме дохода.StandardScaler().fit_transform(...)z-scaling этих столбцов.
income позже, отдельной ячейкой.Своя kNN
В ноутбуке дан пустой класс CustomKNeighborsRegressor. Его надо дописать так, чтобы он давал тот же результат, что готовый KNeighborsRegressor из sklearn.
- Класс
- Шаблон «объекта» со своими данными и функциями (методами). Здесь методы
fitиkneighbors. - self
- «Этот самый объект».
self.X_train= данные, которые объект запомнил.
import numpy as np
from scipy.spatial import distance
class CustomKNeighborsRegressor:
def __init__(self, n_neighbors=3):
self.n_neighbors = n_neighbors
def fit(self, X, y):
# kNN ничего не обучает: просто запоминает обучающую выборку
self.X_train = np.asarray(X, dtype=float)
self.y_train = np.asarray(y)
return self
def kneighbors(self, X_test):
all_distances = distance.cdist(np.asarray(X_test, dtype=float), self.X_train, metric='euclidean')
indices = np.argsort(all_distances, axis=1)[:, :self.n_neighbors]
distances = np.take_along_axis(all_distances, indices, axis=1)
return distances, indices
__init__вызывается при создании: запоминаем, сколько соседей брать (3).fit«обучение». kNN ничего не вычисляет заранее, он просто запоминает 2216 клиентов с известным доходом.np.asarray(X, dtype=float)превращаем таблицу pandas в массив чисел numpy, с ним считать быстрее.distance.cdist(A, B)таблица всех расстояний: 24 строки (клиенты без дохода) × 2216 столбцов (с доходом).np.argsort(..., axis=1)в каждой строке выдаёт номера столбцов от ближайшего к дальнему. Не сами расстояния, а номера![:, :self.n_neighbors]«все строки, первые 3 столбца» = номера трёх ближайших.np.take_along_axis(...)по этим номерам достаём сами расстояния.return distances, indicesвозвращаем в том же виде, что и sklearn: две таблицы 24 × 3.
axis=1 = «работай внутри каждой строки отдельно». axis=0 = «работай внутри каждого столбца». Здесь у каждого из 24 клиентов своя строка расстояний, поэтому axis=1.Проверка с sklearn
print('Расстояния совпадают:', np.allclose(np.sort(custom_distances, axis=1), np.sort(distances, axis=1)))
print('Соседи совпадают: ', np.array_equal(np.sort(custom_indices, axis=1), np.sort(indices, axis=1)))
Вывод: True и True. Сортируем перед сравнением, потому что условие разрешает соседям идти в другом порядке. allclose вместо ==, потому что дробные числа могут отличаться в 15-м знаке.
Заполняем пропуски
train = df.dropna()
neighbor_incomes = train['income'].values[indices] # доходы 3 соседей: таблица 24 x 3
df.loc[df['income'].isna(), 'income'] = neighbor_incomes.mean(axis=1)
print('Пропусков в income осталось:', df['income'].isna().sum())
df.dropna()клиенты без пропусков, та же выборка, по которой искали соседей.indicesэто номера строк именно в ней..values[indices]по таблице номеров 24 × 3 достаём доходы: получается таблица доходов 24 × 3..mean(axis=1)среднее в каждой строке: 24 числа, по одному на клиента.df.loc[маска пропусков, 'income'] = ...кладём эти числа ровно в пустые ячейки, по порядку.- Печать:
Пропусков в income осталось: 0.
Вопросы на защите
Зачем нормализовать перед kNN?
Евклидово расстояние складывает квадраты разниц. Признак с большими числами (траты на вино до 1493) заглушил бы признаки с маленькими (жалоба 0 или 1).
Зачем one-hot, а не просто номера категорий?
Номера создают ложный порядок и ложные расстояния. One-hot делает любые две разные категории одинаково далёкими.
Что делает fit у kNN?
Только запоминает данные. Вся работа (расстояния, поиск соседей) идёт в момент предсказания. Поэтому kNN называют «ленивым» алгоритмом.
Чем kNN лучше заполнения средним?
Средним все 24 клиента получили бы одинаковый доход. kNN даёт каждому свой доход, похожий на доход людей с похожей жизнью.
Почему сравнение с sklearn через сортировку?
Важен набор соседей, а не порядок. После сортировки совпадение проверяется честно.