Перейти к содержимому
Ступень 1111.1 Как на работе

Грязные данные: как выглядят настоящие датасеты

Чем рабочая выгрузка отличается от учебного датасета — каталог проблем на русских и английских обращениях в поддержку и инженерный способ с ними работать — разведка по чек-листу, контракт данных, каппа Коэна, правила разметки, карточка датасета и версии данных.

УРОКstage-11/11-1-real-work/01-messy-data.mdx2 ч 30 минУрок 1 из 5
После урока вы сможете
  • Узнавать в выгрузке дубли и почти-дубли, заглушки вместо пропусков, разнобой форматов и кодировок, HTML-мусор, перепутанные столбцы, утечку цели, шум разметки, перекос классов и сдвиг во времени
  • Писать чистку как код с тестами — контракт данных, карантин для плохих строк и отчёт о качестве, где учтена каждая строка
  • Считать согласие разметчиков (каппу Коэна) по таблице расхождений и превращать расхождения в правила разметки
  • Описывать результат карточкой датасета и фиксировать версию данных, чтобы эксперимент можно было повторить

Все датасеты курса до сих пор были кем-то вычищены: синтетика с фиксированным seed, MNIST, наборы с Hugging Face. Даже «грязные» данные из урока 2.4.2 были испорчены аккуратно — ровно три проблемы, спрятанные нарочно. На работе вам дают выгрузку: файл, который кто-то собрал из трёх систем, двух часовых поясов и пяти лет истории. В нём всё сразу.

Инженеры повторяют присказку «80% качества модели — это данные». Это не измеренная величина, а правило большого пальца, но вы уже видели его в действии. LIMA дообучила модель на 1000 тщательно отобранных примеров (6.1.2). Модели на FineWeb-Edu заметно сильнее на MMLU и ARC, чем на исходном FineWeb, хотя архитектура та же — поменялся только отбор данных (5.1.2). Судья, который «согласен с человеком в 82% случаев», оказался почти бесполезным (7.3.2).

Главная мысль урока: чистка данных — это инженерия с тестами, а не ручное ковыряние в Excel. Ручная правка не воспроизводится: завтра придёт новая выгрузка, и всё начнётся сначала, а коллега не узнает, что вы удалили и почему. Код, тесты и отчёт — воспроизводятся.

Учебный датасетРабочая выгрузка
Кто чистилавторы бенчмарка, годаминикто, или вы
Схемаописана в статьев голове у человека, который уволился
ПропускиNaNNaN, «-», «N/A», «нет», 0, 01.01.1970
Меткиодна, «правильная»две от разных людей, иногда противоречат
Времяне важнораспределение меняется каждый месяц
Разбиениеготовое train/testпридумываете сами, и от него зависит честность метрик
Завтратот же файлновая выгрузка, новые сюрпризы

Дальше — каталог проблем на сквозном примере: обращения в поддержку маркетплейса. Каждое обращение — номер, клиент, дата, канал, сумма заказа, текст и категория от двух разметчиков. Задача на будущее — классификатор, который сам раскладывает обращения по категориям «оплата», «доставка», «возврат», «товар», «аккаунт». Вот пять строк из такой выгрузки, как они лежат в CSV:

ticket_id,customer_id,created_at,channel,order_amount,text,label_a,label_b,resolved_team
T-000583,C-00412,2026-02-17,web,n/a,"Банк заблокировал платёж на 38 524 ₽…",оплата,оплата,Финансы
T-000936,C-00655,1 марта 2026 г.,App,585,"Hello.<br>Деньги за заказ AB-7230836 списали дважды…",оплата,ОПЛАТА,Финансы
T-001722,C-01206,1772323200,чат,"1108,50 руб","Сдал заказ №99113746 в пункт выдачи 28 дней назад…",возврат,возврат,Возвраты
T-001613,C-01129,"March 9, 2026",app,2902.00,"<p>I want to return the rice cooker, wrong size. Order 87578361…</p> 😡😡",возврат,доставка,Возвраты
T-000195,C-00137,15.02.2026,app,1964.00,"Добрый день. Заказывал…",товар,товар,Качество

Найдите глазами хотя бы восемь проблем, прежде чем читать дальше.

Точный дубль — строка повторяется байт в байт: выгрузку запустили дважды. Ловится df.duplicated(). Почти-дубль (почти-дубликатпочти-дубликатEN near-duplicate) — тот же смысл, другая запись: клиент написал второй раз капсом, без точки, со смайликом, добавил «повторно». Чем опасно:

  • в обучении один пример весит вдвое — модель переучивается на частых жалобах;
  • если копии попали и в train, и в test, модель проверяют на том, что она уже видела, и метрика завышена — та же загрязнение данныхзагрязнение данных (утечка бенчмарка в обучение)EN data contaminationЗадачи из тестового бенчмарка попали в обучающий корпус LLM — оценка становится завышенной. Родственное явление — «шуабан», подгонка модели под лидерборд., что у бенчмарков LLM.

Рецепт из урока 5.1.2 работает и здесь: сначала ключ нормализации (регистр, «ё», знаки, HTML, эмодзи уходят), потом шинглы и сходство Жаккарасходство ЖаккараEN Jaccard similarity|A ∩ B| / |A ∪ B| — доля общих элементов двух множеств.. У коротких обращений словесных шинглов слишком мало, а опечатка портит все словесные шинглы с этим словом, но лишь несколько символьных, поэтому шинглы берут символьные:

import re
def dedup_key(text):
t = re.sub(r"<[^>]+>", " ", text).lower().replace("ё", "е") # теги → пробел, регистр, ё = е
return " ".join(re.sub(r"[\W_]+", " ", t).split()) # всё, кроме букв и цифр, → пробел
def shingles(key, k=4):
return {key[i:i + k] for i in range(len(key) - k + 1)} # символьные 4-граммы
def jaccard(a, b):
return len(a & b) / len(a | b)
t1 = "Здравствуйте! Заказ №58233662 уже 9 дней лежит на сортировке в Казани. Когда доставят?"
t2 = "<p>ЗДРАВСТВУЙТЕ!!! заказ 58233662 уже 9 дней лежит на сортировке в казани когда доставят 😡</p>"
t3 = "Здравствуйте, пишу повторно! Заказ №58233662 уже 9 дней лежит на сортировке в Казани. Когда доставят?"
t4 = "Здравствуйте! Заказ №71904415 уже 12 дней лежит на сортировке в Перми. Когда доставят?"
k1, k2, k3, k4 = (dedup_key(t) for t in (t1, t2, t3, t4))
print(k1 == k2, round(jaccard(shingles(k1), shingles(k3)), 2), round(jaccard(shingles(k1), shingles(k4)), 2))
True 0.81 0.55

Второй текст после нормализации совпал с первым. Третий — почти-дубль с J=0.81J = 0.81. Четвёртый — другое обращение по тому же шаблону, и всё равно J=0.55J = 0.55: шаблонные тексты похожи. Порог 0.8 между ними проходит, но с небольшим запасом — поэтому порог выбирают, глядя на пары по обе стороны от него, а не «по учебнику». Сравнивать все пары 100 тысяч обращений — 5⋅1095 \cdot 10^9 пар. В вебе эту задачу решает MinHashMinHashEN MinHashКороткая подпись множества; доля совпавших позиций двух подписей — оценка сходства Жаккара. (урок 5.1.2), а в поддержке помогает знание предметной области: почти-дубль пишет тот же клиент, поэтому сравнивать достаточно внутри клиента.

Пропускпропуск (пропущенное значение)EN missing value (NaN)Клетка без значения; в pandas — NaN (для дат — NaT). Агрегаты вроде mean пропуски пропускают. в выгрузке редко выглядит как NaN. Чаще это значение-заглушказначение-заглушкаEN sentinel (placeholder) valueЗначение, которое на самом деле означает «нет данных»: «-», «N/A», «нет», 0, 01.01.1970, 99999. Опаснее явного пропуска: выглядит как настоящее значение и попадает в расчёты.: «-», «N/A», «нет», «не указано», 0 вместо суммы, 01.01.1970 вместо даты (это ноль unix-времени), 99999 вместо возраста. Заглушка опаснее явного пропуска: она выглядит как настоящее значение и попадает в средние. И pandas здесь не помогает, а решает за вас:

import io
import pandas as pd
csv = "ticket_id,amount,channel\n007,N/A,app\n008,-,null\n009,,нет\n"
default = pd.read_csv(io.StringIO(csv)) # как читают «по умолчанию»
raw = pd.read_csv(io.StringIO(csv), dtype=str, keep_default_na=False) # как читают сырые данные
print(default["ticket_id"].tolist(), default["amount"].tolist(), default["channel"].tolist())
print(raw["ticket_id"].tolist(), raw["amount"].tolist(), raw["channel"].tolist())
[7, 8, 9] [nan, '-', nan] ['app', nan, 'нет']
['007', '008', '009'] ['N/A', '-', ''] ['app', 'null', 'нет']

По умолчанию «N/A», «null» и пустая строка стали NaN, а «-» и «нет» — нет; номер «007» превратился в число 7. Правило: сырой файл читаем как строки, а список заглушек держим в коде, на виду, и считаем, сколько ячеек заменили. Тогда в отчёте видно: «в order_amount 202 заглушки», и это число можно обсудить.

Даты. «2026-03-01», «01.03.2026», «1 марта 2026», «March 1, 2026», «1772323200» (unix-время, секунды с 1 января 1970 UTC) — одна и та же дата. Хуже, когда формат неоднозначен: «01.02.2026» в России — 1 февраля, а «01/02/2026» из англоязычной формы — 2 января. Правило фиксируют письменно (точки — день первым, косая черта — месяц первым) и никогда не «угадывают»: если «13/02/2026» не разбирается как месяц/день, честный ответ — None и строка в отчёте, а не тихая перестановка. Тихая ошибка в 10% дат ломает все признаки «сколько дней прошло».

Единицы. «1 200 ₽», «1200.00», «120000 коп.», «1,200.50», «1.200,50» — суммы в рублях и копейках, с разными разделителями тысяч и дробной части. Деньги храните целыми копейками: 0.1 + 0.2 == 0.3 в Python даёт False, а float("1200.10") * 100 = 120009.99999999999 — после int() копейка пропала.

Кодировки (кодировкакодировка (символов)EN character encodingСпособ записать символы байтами. UTF-8: латиница — 1 байт, кириллица — 2, китайские иероглифы — 3.). Из урока 0.2.4 вы знаете: текст в UTF-8, прочитанный как Windows-1251, превращается в кракозябрыкракозябры (mojibake)EN mojibakeНечитаемый текст из-за декодирования байтов не той кодировкой, например «РџСЂРёРІРµС‚» вместо «Привет».. Старая CRM выгружает в cp1251, Excel добавляет в начало UTF-8 невидимый BOM, китайские системы пишут в GBK. Кракозябры часто обратимы: "Привет".encode("cp1251").decode("utf-8") возвращает «Привет».

Юникод. Один символ можно записать по-разному: полноширинные цифры и запятые из китайской раскладки, которые выглядят как обычные, но имеют другие коды Unicode (U+FF11 вместо «1», U+FF0C вместо «,»), неразрывный пробел, невидимый пробел нулевой ширины U+200B из мессенджера. Нормализация NFKCнормализация ЮникодаEN Unicode normalization (NFC, NFKC)Приведение «одинаковых на вид» символов к одной записи. NFKC превращает полноширинные цифры и запятую (U+FF11–U+FF13, U+FF0C) в обычные «123» и «,», но заодно «№» в «No». приводит их к обычной записи, но у неё есть побочный эффект, который ловят только тестом: unicodedata.normalize("NFKC", "Заказ №5") даёт «Заказ No5».

Тексты из веб-форм и почты приходят с тегами <p>, <br>, сущностями &nbsp; и &laquo;, а из старых систем — ещё и с <script>, <style> и комментариями, у которых содержимое тоже мусор. Иногда текст экранирован дважды: &amp;lt;br&amp;gt;. Эмодзи и капс («СРОЧНО!!! 😡») для классификатора — сигнал, их не выбрасывают из текста для модели, но убирают из ключа для поиска дублей. Отсюда правило: две разные нормализации для двух задач — «текст для модели» и «ключ для сравнения».

Выгрузка сдвинула значения: в created_at лежит «1 200 ₽», а в order_amount — «15.03.2026». Такое бывает, когда в тексте была незакрытая кавычка или запятая, а CSV собирали склейкой строк. Ловится проверкой типов по каждому столбцу: дата не разбирается как дата, но разбирается как сумма — подозрение. Чинить такую строку можно только по явному правилу, и каждое исправление — в отчёт.

В выгрузке есть поле resolved_team — команда, закрывшая обращение. Оно почти однозначно задаёт категорию: «Финансы» → «оплата», «Логистика» → «доставка». Модель с этим признаком покажет на валидации 95% и будет бесполезна: в момент, когда обращение только пришло, его ещё никто не закрыл. Это та же утечка данныхутечка данныхEN data leakage, что в уроке 2.4.2, и тот же вопрос к каждому столбцу: известен ли он в момент предсказания? Автоматическая проверка — обучить на каждом признаке в одиночку простейшую модель «значение → самая частая метка» и оценить её out-of-fold (на строках, которых она не видела). Out-of-fold важен: на тех же строках уникальный номер обращения «угадает» метку идеально и поднимет ложную тревогу.

Два разметчика ставят разные метки на 15% обращений — это нормально для живых людей, но это потолок для модели: она не может быть согласнее с «правдой», чем разметчики между собой. Шум разметкишум разметкиEN label noiseНеверные метки в данных. Бывают даже в тестовых наборах известных бенчмарков (Northcutt и др., 2021). есть даже в тестовых наборах знаменитых бенчмарков: Northcutt, Athalye и Mueller (NeurIPS 2021) нашли ошибки в тестовых наборах всех десяти популярных датасетов, которые проверили, — от ImageNet и MNIST до IMDB и AudioSet, — и показали, что исправление меток может поменять местами модели в рейтинге. Отдельный сигнал — дубли с разными метками: один и тот же текст получил «возврат» и «товар».

Перекос классовдисбаланс классовEN class imbalance: «доставка» — почти треть обращений, «аккаунт» — 11%. Accuracy обманывает, нужны метрики по классам и стратифицированное разбиение (урок 2.2.3 и 2.4.4).

Сдвиг распределениясдвиг распределенияEN distribution shiftДанные, на которых модель работает, распределены иначе, чем обучающие: сезонность, новые продукты, новые каналы. Поэтому валидацию делают «прошлое → будущее».: в ноябре–декабре, после распродажи 11.11, доля жалоб на доставку растёт с 29% до 44%. Если перемешать год случайно и разбить 80/20, модель на валидации видит те же месяцы, что в обучении, и метрика завышена. Модель будет работать в будущем — значит, и проверять её надо «прошлое → будущее»: обучать на сентябре–мае, проверять на июне–августе. В продакшене тот же эффект называется дрейфомдрейф данныхEN data driftИзменение распределения входов со временем — новый язык, новые темы, сезонность. Эвал-набор при этом перестаёт отражать реальный трафик., его мониторинг — тема урока 11.1.4.

flowchart LR
A["Сырая выгрузка<br/>(не правим руками)"] --> B["Разведка<br/>по чек-листу"]
B --> C["Решения:<br/>правила в README"]
C --> D["Код чистки<br/>+ тесты на правила"]
D --> E{"Контракт<br/>данных"}
E -->|"нарушения"| F["Карантин<br/>и отчёт"]
E -->|"ок"| G["Чистый набор<br/>+ карточка + версия"]
G --> H["Обучение"]
F -.->|"новое правило"| C
A -.->|"завтрашняя выгрузка"| D
Чистка как конвейер: сырые данные неизменны, все решения записаны, каждая строка учтена

Четыре принципа, на которых держится схема.

  1. Сырые данные неизменны. Исходный файл не правят никогда — ни руками, ни скриптом «на месте». Всё, что вы сделали, — функция от сырого файла. Тогда чистку можно перезапустить на завтрашней выгрузке.
  2. Каждое решение записано. «Точки — день первым», «копейки — целые», «почти-дубль — J≥0.8J \ge 0.8 внутри клиента». Решение без записи живёт в голове одного человека.
  3. Каждое правило — тест. Наткнулись на «1.200» — добавили тест «1.200 → 120000 копеек». Правила копятся, регрессий нет.
  4. Каждая строка учтена. Отчёт сходится: строк на входе = строк на выходе + точных дублей + почти-дублей + в карантине. Если не сходится — где-то строки теряются молча.

Чек-лист из урока 2.4.2 (структура → распределения → выбросы → связи → цель и утечки → дубликаты) работает и здесь. Для текстов и сырых выгрузок добавьте пять пунктов:

  • value_counts() по сырым строкам каждого столбца: сколько форматов дат, какие написания каналов, какие заглушки;
  • длина текстов: очень короткие («ок», «???») и очень длинные (склеенные письма);
  • символы: доля строк с <, &, U+200B, полноширинными символами, кракозябрами;
  • метки: таблица «разметчик A × разметчик B» и доли классов по месяцам;
  • для каждого столбца — вопрос про утечку: когда он заполняется?

Контракт данныхконтракт данныхEN data contractЗаписанная кодом договорённость о данных: столбцы, типы, обязательность, уникальность, допустимые значения и диапазоны. Валидатор проверяет каждую выгрузку и не пускает дальше строки с нарушениями. — договорённость о том, какой должна быть таблица, записанная кодом: для каждого столбца тип, обязательность, уникальность, допустимые значения, диапазон, шаблон. Валидатор возвращает список нарушений с номером строки и правилом — по нему видно, что и где сломалось. Строки с нарушениями уходят в карантин: их не выбрасывают молча, их номера — в отчёте.

contract = {
"channel": {"required": True, "allowed": {"app", "web", "email", "chat"}},
"amount_rub": {"required": False, "min": 0, "max": 1_000_000},
}
def violations(df, contract):
found = []
for col, rules in contract.items():
s = df[col]
masks = {} # правило → булева маска нарушений
if rules.get("required"):
masks["required"] = s.isna()
if "allowed" in rules:
masks["allowed"] = s.notna() & ~s.isin(rules["allowed"])
if "min" in rules:
masks["min"] = s < rules["min"] # NaN < 0 → False: пропуск не нарушает min
if "max" in rules:
masks["max"] = s > rules["max"]
for rule, mask in masks.items():
found += [(row, col, rule) for row in df.index[mask]]
return sorted(found)
df = pd.DataFrame({"channel": ["app", "phone", None, "web"], "amount_rub": [1200, -500, None, 10_000_000]})
print(violations(df, contract))
[(1, 'amount_rub', 'min'), (1, 'channel', 'allowed'), (2, 'channel', 'required'), (3, 'amount_rub', 'max')]

Канал «phone» — не опечатка: в марте подключили телефонию, а контракт не обновили. Валидатор сказал об этом в первый же день, а не через месяц по упавшей метрике. В индустрии для таких проверок есть библиотеки (например, pandera и Great Expectations), но идея у них та же: правила описаны как данные, результат — список нарушений.

Доля совпадений обманывает: если оба разметчика почти всегда ставят «доставку», они будут часто совпадать случайно. каппа Коэнакаппа КоэнаEN Cohen's kappaСогласие двух разметчиков с поправкой на случайное совпадение. 0 — как наугад, 1 — полное согласие. (урок 7.3.2) вычитает случайное согласие:

κ=po−pe1−pe,pe=∑cpA(c) pB(c).\kappa = \frac{p_o - p_e}{1 - p_e}, \qquad p_e = \sum_{c} p_A(c)\, p_B(c).

Здесь pop_o — наблюдаемая доля совпадений, pA(c)p_A(c) и pB(c)p_B(c) — доли класса cc у каждого разметчика. Пример: 100 обращений, три класса, таблица «A × B»:

A \ Bдоставкавозвратоплатаитого A
доставка408250
возврат520530
оплата131620
итого B463123100

Совпадения — на диагонали: po=(40+20+16)/100=0.76p_o = (40 + 20 + 16)/100 = 0.76. Случайное согласие: pe=0.50⋅0.46+0.30⋅0.31+0.20⋅0.23=0.230+0.093+0.046=0.369p_e = 0.50 \cdot 0.46 + 0.30 \cdot 0.31 + 0.20 \cdot 0.23 = 0.230 + 0.093 + 0.046 = 0.369. Тогда κ=(0.76−0.369)/(1−0.369)≈0.62\kappa = (0.76 - 0.369)/(1 - 0.369) \approx 0.62 — «существенное» согласие по шкале Лэндиса и Коха, но четверть ответов расходится. Самая частая пара — «доставка» и «возврат» (8 + 5 = 13 случаев). Это подсказка, куда смотреть.

Расхождения разметчиков чаще говорят о плохих правилах, чем о плохих людях. Клиент пишет «посылка не пришла, верните деньги» — это «доставка» или «возврат»? Если правила молчат, каждый решает по-своему. Правила разметкиправила разметкиEN annotation guidelinesДокумент для разметчиков: определения классов, правила для пограничных случаев, примеры «да» и «нет», что делать при сомнении. Правят по разбору расхождений. содержат:

  • определение каждого класса одним-двумя предложениями;
  • правила для пограничных случаев: «товар не доставлен, клиент просит деньги → доставка: причина важнее желаемого результата»;
  • 3–5 примеров «да» и «нет» на класс, лучше настоящих и спорных;
  • что делать при сомнении: метка «не уверен» или эскалация, а не угадывание.

Цикл такой: пилот на 100–200 примерах двумя разметчиками → каппа и таблица расхождений → разбор спорных пар вместе → правка правил → новый замер. Строки, где разметчики так и разошлись, не «усредняют»: их отдают третьему человеку, а до тех пор они идут в обучение без метки.

Карточка датасетакарточка датасетаEN dataset card (datasheet)Документ к датасету: откуда данные, за какой период, как размечены и почищены (с числами), перекосы, ограничения, версия. Пара к карточке модели. — пара к карточке модели из урока 9.3.5. Вопросы для неё предложили Gebru и др. в «Datasheets for Datasets»; на Hugging Face карточка лежит в README репозитория датасета. Минимальный шаблон:

# Обращения в поддержку, версия 2026-10-06 (sha256: 3f9a…)
- Источник и период: выгрузка CRM и веб-формы, 2025-09-01 — 2026-08-31.
- Размер: 2100 строк на входе → 1965 в чистом наборе: 40 точных дублей, 60 почти-дублей, 35 в карантине.
- Разметка: два разметчика, κ = 0.79; 299 спорных строк без метки (ждут третьего мнения).
- Классы: доставка 27%, возврат 16%, оплата 17%, товар 14%, аккаунт 10%, без метки 15%.
- Сдвиг: в ноябре–декабре доля «доставки» 44% против 29% в остальные месяцы (распродажа 11.11).
- Удалено как утечка: resolved_team. Персональные данные: номера заказов в тексте не маскированы.
- Как получить: python clean.py raw/tickets.csv (код — коммит a1b2c3d).

Последняя строка — происхождение данныхпроисхождение данных (lineage)EN data lineageДля каждой строки и каждого признака известно, из какого источника и какими шагами они получены. Простейший вариант — сохранять номер строки сырого файла.: из какого сырого файла и каким кодом получен набор. Её дополняет версионирование данныхверсионирование данныхEN data versioningКаждая версия набора данных получает неизменный идентификатор (обычно хэш содержимого), который записан рядом с кодом. Идея инструмента DVC: данные в хранилище, их хэши — в git.. Идея DVC: файлы данных лежат в отдельном хранилище, а в git попадает маленький файл с их хэшем. Тогда коммит кода однозначно указывает и на версию данных:

Окно терминала
dvc init # один раз в репозитории
dvc add data/raw/tickets.csv # хэш файла → data/raw/tickets.csv.dvc, сам файл → в .gitignore
git add data/raw/tickets.csv.dvc data/raw/.gitignore && git commit -m "Выгрузка за 2025/26"
dvc push # данные — в удалённое хранилище (dvc remote add)
git checkout <старый коммит> && dvc checkout # вернуть данные, на которых обучали тогда

Без DVC идею можно воспроизвести вручную: посчитать hashlib.sha256 сырого и чистого файла и записать хэши в отчёт эксперимента рядом с git-хэшем кода. Об этом — урок 11.1.3.

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

Валидатор выгрузки обращений: контракт, дубли, метки, отчётpandas · нужен интернет7 тестов
import re  # понадобится в normalize_text и check_schema

import pandas as pd

LABELS = ["оплата", "доставка", "возврат", "товар", "аккаунт"]

# Контракт данных: что должно быть верно для КАЖДОЙ строки выгрузки
SCHEMA = {
    "ticket_id": {"required": True, "unique": True, "pattern": r"T-\d{4}"},
    "created_at": {"required": True, "min": pd.Timestamp("2025-09-01"), "max": pd.Timestamp("2026-08-31")},
    "channel": {"required": True, "allowed": ["app", "web", "email", "chat"]},
    "amount_rub": {"required": False, "min": 0, "max": 1_000_000},
    "text": {"required": True},
    "label_a": {"required": True, "allowed": LABELS},
    "label_b": {"required": False, "allowed": LABELS},
}


def make_tickets():
    """24 обращения в поддержку маркетплейса — маленькая копия настоящей выгрузки со всеми её болячками."""
    rows = [
        ("T-0001", "2026-03-01", "app", 1200, "Где мой заказ №123? Трек не обновляется", "доставка", "доставка"),
        ("T-0002", "2026-03-01", "web", 560, "Списали деньги дважды за заказ №456", "оплата", "оплата"),
        ("T-0003", "2026-03-02", "app", 1200, "<p>где мой ЗАКАЗ 123?! трек не обновляется</p>", "доставка", "возврат"),
        ("T-0004", "2026-03-02", "email", None, "Не приходит СМС с кодом для входа", "аккаунт", "аккаунт"),
        ("T-0005", "2026-03-03", "chat", 3400, "Хочу вернуть куртку, не подошёл размер", "возврат", "возврат"),
        ("T-0006", "2026-03-03", "app", 89000, "Пришёл бракованный пылесос, требую замену", "товар", "возврат"),
        ("T-0007", "2026-03-04", "phone", 1500, "Курьер не позвонил и не приехал", "доставка", "доставка"),
        ("T-0007", "2026-03-04", "web", 700, "Промокод не применился к заказу", "оплата", "оплата"),
        (None, "2026-03-05", "app", 2300, "Посылка третью неделю на сортировке", "доставка", "доставка"),
        ("T-0010", "2026-03-05", "app", -500, "Верните деньги за отменённый заказ", "возврат", "возврат"),
        ("T-0011", "2026-03-06", "web", 10_000_000, "Оплатил телевизор, а заказ не оформился", "оплата", "оплата"),
        ("T-0012", "2026-03-06", "chat", 990, "My order still has not arrived after two weeks", "доставка", "доставка"),
        ("T-0013", "2026-03-07", "chat", 990, "My order still has NOT arrived after two weeks!", "доставка", "доставка"),
        ("T-0014", "2026-03-07", "app", 4500, "Хочу вернуть куртку, не подошёл размер!!!", "товар", "возврат"),
        ("T-0015", "2027-01-15", "web", 1200, "Не могу поменять почту в профиле", "аккаунт", "аккаунт"),
        ("T-0016", "2026-03-08", "email", 15000, "Ноутбук греется и выключается", "товар", "товар"),
        ("T-0017", "2026-03-08", "app", 250, "Курьер оставил коробку у соседей", "доставка", "доставка"),
        ("T-0018", "2026-03-09", "web", 1800, "Деньги списали, а заказ не оплачен", "оплата", "возврат"),
        ("T-0019", "2026-03-09", "app", 600, "Ёлочная гирлянда не работает", "товар", "товар"),
        ("T-0020", "2026-03-10", "chat", None, "Аккаунт заблокировали без причины", "аккаунт", None),
        ("T-0021", "2026-03-10", "app", 3100, "Где возврат денег за наушники?", "возврат", "спам"),
        ("T-0022", "2026-03-11", "web", 2700, "елочная гирлянда не работает", "товар", "товар"),
        ("T-0023", "2026-03-11", "app", 1900, "   ", "доставка", "доставка"),
        ("T-0024", "2026-03-12", "app", 1450, "Курьер нахамил по телефону", "доставка", "доставка"),
    ]
    df = pd.DataFrame(rows, columns=["ticket_id", "created_at", "channel", "amount_rub", "text", "label_a", "label_b"])
    df["created_at"] = pd.to_datetime(df["created_at"])
    return df


def is_missing(value):
    """Пропуск: None, NaN, NaT или строка из одних пробелов. Готовая функция."""
    if value is None or value is pd.NaT or value is pd.NA:
        return True
    if isinstance(value, float) and value != value:          # NaN не равен сам себе
        return True
    return isinstance(value, str) and not value.strip()


def normalize_text(text):
    """Ключ для поиска дублей. По шагам:
    1) пропуск (is_missing) → "";
    2) HTML-теги <...> → пробел (re.sub);
    3) нижний регистр и «ё» → «е»;
    4) всё, кроме букв и цифр (любых алфавитов — и кириллицы, и латиницы), → пробел: подойдёт re.sub(r"[\W_]+", " ", t);
    5) схлопнуть пробелы и обрезать края: " ".join(t.split()).
    Пример: "<p>Где мой ЗАКАЗ №123?!</p>" → "где мой заказ 123".
    """
    # TODO: ваш код
    ...


def check_schema(df, schema):
    """Нарушения контракта schema: отсортированный список кортежей (метка строки, столбец, правило).

    Для каждого столбца schema и каждой его ячейки (df[col].items() даёт пары метка, значение):
      * пропуск (is_missing) → ("required"), если столбец обязательный; другие правила к пропуску не применяем;
      * "unique": значение уже встречалось выше → "unique" (первое появление — не нарушение);
      * "pattern": re.fullmatch(шаблон, str(значение)) не нашёл совпадения → "pattern";
      * "allowed": значения нет в списке → "allowed";
      * "min" / "max": значение меньше min или больше max → "min" / "max".
    Верните sorted(список).
    """
    # TODO: ваш код
    ...


def find_duplicates(df, col="text"):
    """Группы дублей: метки строк с одинаковым normalize_text(текст). Только группы из 2+ строк,
    каждая группа отсортирована, список групп отсортирован. Строки с пустым ключом пропускаем.
    Подсказка: словарь ключ → список меток строк.
    """
    # TODO: ваш код
    ...


def cohen_kappa(a, b):
    """Каппа Коэна: (p_o − p_e) / (1 − p_e) по парам, где ОБЕ метки не пропуски (is_missing).
    p_o — доля совпадений; p_e = Σ по меткам c: (доля c у первого разметчика) · (доля c у второго).
    """
    # TODO: ваш код
    ...


def suspicious_labels(df, groups):
    """Строки с подозрительной разметкой — отсортированный список меток строк:
    1) обе метки (label_a и label_b) есть и не совпадают;
    2) строка входит в группу дублей из groups, где у дублей разные label_a (один текст — разные категории).
    """
    # TODO: ваш код
    ...


def quality_report(df, schema):
    """Отчёт о качестве — словарь (только int, float, str, bool, dict — чтобы сохранить в JSON):
      "rows"               — число строк;
      "violations"         — {"столбец:правило": сколько} по check_schema, ключи по алфавиту;
      "duplicate_groups"   — сколько групп дублей (find_duplicates);
      "rows_to_drop"       — сколько строк уйдёт при дедупликации (в каждой группе одна остаётся);
      "suspicious_labels"  — сколько строк с подозрительной разметкой;
      "kappa"              — каппа Коэна label_a и label_b, round(..., 3);
      "class_share"        — {метка: доля} по label_a (только метки из LABELS), round(..., 3), ключи по алфавиту;
      "ok"                 — True, только если нет ни нарушений, ни дублей.
    """
    # TODO: ваш код
    ...


tickets = make_tickets()
print(tickets.head(3).to_string(), "\n")
print("ключ:", normalize_text(tickets.loc[2, "text"]))
problems = check_schema(tickets, SCHEMA)
print("нарушений контракта:", None if problems is None else len(problems))
print("группы дублей:", find_duplicates(tickets))
report = quality_report(tickets, SCHEMA)
for key, value in (report or {}).items():
    print(f"{key:18} {value}")
Проверь себя6 вопросов
1 Какие значения pd.read_csv с настройками по умолчанию превратит в NaN?Можно выбрать несколько ответов
2 Парсер дат встретил «13/02/2026». По правилу проекта косая черта означает «месяц/день/год». Что правильно вернуть?
3 Два разметчика совпали в 90% случаев, и каждый ставит «доставку» в 90% случаев (остальное — «возврат»). Чему равна каппа Коэна? Округлите до двух знаков.
4 Классификатор должен раскладывать обращение по категориям сразу, как оно пришло. Какой столбец выгрузки — утечка?
5 В выгрузке 3% обращений — почти-дубли (клиент написал ещё раз). Вы перемешали данные и разбили 80/20 без дедупликации. Что вероятнее всего произойдёт?
6 Обращения за сентябрь 2025 — август 2026, модель будет работать с сентября 2026. В ноябре–декабре доля жалоб на доставку в полтора раза выше. Как честнее всего выбрать валидацию?
  • Рабочая выгрузка отличается от учебного датасета всем сразу: дубли и почти-дубли, заглушки вместо пропусков, разнобой дат, денег и кодировок, HTML-мусор, перепутанные столбцы, утечки, шум разметки, перекос классов и сдвиг во времени.
  • Чистка — инженерия: сырые данные неизменны, решения записаны, на каждое правило — тест, каждая строка учтена в отчёте. Ручная правка не воспроизводится.
  • Контракт данных проверяет каждую выгрузку и отправляет плохие строки в карантин. Неоднозначное не угадывают, а возвращают None и считают.
  • Согласие разметчиков меряют каппой, а расхождения превращают в правила разметки. Спорные строки — третьему человеку, а не «как-нибудь».
  • Результат описывают карточкой датасета и фиксируют версию данных хэшем — тогда эксперимент можно повторить.

Дальше: урок 11.1.2 — чужой код: как читать большой репозиторий, расследовать баг через git и ловить «тихие» ошибки ML-кода, которые не падают, а портят метрики.