ЛР2
Предобработка и kNN

Раздел 3 · 2 балла

Предобработка и kNN

У 24 клиентов не указан доход. Мы заполним его средним доходом трёх самых похожих на них клиентов. Для этого надо превратить клиента в набор чисел и уметь мерить, насколько два клиента похожи.

На пальцах. В анкете нового соседа не написана зарплата. Ты находишь трёх людей, максимально похожих на него (возраст, семья, образование, траты), и считаешь, что его зарплата примерно как среднее у этих троих. Это и есть kNN.
kNN
k Nearest Neighbours, «k ближайших соседей»: ответ для объекта берём у k самых похожих объектов. У нас k = 3.
Признак
Одно свойство клиента, один столбец таблицы.
Вектор
Клиент, записанный как список чисел: [год рождения, дети, траты на вино, ...].
Евклидово расстояние
Обычное расстояние «по линейке» между двумя такими списками.

Как мерить расстояние

Идея из школьной геометрии: на плоскости расстояние между точками считают по теореме Пифагора. Разница по одной оси и разница по другой оси это катеты, а расстояние это гипотенуза. Для 30 признаков всё то же самое, только катетов 30.

расстояние = √( (a₁ − b₁)² + (a₂ − b₂)² + ... )

Пример на двух признаках, клиенты 5524 и 2174 (дети и дни с последней покупки):

kidhome: 0 и 1, recency: 58 и 38 расстояние = √( (0 − 1)² + (58 − 38)² ) = √(1 + 400) = √401 ≈ 20.0

Видно проблему: разница в детях дала 1, а разница в днях дала 400. Дни «перекричали» детей только потому, что они измеряются большими числами. Поэтому данные сначала приводят к одному масштабу.

Предобработка: 4 шага

ШагЧто делаемЗачем
1. Удалить idВыбрасываем номер клиентаНомер ничего не говорит о человеке. Иначе клиенты №5 и №6 считались бы похожими
2. One-hoteducation и marital_status превращаем в столбцы из 0 и 1Расстояние считается только по числам. Номера 0, 1, 2 дали бы ложный порядок
3. Дата → min-maxdt_customer → число дней от первой даты → отрезок [0, 1]Дату-строку нельзя вычитать, а число дней можно
4. z-scalingВсе столбцы, кроме income, приводим к среднему 0 и разбросу 1Чтобы большие числа не заглушали маленькие

One-hot на примере

Было: один столбец education со словами. Стало: пять столбцов, в каждой строке ровно одна единица.

education_2n Cycle_Basic_Graduation_Master_PhD
PhD00001
Graduation00100

Если бы мы закодировали Basic = 0, ..., PhD = 4, то PhD оказался бы «в 4 раза дальше» от Basic, чем Graduation. One-hot делает все категории одинаково далёкими друг от друга.

Min-max на примере

Идея: самую раннюю дату делаем 0, самую позднюю 1, остальные пропорционально между ними.

x' = (x − min) / (max − min) Первая дата 30.07.2012, последняя через 699 дней. Клиент 5524 зарегистрирован 04.09.2012, через 36 дней: x' = (36 − 0) / (699 − 0) ≈ 0.05

z-scaling на примере

Идея: показываем, насколько клиент выше или ниже среднего, в «типичных отклонениях». 0 = ровно средний, +1 = выше среднего на одно типичное отклонение.

z = (x − среднее) / стандартное отклонение recency клиента 5524: (58 − 49.1) / 29.0 ≈ 0.31
Стандартное отклонение
Насколько в среднем значения отходят от среднего. Мера разброса.
На пальцах. Нельзя честно сравнить рост в метрах и вес в граммах: граммы всегда «больше». z-scaling переводит всё в одну шкалу: «насколько ты выше среднего».
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])
  1. df это уже склеенная таблица из всех трёх (её склеил сам ноутбук ячейкой выше).
  2. df.drop(columns='id') удаляем столбец номера.
  3. pd.get_dummies(..., dtype=int) one-hot; dtype=int чтобы были 0/1, а не True/False.
  4. pd.to_datetime(..., format='%d-%m-%Y') превращаем текст «04-09-2012» в дату: день-месяц-год.
  5. (dates - dates.min()).dt.days сколько дней прошло от самой ранней даты.
  6. MinMaxScaler().fit_transform(...) fit запоминает min и max, transform пересчитывает по формуле. Двойные скобки [['dt_customer']] потому, что sklearn ждёт таблицу, а не один столбец.
  7. df.columns.drop('income') все столбцы, кроме дохода.
  8. StandardScaler().fit_transform(...) z-scaling этих столбцов.
Почему income не трогаем. Его мы восстанавливаем. По нему не ищут соседей (у 24 клиентов его и нет), а усреднять соседей удобнее в настоящих рублях. Ноутбук масштабирует 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
  1. __init__ вызывается при создании: запоминаем, сколько соседей брать (3).
  2. fit «обучение». kNN ничего не вычисляет заранее, он просто запоминает 2216 клиентов с известным доходом.
  3. np.asarray(X, dtype=float) превращаем таблицу pandas в массив чисел numpy, с ним считать быстрее.
  4. distance.cdist(A, B) таблица всех расстояний: 24 строки (клиенты без дохода) × 2216 столбцов (с доходом).
  5. np.argsort(..., axis=1) в каждой строке выдаёт номера столбцов от ближайшего к дальнему. Не сами расстояния, а номера!
  6. [:, :self.n_neighbors] «все строки, первые 3 столбца» = номера трёх ближайших.
  7. np.take_along_axis(...) по этим номерам достаём сами расстояния.
  8. return distances, indices возвращаем в том же виде, что и sklearn: две таблицы 24 × 3.
Про axis. 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())
  1. df.dropna() клиенты без пропусков, та же выборка, по которой искали соседей. indices это номера строк именно в ней.
  2. .values[indices] по таблице номеров 24 × 3 достаём доходы: получается таблица доходов 24 × 3.
  3. .mean(axis=1) среднее в каждой строке: 24 числа, по одному на клиента.
  4. df.loc[маска пропусков, 'income'] = ... кладём эти числа ровно в пустые ячейки, по порядку.
  5. Печать: Пропусков в income осталось: 0.

Вопросы на защите

Зачем нормализовать перед kNN?

Евклидово расстояние складывает квадраты разниц. Признак с большими числами (траты на вино до 1493) заглушил бы признаки с маленькими (жалоба 0 или 1).

Зачем one-hot, а не просто номера категорий?

Номера создают ложный порядок и ложные расстояния. One-hot делает любые две разные категории одинаково далёкими.

Что делает fit у kNN?

Только запоминает данные. Вся работа (расстояния, поиск соседей) идёт в момент предсказания. Поэтому kNN называют «ленивым» алгоритмом.

Чем kNN лучше заполнения средним?

Средним все 24 клиента получили бы одинаковый доход. kNN даёт каждому свой доход, похожий на доход людей с похожей жизнью.

Почему сравнение с sklearn через сортировку?

Важен набор соседей, а не порядок. После сортировки совпадение проверяется честно.

Одна фразаПеревёл клиентов в числа (one-hot, min-max для даты, z-scaling), написал kNN через cdist и argsort, он совпал с sklearn, и заполнил 24 дохода средним по трём ближайшим соседям.