Интернет-магазин с десятками тысяч позиций собирает сотни отзывов в день, и читать их руками уже некому: поддержка тонет в очереди, категорийный менеджер видит только среднюю оценку, а причины недовольства остаются невидимыми до тех пор, пока товар не перестаёт продаваться. Анализ тональности переводит этот поток в карту проблем: каждый отзыв получает метку настроения, а вместе с ней - привязку к конкретному аспекту вроде доставки, качества или размера. Статья описывает весь путь: от подготовки данных и готовых нейросетевых моделей до собственных классификаторов, аспектной разметки, метрик и промышленного контура на библиотеке transformers версии 5.20.2.

Средняя оценка обманывает и текст отзывов честнее звёзд

Три звезды - самая коварная оценка в e-commerce. За ней скрывается и восторг "товар отличный, но доставка мучительная", и сдержанное "нормально, ничего особенного", и разочарование "материал хорош, но через неделю разошёлся шов". Средний рейтинг смешивает эти истории в одно число, и менеджер принимает решения по шуму. Текст честнее: в нём покупатель объясняет, что именно сломалось, кто виноват и что исправить.

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

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

  1. собрать отзывы за год, убрать дубликаты и шаблонные благодарности из автоматических писем;
  2. разметить полторы-две тысячи текстов по трём классам с инструкцией и сверкой двух разметчиков;
  3. выбрать модель: готовая нейросеть, дообучение под домен или классический алгоритм;
  4. измерить качество на отложенной выборке и вручную разобрать спорные ошибки;
  5. встроить автоматическую разметку в рабочий контур и назначить владельца периодического переобучения.

Разметка - самый трудоёмкий шаг, и на нём экономить нельзя: инструкция с пограничными примерами ("нормально" - нейтрально или позитивно?), второй разметчик для сложных случаев и обсуждение расхождений дают выборку, на которую можно полагаться.

Классификация готовыми моделями через pipeline из transformers

Быстрый старт занимает минуту и не требует обучения: библиотека transformers скачивает предобученную модель с открытого хранилища и оборачивает её в единый интерфейс pipeline:

from transformers import pipeline

clf = pipeline(
    task="text-classification",
    model="blanchefort/rubert-base-cased-sentiment",
    device=-1
)

result = clf("Курьер привёз на день раньше, упаковка целая!")
print(result)

Модель обучена на русских текстах и различает три класса, но подписи у неё технические, поэтому вывод сразу перекладывают в человеческие метки:

label_map = {
    "LABEL_0": "negative",
    "LABEL_1": "neutral",
    "LABEL_2": "positive"
}

text = "Размер подошёл, ношу вторую неделю, довольна"
pred = clf(text)[0]
print(label_map[pred["label"]], round(pred["score"], 3))

Такой скрипт обрабатывает несколько отзывов в секунду на обычном процессоре, а на видеокарте счёт идёт на тысячи. Для черновой сортировки больших массивов есть компактная альтернатива cointegrated/rubert-tiny-sentiment-balanced: модель в разы меньше и быстрее, точность ниже, зато вечерняя обработка миллиона сообщений укладывается в часы без бюджета на железо.

Если магазин работает на нескольких языках, выручает multilingual-модель nlptown, предсказывающая оценку от одной до пяти звёзд. А для англоязычного сегмента существует быстрый лексический разбор без нейросетей - пакет vaderSentiment версии 3.3.2:

from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer

analyzer = SentimentIntensityAnalyzer()
analyzer.polarity_scores("Great quality, fast shipping")

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

Собственная модель на TF-IDF и логистической регрессии из scikit-learn

Нейросеть - не единственный путь, и часто не лучший для начала. На двух тысячах размеченных отзывов классическая связка из TF-IDF и логистической регрессии обучается за секунды и служит честной базовой линией: всё, что сложнее, обязано обыграть её по метрикам, иначе усложнение не оправдано. В scikit-learn версии 1.9.1 это выглядит так:

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

model = make_pipeline(
    TfidfVectorizer(ngram_range=(1, 2), min_df=3, sublinear_tf=True),
    LogisticRegression(C=3.0, class_weight="balanced", max_iter=1000)
)

model.fit(train_texts, train_labels)
model.predict(["Кнопка включения не работает с первого дня"])

Параметры не случайны. Диапазон биграмм ловит фразы вроде "не работает" и "пришло быстро", min_df=3 отсекает одноразовые опечатки, sublinear_tf сглаживает частоты повторяющихся слов, class_weight="balanced" выравнивает перевес позитивных отзывов, которых в жизни всегда больше.

У классической модели есть бонус, недоступный нейросети: веса признаков читаются напрямую. Разбор коэффициентов показывает, что фраза "не пришло" тянет текст к негативу сильнее, чем слово "плохо", и такие наблюдения сами превращаются в правила для автоответов и модерации. Для русской морфологии перед векторизацией текст прогоняют через токенизатор razdel, а именованные сущности - бренды, города, имена - извлекает библиотека natasha версии 1.6.0.

Дообучение доменной модели на своих отзывах с контролем валидации

Готовая модель обучена на чужих текстах: посты, короткие сообщения, рецензии. Отзывы про маломерящие кроссовки и подтекающие термопоты пишутся иначе, и потолок готовой модели ощущается быстро. Решение - дообучение: за основу берётся открытый энкодер DeepPavlov/rubert-base-cased, поверх него ставится классификационная голова с тремя выходами:

from transformers import (
    AutoModelForSequenceClassification,
    AutoTokenizer,
    TrainingArguments
)

tokenizer = AutoTokenizer.from_pretrained(
    "DeepPavlov/rubert-base-cased"
)
model = AutoModelForSequenceClassification.from_pretrained(
    "DeepPavlov/rubert-base-cased", num_labels=3
)

args = TrainingArguments(
    output_dir="models/review-sentiment",
    num_train_epochs=3,
    per_device_train_batch_size=16,
    learning_rate=2e-5
)

Гиперпараметры консервативны не случайно. Скорость обучения 2e-5 бережёт веса, накопленные энкодером при предобучении на больших русских корпусах, три эпохи защищают от заучивания разметки, батч из шестнадцати примеров устойчиво ложится в память рядовой видеокарты. Дообучение на паре тысяч отзывов занимает десятки минут, а выигрыш у готовой модель даёт именно на доменных формулировках.

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

Аспектная тональность для доставки, качества и размера товара

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

ASPECTS = {
    "delivery": ["доставка", "курьер", "привезли", "срок", "пункт выдачи"],
    "quality": ["качество", "брак", "сломался", "шов", "материал"],
    "size": ["размер", "маломерит", "большемерит", "подошёл", "мерка"]
}

def detect_aspects(sentence: str) -> list[str]:
    found = []
    for aspect, words in ASPECTS.items():
        if any(word in sentence.lower() for word in words):
            found.append(aspect)
    return found

Каждое предложение прогоняется через классификатор тональности, результат прикрепляется к найденным аспектам, и на выходе продукт получает не одну цифру, а таблицу: 78% негатива по размеру, 12% по доставке, 4% по качеству. Для одежды размер мгновенно становится темой для карточки товара с таблицей мерок, а для бытовой техники сигналом о партии брака становится всплеск негатива по качеству на конкретной модели.

Агрегация по товару собирается в простую структуру:

def aspect_report(sentences: list[str]) -> dict:
    report: dict = {}
    for sentence in sentences:
        pred = clf(sentence)[0]
        label = label_map[pred["label"]]
        for aspect in detect_aspects(sentence):
            report.setdefault(
                aspect,
                {"positive": 0, "neutral": 0, "negative": 0}
            )
            report[aspect][label] += 1
    return report

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

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

Метрики качества и разбор ошибок на живых отзывах магазина

Считать точность на несбалансированных данных - верный способ обмануть себя. Если позитивных отзывов 85%, модель, всем говорящая "позитив", получит точность 85% при нулевой пользе. Поэтому смотрят на полноту по негативу, точность по каждому классу и агрегированную метрику F1:

from sklearn.metrics import classification_report, confusion_matrix

y_pred = model.predict(test_texts)
print(classification_report(test_labels, y_pred))
print(confusion_matrix(test_labels, y_pred))

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

Отложенная выборка держится неприкосновенной: 15-20% помеченных текстов, на которых модель ни разу не училась, случайный random_state для повторяемости экспериментов. Порог уверенности встраивается в продукт: предсказание со score ниже 0.6 уходит в очередь ручной проверки, а не на дашборд, потому что тихая ошибка хуже явного "не знаю".

Промышленный контур от батчей на CPU до триггеров для поддержки

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

def route_review(pred: dict) -> str:
    if pred["score"] < 0.6:
        return "manual_queue"
    if pred["label"] == "negative":
        return "priority_ticket"
    return "dashboard_only"

За маршрутизатором виден главный принцип внедрения: система не обязана быть безошибочной, она обязана честно признавать неуверенность и передавать спорные случаи человеку.

Для доменных текстов модель дообучают по схеме из раздела выше, а компактная модель dostoevsky версии 0.6.0 служит черновым фильтром коротких русских сообщений, когда железа нет вовсе. Мониторинг дрейфа обязателен: новые сленговые слова, смена упаковки, новая служба доставки - всё это меняет язык отзывов, и модель молча деградирует, если раз в квартал не пересчитывать метрики на свежей разметке.

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