Решил посчитать, сколько раз языковые модели отзываются плохо о конкретной компании. Вроде простая задача: прогнать запросы через несколько моделей, разметить тональность ответов, поделить негативные на общее число. У меня получилось три негативных ответа из 358 — 0,8%. Число выглядело спокойным, и я чуть не отправил его в отчёт как есть.

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

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

Как я вообще получил цифру 0,8%

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

Первый подсчёт выглядел так: беру все 358 меток, считаю, сколько из них negative, делю на общее число ответов. Получаю 3 из 358 — 0,8%. Дальше я собирался написать в отчёте, что негатива практически нет, и двигаться к следующей метрике.

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

Знаменатель, который решает всё

Из 358 ответов прогона для метрики годятся 22, после отсева подмены компании — 19.
Из 358 ответов прогона для метрики годятся 22, после отсева подмены компании — 19.

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

# Sentiment только если бренд упомянут (экономия)
if not brand_signals["brand_mentioned"]:
    sentiment = {
        "label": "neutral",
        "confidence": 1.0,
        "judge_model": "skipped",
        "reasoning": "brand not mentioned",
    }
elif no_judge:
    # --no-judge: тональность проставит Claude Code в сессии (на подписке, без aitunnel)
    sentiment = {
        "label": "pending",
        "confidence": None,
        "judge_model": "in_session_pending",
        "reasoning": "awaiting Claude Code in-session judgement",
    }
else:
    sentiment = judge_sentiment(answer, config["brand"])

Каждая ветка закрывает свой случай. Бренда в ответе нет — метка проставляется нейтральной с пометкой skipped, и дорогой вызов модели-судьи не делается вовсе. Запуск идёт с флагом --no-judge — ответ откладывается со статусом pending, а тональность потом размечает модель прямо в рабочей сессии, по подписке, минуя платный шлюз к моделям (в комментарии он назван по имени — aitunnel). Всё остальное идёт обычным путём: судья вызывается сразу.

Первая ветка как раз и указывает на место, где ошибается наивная метрика. Из 358 ответов бренд по имени назван только в 22 — это 6,1% от всего прогона. Когда я делил 3 негативных на 358, я делил на знаменатель, 336 элементов которого физически не могли содержать негатив — как и позитив. Это всё равно что посчитать долю недовольных клиентов среди всех прохожих на улице, а не среди тех, кто вообще заходил в магазин.

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

Если посчитать негативные ответы относительно тех 22, где бренд действительно назван, доля негатива подскакивает с 0,8% до 13,6%. Это одни и те же три отрицательных ответа — разница только в том, что стоит в знаменателе.

total_answers = 358
brand_mentioned = 22
negative = 3

naive_rate = negative / total_answers      # 0.008
correct_rate = negative / brand_mentioned  # 0.136

print(f"наивная доля негатива:    {naive_rate:.1%}")
print(f"корректная доля негатива: {correct_rate:.1%}")

Разрыв между двумя цифрами равен отношению знаменателей: 358 / 22 ≈ 16,3 раза. На округлённых процентах (13,6 против 0,8) он выглядит как семнадцатикратный, но отношение здесь одно, и дальше в тексте я говорю про разрыв в 16 раз — по числу ответов, а не по округлённым процентам.

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

Вторая ловушка: негатив может быть не о вас

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

Я прочитал текст ответа целиком. Модель действительно назвала бренд по имени, но дальше описывала не тот бизнес: вместо компании, которая занимается продвижением в нейросетях, речь шла про разные сервисы телефонии и колл-центров, а также музыкального дистрибьютора с похожим названием. Классификатор тональности сработал корректно: он увидел негативные формулировки рядом с именем бренда и поставил метку negative. Проблема не в классификаторе, а в том, что перед ним не стоял вопрос, о той ли компании вообще идёт речь.

Это отдельный класс ошибки — не про оценку, а про сопоставление сущности. В обработке текста такое сопоставление имени с конкретным объектом называют связыванием сущностей (entity linking), и это самостоятельная задача, которую классификатор тональности не решает и решать не обязан. Модель нашла в своей памяти или в поиске другую компанию с похожим именем и приняла её за целевую. Текст она оценила правильно: там действительно негативная тональность. Просто эта тональность о другом бизнесе.

После того как я убрал из выборки все ответы, где модель явно говорит не про ту компанию, а про однофамильца, из оставшихся 19 ответов о самой компании негативных не осталось ни одного — 0 из 19.

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

Проверка сущности не требует отдельной модели, и половина нужных данных уже лежит в логе. Рядом с каждым найденным упоминанием пишется поле context_snippet — обрезок текста вокруг найденного алиаса, то есть вокруг того написания названия, по которому шёл поиск: сто символов слева, полтораста справа. Именно в этом обрезке и видно, о телефонии идёт речь или о продвижении в нейросетях, — читать весь ответ целиком для этого не нужно.

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

def is_about_target_entity(answer: str, brand_aliases: list[str], disambiguators: list[str]) -> bool:
    """Проверка, что упомянутый бренд — это нужная компания, а не омоним.

    disambiguators — слова или фразы, которые почти всегда встречаются
    рядом с названием именно у вашей компании (сфера, продукт, город)
    и почти никогда — у похожего по названию бизнеса.
    """
    answer_lower = answer.lower()
    brand_found = any(alias.lower() in answer_lower for alias in brand_aliases)
    if not brand_found:
        return False
    return any(marker.lower() in answer_lower for marker in disambiguators)

Классификатор тональности он не заменяет — он стоит перед ним. Ответ проходит в подсчёт негатива только если в нём есть и название бренда, и контекст, который подтверждает, что речь о нужной компании. Иначе ответ помечается как омоним и не участвует ни в наивном, ни в корректном знаменателе — потому что тональность в нём относится не к тому объекту, который вы измеряете.

Третья ловушка: отказ — это не нейтральный ответ

Одни и те же три негативных ответа: 0,8%, 13,6% или 0% — в зависимости от знаменателя.
Одни и те же три негативных ответа: 0,8%, 13,6% или 0% — в зависимости от знаменателя.

Модель может вообще отказаться отвечать на запрос — по соображениям безопасности, из-за формулировки вопроса или без объяснения причины. В одном из прогонов, где я считал ответы по другой теме, 13 запросов по 5 каналам дали 65 ответов: 64 содержательных и один отказ.

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

Правильная механика — считать отказы отдельной категорией, исключать их из знаменателя тональности и одновременно фиксировать их долю как самостоятельный показатель.

Как выглядит структура, когда негатив реальный

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

Обезличенный второй контур: среднее 48% скрывает разброс от 0% до 84% по блокам запросов.
Обезличенный второй контур: среднее 48% скрывает разброс от 0% до 84% по блокам запросов.

Знаменатель здесь 64: те самые 65 ответов минус один отказ. Три метрики верхнего уровня:

Метрика

Значение

Доля ответов с негативом

48% (31 из 64)

Доля ответов с объективным контекстом (титулы, достижения)

40% (26 из 64)

Усреднённая тональность (от −1 до +1)

−0,2

Знаменатель в этом замере проблемой не был: имя клуба стояло в самом запросе, поэтому упоминание есть почти в каждом ответе. Зато средняя доля 48% сама по себе мало о чём говорит, пока её не разбить на смысловые блоки запросов. Блоки были заданы заранее, до подсчёта, а не подобраны задним числом под результат:

Блок запросов

Доля негатива

Околоспортивный фольклор, байки, мемы

84% (16 из 19)

«За что не любят»

55% (11 из 20)

Третий блок

27% (4 из 15)

Нейтральные репутационные запросы

0% (0 из 10)

Средняя цифра 48% в этой картине почти бессмысленна как единственное число: она означает, что половина запросов даёт негатив, но по факту негатив живёт в двух блоках из четырёх, а в двух других его почти нет. Разбивка по смысловым блокам показывает, где именно проблема, и это то, с чем можно работать: если весь негатив идёт из блока фольклора и мемов, это совсем другая задача, чем если бы он был размазан ровно по всем типам запросов.

Сколько стоит классификатор

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

def judge_sentiment(answer: str, brand: str) -> dict:
    """Sentiment classification через Claude Sonnet 4.5."""
    prompt = (
        f'Текст: """{answer[:2000]}"""\n\n'
        f'Бренд: "{brand}"\n\n'
        'Опиши, как этот текст описывает бренд:\n'
        '- positive: явно хвалит, рекомендует, ставит выше конкурентов\n'
        '- neutral: упоминает без оценки или не упоминает\n'
        '- negative: критикует, предупреждает, ставит ниже конкурентов\n\n'
        'Верни строго JSON: {"label": "positive|neutral|negative", '
        '"confidence": 0.0-1.0, "reasoning": "1 строка"}'
    )
    try:
        client = _aitunnel_client()
        resp = client.chat.completions.create(
            model=JUDGE_MODEL,
            messages=[{"role": "user", "content": prompt}],
        )
        text = (resp.choices[0].message.content or "").strip()
        m = re.search(r"\{.*\}", text, re.DOTALL)
        if not m:
            return {"label": "unknown", "confidence": 0.0, "judge_model": JUDGE_MODEL, "raw": text[:200]}
        data = json.loads(m.group(0))
        data["judge_model"] = JUDGE_MODEL
        return data
    except Exception as e:
        return {"label": "error", "confidence": 0.0, "error": str(e)[:200], "judge_model": JUDGE_MODEL}

Каждый вызов судьи — это отдельный запрос к модели, то есть деньги и секунды. Из-за проверки brand_mentioned он делается только для 22 ответов из 358: судья не тратится на 336 ответов, где компании нет вообще. Экономия здесь те же 16 раз, что и разрыв между двумя знаменателями, — и это не побочный эффект, а прямое следствие правильной постановки знаменателя. Метод, который сначала отбирает ответы по факту упоминания бренда и только потом классифицирует отобранное, выходит одновременно и точнее, и дешевле подхода «прогнать всё через судью, а проценты посчитать потом».

В моём прогоне все 22 разметки сделала модель в сессии, без обращения к платному API, — это видно в логах по полю judge_model: claude-code-in-session. На саму метрику режим не влияет, зато влияет на цену эксперимента: если бы через отдельный вызов судьи гнался каждый из 358 ответов, дорогая часть эксперимента выросла бы в те же 16 раз без выигрыша в точности.

Что эти цифры не доказывают

Всё, что написано выше, — разбор одного конкретного прогона, а не общий закон:

  • Это один прогон по одной нише, 358 ответов, а выборка ответов с упоминанием бренда мала — всего 22, а после отсева омонимов — 19. На таких числах любая доля неустойчива.

  • 19 ответов, оставшихся после отсева подмены сущности, — это не доказательство отсутствия негатива, это выборка, на которой негатива не нашлось. При выборке в 19 наблюдений один негативный ответ на следующем прогоне сдвинет долю с 0% до 5%, и этот сдвиг не будет значить, что репутация ухудшилась, — он будет значить, что выборка слишком мала для устойчивых выводов в любую сторону.

  • Метрика не показывает разницу между двумя случаями: «модель ничего не знает о компании» и «модель о ней молчит по какой-то другой причине». 336 ответов без упоминания бренда могут означать, что запросы по смыслу были не про эту компанию, а могут означать, что компания настолько слабо представлена в данных обучения, что не всплывает даже там, где могла бы. Различить эти два случая по одному прогону нельзя: нужен второй замер с запросами, прицельно заточенными под компанию, и сравнение доли упоминаний между ними.

  • Разметку тональности делает языковая модель, и её решения не проверялись вторым независимым разметчиком — человеком или другой моделью.

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

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

Что в итоге измеряет метрика

Наивная доля негатива, посчитанная по всем ответам без разбора, отвечает не на вопрос о репутации, а на вопрос о том, насколько редко бренд вообще всплывает в ответах моделей. Знаменатель нужно строить из ответов, где бренд назван, а не из всех ответов подряд — иначе разрыв между реальной и наивной цифрой может достигать порядка, как в этом случае: 16 раз.

Но даже правильный знаменатель не спасает, если не проверено предположение, которое метрика делает молча: что модель говорит именно о вашей компании, а не о ком-то с похожим именем. В моём случае это предположение оказалось неверным ровно для трёх ответов из 22 — и именно эти три ответа были всем негативом, который показывала метрика. Без проверки сущности статья закончилась бы выводом о репутационной проблеме там, где была лишь ошибка распознавания названия.

Третья ловушка тише двух первых, но работает так же: отказы. Ответа не было — значит, тональности в нём нет, и в знаменателе такому событию не место. В отчёте место есть, отдельной строкой: «по этому запросу модель не отвечает» — это самостоятельный результат замера.

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