Как подготовить данные к обучению модели
Представим службу поддержки, которая получает тысячи обращений. Часть вопросов касается оплаты, часть доставки, остальные связаны с доступом к аккаунту. Команда хочет обучить модель определять тему нового обращения и предлагать подходящую очередь обработки.
В базе уже есть история заявок. Кажется, что работа проста: выгрузить таблицу, передать тексты модели и научить её предсказывать отдел. На практике именно здесь возникает большинство вопросов, от которых зависит результат.
Можно ли считать отдел, куда обращение попало первым, правильным ответом? Что делать, если клиент прислал одно и то же сообщение через почту и форму на сайте? Разрешено ли использовать итоговый комментарий сотрудника, если в момент поступления заявки его ещё не было? Почему модель показывает отличное качество на проверке, но ошибается после запуска?
Эта статья разбирает путь от записей в рабочих системах до обучающей выборки. Сквозной пример один: классификация темы обращения в момент его поступления. На нём можно увидеть, зачем нужен каждый шаг подготовки и как обнаружить ошибку до обучения сложной модели.
Начните с момента, в который модель должна принять решение
Данные нельзя правильно подготовить, пока не определена задача. Для нашего примера она звучит так:
Когда поступило новое обращение, определить его тему по информации, доступной в этот момент.
Слова «в этот момент» задают границу. Допустим, запись в базе содержит следующие поля:
| Поле | Что означает | Когда появляется |
|---|---|---|
case_id |
Идентификатор обращения | При создании |
created_at |
Время поступления | При создании |
message_text |
Первое сообщение клиента | При создании |
channel |
Почта, форма или чат | При создании |
attachment_count |
Число приложенных файлов | При создании |
assigned_team |
Команда, которой передали обращение | Во время обработки |
resolution_text |
Итоговый комментарий сотрудника | После решения |
closed_at |
Время закрытия | После решения |
reviewed_topic |
Тема, подтверждённая при проверке | После разметки |
Объект обучающей выборки - одно обращение. Признаки, обычно обозначаемые X, - информация, которую модель получит для нового объекта. Целевая переменная, или y, - правильный ответ, который модель должна научиться предсказывать.
В нашем случае message_text, channel и attachment_count могут быть признаками. reviewed_topic может быть целевой переменной. resolution_text нельзя использовать как признак: этот текст пишут после обработки. assigned_team тоже опасен. Он может появиться только после того, как сотрудник уже определил маршрут заявки.
Удобная проверка для каждого кандидата в признаки:
Могли бы мы получить это значение для нового обращения до того, как узнаем правильную тему?
Если ответ «нет», поле не подходит для этой постановки задачи.
При этом одно и то же поле может быть допустимым в другой задаче. Если система прогнозирует повторное обращение после закрытия заявки, итоговый комментарий уже существует. Доступность признака определяется не названием столбца, а временем решения, которое мы моделируем.
Главное:
- Зафиксируйте объект, целевой ответ и момент предсказания до выгрузки данных.
- Для каждого признака проверьте, будет ли он доступен в этот момент.
- Поздние поля нельзя добавлять только потому, что они хорошо предсказывают ответ в исторической таблице.
Историческая запись ещё не является правильной разметкой
Самый удобный ответ часто уже есть в базе: отдел, который обрабатывал обращение. Но «удобно выгрузить» и «правильно для обучения» - разные свойства.
Допустим, клиент написал: «Деньги списались, а заказ не появился». Первая линия отправила обращение в техническую поддержку. Через час его перенаправили специалистам по оплате. Если взять assigned_team сразу после создания, правильным классом станет «техническая поддержка». Если взять последнюю команду, классом станет «оплата».
Какой вариант нужен модели? Это зависит от цели. Если мы хотим определять тему обращения, лучше использовать подтверждённую тему, а не случайный маршрут исторического процесса. Если мы хотим предсказывать команду, которая решит вопрос, нужно отдельно определить, что считать решением в случаях совместной работы нескольких команд.
Здесь появляется правило разметки: короткое описание того, как человеку присваивать правильный ответ. Например:
«Оплата» включает двойное списание, отказ платежа и вопрос о возврате средств. Вопрос о том, почему оплаченный заказ не отображается в аккаунте, относится к «доступу и отображению заказа», если платёж подтверждён.
Последняя фраза важна: без неё разные специалисты могут размечать похожие обращения по-разному. Модель не способна выучить устойчивую границу между классами, если в примерах эта граница меняется от сотрудника к сотруднику.
Для проверки разметки возьмите небольшой набор обращений, который два специалиста разметят независимо. Затем разберите расхождения. Они обнаружат не только человеческие ошибки, но и плохо определённые классы. Иногда оказывается, что требуется новая категория или правило передачи спорных обращений человеку.
Справка: разметка и «истина». В ML правильный ответ часто называют ground truth. Название не делает историческую запись безошибочной. Это принятый для задачи эталон, качество которого нужно проверять.
Ещё одна ловушка связана с выбором записей. Если в выгрузку попали только закрытые обращения, длительные и сложные случаи исчезнут. Модель обучится на более простой части потока. Если вручную размечались лишь необычные обращения, выборка, наоборот, окажется тяжелее реальной. До обучения полезно сравнить выбранные записи со всем потоком по датам, каналам, длине сообщений и доле различных тем.
Главное:
- Опишите, что означает каждый класс и как разбирать пограничные случаи.
- Проверьте разметку на независимом просмотре части примеров.
- Сравните обучающую выборку со всем потоком обращений: способ отбора способен исказить задачу.
Определите единицу наблюдения и найдите повторы
Слова «одно обращение» пока недостаточно точны. Клиент может написать сначала в чат, затем продублировать вопрос по почте. В базе появятся две строки, но для проверки модели это почти один и тот же случай.
Если одна копия попадёт в обучение, а другая в тест, модель увидит знакомый текст. Качество теста будет завышено. После запуска ей встретятся новые формулировки, и результат ухудшится.
Есть несколько уровней повторов:
- Точный дубликат: одна запись импортирована дважды с тем же
case_id. - Повторное сообщение: клиент отправил тот же текст по другому каналу.
- Почти одинаковый случай: отличается номер заказа или короткая вводная фраза.
- Продолжение переписки: сообщения относятся к одному делу, но содержат новую информацию.
Удалять все похожие тексты нельзя. Фраза «Не могу войти в аккаунт» у разных клиентов может описывать независимые случаи. Нужно решить, что считать одной единицей оценки. В нашем примере удобно хранить case_id для всего обращения и брать первое сообщение, доступное в момент автоматической маршрутизации. Поздние ответы клиента относятся к тому же делу, но не становятся новым независимым обучающим объектом.
Если бизнес-задача состоит в обработке каждого нового сообщения внутри переписки, единица наблюдения меняется. Тогда понадобятся отдельный message_id, состояние переписки на момент сообщения и другое правило разметки. Подготовка данных начинается заново с новой постановки.
Идентификатор клиента тоже не стоит автоматически подавать модели как признак. Модель может запомнить, в какой отдел обычно попадал конкретный клиент. Такой сигнал иногда допустим, но тогда нужно ясно понимать последствия: качество для постоянных клиентов и для новых клиентов будет разным. Сам идентификатор полезен прежде всего для анализа повторов и состава выборок.
Главное:
- Решите, что представляет одна строка: обращение целиком или отдельное сообщение.
- Не допускайте, чтобы один случай в разных копиях оказался и в обучении, и в проверке.
- Не используйте идентификаторы как признаки без понимания того, что модель сможет по ним запомнить.
Проверьте качество исходных полей
Перед преобразованием текста в числа нужна техническая проверка самой выгрузки. Начните не с «очистки всего подряд», а с договора о структуре данных.
Для каждой колонки зафиксируйте тип, допустимые значения, возможность пропуска и источник. Например, created_at должен быть датой с согласованной временной зоной. channel должен принимать ожидаемые значения, но система должна быть готова к новому каналу. attachment_count не может быть отрицательным. message_text может быть пустым, если клиент отправил только файл.
Простейшая проверка в Python:
required = {
"case_id",
"created_at",
"message_text",
"channel",
"attachment_count",
"reviewed_topic",
}
missing_columns = required - set(data.columns)
if missing_columns:
raise ValueError(f"Нет колонок: {sorted(missing_columns)}")
if data["case_id"].duplicated().any():
raise ValueError("Выгрузка содержит несколько строк на одно обращение")
if (data["attachment_count"].dropna() < 0).any():
raise ValueError("Количество вложений не может быть отрицательным")
Этот код не объявляет любую пустую ячейку ошибкой. Он проверяет предположения, на которых дальше строится пример.
Что означают пропуски
Пустое значение может иметь разные причины. Клиент не приложил файл. Источник не передал число приложений из-за ошибки. Старые записи были созданы до появления нового поля. В этих трёх случаях одинаковый NULL означает разные вещи.
Если отсутствие само по себе информативно, его можно передать модели отдельным признаком. Но сначала нужно выяснить причину: иначе модель может научиться распознавать версию старой системы вместо темы обращения.
Для числового поля часто используют подстановку медианы. Медиана - середина отсортированных значений. Она устойчивее среднего к отдельным очень большим числам. Однако подстановка не восстанавливает реальное неизвестное значение; она позволяет алгоритму работать с записью. Если поле критически важно для решения, лучше определить, почему оно пропало, либо направлять такой случай в отдельный маршрут.
Справка: три вида отсутствия данных. Иногда значение отсутствует по случайной технической причине. Иногда пропуск зависит от других известных полей. Иногда он связан с самим скрытым значением: например, клиент сознательно не указал сведения о проблемной операции. Эти случаи нельзя всегда лечить одной подстановкой. Для практической работы сначала исследуют источник пропуска и сравнивают долю пропусков по периодам и группам.
Что нельзя удалять при очистке текста
Для классификации сообщений хочется привести текст к единому виду: убрать лишние пробелы, служебную HTML-разметку и одинаковые подписи писем. Но агрессивная очистка может уничтожить полезные признаки.
Код PAY_104, номер тарифа или название продукта могут определять тему точнее общих слов. Если регулярное выражение удаляет цифры и подчёркивания, такие сигналы исчезнут. Если автоматически вырезать каждое предложение после слова «ошибка», можно удалить описание самой проблемы.
Правило здесь простое: каждое преобразование текста должно иметь проверяемую причину. Сохраните небольшой набор исходных сообщений и сравните его с результатом очистки вручную.
Главное:
- Проверяйте схему данных до обучения и фиксируйте ожидаемые значения.
- Разбирайте причину пропуска, прежде чем выбирать способ заполнения.
- Сохраняйте смысловые элементы текста, включая коды ошибок и названия продуктов.
Разделите данные до обучения преобразований
Теперь есть проверенные записи и подтверждённые метки. Нужно отделить данные для обучения от данных для проверки. Это не формальность, которую можно выполнить в конце.
Обучающая выборка используется для настройки модели и преобразований. Проверочная выборка помогает выбрать настройки и обнаружить ошибки решения. Тестовая выборка нужна для итоговой независимой оценки. Если многократно подбирать параметры по тесту, он перестанет быть независимым.
Для обращений полезна проверка по времени:
Старые обращения -> обучение
Более новые обращения -> выбор преобразований и модели
Самый новый период -> итоговая проверка
Такое разделение лучше отвечает на практический вопрос: справится ли система с обращениями после даты обучения? Случайное перемешивание может смешать старые и новые правила, версии интерфейса и повторные случаи.
Есть ещё одна временная граница: когда стала известна метка. Пусть обращение создано 28 июня, а его тему подтвердили 5 июля. Если мы мысленно запускаем обучение 1 июля, эта метка ещё недоступна. В обучающую выборку на 1 июля её включать нельзя, даже если запись создана в июне.
Для некоторых задач между обучающим и проверочным периодами оставляют зазор. Он полезен, если один процесс длится несколько дней и сведения из него могут пересекать границу периодов. Инструмент TimeSeriesSplit поддерживает параметр gap, но конкретный размер зазора определяется процессом, а не удобным значением по умолчанию. Документация scikit-learn отдельно описывает временные разбиения.
Группировка требует собственного решения. Если почти одинаковые сообщения принадлежат одному case_id, все записи дела должны остаться в одной части. Если после запуска модель должна обслуживать преимущественно новых клиентов, полезна отдельная проверка на клиентах, которых не было в обучении. Если она работает в основном с постоянными клиентами, такое разбиение отвечает уже на другой вопрос и может быть слишком строгим для основной оценки.
Как преобразование может подсмотреть тест
Допустим, числовой признак в обучении имеет значения 10, 20, 30, а в тесте - 40. Для стандартизации вычисляют среднее μ и стандартное отклонение σ, затем преобразуют значение:
z = (x - μ) / σ
По обучающим значениям μ_train = 20, σ_train ≈ 8,16. Тестовое 40 превращается примерно в (40 - 20) / 8,16 ≈ 2,45.
Если до разделения посчитать статистику по всем четырём значениям, получится μ_all = 25, σ_all ≈ 11,18, а тестовое значение станет примерно 1,34. Проверочный пример повлиял на правило преобразования. Модель и оценка получили информацию из части данных, которую должны были видеть только после обучения.
То же относится к словарю текста, заполнению пропусков, отбору признаков и любым параметрам преобразования. Метод, который учится на данных, настраивается только на обучающей части. Затем то же преобразование применяется к проверочной и тестовой частям. Это правило и типичные нарушения разобраны в руководстве scikit-learn по утечке данных.
Главное:
- Разделяйте данные до вычисления словаря, медианы, масштаба и других статистик.
- Проверяйте не только время создания объекта, но и время появления правильного ответа.
- Выбирайте временное или групповое разделение под будущий сценарий применения модели.
Превратите разные типы данных в признаки
Модель не принимает таблицу «как она выглядит человеку». Ей нужны числовые признаки. Но текст, категория канала и число вложений требуют разных преобразований.
Текст обращения
Простой исходный подход - представить текст набором слов или сочетаний соседних слов. Для каждого документа получается числовой вектор. Можно считать, сколько раз встретился термин, но частые общие слова начнут встречаться почти везде. Поэтому часто применяют TF-IDF.
Идея выводится из двух наблюдений:
- Если термин чаще встречается в конкретном сообщении, он может быть важен для этого сообщения. Это часть
tf, частота термина. - Если термин встречается почти во всех сообщениях, он слабее различает темы. Его вес нужно уменьшить. Это часть
idf, обратная частота документов.
Вариант расчёта, используемый TfidfVectorizer при стандартном сглаживании, можно записать так:
tf-idf(t, d) = tf(t, d) · idf(t)
idf(t) = log((1 + N) / (1 + df(t))) + 1
Здесь t - термин, d - документ, N - число обучающих документов, df(t) - число документов, содержащих термин. Добавление единицы защищает расчёт от нулевого знаменателя; логарифм сглаживает разницу между очень редкими и обычными словами.
Предположим, у нас 100 обучающих обращений. Термин «здравствуйте» встречается в 90, а «списание» в 10. Тогда:
idf("здравствуйте") ≈ log(101 / 91) + 1 ≈ 1,10
idf("списание") ≈ log(101 / 11) + 1 ≈ 3,22
При одинаковой частоте в конкретном сообщении «списание» получит больший вес. Эта арифметика не означает, что модель уже понимает смысл слова. Она лишь делает термин более заметным для классификатора. Подробности формулы и нормировки есть в документации scikit-learn по извлечению текстовых признаков.
TF-IDF хорошо подходит как понятная отправная точка. Его ограничения тоже ясны: «не проходит оплата» и «платёж отклонён» используют разные слова. Для таких случаев можно сравнить его с эмбеддингами текста, которые пытаются учитывать смысловую близость. Более сложное представление стоит добавлять после честного базового измерения, а не вместо него.
Для кодов ошибок нужен контроль токенизации. Стандартное разбиение текста может обработать PAY_104 не так, как ожидает команда. Проверьте получившийся словарь на реальных сообщениях, прежде чем считать преобразование завершённым.
Категория канала
channel принимает значения вроде email, web, chat. Превращать их в числа 1, 2, 3 обычно не стоит: это искусственно создаёт порядок и расстояние между каналами.
One-hot encoding создаёт отдельные признаки:
channel=email -> [1, 0, 0]
channel=web -> [0, 1, 0]
channel=chat -> [0, 0, 1]
Если после запуска появился новый канал, преобразователь должен иметь определённое поведение. Например, игнорировать неизвестную категорию и записать событие в мониторинг. Молчаливое падение всей функции из-за нового канала хуже предсказуемого запасного пути.
Число вложений
attachment_count уже числовое, но может содержать пропуски и иметь иной масштаб, чем остальные признаки. Сначала выбирают способ обработки пропусков по причинам их появления. Затем, если выбранная модель чувствительна к масштабу, можно стандартизировать число по статистике обучающей части.
Логистической регрессии масштаб признаков может быть важен при регуляризации и оптимизации. Для деревьев необходимость стандартизации обычно иная. Поэтому подготовку данных нельзя выбирать полностью независимо от модели.
Справка: преобразование признаков тоже обучается. Словарь TF-IDF, медиана для заполнения пропусков и категории one-hot encoding определяются по обучающим данным. Их нужно сохранять вместе с моделью и точно так же применять к новым обращениям.
Главное:
- Выбирайте преобразование по типу данных и задаче модели.
- TF-IDF взвешивает слова по частоте в документе и редкости в обучающем корпусе; он не является полноценным пониманием смысла.
- Сохраняйте один и тот же обученный преобразователь для проверки и работы после запуска.
Соберите воспроизводимый конвейер
В следующем примере предполагается, что выгрузка уже содержит одну строку на одно обращение, тему подтвердил специалист, а колонки имеют описанный выше смысл. Даты служат примером временного разделения; для реального проекта их выбирают по доступной истории и моменту запуска.
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
data = pd.read_parquet("tickets_reviewed.parquet")
data["created_at"] = pd.to_datetime(data["created_at"], utc=True)
data["reviewed_at"] = pd.to_datetime(data["reviewed_at"], utc=True)
data["message_text"] = data["message_text"].fillna("")
if data["case_id"].duplicated().any():
raise ValueError("Ожидается одна строка на одно обращение")
if (data["attachment_count"].dropna() < 0).any():
raise ValueError("Найдено отрицательное число вложений")
labeled = data.dropna(
subset=["created_at", "reviewed_at", "reviewed_topic"]
).copy()
train_end = pd.Timestamp("2026-07-01", tz="UTC")
valid_end = pd.Timestamp("2026-08-01", tz="UTC")
train = labeled.loc[
(labeled["created_at"] < train_end)
& (labeled["reviewed_at"] < train_end)
]
valid = labeled.loc[
(labeled["created_at"] >= train_end)
& (labeled["created_at"] < valid_end)
]
test = labeled.loc[
labeled["created_at"] >= valid_end
]
feature_columns = [
"message_text",
"channel",
"attachment_count",
]
numeric = Pipeline(
steps=[
("impute", SimpleImputer(strategy="median", add_indicator=True)),
("scale", StandardScaler()),
]
)
prepare = ColumnTransformer(
transformers=[
(
"text",
TfidfVectorizer(min_df=2, ngram_range=(1, 2)),
"message_text",
),
(
"channel",
OneHotEncoder(handle_unknown="ignore"),
["channel"],
),
(
"attachments",
numeric,
["attachment_count"],
),
]
)
model = Pipeline(
steps=[
("prepare", prepare),
("classifier", LogisticRegression(max_iter=1000)),
]
)
model.fit(
train[feature_columns],
train["reviewed_topic"],
)
valid_predictions = model.predict(valid[feature_columns])
ColumnTransformer направляет текст, категорию и число в разные преобразования, а Pipeline связывает их с классификатором. При вызове fit словарь текста, категории и медиана определяются по train. При вызове predict для valid используются уже найденные правила. Такой способ сборки показан и в официальном примере scikit-learn для смешанных типов данных.
Этот код демонстрирует подготовку, а не завершённую оценку модели. Прежде чем доверять valid_predictions, нужно проверить размер каждой части, состав классов и долю обращений без разметки. Если в обучении нет примеров одной из тем, её нельзя ожидать в ответах модели. Если новая тема появилась только в тестовом периоде, это не обычная ошибка преобразования: состав задачи изменился.
Не все данные обязательно должны идти в модель. case_id нужен для проверки структуры и поиска результата, created_at и reviewed_at нужны для разбиения. Они сознательно не включены в feature_columns.
В рабочем проекте стоит также сравнить модель с простым базовым решением: наиболее частой темой или понятными правилами для характерных фраз. Сложная модель полезна, когда улучшает нужный показатель на новых данных.
Главное:
- Сохраняйте преобразования и модель как единый конвейер.
- Подавайте в модель только поля, разрешённые постановкой задачи; служебные поля могут оставаться полезными для проверки.
- Перед оценкой предсказаний проверьте размеры, периоды и состав классов во всех частях выборки.
Разберите типичные сбои до запуска
Хорошая подготовка данных проявляется не отсутствием ошибок в коде, а способностью объяснить неожиданный результат.
| Симптом | Возможная причина | Что проверить и исправить |
|---|---|---|
| На тесте почти идеальное качество, после запуска резкое падение | В признаки попало позднее поле или копии случаев оказались в обеих частях | Проверить время появления полей и пересечение case_id или повторных сообщений |
| Модель плохо работает с новым каналом | Канал отсутствовал в обучении либо схема входа изменилась | Проверить неизвестные категории, предусмотреть запасное поведение и мониторинг |
| Редкая тема почти не находится | Мало подтверждённых примеров или классы размечались непоследовательно | Проверить число и качество меток, отдельно оценить редкий класс |
| Слова и коды ошибок исчезли | Очистка или токенизация удалила полезные части текста | Сравнить исходные сообщения со словарём преобразователя |
| Повторное обучение даёт непонятно иной результат | Не зафиксированы версия данных, правила отбора и код подготовки | Версионировать набор, разбиение, разметку и конвейер |
| В старых данных качество высокое, в новых ниже | Изменились продукт, словарь клиентов или правила маршрутизации | Проверить качество по периодам и выяснить, нужна ли новая разметка |
| Модель плохо работает с новыми клиентами | Запомнила постоянных клиентов или повторяющиеся формулировки | Проверить признаки-идентификаторы и провести отдельную групповую оценку |
Есть соблазн ответить на любой такой симптом обучением более мощной модели. Но если разметка противоречива или в обучении не хватает нужных случаев, новая архитектура не создаст отсутствующую информацию.
Главное:
- При падении качества сначала локализуйте этап: отбор, разметка, признаки, разделение или преобразование.
- Новая модель не исправляет автоматически ошибочную постановку задачи.
- Сохраняйте примеры ошибок вместе с причиной: они полезны для следующей версии данных.
Что передать вместе с данными на следующий этап
Результат подготовки - не просто файл с числами. Следующему этапу обучения нужны:
- описание задачи и момента предсказания;
- правило разметки и перечень классов;
- схема исходных полей и список разрешённых признаков;
- правила отбора и исключения записей;
- обучающая, проверочная и тестовая части с объяснением их границ;
- обученный конвейер преобразования признаков;
- отчёт о пропусках, повторах и составе классов;
- версия исходной выгрузки и кода подготовки.
Всё это можно проверить на одном новом обращении. Система получает сообщение, проверяет схему, применяет тот же сохранённый преобразователь и создаёт вектор признаков. Модель получает этот вектор и выдаёт прогноз. Если для нового обращения требуется поле, которого в момент поступления нет, подготовка была спроектирована неверно. Если команда не может объяснить, откуда взялась правильная метка в обучении, прогноз также нельзя считать надёжно проверенным.
Главный принцип подготовки данных: обучающая выборка должна честно воспроизводить информацию, доступную системе при будущем решении. Остальные операции - очистка, разметка, преобразование и разделение - служат этой цели.