Глубина
~20 мин

Feature Engineering

Генерация признаков, обработка категорий, текстов и временных рядов.

Feature Engineering — данные важнее модели

Можно взять простую логистическую регрессию с хорошими признаками и обойти глубокую нейросеть на сырых данных. Это не теория — на Kaggle-соревнованиях feature engineering решает 50-80% успеха. Модель умеет находить закономерности, но ты должен дать ей правильный «словарь» для описания мира.

Аналогия: представь, что ты объясняешь другу, как выбрать квартиру. Ты не даёшь ему сырой адрес «ул. Ленина, 42» — ты говоришь: «10 минут до метро, второй этаж, свежий ремонт, тихий район». Каждый из этих признаков — результат трансформации сырых данных в полезную информацию. Feature engineering — это ровно то же самое, но для модели.

Большая картина: от сырых данных к фичам

Feature engineering — это не одно действие, а целый пайплайн между сырыми данными и моделью:

Шаг 1. Сырые данные (Raw Data). Таблица из базы данных: числа, строки, даты, пропуски, JSON-поля. Модель не может с этим работать напрямую. Шаг 2. Очистка (Cleaning). Убираем дубликаты, обрабатываем пропуски, фиксим типы данных. Доход «-999» — это не минус 999 тысяч, а код пропуска. Шаг 3. Трансформация (Transform). Нормализуем числа, кодируем категории, извлекаем фичи из дат и текстов. Создаём новые признаки (например, «возраст аккаунта в днях» из даты регистрации). Шаг 4. Отбор (Selection). Убираем шум — признаки, которые не помогают или мешают. Корреляция, feature importance, L1-регуляризация. Шаг 5. Модель. Чистые, информативные фичи → модель учится быстрее и точнее.

Пайплайн: raw data → clean → transform → select → model
Пайплайн feature engineering: сырые данные → очистка → трансформация → отбор → модель

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

Числовые признаки: нормализация и трансформации

У тебя признак «доход» от 10 000 до 10 000 000, а «возраст» от 18 до 80. Линейная модель будет думать, что доход в тысячу раз важнее — просто потому что числа больше. Нормализация приводит признаки к одному масштабу, чтобы модель оценивала их честно.

StandardScaler — вычитаем среднее, делим на стандартное отклонение. Результат: среднее = 0, σ = 1. Стандартный выбор для линейных моделей и нейросетей.

μ — среднее значение признака, σ — стандартное отклонение. Считаются ТОЛЬКО на train

MinMaxScaler — сжимает значения в диапазон [0, 1]. Полезен, когда нужны ограниченные значения (вероятности, входы нейросети с сигмоидой). Чувствителен к выбросам — один экстремальный объект «сожмёт» все остальные в узкий диапазон.

xₘᵢₙ и xₘₐₓ — минимум и максимум НА ОБУЧАЮЩЕЙ выборке

Логарифм (log transform) — спасение для скошенных распределений. Доход, цены, количество кликов — всё это имеет длинный правый хвост. log(x) «сжимает» хвост, делая распределение ближе к нормальному. Линейные модели работают с таким распределением намного лучше.

Binning (бинаризация) — превращает число в категорию. Возраст → «18-25», «26-35», «36-50», «50+». Зачем? Иногда зависимость нелинейная: доход растёт до 40 лет, потом стабилен. Binning позволяет линейной модели поймать такую зависимость без полиномиальных фичей.

Деревьям нормализация не нужна

Деревья решений (и бустинг) работают с порогами: «доход > 50K?». Им неважен масштаб — порядок значений не меняется. Нормализация нужна только для моделей, основанных на расстояниях или градиентах: линейные модели, SVM, kNN, нейросети.
import numpy as np
from sklearn.preprocessing import StandardScaler, MinMaxScaler

# Данные: доход (тыс. руб), возраст
X_train = np.array([[30, 25], [150, 40], [500, 55], [80, 32]])

# StandardScaler: среднее=0, std=1
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_train)
# Результат: [[-0.96, -1.07], [0.02, 0.47], [2.08, 1.70], [-0.54, -0.29]]

# Log-transform для скошенного "дохода"
X_train_log = X_train.copy().astype(float)
X_train_log[:, 0] = np.log1p(X_train[:, 0])  # log(1+x), чтобы log(0) не упал

Категориальные признаки: от строк к числам

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

One-Hot Encoding — создаём отдельный столбец для каждой категории. Город = «Москва» → [1, 0, 0], «Питер» → [0, 1, 0]. Плюс: нет ложного порядка. Минус: если категорий 10 000 (города России) — получим 10 000 столбцов. Разреженная матрица убивает линейные модели. Когда использовать: линейные модели + мало категорий (< 30-50).

Label Encoding — каждой категории присваиваем число: «Москва» = 0, «Питер» = 1, «Новосибирск» = 2. Просто и компактно, но линейная модель решит, что «Новосибирск (2) > Питер (1)» — а это бессмыслица. Когда использовать: деревья и бустинг (они смотрят на пороги, не на порядок).

Target Encoding — заменяем категорию средним значением целевой переменной для этой категории. Город «Москва» — средний доход 120K, «Томск» — 45K. Мощный метод: одна колонка вместо тысяч, и она несёт реальную информацию о связи с таргетом. Опасность: target leakage. Если считать среднее по всем данным (включая test), модель «подсматривает» ответы. Результат: метрики на валидации отличные, на проде — катастрофа.

Frequency Encoding — заменяем категорию частотой её появления. «Москва» встречается в 30% строк → 0.3. Нет утечки данных, работает как proxy для «популярности». Для городов, товаров, доменов — часто достаточно.

import pandas as pd
from category_encoders import TargetEncoder

# Target encoding — ТОЛЬКО на train!
encoder = TargetEncoder(smoothing=10)  # сглаживание к глобальному среднему
X_train_enc = encoder.fit_transform(X_train[['city']], y_train)
X_val_enc = encoder.transform(X_val[['city']])  # БЕЗ fit!

# Frequency encoding — безопасный и простой
freq = X_train['city'].value_counts(normalize=True)
X_train['city_freq'] = X_train['city'].map(freq)
X_val['city_freq'] = X_val['city'].map(freq).fillna(0)  # новые категории → 0

⚠️ Утечка данных — смертный грех

Любая обработка (скалирование, импутация, target encoding) должна fit-иться только на train. Если вычислишь среднее по всем данным включая test — модель уже «видела» тестовую выборку. Правило: train → fit_transform, val/test → только transform.

Текстовые признаки: от слов к числам

Отзывы, описания товаров, тикеты поддержки — текст несёт огромную информацию, но модель работает с числами. Три основных подхода, от простого к сложному:

Bag of Words (мешок слов) — считаем, сколько раз каждое слово встречается в документе. «Кошка сидела на коврике» → {кошка: 1, сидела: 1, на: 1, коврике: 1}. Порядок слов теряется (поэтому «мешок»), но для классификации тональности часто достаточно. TF-IDF — улучшение BoW. Слово «и» встречается везде — оно неинформативно. TF-IDF умножает частоту слова в документе (TF) на «редкость» в корпусе (IDF). Редкие, но частые в конкретном тексте слова получают высокий вес. Embeddings (Word2Vec, BERT) — каждое слово или предложение → вектор в пространстве, где семантически похожие слова рядом. «Кот» и «кошка» — соседи, «автомобиль» — далеко. Для сложных задач (similarity, QA) — единственный вариант.

from sklearn.feature_extraction.text import TfidfVectorizer

# TF-IDF: важность слова = частота × редкость
tfidf = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
X_tfidf = tfidf.fit_transform(train_texts)  # sparse matrix

# Биграммы (ngram_range=(1,2)) ловят "не нравится" — одно слово "не" бесполезно

Тема текстовых признаков огромна — подробнее в нодах Word Embeddings и BERT. Здесь запомни: для табличных задач с текстовым полем TF-IDF + бустинг — сильный бейзлайн.

Временные признаки: золотая жила из одной колонки

Из одного столбца «дата заказа» можно создать десяток полезных фичей. Временные данные делятся на три группы:

Datetime features — извлекаем компоненты даты: час, день недели, месяц, квартал, «выходной или нет», «рабочее время или нет». Паттерны реальны: люди покупают чаще по вечерам, отток клиентов выше в январе. Lag features — значение признака N периодов назад. Продажи вчера, неделю назад, месяц назад. Лаги ловят инерцию: если продажи росли 3 дня подряд — скорее всего вырастут и сегодня. Rolling statistics — скользящие агрегаты: среднее за 7 дней, максимум за 30 дней, стандартное отклонение за 14 дней. Ловят тренды и волатильность.

import pandas as pd

# Datetime features
df['dow'] = df['date'].dt.dayofweek          # 0=Пн, 6=Вс
df['hour'] = df['date'].dt.hour
df['is_weekend'] = df['dow'].isin([5, 6]).astype(int)
df['month'] = df['date'].dt.month
df['quarter'] = df['date'].dt.quarter

# Lag features (осторожно с утечкой будущего!)
df['sales_lag_1'] = df.groupby('store_id')['sales'].shift(1)
df['sales_lag_7'] = df.groupby('store_id')['sales'].shift(7)

# Rolling statistics
df['sales_roll_7_mean'] = (
    df.groupby('store_id')['sales']
    .transform(lambda x: x.shift(1).rolling(7).mean())  # shift(1) — без текущего дня!
)
df['sales_roll_7_std'] = (
    df.groupby('store_id')['sales']
    .transform(lambda x: x.shift(1).rolling(7).std())
)

⚠️ Утечка будущего

При создании lag и rolling фичей всегда проверяй: не подглядываешь ли ты в будущее? shift(1) — значение вчера, это ок. rolling(7).mean() без shift — включает текущий день, это утечка. На собесе это спрашивают обязательно.

Отбор признаков: убрать шум, оставить сигнал

Больше фичей ≠ лучше. Лишние признаки — это шум, который мешает модели обобщать. 100 случайных признаков могут ухудшить бустинг, хотя он вроде бы «сам» отбирает важные. Три подхода к отбору:

Filter (фильтрация) — статистические тесты до обучения модели. Самый простой: считаем корреляцию каждого признака с целевой переменной и отбрасываем слабые. Также полезно удалить пары сильно коррелированных между собой признаков (корреляция > 0.95) — они дублируют информацию. Плюс: быстро. Минус: не учитывает взаимодействия между признаками.

Wrapper (обёртка) — обучаем модель, оцениваем подмножества признаков. RFE (Recursive Feature Elimination): обучаем модель на всех признаках, убираем наименее важный, повторяем. «Жадный» поиск — медленнее, но учитывает взаимодействия. Плюс: точнее filter. Минус: дорого — обучаем модель N раз.

Embedded (встроенный) — модель сама отбирает признаки в процессе обучения. L1-регуляризация (Lasso) зануляет веса ненужных признаков — буквально выбрасывает их из модели. Feature importance в бустинге показывает, какие признаки модель реально использует. Плюс: отбор как часть обучения. Минус: зависит от конкретной модели.

from sklearn.feature_selection import RFE
from sklearn.linear_model import Lasso
from sklearn.ensemble import GradientBoostingClassifier

# Filter: корреляция с таргетом
corr = X_train.corrwith(y_train).abs().sort_values(ascending=False)
top_features = corr[corr > 0.05].index.tolist()

# Wrapper: RFE — рекурсивное удаление признаков
rfe = RFE(estimator=GradientBoostingClassifier(), n_features_to_select=20)
rfe.fit(X_train, y_train)
selected = X_train.columns[rfe.support_]

# Embedded: L1-регуляризация зануляет ненужные веса
lasso = Lasso(alpha=0.01)
lasso.fit(X_train, y_train)
important = X_train.columns[lasso.coef_ != 0]  # ненулевые — важные

Feature importance: какие фичи реально работают

Ты создал 200 признаков — какие из них модель реально использует? Feature importance отвечает на этот вопрос. Три подхода, от простого к мощному:

Встроенная importance — бустинг (XGBoost, LightGBM, CatBoost) считает, сколько раз каждый признак использовался в разбиениях деревьев и насколько он снизил ошибку. Быстро, бесплатно (уже посчитано при обучении), но зависит от модели. Permutation importance — перемешиваем значения одного признака случайным образом и смотрим, насколько упало качество модели. Если упало сильно — признак важен. Если не упало — можно выбросить. Не зависит от модели, работает с любым алгоритмом. SHAP (SHapley Additive exPlanations) — золотой стандарт. Для каждого конкретного предсказания SHAP показывает вклад каждого признака. Не просто «доход важен», а «для клиента #42 высокий доход увеличил вероятность одобрения на 0.15». Основан на значениях Шепли из теории игр — честное распределение «вклада» между игроками (признаками).

import shap
from sklearn.inspection import permutation_importance

# Встроенная importance (CatBoost/LightGBM/XGBoost)
importance = model.feature_importances_
top_10 = sorted(zip(feature_names, importance), key=lambda x: -x[1])[:10]

# Permutation importance — model-agnostic
perm = permutation_importance(model, X_val, y_val, n_repeats=10)
# perm.importances_mean — средняя потеря качества при перемешивании

# SHAP — объяснение каждого предсказания
explainer = shap.TreeExplainer(model)   # для бустинга — быстро
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val)   # beeswarm plot — красиво и информативно

Permutation vs встроенная importance

Встроенная importance бустинга может обманывать: высококардинальный признак (id пользователя) получит высокую importance просто потому, что у него много уникальных значений для разбиений. Permutation importance честнее — она смотрит на реальное влияние на метрику.

Пропуски — не проблема, а информация

Пустое поле «доход» может означать: человек не хочет раскрывать зарплату (высокий доход?), безработный, или данные потеряли при миграции. Сам факт пропуска — это признак. Стратегия зависит от природы пропуска и модели.

Удаление строк — если пропусков < 5% и они случайны (MCAR — missing completely at random). Просто и безопасно, но теряем данные. Заполнение константой — медиана для числовых (устойчива к выбросам), мода для категориальных. Самый частый подход. Для линейных моделей — почти обязательно, иначе NaN сломает вычисления. Индикатор пропуска — создаём новый бинарный признак: income_is_null = 1/0. Часто индикатор сам по себе предсказывает таргет лучше, чем заполненное значение. Используй совместно с заполнением. Нативная обработка — CatBoost, LightGBM, XGBoost умеют работать с NaN из коробки. Дерево само решает, в какую ветку отправить объекты с пропуском. Часто это лучше ручного заполнения.

from sklearn.impute import SimpleImputer
import pandas as pd

# 1. Индикатор пропуска (до заполнения!)
df['income_is_null'] = df['income'].isnull().astype(int)

# 2. Заполнение медианой
imputer = SimpleImputer(strategy='median')
df['income'] = imputer.fit_transform(df[['income']])

# 3. Для бустинга — можно оставить NaN
# CatBoost/LightGBM сами разберутся, и часто — лучше нас

🎯 На собеседовании

Junior

Зачем нормализовать признаки? Чтобы модель не считала признак с большим масштабом более важным. Нужно для линейных моделей, kNN, нейросетей. Для деревьев — не нужно. • Как закодировать категорию с 10 000 значений? Target encoding, frequency encoding или эмбеддинги. One-hot на 10K — убьёт модель. • Зачем log-transform? Сжимает скошенное распределение (доход, цены), делает его ближе к нормальному. Линейные модели работают лучше. • Как обработать пропуски? Медиана для чисел, мода для категорий + индикатор пропуска как отдельная фича. Бустинг обрабатывает NaN сам.

Middle

Target encoding — в чём опасность? Target leakage: модель «видит» ответы через закодированный признак. Считать ТОЛЬКО на train, использовать сглаживание (smoothing) при малом числе объектов в категории. • Filter vs wrapper vs embedded отбор? Filter: корреляция, быстро, не видит взаимодействия. Wrapper: RFE, точно, но дорого (N обучений). Embedded: L1, feature importance — отбор как часть обучения. • Permutation importance vs встроенная? Встроенная bias-ана к высококардинальным признакам. Permutation — честнее, model-agnostic, но дороже. • Какие фичи создать для прогноза оттока? Пользовательские (активность за 7/30 дней, тренд), транзакционные (сумма, частота), сервисные (обращения в поддержку), временные (дни с последнего визита).

Senior

Как SHAP работает под капотом? Основан на Shapley values — считает маргинальный вклад каждого признака по всем возможным коалициям. TreeSHAP для деревьев — полиномиальный алгоритм вместо экспоненциального. • Когда target encoding лучше embeddings? На табличных данных с достаточным числом объектов в каждой категории (>30). Embeddings лучше при малых категориях + нужна генерализация на новые значения. • Как ловить feature leakage в пайплайне? Validation curve: если train AUC ≈ 1.0 при простой модели — подозрение на утечку. Проверять: временной порядок данных, агрегаты от будущего, target encoding на полном датасете. • Feature selection для 10K признаков? Двухступенчатый: сначала filter (убрать нулевую дисперсию, корреляцию > 0.95), потом embedded (L1 или importance из бустинга). RFE на 10K — слишком дорого.

Собираем всё вместе

Feature engineering — это мост между сырыми данными и моделью. Числовые признаки нормализуем (StandardScaler, log) — для линейных моделей. Категориальные кодируем (one-hot для малых, target/frequency encoding для больших). Текстовые превращаем в TF-IDF или эмбеддинги. Временные раскладываем на компоненты, лаги и rolling-статистики.

Отбор признаков — тоже часть feature engineering. Корреляция (filter), RFE (wrapper), L1 и feature importance (embedded) — три инструмента для удаления шума. SHAP — для объяснения, какие фичи работают и почему.

Если запомнить одну вещь: правильные фичи > правильная модель. CatBoost на хороших признаках победит нейросеть на сырых данных. Трать 80% времени на данные и фичи, 20% — на подбор модели.

Дальше на роадмапе: Классический ML расскажет, какие модели чем отличаются, а Model Selection — как правильно валидировать и не переобучиться.