GroupKFold: один клиент попал в train и test — как исправить валидацию

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

Для таких данных единица разбиения крупнее строки. Это может быть клиент, пациент, устройство или исходный документ, из которого нарезали несколько фрагментов. Сначала определите, на каких новых объектах будет работать модель, затем выбирайте способ валидации.

Измерьте пересечение объектов

Сохраните идентификатор группы рядом с признаками, даже если сам идентификатор не используется моделью. После split сравните множество client_id в train с множеством в validation. При оценке на новых клиентах пересечение должно быть пустым. Проверяйте именно ключ клиента: разные записи одной заявки или несколько строк после join не становятся независимыми наблюдениями из-за нового row_id.

Для перекрёстной проверки используйте GroupKFold. Он выделяет группы целиком: в конкретном fold наблюдения одного клиента попадут только в одну сторону. Число различных групп должно быть не меньше числа folds. Баланс классов при этом нужно проверить отдельно.

Сравните два разбиения на небольшом примере

В примере ниже у двенадцати синтетических клиентов по три наблюдения. Первый признак специально представляет устойчивый профиль клиента; все его наблюдения одинаковы. Метки чередуются между клиентами. Ближайший сосед легко узнаёт уже встречавшийся профиль, но не получает правила для следующего клиента. Установите NumPy и scikit-learn, сохраните код в файл и запустите Python.

import numpy as np
from sklearn.model_selection import GroupKFold, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score

# Три одинаковых по устойчивому профилю наблюдения на клиента.
groups = np.repeat(np.arange(12), 3)
X = np.column_stack([groups.astype(float), np.zeros(len(groups))])
y = np.repeat(np.arange(12) % 2, 3)

indices = np.arange(len(y))
tr, va = train_test_split(indices, test_size=1/3, random_state=7,
                         stratify=y)
overlap = set(groups[tr]) & set(groups[va])
model = KNeighborsClassifier(n_neighbors=1).fit(X[tr], y[tr])
print("row split:", len(overlap), round(accuracy_score(y[va], model.predict(X[va])), 3))
assert overlap

scores = []
seen_test_groups = set()
for fold, (tr, va) in enumerate(GroupKFold(n_splits=3).split(X, y, groups), 1):
    train_groups, test_groups = set(groups[tr]), set(groups[va])
    assert train_groups.isdisjoint(test_groups)
    assert seen_test_groups.isdisjoint(test_groups)
    seen_test_groups.update(test_groups)
    assert set(y[tr]) == {0, 1} and set(y[va]) == {0, 1}
    model = KNeighborsClassifier(n_neighbors=1).fit(X[tr], y[tr])
    score = accuracy_score(y[va], model.predict(X[va]))
    scores.append(score)
    print("group fold:", fold, "overlap:", len(train_groups & test_groups),
          "accuracy:", round(score, 3))
assert seen_test_groups == set(groups)
print("mean grouped accuracy:", round(float(np.mean(scores)), 3))

Вывод сначала показывает число общих клиентов при обычном split и accuracy на строках. Затем для каждого группового fold выводятся нулевое пересечение и собственная accuracy. Среднее считается по трём folds. Набор намеренно делает узнавание клиента полезным для обычного split: разница показывает, какие разные условия проверки создают два подхода.

Такой результат не означает, что качество вашей модели обязано упасть на ту же величину. На реальных данных нужно пересчитать метрику с вашими признаками и проверить состав групп. Полезный результат этого скрипта — конкретные assertions, которые обнаружат возврат пересекающихся клиентов в оценку.

Обучайте преобразования внутри каждого fold

Если перед классификатором стоят нормализация, заполнение пропусков или отбор признаков, включите их в Pipeline и обучайте только на train текущего fold. Подготовка на всей таблице до GroupKFold может передать информацию из validation через параметры преобразования. Корректное разбиение не исправит утечку, возникшую раньше.

Для агрегатных признаков отдельно проверьте доступность исходных данных. Например, сумма покупок клиента за всё время может включать будущие покупки относительно даты прогноза. GroupKFold запрещает пересечение людей, но не задаёт временную границу. Если задача — прогноз следующего месяца для уже известных клиентов, понадобится разбиение по времени и построение признаков только из прошлого.

Что делать с редким классом и разным размером групп

Посмотрите на число положительных и отрицательных примеров в каждой стороне. Если все положительные наблюдения принадлежат одному клиенту, независимую устойчивую оценку по клиентам такой набор не даст. Стратификация строк создаст красивый баланс ценой возвращения утечки. Сначала разберитесь с составом данных и доступными группами.

Когда важны одновременно группы и доли классов, проверьте StratifiedGroupKFold. Он пытается сохранить распределение классов при разделении групп, но крупная группа всё равно остаётся неделимой. Кроме общей метрики полезно посмотреть результат по клиентам: один активный клиент с тысячей обращений может определять среднюю ошибку по строкам.

В платном курсе «Data Scientist: от данных до деплоя моделей» вы начнёте с постановки задачи, поиска утечек и выбора стратегии валидации, затем перейдёте к experiment tracking, error analysis и inference API. Откройте программу курса: она помогает связать честную проверку модели с её будущей работой в продукте. Общую схему разбиения можно предварительно разобрать в открытом уроке Train, validation и test.