Почему «чем проще, тем лучше» не работает на ИИ-классификаторе. bert.. bert. class imbalance.. bert. class imbalance. machine learning.. bert. class imbalance. machine learning. multi-label classification.. bert. class imbalance. machine learning. multi-label classification. nlp.. bert. class imbalance. machine learning. multi-label classification. nlp. PyTorch.. bert. class imbalance. machine learning. multi-label classification. nlp. PyTorch. ruBERT.. bert. class imbalance. machine learning. multi-label classification. nlp. PyTorch. ruBERT. text classification.

Micro F1 = 0.9358. Число, с которым можно спокойно закрывать задачу в трекере — если не разбираться дальше. Но стоило посмотреть на precision и recall по каждому из 15 классов отдельно, как выяснилось: качество части классов тестовый набор фактически не проверяет — для одного из них в test split вообще не было положительных примеров.

Это разбор конкретного случая: как агрегированная метрика может скрывать классификатор, бесполезный именно там, где он важнее всего. И почему принцип KISS, на который я обычно опираюсь, в multi-label задачах работает не так прямолинейно, как кажется.

Контекст: зачем вообще классификатор

Я делаю AI-модерацию для Discord-сообщества — OmniBot отправляет сообщения из выбранных каналов в отдельный сервис, AI-Moderation, а тот на локальной модели ruBERT tiny2, дообученной под задачу, решает, что это: реклама, спам, токсичность, угроза — или всё сразу. Модель multi-label: одно сообщение может получить сразу несколько меток.

Например:

«ЗАХОДИ НА МОЙ СЕРВЕР!!! discord.gg/example»

Для такой строки классификатор должен одновременно вернуть ADVERTISEMENT, INVITE и SPAM. Всего меток 15 — от SAFE и TOXIC до SCAM, THREAT, EVASION и FLOOD.

Датасет для обучения — 1 049 399 размеченных примеров:

train       735 000
validation  157 202
test        157 197

Я люблю простые решения. Меньше компонентов, меньше скрытой магии — легче поддерживать. Поэтому первый план выглядел линейно: взять ruBERT tiny2, дообучить его на этих данных, посмотреть на итоговый F1 и забрать лучший checkpoint. Просто, понятно, никакой лишней инженерии.

Когда 0.93 выглядит лучше, чем есть на самом деле

После обучения лучший checkpoint показал вот что:

validation micro F1:  0.9298
validation macro F1:  0.8381

test micro F1:        0.9358
test macro F1:        0.8396
Рисунок 1. Micro F1, macro F1 и exact match по эпохам.

Рисунок 1. Micro F1, macro F1 и exact match по эпохам.

На первый взгляд метрика проходная — придраться не к чему. Но между micro F1 и macro F1 разница почти в девять сотых. Отдельного статистического теста на значимость этого разрыва я не проводил — не было ни повторных прогонов с разными seed, ни bootstrap-интервалов, — поэтому не буду называть это «не шумом». Но для меня это стало достаточным сигналом, чтобы не доверять одной агрегированной цифре и посмотреть на распределение классов и метрики каждой метки отдельно.

Дело в распределении классов. В датасете около 400 тысяч примеров SAFE, 212 тысяч TOXIC, 159 тысяч URL — и всего порядка 35 тысяч EVASION. Часть классов встречается на порядок реже других.

Micro F1 сначала суммирует TP, FP и FN по всем меткам и только потом вычисляет итоговую формулу — поэтому классы с большим числом примеров сильнее влияют на результат просто за счёт объёма наблюдений. Ошибки на редком классе могут заметно ухудшить его собственный F1 и почти не сдвинуть общий micro F1: он никуда не «теряется», его вклад просто тонет в массе более частых меток.

Проще всего это видно на игрушечном примере — не как точный математический аналог micro F1, а как иллюстрация того, как агрегированная метрика может скрывать провал на меньшинстве. Есть 100 сообщений: 90 обычных и 10 токсичных. Классификатор, который всегда отвечает SAFE, получит accuracy 90% — и при этом как модератор будет абсолютно бесполезен.

«Просто обучим модель» тоже не сработало

Следующий шаг, где простота подводит — сам процесс обучения. Самый очевидный вариант loss-функции для multi-label:

loss = BCEWithLogitsLoss()

Для такой задачи это нормальная стартовая точка. Но если один класс встречается сотни тысяч раз, а другой — в разы реже, модель получает несоизмеримо больше сигнала от популярного класса и постепенно перестаёт «замечать» редкий.

Поэтому в пайплайне я считаю вес положительных примеров отдельно для каждого класса:

positives = labels.sum(axis=0)
negatives = labels.shape[0] - positives

weights = negatives / np.maximum(positives, 1.0)
weights = np.clip(weights, 1.0, 12.0)

И передаю эти веса прямо в loss:

loss_function = torch.nn.BCEWithLogitsLoss(
    pos_weight=weight_tensor
)

pos_weight увеличивает вклад ошибок на редких положительных примерах в loss. Это не гарантирует роста качества конкретного класса, но не позволяет обучению относиться к ошибке на редком positive так же, как при полностью сбалансированном распределении. Честно говоря: отдельного ablation-теста «с весами / без весов» в этом цикле обучения я не проводил, поэтому не приписываю итоговые цифры именно этому изменению — это осознанная инженерная мера, а не измеренный прирост.

Рисунок 2. Распределение меток в обучающем датасете.

Рисунок 2. Распределение меток в обучающем датасете.

Тут и проявляется разница между упрощением кода и упрощением задачи. Убрать веса классов ради более короткого пайплайна — соблазнительно, но если один класс встречается в десять раз реже другого, а модель обучается так, будто они представлены одинаково, это уже не KISS, а потеря информации о самой задаче.

Самая неприятная цифра оказалась не 0.93

После общей оценки я разложил метрики по каждому классу отдельно — и вот тут стало по-настоящему интересно.

Для TOXIC на test split:

precision ≈ 0.83
recall    = 0.7816
F1        ≈ 0.81

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

А вот FLOOD и IMAGE_SCAM в test split почти не представлены — но это отдельная история, и тут важно не путать причину. FLOOD в проде определяется не ruBERT, а отдельным алгоритмом на основе политики сервера — частота сообщений, окна повторов, — поэтому текстовых положительных примеров в датасете для него почти нет по конструкции, а не из-за просчёта в обучении. IMAGE_SCAM — модуль, который всё ещё в разработке: он должен сверять метаданные изображений с базой уже размеченных скам-картинок и вытаскивать текст с изображения (OCR), чтобы проверить его на триггерные фразы. Текстовый классификатор в принципе не тот компонент, который должен ловить этот класс в одиночку.

Рисунок 3. Test support по каждому из 15 классов.

Рисунок 3. Test support по каждому из 15 классов.

Формально таблицу метрик по всем 15 меткам построить можно. Но по факту она полезна только там, где метка действительно принадлежит зоне ответственности текстового классификатора. Именно на этом моменте принцип «не усложняй отчёт» окончательно перестал работать. Строка micro F1 = 0.9358 выглядит гораздо убедительнее, чем честная версия:

  • общее качество высокое;

  • TOXIC нужно подтягивать по recall — это ответственность модели;

  • FLOOD и IMAGE_SCAM в этой таблице неинформативны, потому что решаются другими компонентами системы.

Второй вариант хуже смотрится в README. Зато он единственный, на который можно реально опираться, принимая решение — катить модель в прод или нет, и на какой класс тратить следующий цикл разметки.

![Рисунок 4. Precision, recall и F1 отдельно по классам (обновлённая версия).](рисунок-4-precision-recall-per-class.png)

Почему лучший checkpoint выбирается не по loss

Была и ещё одна соблазнительно простая идея — взять checkpoint с минимальным training loss. Но training loss отвечает только на вопрос, насколько хорошо модель подстроилась под обучающие данные. А мне нужен ответ на другой вопрос: как она поведёт себя на данных, которых не видела.

В моём случае training loss продолжал снижаться уже после того, как validation loss прошёл свой минимум — явный сигнал возможного переобучения. Но при этом лучшим по macro F1 оказался checkpoint не последней, а четвёртой эпохи, checkpoint-183752: macro F1 ещё мог улучшаться даже там, где validation loss уже ухудшался. Это не противоречие, а нормальная ситуация для BCE с фиксированным threshold — loss и эксплуатационная метрика в такой связке вполне могут указывать на разные checkpoint’ы.

Поэтому выбор модели в конфиге обучения выглядит так:

load_best_model_at_end=True,
metric_for_best_model="macro_f1",
greater_is_better=True,

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

KISS никуда не делся — просто применяется не там, где кажется

Классификатор не был слишком сложным. Упрощать нужно было не задачу, а её реализацию — а это разные вещи, которые легко перепутать под давлением дедлайна.

Компактный ruBERT tiny2 — можно. Короткий training pipeline — можно. Без десятков слоёв абстракции — тоже можно. А вот что убрать без последствий нельзя:

  • дисбаланс классов;

  • разницу между micro и macro метриками;

  • precision и recall по каждому классу отдельно;

  • размер test support для редких меток — и понимание, какой компонент вообще отвечает за конкретную метку;

  • разную цену ошибок для разных классов.

Сейчас в пайплайне всё ещё есть упрощение, которое меня не до конца устраивает: для расчёта метрик используется один общий threshold на все 15 классов. Это нормально как первая рабочая версия, но следующий логичный шаг — откалибровать пороги отдельно для каждого класса. Ошибка на SAFE, TOXIC и THREAT стоит по-разному, и нет причин верить, что один и тот же порог одинаково хорошо подходит всем меткам сразу.

Если переформулировать KISS под эту задачу, получится примерно так: используй самое простое решение, которое всё ещё сохраняет структуру реальной задачи. Одна модель вместо ансамбля — да, можно. Одна метрика вместо разбора ошибок по классам — уже нет. Один красивый F1 в README — удобно для питча. Но в проде сообщение удаляет не F1. Его удаляет конкретная ошибка конкретного класса.

Вопрос для обсуждения

Если вы работали с multi-label классификацией — где для вас проходит граница между полезным упрощением и потерей важной структуры задачи? Начинали бы вы с одного общего threshold или сразу калибровали пороги отдельно для критичных классов?

Автор: 6oT9lpa

Источник