Грязные данные: как выглядят настоящие датасеты
Чем рабочая выгрузка отличается от учебного датасета — каталог проблем на русских и английских обращениях в поддержку и инженерный способ с ними работать — разведка по чек-листу, контракт данных, каппа Коэна, правила разметки, карточка датасета и версии данных.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Все датасеты курса до сих пор были кем-то вычищены: синтетика с фиксированным 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. Ручная правка не воспроизводится: завтра придёт новая выгрузка, и всё начнётся сначала, а коллега не узнает, что вы удалили и почему. Код, тесты и отчёт — воспроизводятся.
Учебный датасет и рабочая выгрузка
Заголовок раздела «Учебный датасет и рабочая выгрузка»| Учебный датасет | Рабочая выгрузка | |
|---|---|---|
| Кто чистил | авторы бенчмарка, годами | никто, или вы |
| Схема | описана в статье | в голове у человека, который уволился |
| Пропуски | NaN | NaN, «-», «N/A», «нет», 0, 01.01.1970 |
| Метки | одна, «правильная» | две от разных людей, иногда противоречат |
| Время | не важно | распределение меняется каждый месяц |
| Разбиение | готовое train/test | придумываете сами, и от него зависит честность метрик |
| Завтра | тот же файл | новая выгрузка, новые сюрпризы |
Дальше — каталог проблем на сквозном примере: обращения в поддержку маркетплейса. Каждое обращение — номер, клиент, дата, канал, сумма заказа, текст и категория от двух разметчиков. Задача на будущее — классификатор, который сам раскладывает обращения по категориям «оплата», «доставка», «возврат», «товар», «аккаунт». Вот пять строк из такой выгрузки, как они лежат в CSV:
ticket_id,customer_id,created_at,channel,order_amount,text,label_a,label_b,resolved_teamT-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Второй текст после нормализации совпал с первым. Третий — почти-дубль с . Четвёртый — другое обращение по тому же шаблону, и всё равно : шаблонные тексты похожи. Порог 0.8 между ними проходит, но с небольшим запасом — поэтому порог выбирают, глядя на пары по обе стороны от него, а не «по учебнику». Сравнивать все пары 100 тысяч обращений — пар. В вебе эту задачу решает 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 ioimport 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».
HTML-мусор и эмодзи
Заголовок раздела «HTML-мусор и эмодзи»Тексты из веб-форм и почты приходят с тегами <p>, <br>, сущностями и «, а из старых систем —
ещё и с <script>, <style> и комментариями, у которых содержимое тоже мусор. Иногда текст экранирован
дважды: &lt;br&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.200» — добавили тест «1.200 → 120000 копеек». Правила копятся, регрессий нет.
- Каждая строка учтена. Отчёт сходится: строк на входе = строк на выходе + точных дублей + почти-дублей + в карантине. Если не сходится — где-то строки теряются молча.
Разведка по чек-листу
Заголовок раздела «Разведка по чек-листу»Чек-лист из урока 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) вычитает случайное согласие:
Здесь — наблюдаемая доля совпадений, и — доли класса у каждого разметчика. Пример: 100 обращений, три класса, таблица «A × B»:
| A \ B | доставка | возврат | оплата | итого A |
|---|---|---|---|---|
| доставка | 40 | 8 | 2 | 50 |
| возврат | 5 | 20 | 5 | 30 |
| оплата | 1 | 3 | 16 | 20 |
| итого B | 46 | 31 | 23 | 100 |
Совпадения — на диагонали: . Случайное согласие: . Тогда — «существенное» согласие по шкале Лэндиса и Коха, но четверть ответов расходится. Самая частая пара — «доставка» и «возврат» (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, сам файл → в .gitignoregit 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.
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}")
Проверь себя
Заголовок раздела «Проверь себя»- Рабочая выгрузка отличается от учебного датасета всем сразу: дубли и почти-дубли, заглушки вместо пропусков, разнобой дат, денег и кодировок, HTML-мусор, перепутанные столбцы, утечки, шум разметки, перекос классов и сдвиг во времени.
- Чистка — инженерия: сырые данные неизменны, решения записаны, на каждое правило — тест, каждая строка учтена в отчёте. Ручная правка не воспроизводится.
- Контракт данных проверяет каждую выгрузку и отправляет плохие строки в карантин. Неоднозначное не угадывают,
а возвращают
Noneи считают. - Согласие разметчиков меряют каппой, а расхождения превращают в правила разметки. Спорные строки — третьему человеку, а не «как-нибудь».
- Результат описывают карточкой датасета и фиксируют версию данных хэшем — тогда эксперимент можно повторить.
Дальше: урок 11.1.2 — чужой код: как читать большой репозиторий, расследовать баг через git и ловить «тихие» ошибки ML-кода, которые не падают, а портят метрики.