Всем привет, меня зовут Сергей Прощаев, я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.
В этой статье расскажу про восемь проверок датасета, которые нужно прогнать до того, как кто‑то в команде напишет model.fit().
Пятница, релиз скоринга. На отложенной выборке ROC‑AUC 0.94, все довольны, катим в прод. Через две недели приходят цифры с боевого трафика: 0.71. Начинается ритуал — тюним гиперпараметры, пробуем другой бустинг, добавляем признаки.
Проходит спринт. А причина оказывается в том, что в обучающую выборку попал признак, которого в момент предсказания физически ещё не существует: он заполняется оператором уже после того, как решение принято.
Модель тут ни при чём — вопросы надо было задавать датасету, а их никто не задал.
Ниже — маршрут из восьми проверок. Каждая закрывает один конкретный риск, к каждой есть код и понятный признак «прошло / не прошло». Это не теория из учебника, а то, что мы в итоге зашили в пайплайн подготовки данных как обязательный шаг перед обучением.

Исходные условия
Сразу оговорюсь про свою роль. Я не ML‑инженер. Но данные, на которых учатся антифрод‑ и скоринговые модели, приходят из сервисов, за которые отвечаю я. И несколько раз бывало так: DS‑команда приходит с претензией «модель деградировала», а через два дня выясняется, что в моём сервисе поменяли семантику поля. Смотрю я на тему со стороны поставщика данных, а не со стороны того, кто крутит гиперпараметры.
Рамки статьи. Табличные данные, влезающие в память одной машины: до нескольких десятков миллионов строк, до пары сотен признаков.
Задача — бинарная классификация с сильным дисбалансом, для финтеха и e‑commerce типовая.
Источник — витрина в хранилище, выгрузка в parquet.
Примеры воспроизводились на Python 3.12, pandas 3.0.5, pandera 0.32.1, scikit‑learn 1.9.0. Версии пиньте в окружении: код ниже демонстрационный, он показывает суть проверки, а не готовый production‑модуль. В боевом пайплайне часть этих проверок уезжает в SQL, часть — в отдельный сервис валидации.
Порядок проверок не единственно возможный, но в нашем пайплайне работает хорошо: сначала структурные нарушения, потом семантика и время, затем политика значений и фиксация артефакта.
Ловить утечку до того, как разобрались с дублями, бесполезно — дубли дают эффект, который легко с ней спутать.
Про CI отдельно: там на каждый коммит гоняются схема, логика проверок и синтетические фикстуры — это секунды. Полный датасет проверяется уже в пайплайне подготовки данных, перед обучением.
Сколько стоит пропущенная проверка
Мне как‑то попалась история Unity, и с тех пор я использую её как аргумент, когда кто‑то говорит «давайте потом добавим валидацию».
В первом квартале 2022 года Unity столкнулась с двумя проблемами сразу: сбоем в платформе, снизившим точность рекламного инструмента Audience Pinpointer, и потерей части ценности обучающих данных из‑за того, что в систему попали некорректные данные от крупного клиента.
Заметили это не по алертам, а по выручке: рекламодатели увидели падение эффективности и стали меньше тратить. Суммарное влияние обеих проблем менеджмент оценил примерно в 110 миллионов долларов, акции упали примерно на 37%.
Обратите внимание на деталь: никто ничего не «сломал». Пайплайн работал, модель обучалась, метрики считались. Просто данные на входе перестали означать то, что все думали. Такой дефект не ловится unit‑тестами и остаётся невидимым, если пайплайн логирует технические ошибки, но не качество и семантику данных.
Помню похожую, хоть и куда более дешёвую историю. Партнёр поменял формат идентификатора на строковый с ведущими нулями. Джойн не упал, он просто перестал находить часть записей. Модель обучилась на выборке, где целый сегмент клиентов был представлен вдвое слабее.
Одно правило, которое держит половину проверок
Прежде чем идти по списку, зафиксирую концепцию, вокруг которой крутятся проверки 3, 4 и 5. У каждого значения в датасете есть момент, когда оно становится известно системе. На рис. 2 — эта ось времени для одной транзакции.

Главная мысль схемы умещается в неравенстве feature_available_at <= prediction_time < target_available_at. Признак имеет право попасть в датасет, только если он известен до момента предсказания. Метка, наоборот, появляется после.
Нарушили левую часть — получили target leakage, правую — незрелый таргет и завышенную оценку на свежих данных.
Оговорюсь: это упрощённая модель. У скользящего агрегата вроде «средний чек за 7 дней» единой даты доступности нет вовсе — она зависит от окна, watermark и опоздавших событий.
Точная формулировка скучнее: для каждого признака должна быть определена семантика доступности относительно момента предсказания, а неравенство выше — простейший её случай.
Проверка 1. Контракт схемы
Чем рискуем: изменение схемы проявляется не там, где произошло. Колонка исчезает, приходит с другим типом или downstream‑преобразование молча приводит значения к NaN.
Мой вариант, который я обычно использую — объявить схему декларативно и валидировать её на каждом запуске. Не в ноутбуке, а в коде рядом с датасетом.
import pandera.pandas as pa from pandera.typing import Series class Transactions(pa.DataFrameModel): transaction_id: Series[str] = pa.Field(unique=True, nullable=False) client_id: Series[str] = pa.Field(nullable=False) event_ts: Series[pa.DateTime] = pa.Field(nullable=False) amount: Series[float] = pa.Field(ge=0, le=5_000_000) channel: Series[str] = pa.Field(isin=["web", "mobile", "pos", "api"]) is_fraud: Series[int] = pa.Field(isin=[0, 1], nullable=False) class Config: strict = True # лишняя или пропавшая колонка - это ошибка, а не сюрприз coerce = False # приведение типов делаем осознанно на ingestion df = Transactions.validate(df, lazy=True) # lazy соберёт все нарушения разом
Зелёный сигнал: контракт валидируется на каждой новой партиции, а известные прошлые инциденты закреплены как regression‑фикстуры в тестах.
Проверка 2. Гранулярность, дубликаты и пересечение сущностей
Чем рискуем: одна сущность встречается несколько раз, а между обучением и тестом протекает информация. Это два разных дефекта, и проверяются они по‑разному.
Сначала гранулярность. Ключ должен быть естественным идентификатором, а не парой «клиент плюс время»: две легитимные операции одного клиента могут произойти в одну миллисекунду.
assert df["transaction_id"].is_unique, "нарушена гранулярность" print("полных дублей строк:", df.duplicated().sum()) # контаминация между обучением и тестом assert set(train["transaction_id"]).isdisjoint(test["transaction_id"]) overlap = len(set(train["client_id"]) & set(test["client_id"])) print(f"клиентов в обеих выборках: {overlap}")
Дубли редко приходят из явных ошибок: обычно это безобидный джойн со справочником, у которого отношение оказалось one‑to‑many. Могу себе представить, как такое проезжает ревью — на количество строк никто не смотрит как на метрику.
Зелёный сигнал: правило гранулярности записано одной фразой — «одна строка = одна транзакция», а стратегия сплита выбрана осознанно и обоснована постановкой.
Проверка 3. Целостность и зрелость таргета
Чем рискуем: пропуски в метке, незрелая разметка и класс меньшинства в 0.3%.
Про зрелость подробнее, потому что о ней забывают чаще всего. В антифроде метка формируется не мгновенно: сначала транзакция, потом разбор, потом возможный chargeback.
Между событием и подтверждённой меткой проходит 30, 60, иногда 90 дней. Свежие строки размечены не до конца: часть мошенничества там ещё не проявилась и лежит с нулём.
import pandas as pd print(df["is_fraud"].value_counts(dropna=False, normalize=True)) # зрелость отсчитываем от даты сборки датасета, а не от максимума в выборке as_of = pd.Timestamp("2026-07-15", tz="UTC") mature = df["event_ts"] <= as_of - pd.Timedelta(days=60) print("доля строк с незакрытым окном:", f"{(~mature).mean():.1%}") train_pool = df[mature]
Отсчёт именно от as_of принципиален: если считать от max(event_ts), то при сборке в конце августа давно зрелые события начала июня всё равно попадут в незакрытое окно.
Отдельно про конфликтующие метки. Если transaction_id уникален по контракту из проверки 1, искать противоречия на нём бессмысленно: группировка по уникальному ключу всегда вернёт одну метку.
Смысл появляется уровнем выше, где несколько транзакций собираются в бизнес‑кейс: df.groupby("case_id")["is_fraud"].nunique(). А группировка по всему набору признаков, которую любят в туториалах, не работает никогда: с timestamp и суммой почти каждая строка уникальна.
Про метрику. Если положительных 0.3%, accuracy 99.7% — не результат, а константа. Но и одной ROC‑AUC при таком дисбалансе мало: смотрите PR‑AUC и бизнес‑метрику на рабочей точке — precision@k или recall при фиксированном уровне ложных срабатываний.
Зелёный сигнал: известна доля мажоритарного класса, выбрана метрика на рабочей точке, а окно вызревания отсчитывается от даты сборки датасета.
Проверка 4. Утечка таргета
Та самая проверка, из‑за которой я и начал писать этот чек‑лист.
В нашем случае это было поле manual_review_result. Его заполняет оператор в течение суток после того, как транзакция ушла на ручной разбор.
В обучающей выборке оно было непустым примерно у 12% строк, и почти все эти строки оказывались мошенническими. Модель с ним давала 0.94 на отложенной выборке, без него — 0.86. В проде поле всегда пустое, потому что решение принимается до разбора. Настоящее качество было 0.86 всегда, просто мы две недели верили в 0.94 и успели пообещать эту цифру бизнесу.
Основной механизм защиты здесь не код, а контракт доступности из рис. 2. Скрипт ниже — только эвристика, подсвечивающая кандидатов на разбор.
from sklearn.metrics import roc_auc_score # признаки, для которых сильный сигнал легитимен и подтверждён контрактом ALLOWED_HIGH_SIGNAL = {"bureau_risk_score"} suspects = [] for col in df.select_dtypes("number").columns.drop("is_fraud"): if col in ALLOWED_HIGH_SIGNAL: continue s = df[[col, "is_fraud"]].dropna() if len(s) < 1000 or s["is_fraud"].nunique() < 2: continue # на одноклассовом срезе ROC-AUC не определён auc = roc_auc_score(s["is_fraud"], s[col]) if max(auc, 1 - auc) > 0.90: suspects.append(col) # категориальные смотрим отдельно: вырожденная доля класса внутри категории for col in CATEGORICAL_COLS: stats = df.groupby(col)["is_fraud"].agg(["mean", "count"]) rate = stats.loc[stats["count"] >= 100, "mean"] # редкие категории не считаем if len(rate) and ((rate < 0.01) | (rate > 0.99)).mean() > 0.5: suspects.append(col) print("на ручной разбор:", suspects)
Высокая предсказательная сила — повод для расследования, а не диагноз. Скоринговый балл внешнего бюро вполне законно может давать AUC 0.95. И наоборот, эвристика не поймает утечку через агрегат, через target encoding, через post‑event join или через взаимодействие двух безобидных признаков.
Единственный надёжный фильтр — вопрос к каждому признаку: когда это значение становится известно относительно момента предсказания?
Зелёный сигнал: у каждого признака в описании датасета указано время доступности. Не «откуда взяли», а «когда становится известно».
Проверка 5. Point‑in‑time корректность и сплит
Чем рискуем: случайный train_test_split на данных, у которых есть время, и агрегаты, посчитанные с заглядыванием в будущее. Это temporal leakage — родственник утечки таргета, но механизм другой: признак законный, а вот окно его расчёта захватывает то, чего в момент предсказания ещё не было.
cut = df["event_ts"].quantile(0.8) train, test = df[df.event_ts <= cut], df[df.event_ts > cut] assert train.event_ts.max() < test.event_ts.min() # сортировка по transaction_id нужна только для детерминизма примера: # причинный порядок событий внутри одной миллисекунды она не задаёт df = df.sort_values(["client_id", "event_ts", "transaction_id"]) df["client_amount_mean_past"] = ( df.groupby("client_id")["amount"] .transform(lambda s: s.shift(1).expanding().mean()) )
Честно скажу: для production‑расчёта признаков я бы не оставлял этот pandas‑приём. Детерминированный порядок и причинный — разные вещи: если две транзакции пришли одной миллисекундой, сортировка по идентификатору не знает, какая из них была доступна системе раньше. Point‑in‑time корректность правильнее выражать через as‑of join с явной семантикой «строго раньше» и отдельным полем времени поступления, а не через сортировку и shift().
И важное про критерий. Временной сплит может честно давать результат выше случайного: например, если в старых данных много concept drift, а свежий период однороднее. Само по себе соотношение двух чисел ничего не доказывает.
Зелёный сигнал: сплит воспроизводит то, как модель будет использоваться в проде, а расхождение между временным и случайным сплитом объяснено, а не просто замечено.
Проверка 6. Пропуски и значения‑заглушки
Чем рискуем: -1, 9999, 1970-01-01. Формально это не NaN, поэтому счётчики пропусков показывают ноль, а модель учится на заглушках как на осмысленных числах.
Глобальный список заглушек на все колонки — плохая идея: ноль в поле amount или retries совершенно легитимен. Список должен быть поколоночным и жить там же, где схема.
SENTINELS = { "age": {-1, 999}, "bureau_risk_score": {-1}, "amount": set(), # ноль здесь валиден } for col, values in SENTINELS.items(): if not values: continue share = df[col].isin(values).mean() if share > 0.01: print(f"{col}: заглушки в {share:.1%} строк") print(df.isna().mean().sort_values(ascending=False).head(10))
Зелёный сигнал: для каждой колонки с пропусками записано, чем её закрывать и почему. Медиана «по умолчанию» — не решение, а его откладывание.
Проверка 7. Категории: кардинальность и новизна
Чем рискуем: категории, которых в обучении не было, а в проде они приходят каждый день, и колонки, где число уникальных значений сопоставимо с числом строк.
Само по себе большое количество уникальных значений — не дефект. Пятнадцать тысяч значений нормальны для merchant_id и невозможны для country. Смотреть надо на кардинальность относительно размера выборки и семантики поля.
# список категориальных берём из схемы, а не из select_dtypes("object"): # категории могут лежать в string, category или числовых кодах for col in CATEGORICAL_COLS: n = df[col].nunique() unseen = set(test[col].unique()) - set(train[col].unique()) print(f"{col}: уникальных {n} ({n / len(df):.2%} от числа строк), " f"новых в тесте {len(unseen)} ({test[col].isin(unseen).mean():.1%} строк)")
Зелёный сигнал: политика для незнакомых категорий выбрана до обучения — отдельный бакет, частотное кодирование или отсечка по редкости.
Проверка 8. Фиксация артефакта и манифест
Чем рискуем: «перезапустили пайплайн — получили другие цифры». Витрина живая, вчерашний срез сегодня уже другой.
Хэш файла фиксирует конкретный бинарный артефакт, и этого мало. Нужен манифест: адреса и хэши всего, что участвовало в сборке — источника, запроса, кода, схемы, сплита и окружения.
import hashlib, json, pathlib, subprocess path = pathlib.Path("datasets/fraud_2026_07.parquet") df.to_parquet(path, index=False) lock = pathlib.Path("requirements.lock") manifest = { "dataset_uri": "s3://ml-datasets/fraud/2026-07.parquet", "file_sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "rows": len(df), "schema_version": "transactions.v3", "source_snapshot": "dwh.transactions@2026-07-15T09:00:00Z", "query_uri": "git://pipelines/fraud/extract.sql", "query_sha256": hashlib.sha256(QUERY.encode()).hexdigest(), "pipeline_commit": subprocess.check_output( ["git", "rev-parse", "HEAD"], text=True).strip(), "split": {"strategy": "time", "cut": "2026-06-20"}, "env_lock_uri": str(lock), "env_lock_sha256": hashlib.sha256(lock.read_bytes()).hexdigest(), } path.with_suffix(".manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2))
Полной воспроизводимости манифест не гарантирует. Хэш запроса идентифицирует текст, но не результат: на выдачу влияют вьюхи, UDF, настройки сессии и таймзона. Манифест адресует артефакты и доказывает, что вы работали именно с ними.
Зелёный сигнал: коллега воспроизводит вашу метрику по манифесту и адресуемым из него артефактам.
Сводный чек‑лист
Если забирать из статьи что‑то одно, забирайте эту таблицу. Человекочитаемое описание живёт у нас в README, а исполняемые ограничения — рядом с кодом сборки датасета.
№ | Проверка | Что ломается без неё | Зелёный сигнал |
|---|---|---|---|
1 | Контракт схемы | Изменение схемы всплывает не там, где произошло | Контракт валидируется на каждой партиции, инциденты закреплены фикстурами |
2 | Гранулярность, дубликаты, overlap | Метрика завышена за счёт протечки между выборками | Правило гранулярности записано, стратегия сплита обоснована |
3 | Целостность и зрелость таргета | Свежие строки размечены не до конца | Известен baseline, метрика на рабочей точке и окно вызревания от as_of |
4 | Утечка таргета | 0.94 на истории, 0.86 в реальности | У каждого признака указано время доступности |
5 | Point‑in‑time и сплит | Агрегаты заглядывают в будущее | Сплит повторяет production‑сценарий |
6 | Пропуски и заглушки | −1 и 9999 обучают модель как реальные числа | Поколоночный список заглушек и политика пропусков |
7 | Кардинальность и новизна | Инференс падает на незнакомой категории | Политика для unseen выбрана до обучения |
8 | Артефакт и манифест | Перезапуск даёт другие цифры | Метрика воспроизводится по манифесту и его артефактам |
Порядок прогона и владельцы
На рис. 3 — порядок, в котором мы прогоняем проверки, и куда идти при каждом отказе.

Главная мысль схемы — у каждого отказа есть владелец. Пока проверка не заканчивается конкретным именем, она превращается в предупреждение в логе, которое все привыкают игнорировать.
Чем это делают команды в 2026 году
Инструментальный ландшафт за последний год заметно сдвинулся.
В Pandera 0.32 появился опциональный бэкенд на Narwhals: он включается флагом и уводит валидацию pandas, Polars, Ibis и PySpark SQL в единый код‑путь. PyArrow — отдельный случай: нативного бэкенда у него нет, он обслуживается только через Narwhals. Практический смысл в том, что схему не придётся переписывать при переезде датасета с pandas на Polars или Spark.
С Great Expectations история поучительнее. В мае 2026 GX объявила о сделке с FICO: облачный продукт GX Cloud перестал быть публично доступен с 1 июня, а Fivetran заявил о намерении взять на себя опеку над открытым ядром GX Core и сообществом.
Вывод для нас простой и он не про GX: source of truth для проверок качества должен быть исполняемым и лежать под контролем версий. Где именно он материализуется — в оркестраторе, в каталоге, в schema registry — вопрос второй.
Для ML‑специфичных вещей удобен Deepchecks: дрейф между train и test и подозрения на утечку идут готовыми сьютами, для мониторинга после выката — Evidently. Тащить три библиотеки сразу я бы не стал: девяносто процентов пользы дала одна pandera плюс десяток самописных ассертов.
И отдельно про подход, который я бы предпочёл любому инструменту: контракты данных. Схема из проверки 1 — не файл в папке DS‑команды, а договор между сервисом‑источником и потребителем, который ломает сборку при нарушении.
У нас на стороне Java‑сервисов это схема в реестре и тест в пайплайне поставщика. Дороже в настройке, зато отказ прилетает тому, кто его вызвал. И, как и предполагал, самой полезной частью оказался не код, а описание датасета: колонка, тип, момент доступности, политика пропусков, владелец.
Где этот подход не сработает
Всё описанное рассчитано на табличные данные, влезающие в память.
Для терабайтных витрин проверки переезжают в SQL или Spark, но не все на семплах: схема, уникальность, доля пропусков и ссылочная целостность считаются полностью, семплирование допустимо только для тяжёлых профилировочных проверок.
Для картинок, аудио и текста чек‑лист закрывает от силы половину рисков: добавляются битые файлы, дубликаты по перцептивному хэшу, качество разметки.
Проверка 4 не ловит косвенные утечки — когда признак сам по себе безобиден, но его комбинация с другим восстанавливает таргет. Это лечится только пониманием домена и разговором с бизнесом.
И главное: восемь зелёных проверок не означают, что модель будет работать. Они означают, что если она не заработает, вы будете чинить модель, а не искать, в каком месте данные врали.
Самая тяжёлая из восьми — четвёртая. Автоматика даёт только список подозреваемых, дальше нужен человек, понимающий, как устроен сбор данных и когда появляется каждое поле. Этому не учит документация библиотек, это разбирают на живых примерах.

Модель запускается, метрики выглядят хорошо, но в проде качество внезапно падает? Часто проблема начинается ещё до обучения — с данных, которые попали в датасет. Разобраться с подготовкой данных и научиться собирать надёжный pipeline — один из ключевых навыков для работы с ML‑моделями.
Открытые уроки по теме:
7 сентября в 18:00. «Учимся готовить данные для ML‑моделей». Записаться
16 сентября в 18:00. «Задача классификации от 0 до 9». Записаться
Полный список бесплатных уроков сентября смотрите в дайджесте.

