Привет, Хабр!

У вас столбец «город» с 800 уникальными значениями. One‑hot encoding превратит его в 800 бинарных столбцов, разреженную матрицу и модель, которая переобучится на третьей эпохе. Label encoding присвоит числа от 0 до 799, но модель решит, что Москва (0) «меньше» Владивостока (799), хотя никакого порядка между городами нет. Frequency encoding скажет, что Москва и Питер похожи, потому что обе встречаются часто, хотя по целевой переменной могут отличаться кардинально.

Target encoding — подход, при котором каждое значение категории заменяется агрегатом целевой переменной по этой категории. Для задачи регрессии — средним, для классификации — средней вероятностью положительного класса. Город «Москва» с конверсией 12% превращается в число 0.12. «Тюмень» с конверсией 8% — в 0.08.

Идея простая. Но и ловушка тут тоже простая: если считать среднее по всей обучающей выборке и подставлять в неё же — модель увидит утечку из целевой переменной (target leakage), переобучится и покажет нереалистично высокие метрики на трейне. Разберём, как делать правильно.

Как работает: базовая формула

Для каждого значения категории c вычисляем:

encoded(c) = (count(c) * mean_target(c) + m * global_mean) / (count(c) + m)

Где count(c) — сколько раз категория встречается в обучающей выборке, mean_target(c) — среднее значение целевой переменной для этой категории, global_mean — среднее по всей выборке, m — параметр сглаживания (smoothing).

Сглаживание m — ключевой элемент. Без него категория, встретившаяся один раз с target=1, получит encoding=1.0, что конечно странновато, одно наблюдение ничего не говорит о категории. Сглаживание подтягивает редкие категории к глобальному среднему: чем меньше наблюдений, тем ближе encoding к global_mean.

При m=0 сглаживания нет (голое среднее по категории). При m=100 категории с менее чем 100 наблюдениями будут почти неотличимы от глобального среднего. Типичные значения m — от 10 до 300, подбирается на валидации.

Проблема: target leakage

Большинство допускают ошибку именно тут.

# НЕПРАВИЛЬНО — утечка целевой переменной
import pandas as pd

means = df.groupby('city')['target'].mean()
df['city_encoded'] = df['city'].map(means)

Строка из обучающей выборки участвует в подсчёте среднего, которое потом используется как признак этой же строки. Модель фактически подглядывает в ответ. На трейне метрики будут завышены, а на реальных данных модель провалится.

Особенно опасно для категорий с малым количеством наблюдений. Если город встретился 3 раза, а target у всех трёх = 1, encoding = 1.0. Модель запоминает эти три строки через encoding, вместо того чтобы обобщать.

Решение 1: Leave‑One‑Out (LOO)

Для каждой строки i среднее считается по всем строкам категории, кроме самой строки i:

# Leave-One-Out target encoding
def loo_target_encode(df, col, target, m=10):
    global_mean = df[target].mean()
    agg = df.groupby(col)[target].agg(['sum', 'count'])
    
    encoded = df.apply(
        lambda row: (
            (agg.loc[row[col], 'sum'] - row[target] + m * global_mean) /
            (agg.loc[row[col], 'count'] - 1 + m)
        ), axis=1
    )
    return encoded

Строка не участвует в вычислении своего собственного encoding. Утечка существенно снижается, но не исчезает полностью, оставшиеся N-1 строк категории всё ещё коррелируют с таргетом.

Решение 2: K‑Fold Target Encoding (рекомендуемый)

Обучающая выборка делится на K фолдов. Для строк фолда k encoding считается по строкам всех остальных фолдов:

import numpy as np
from sklearn.model_selection import KFold

def kfold_target_encode(df, col, target, n_splits=5, m=20):
    global_mean = df[target].mean()
    encoded = pd.Series(np.nan, index=df.index)
    
    kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
    
    for train_idx, val_idx in kf.split(df):
        # Считаем статистики ТОЛЬКО по train-части фолда
        train = df.iloc[train_idx]
        agg = train.groupby(col)[target].agg(['mean', 'count'])
        
        # Encoding со сглаживанием
        mapping = (
            (agg['count'] * agg['mean'] + m * global_mean) /
            (agg['count'] + m)
        )
        
        # Применяем к val-части фолда
        encoded.iloc[val_idx] = df.iloc[val_idx][col].map(mapping)
    
    # Новые категории (не встретились в фолде) — global_mean
    encoded.fillna(global_mean, inplace=True)
    return encoded

Каждая строка кодируется статистиками, вычисленными без её участия. При K=5 утечка практически равна нулю.

Для тестовой выборки encoding считается по всей обучающей:

def target_encode_test(train, test, col, target, m=20):
    global_mean = train[target].mean()
    agg = train.groupby(col)[target].agg(['mean', 'count'])
    
    mapping = (
        (agg['count'] * agg['mean'] + m * global_mean) /
        (agg['count'] + m)
    )
    
    encoded = test[col].map(mapping).fillna(global_mean)
    return encoded

Готовые реализации

В category_encoders (популярная библиотека, совместимая со sklearn):

import category_encoders as ce
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import GradientBoostingClassifier

encoder = ce.TargetEncoder(cols=['city', 'device_type'], smoothing=20)

# В пайплайне sklearn
from sklearn.pipeline import Pipeline

pipe = Pipeline([
    ('encoder', ce.TargetEncoder(cols=['city', 'device_type'], smoothing=20)),
    ('model', GradientBoostingClassifier(n_estimators=200))
])

# cross_val_score корректно применяет encoder на каждом фолде
scores = cross_val_score(pipe, X, y, cv=5, scoring='roc_auc')

cross_val_score с пайплайном автоматически fit‑ит encoder только на train‑фолде. Это правильное поведение, утечки нет. Но если вы вручную вызовете encoder.fit_transform(X_train, y_train) и потом передадите в модель — утечка будет, потому что fit_transform считает статистики по всему X_train.

Когда target encoding лучше альтернатив

  • Высокая кардинальность. IP‑адреса, почтовые индексы, ID товаров, user agents — тысячи и десятки тысяч уникальных значений. One‑hot encoding физически не справится (миллионы столбцов), label encoding не несёт информации. Target encoding сжимает тысячи категорий в один числовой столбец с высокой предсказательной силой.

  • Древесные модели. XGBoost, LightGBM, CatBoost — target encoding хорошо работает именно с ними, потому что деревья строят сплиты по порогу. Числовое значение encoding даёт дереву осмысленный порог: «если encoding города > 0.15, идём в левое поддерево», то есть «если конверсия в этом городе выше 15%».

Кстати, CatBoost реализует target encoding внутри (ordered target statistics) и делает это автоматически, с защитой от утечки. Если используете CatBoost,то отдельный target encoding не нужен, передавайте категории напрямую с указанием cat_features.

  • Линейные модели. Target encoding работает и здесь, но менее выражен эффект — линейная модель может извлечь из one‑hot encoding столько же информации (при достаточном количестве данных). Преимущество target encoding для линейных моделей — в сокращении размерности при высокой кардинальности.

Когда target encoding не нужен

  1. Низкая кардинальность (< 10–15 уникальных значений). One‑hot encoding справится, не создаст проблем с размерностью, и не добавит риска утечки. Для пола, дня недели, типа устройства — не усложняйте.

  2. Мало данных. Если в категории 5 наблюдений, средний таргет по ним — шум, а не сигнал. Сглаживание поможет (подтянет к глобальному среднему), но при совсем малых выборках лучше сгруппировать редкие категории в «другое» и применить one‑hot.

  3. Целевая переменная — непрерывная с высокой дисперсией. Среднее по категории будет зашумлённым. Рассмотрите медиану вместо среднего или quantile encoding.

Что ещё можно кодировать кроме среднего

Target encoding не ограничивается средним. Можно использовать любую агрегатную функцию, и комбинация нескольких даёт модели больше информации:

# Несколько агрегатов одновременно
agg = df.groupby('city')['target'].agg(
    target_mean='mean',
    target_std='std',
    target_median='median',
    count='count'
)

std — насколько стабилен таргет внутри категории. Город с mean=0.1 и std=0.02 — стабильно низкая конверсия. Город с mean=0.1 и std=0.15 — нестабильный, средняя не показательна.

count — сколько наблюдений. Сам по себе полезный признак: частота категории коррелирует с поведением (популярный город отличается от редкого).

Для каждого дополнительного агрегата — тот же K‑Fold подход для защиты от утечки. Дополнительные столбцы не бесплатны: чем больше признаков на основе таргета, тем выше риск переобучения, даже с K‑Fold.

Итог

  • Считайте encoding по K‑Fold на трейне (K=5 или K=10). Не fit_transform на всём трейне.

  • Для теста и прода — encoding по статистикам всего трейна. Сохраняйте маппинг (словарь категория‑значение) как артефакт модели.

  • Новые категории (не встречались в трейне) — заменяйте на global_mean. Не на 0, не на NaN — на среднее по выборке.

  • Подбирайте m (smoothing) на валидации. Типичный диапазон — 10–100 для средних датасетов, 100–500 для больших.

  • Мониторьте drift. Если распределение категорий на проде сместилось (появился новый город с 30% трафика), encoding устаревает. Периодическое переобучение — супер важная штука.

Если вам важно не просто знать термины, а собирать признаки без утечек и получать модели, которым можно доверять, загляните в каталог — там можно выбрать обучение под свои задачи.

По ряду курсов можно бесплатно пройти тестирование и оценить свой текущий уровень. До 30 апреля за прохождение теста действует скидка 15% на обучение.

Чтобы лучше понять, как данные влияют на поведение модели и качество предсказаний, начните с этих открытых уроков:

  • 13 апреля, 20:00 «Архитектура доверия: качество данных». Записаться.

  • 29 апреля, 20:00 «Деревья решений для задач классификации и регрессии». Записаться.