Введение: Почему стоит оглядываться назад

Привет, Хабр!

Недавно я опубликовал статью о своем проекте по детекции автомобильных номеров в видеопотоке. Работа над ним была нетривиальной, но успешной. Однако, анализируя этот опыт, я пришел к выводу, что большая часть успеха была заложена в моем предыдущем, на первый взгляд более простом, pet-проекте — создании легковесного классификатора токсичности.

Именно на нем я столкнулся с фундаментальными проблемами, которые не зависят от области (NLP или CV): "молчаливый" отказ модели обучаться, тонкости оптимизации производительности и подводные камни при подготовке модели к развертыванию. Этот проект стал для меня настоящим "учебным полигоном".

Эта статья — систематизация того опыта. Это история о том, как, стремясь создать production-ready сервис из простого Kaggle-блокнота, я прошел через несколько этапов отладки, каждый из которых преподал мне ценный урок.

Постановка задачи: Проект-фундамент

Перед тем как погружаться в мир Computer Vision, я хотел отработать полный MLOps-цикл на классической задаче. Я выбрал классификацию токсичности в русскоязычных комментариях, используя датасет Toxic Russian Comments from Pikabu and 2ch.

Требования к финальному продукту были приближены к "боевым" в моём понимании:

  1. Легковесность: Квантованная модель должна занимать менее 10 МБ.

  2. Производительность: Инференс на CPU должен быть быстрым, чтобы сервис мог выдерживать нагрузку.

  3. Автономность: Проект не должен зависеть от тяжелых фреймворков вроде transformers.

  4. Готовность к Production: Весь сервис должен быть упакован в виде API, спроектированного по современным практикам.

Первым шагом был анализ данных, который сразу выявил важную особенность: дисбаланс классов.

  • Нетоксичные комментарии (класс 0): ~66.5%

  • Токсичные комментарии (класс 1): ~33.5%

Соотношение примерно 2:1 — это не экстремальный, но значимый дисбаланс. Он означает, что "наивная" модель может достичь точности в 66.5%, просто всегда предсказывая "нетоксично". Это создает риск, что модель плохо научится определять именно тот класс, который нам интересен.

Для борьбы с этим я решил использовать стандартный подход — взвешивание классов (class_weights) в функции потерь. Идея в том, чтобы "штраф" за ошибку на редком токсичном классе был выше. Используя scikit-learn, я рассчитал эти веса.

Первоначальный план: Что могло пойти не так?

План выглядел стандартным для любой NLP-задачи:

  1. Архитектура: Классическая CNN для текста.

  2. Предобработка: Использовать библиотеку natasha для лемматизации.

  3. Оптимизация: Провести Post Training Static Quantization для сжатия модели.

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

Часть 2: Архитектура, данные и "молчаливая" модель

Любой ML-проект стоит на двух китах: архитектуре модели и качестве данных. Я начал с, как мне казалось, самых надежных и проверенных временем решений, а конкретно CNN.

Как "думает" нейронная сеть? Аналогия

Прежде чем мы погрузимся в детали, давайте на простом примере разберем, как свёрточная нейронная сеть (CNN) вообще анализирует информацию. Представьте, что мы хотим научить сеть распознавать котов на картинках.

Картинка от Яндекс.Практикума найденная через Гугл
Картинка от Яндекс.Практикума найденная через Гугл

Процесс будет выглядеть так:

  1. Выборка отличительных характеристик: Первые слои сети не смотрят на кота целиком. Они работают как "детекторы" простых паттернов: усов, ушей, текстуры шерсти. На нашем рисунке это эквивалентно признаку "четыре лапы и хвост".

  2. Абстракция: Следующие слои берут эти простые паттерны и комбинируют их в более сложные концепции. Сеть учится, что "текстура шерсти" + "треугольные уши" вместе образуют что-то "пушистое" и "милое".

  3. Принятие решения: Финальный слой смотрит на эти высокоуровневые концепции и делает вывод: если у объекта есть признаки "пушистый" и "милый", то с высокой вероятностью "это кошка".

Моя CNN для классификации токсичности работает по абсолютно такому же принципу, только вместо пикселей она анализирует последовательности слов:

  • Низкоуровневые признаки: Это не "лапы и хвост", а короткие фразы (n-граммы), например, "ты ведешь себя как" или "полный дурак". Свёртки Conv1d идеально подходят для поиска таких локальных паттернов.

  • Высокоуровневые признаки: Это комбинации этих фраз, которые модель научилась ассоциировать с агрессией.

  • Финальное решение: Это вердикт "токсично" или "нетоксично".

С этим пониманием я приступил к подготовке данных.

Проблема №1: Нестабильность окружения и отказ от лемматизации

Для работы с русским языком выбор пал на библиотеку natasha. Идея была в том, чтобы с помощью лемматизации (приведения слов к начальной форме) уменьшить словарь и улучшить обобщающую способность модели.

На локальной машине все работало прекрасно. Но при запуске кода в Jupyter-блокноте на Kaggle я столкнулся с первой странностью: скрипт построения словаря отрабатывал, но на выходе получался словарь размером в 2 элемента (<pad> и <unk>).

Диагностика:
Простая распечатка результатов обработки текста показала, что компонент natasha, отвечающий за морфологию (NewsMorphTagger), в облачной среде Kaggle по неизвестной причине не мог определить лемму. Он правильно разбивал текст на токены, но для каждого токена возвращал lemma=None.

Решение:
Вместо того чтобы тратить время на отладку чужой библиотеки в чужом окружении, я принял первое важное инженерное решение: упростить и сделать пайплайн более надежным. Я отказался от лемматизации. Вместо этого natasha стала использоваться только для ее стабильного компонента Segmenter (для правильного разбиения на слова), а последующая очистка и приведение к нижнему регистру выполнялись вручную.

Урок №1: Надежность пайплайна данных важнее его теоретической "продвинутости". Нестабильный компонент, даже самый мощный, — это технический долг.

Проблема №2: "Молчаливый провал" и магия числа 0.693

С работающим пайплайном данных я запустил обучение. Логи пошли, эпохи сменяли друг друга, но что-то было не так.

Эпоха 1: Train Loss: 0.6932, F1-score (Токсичный): 0.0000
Эпоха 2: Train Loss: 0.6931, F1-score (Токсичный): 0.0000
Эпоха 3: Train Loss: 0.6933, F1-score (Токсичный): 0.0000
Эпоха 4: Train Loss: 0.6932, F1-score (Токсичный): 0.0000

Сначала я не придал этому значения — "нужно время, чтобы модель нашла градиент". Но на пятой эпохе начало доходить, что что-то не так. Метрики не просто медленно улучшались, они стояли на месте как вкопанные.

Поиск ответа на вопрос "А почему так?", дал ответ: если вы видите loss, застрявший на ~0.693 в задаче бинарной классификации, знайте — это ln(2). Это математический сигнал о том, что модель не обучается и просто предсказывает случайные вероятности около 0.5.

Модель была в коме.

Диагностика:
Осознав это, я понял, что проблема в нестабильности процесса обучения. Градиенты либо "взрывались", либо "затухали", и оптимизатор не мог сдвинуть веса модели в нужную сторону. Моя первоначальная архитектура CNN оказалась слишком "нежной".

Решение (комплексная стабилизация):
Я применил стандартный "джентльменский набор" для стабилизации:

  1. Снизил learning_rate.

  2. Добавил BatchNorm1d после каждого сверточного слоя.

  3. Внедрил планировщик CosineAnnealingLR и Gradient Clipping.

Это сработало, и loss начал уверенно падать. Но, как оказалось, я лишь открыл дверь для следующей, более хитрой проблемы.

Проблема №3: Коллапс модели

Как я упоминал, в данных был дисбаланс, для борьбы с которым я использовал class_weights, сделав ошибку на редком токсичном классе почти вдвое "дороже". И как только модель стала достаточно стабильной, чтобы чему-то учиться, она нашла гениальную в своей лени лазейку.

Вместо того чтобы учиться сложному искусству различать классы, модель обнаружила, что самый простой способ минимизировать общий "штраф" — это полностью перестать делать ошибки на самом "штрафуемом" классе. Она начала предсказывать "токсично" для абсолютно каждого комментария.

Это подтверждалось матрицей ошибок, где модель получала Recall=1.0 для токсичного класса, но Recall=0.0 для нетоксичного. Она "выучила наизусть" самый безопасный для себя ответ.

Решение:
Убрать class_weights. Я решил довериться оптимизатору AdamW и более стабильной архитектуре. И это сработало. Без агрессивных "подсказок", модель была вынуждена искать более честный и сложный путь к минимуму функции потерь, обучаясь различать оба класса.

Процесс обучения модели. Новая итерация, цифры могут отличаться от тех что в тексте
Процесс обучения модели. Новая итерация, цифры могут отличаться от тех что в тексте

Урок №2: Иногда "правильные" и стандартные решения (как class_weights) могут приводить к неожиданным побочным эффектам. Важно не слепо применять техники, а понимать, как они влияют на поведение оптимизатора.

После решения этих двух проблем я наконец-то получил стабильно обучающуюся модель с F1-score около 0.58.

Анализ обученной модели: Что "под капотом"?

После нескольких итераций я получил стабильную FP32-модель, которая показывала осмысленные результаты. Давайте посмотрим на ее "паспорт" — отчет о классификации и матрицу ошибок.

Итоговые метрики
Итоговые метрики

Эти цифры рассказывают нам всю правду о том, что модель выучила.

Матрица ошибок [[1650, 268], [442, 523]] показывает:

  • Модель хорошо распознает нетоксичную речь (1650 True Negatives).

  • Но она пропустила 442 реальных токсичных комментария (False Negatives). Это ее главная слабость.

  • При этом она 268 раз ошиблась, назвав нормальный комментарий токсичным (False Positives).

Ключевые метрики для токсичного класса подтверждают это:

  • Recall: 0.54 (Полнота): Модель находит только 54% всего токсичного контента. Она "бдительна" лишь наполовину.

  • Precision: 0.66 (Точность): Когда модель бьет тревогу, она права в 66% случаев. Примерно в 1 из 3 случаев это будет ложная тревога.

  • F1-score: 0.60: Это уверенный, хороший базовый результат (baseline).

Вывод: Моя модель получила "личность". Она стала "Осторожной, но не очень бдительной". Она неплохо избегает ложных обвинений, но эта осторожность заставляет ее пропускать почти половину реальных нарушений. Это не идеальный, но абсолютно рабочий и адекватный результат для первого MVP.

Теперь, когда у меня была стабильная и понятная в своем поведении FP32-модель весом 23 МБ, я был готов к финальному этапу — превращению ее в сверхлегкий 5 МБ артефакт.

Часть 3: Финальный босс — Квантизационный кошмар PyTorch

Итак, у меня была стабильно обучающаяся FP32-модель весом 23 МБ. Цель — сжать ее до ~5 МБ с помощью квантизации, то есть преобразования весов в 8-битные целые числа. Я был уверен, что уж здесь-то, на этапе Post Training Static Quantization, все пойдет по инструкции.

Я никогда так не ошибался. torch.quantization встретил меня серией загадочных NotImplementedError и AttributeError.

Чтобы не утомлять читателя всеми деталями отладки, я сразу покажу финальную, полностью рабочую и совместимую с квантизацией архитектуру, а затем объясню, почему каждый ее элемент так важен.

class SoloCNNTextClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, num_filters, kernel_sizes, num_classes, dropout_rate, pad_idx=0):
        super(SoloCNNTextClassifier, self).__init__()

        # 1. "Мосты" между float и quantized мирами
        self.quant = torch.quantization.QuantStub()
        self.dequant = torch.quantization.DeQuantStub()
        
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)

        # 2. "Канонический" паттерн Conv-BN-ReLU как модуль
        self.convs = nn.ModuleList([
            nn.Sequential(
                nn.Conv1d(in_channels=embed_dim, out_channels=num_filters, kernel_size=k),
                nn.BatchNorm1d(num_filters),
                nn.ReLU(inplace=True)
            )
            for k in kernel_sizes
        ])
        
        self.dropout = nn.Dropout(dropout_rate)
        self.fc = nn.Linear(len(kernel_sizes) * num_filters, num_classes)

    def forward(self, x):
        # 3. Мир FP32: Embedding работает с float
        embedded = self.embedding(x).permute(0, 2, 1)
        
        # 4. Пересекаем "мост" в мир INT8
        quantized_embedded = self.quant(embedded)
        
        # 5. Мир INT8: Все операции здесь будут квантованы
        conved = [conv(quantized_embedded) for conv in self.convs]
        
        pooled = [F.max_pool1d(c, c.shape[2]).squeeze(2) for c in conved]
        concatenated = self.dropout(torch.cat(pooled, dim=1))
        
        # 6. Проходим через последний квантованный слой (Linear)
        quantized_logits = self.fc(concatenated)
        
        # 7. Пересекаем "мост" обратно в мир FP32 для вывода
        output_logits = self.dequant(quantized_logits)
        
        return output_logits

А теперь — история о том, как я пришел к каждой из этих строк, набив немало шишек.

Ловушка №1: nn.Embedding и особые требования

Первая же попытка квантизации упала с ошибкой, требующей float_qparams_weight_only_qconfig.

  • Проблема: Стандартный "рецепт" квантизации пытается обработать и веса, и активации слоя. Но для nn.Embedding это бессмысленно — его активации это просто индексы токенов. PyTorch поддерживает для него только квантизацию весов (weight-only).

  • Решение: Явно указать для этого слоя особый "рецепт": model.embedding.qconfig = torch.quantization.float_qparams_weight_only_qconfig.

Ловушка №2: F.relu против nn.ReLU (Функция vs Модуль)

Моя первая стабильная модель использовала функциональный вызов F.relu. И это стало причиной следующей ошибки.

  • Проблема: Процесс квантизации в PyTorch ищет определенные паттерны из модулей (например, (Conv, BatchNorm, ReLU)), чтобы "слить" их в одну быструю операцию. Функциональный вызов F.relu он просто "не видел".

  • Решение: Заменить функцию F.relu на модуль nn.ReLU() и встроить его прямо в nn.Sequential.

Ловушка №3: Отсутствие "мостов" и "слияния"

Даже после всех исправлений я получал RuntimeError: Could not run 'quantized::conv1d'. Модель все еще не квантовалась.

  • Проблема №1 (мосты): Тензор после Embedding (FP32) не мог быть подан на вход квантованной свертки (INT8). Между ними не было "моста".

    • Решение: Явно вставить в архитектуру torch.quantization.QuantStub() (вход в мир INT8) и DeQuantStub() (выход).

  • Проблема №2 (слияние): Оказалось, недостаточно просто правильно расположить слои Conv-BN-ReLU.

    • Решение: Перед квантизацией нужно было явно вызвать torch.quantization.fuse_modules. Эта функция физически заменяет последовательность из трех модулей на один, ConvBnReLU1d, для которого и существует быстрая квантованная реализация.

Урок №3: Квантизация в PyTorch — это не магия, а строгий инженерный процесс. Она требует от архитектуры модели явного соблюдения определенных паттернов и вызова специальных процедур подготовки.

Тесты производительности: оправдана ли легковесность?

После успешной квантизации я провел стресс-тест на локальной машине, чтобы измерить "сырую" производительность.

  • Размер модели: 5.6 МБ. 

  • "Сырая" производительность: ~680 RPS на одном ядре и ~2400 RPS на 8 ядрах CPU.

Эти цифры полностью подтвердили, что цель достигнута. Мы получили модель, которая в сотни раз меньше стандартных трансформеров и при этом способна обрабатывать тысячи запросов в секунду на обычном CPU.

От модели к сервису: Упаковка в FastAPI

Работающий ноутбук — это половина дела. Финальным шагом стала упаковка всего этого в production-ready API. Я не буду подробно останавливаться на этом этапе, так как главная идея проекта состояла в создании и оптимизации самой модели. Реализацию API, бота или другого клиента можно отдать профильным разработчикам, предоставив им чистый, документированный сервис.

Тем не менее, для полноты картины я спроектировал API по принципам SOLID, разделив логику на слои (конфигурация, предобработка, классификация), использовал Pydantic для валидации и упаковал все в Docker-контейнер для легкого деплоя.

Заключение: Модель как инструмент, а не "серебряная пуля"

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

Важно понимать, что созданная модель — это не панацея и не замена живым модераторам. С Recall=0.54, она пропускает почти половину токсичных комментариев. Ее роль — не быть основой борьбы с токсичностью, а быть эффективным инструментом в помощь другим системам.

Возможные сценарии использования:

  • Предварительная фильтрация: Модель может работать как "первая линия обороны", отсеивая самые очевидные нарушения и снижая нагрузку на модераторов.

  • Ранжирование жалоб: Комментарии, на которые пожаловались пользователи, можно прогонять через модель, чтобы поднять в очереди на проверку те, у которых toxic_score выше.

  • "Мягкое" предупреждение: В некритичных случаях можно автоматически предупреждать пользователя, если его сообщение имеет высокий toxic_score, давая ему шанс отредактировать его.

Этот опыт оказался бесценным, когда я приступил к своему следующему, более сложному проекту в Computer Vision. Надеюсь, моя история проб и ошибок сэкономит кому-то несколько бессонных ночей.

Весь код, модели, документация и готовое API доступны на GitHub:
https://github.com/Runoi/lightweight-toxic-classifier

Полный процесс обучения и отладки можно воспроизвести в Jupyter-блокноте на Kaggle:
https://www.kaggle.com/code/runoii/solocnnandquant

Комментарии (0)