ESPN DeportesTexas quiere confirmar su buen momento ante Oklahoma en la rivalidad del Red RiverESPNWarriors, Podziemski agree to 5-year, $110M extensionPunchUNICEF, stakeholders seek end to child marriageRTP DesportoSuspensão de Ronaldo. Quais as penas e atenuantes?The Jerusalem PostOil tanker explodes after striking naval mine in Hormuz, IRGC claims - reportZDF heuteAktuelle Pressemitteilungen des ZDFPremium TimesUK migrant workers wear chains, sing Asa’s ‘Jailer’ to protest proposed visa reformsChannel News Asia'They lie to us': One year on, Gazans don't believe in ceasefireBBC Sport'It hurts a lot' - Carrick 'understands' boos after Tottenham drawCBS News8 killed, including 2 children, in mass shooting in Erie; suspect also deadХабр«Нам это не нравится!»: как работать с сопротивлением в IT‑командахSRF NewsGolfküste der USA – Sturm «Isaias» kein Hurrikan mehr – trotzdem Lebensgefahr
The Daily Newsstand · Free, Always
Saturday, October 10, 2026

Хотел выбрать булочную по отзывам, а написал анализатор аномалий с embeddings и графами

Translate

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

Рейтинг заведения сам по себе мне мало что говорит. У большинства мест, которые мне попадаются, он выше четырёх. Поэтому я читаю сами отзывы: ставлю фильтр «сначала отрицательные», и картина иногда меняется сильно. В общей ленте всё выглядит отлично, а в негативных отзывах появляются подробности, которых по звёздам не заметишь.

Листая карты в очередной раз, наткнулся на отзыв на булочную. Он начинался так:

да вот хороший вариант ответа

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

Это просто бомба! Искал в округе настоящую шаурму, и наконец нашел…

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

Так появилась идея инструмента, который ищет странности в отзывах. Мне даже больше нравится слово «аномалии». Например, у заведения внезапно появляется поток пятёрок. Или, наоборот, за день набегают десятки единиц. Интересно было проверить, можно ли находить такие аномалии автоматически.

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

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

В итоге получился ReviewScope: open-source инструмент для анализа отзывов, поиска аномалий, повторяющихся текстов и исследования истории авторов.

GitHub: https://github.com/zinverno/reviewscope

Демо: https://reviewscope-demo.streamlit.app/

Обзор синтетического заведения «Волга Гранд»: обычный и взвешенный рейтинги, показатели активности и доля повторяющихся отзывов.

Обзор синтетического заведения «Волга Гранд»: обычный и взвешенный рейтинги, показатели активности и доля повторяющихся отзывов.

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

Почему я отказался от рейтинга доверия к авторам

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

Но количество отзывов само по себе не делает человека компетентным. Допустим, пользователь написал сорок отзывов о заведениях Москвы, несколько о Петербурге и потом начал оценивать кафе в Казани. Алгоритм увидит смену географии, а на деле это может быть просто поездка. Делать инструмент, который подозревает людей за то, что они выехали из родного города, я не хотел.

Есть и обратный случай. Один человек написал единственный отзыв о кофейне, но в нём сорт кофе, температура напитка, работа бариста и конкретные недостатки. Другой написал сто отзывов по две-три общие фразы. Второй не обязательно полезнее.

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

С текстами тоже пришлось разбираться отдельно. Одно шаблонное «всё понравилось» мало о чём говорит. А вот десятки похожих отзывов от разных аккаунтов уже хочется рассмотреть поближе.

Данные и архитектура

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

Сразу уточню: ReviewScope пока не умеет самостоятельно загружать отзывы по ссылке на организацию в Яндекс Картах. Для анализа нужна подготовленная выгрузка. Булочная стала поводом для разработки, но прямую интеграцию с картами я оставил за пределами первой версии.

Сейчас приложение принимает CSV и JSON, нормализует данные и сохраняет их в DuckDB. Один отзыв в упрощённом виде выглядит так:

review_id
place_id
place_name
place_category

reviewer_id
reviewer_name

rating
text
published_at

city
region
country

latitude
longitude

Все поля заполнять необязательно. Без координат можно искать дубликаты, без истории автора можно анализировать его текст. А вот для оценки опыта в категории уже нужна история, для временных всплесков нужны даты публикации. Когда данных не хватает, интерфейс показывает N/A или поясняет, чего именно недостаёт. Ноль здесь только запутал бы: непонятно, то ли ничего не найдено, то ли искать было не по чему.

Сам pipeline выглядит примерно так:

CSV / JSON
    |
    v
Normalization
    |
    v
DuckDB
    |
    v
AnalysisEngine
    |
    +---- Burst detection
    +---- Rating anomalies
    +---- Duplicate detection
    +---- Topic clustering
    +---- Reviewer metrics
    +---- Text scoring
    |
    v
Scoring / Evidence
    |
    v
Streamlit UI

Для хранения взял DuckDB: всё должно запускаться локально, без отдельного сервера PostgreSQL. В базе лежат отзывы, кэш embeddings и данные для анализа. Интерфейс сделал на Streamlit, чтобы быстрее перейти к алгоритмам. До этого со Streamlit я не работал, так что без мелких проблем не обошлось, но базовый UI собрал быстро. Для выбора организации, просмотра графиков и исследования отзывов его хватает.

Всплески активности

Начал с временных аномалий.

Возьмём булочную, у которой обычно один-два отзыва в неделю. Если за сутки приходит тридцать, почти все пятёрки, это выглядит странно. Но для ресторана с пятьюдесятью отзывами в день те же тридцать ничего особенного не значат. Абсолютное число без истории организации малоинформативно.

Самый простой вариант — сравнивать день со средним количеством отзывов за прошлый период. Среднее чувствительно к выбросам: после нескольких резких всплесков оно растёт, и следующий всплеск уже не так заметен. Поэтому я взял rolling median + MAD.

MAD (Median Absolute Deviation, медианное абсолютное отклонение) — способ оценить, насколько обычные значения расходятся с типичным. Сначала находим медиану, то есть серединное значение набора. Потом смотрим, насколько каждое наблюдение от неё отличается, и снова берём медиану, теперь среди этих отклонений. Один очень необычный день на такую оценку почти не влияет.

Разберём на простом примере. Допустим, за семь дней количество отзывов было таким:

День 1:  1
День 2:  2
День 3:  1
День 4:  0
День 5:  2
День 6:  1
День 7: 30

Медиана здесь равна 1, MAD тоже 1, и день с 30 отзывами получает modified z-score около 19.6. Если считать через обычное среднее и стандартное отклонение вместе с этим днём, среднее поднимется примерно до 5.3, стандартное отклонение до 10.1, а обычный z-score составит около 2.4. Экстремальное значение само сдвигает статистики, с которыми его сравнивают. Это только иллюстрация различия методов: в ReviewScope текущий день в историческое окно не входит и сравнивается с предыдущими наблюдениями.

Окно истории составляет 30 дней. Для каждого дня берётся число новых отзывов, рассчитываются медиана активности и медианное отклонение, затем modified z-score. Фрагмент из BurstDetector:

def _modified_z_score(
    self,
    observed: int,
    median: float,
    mad: float,
) -> float:
    return 0.6745 * (
        observed - median
    ) / max(mad, 1.0)

observed — число отзывов за день, а median и mad описывают обычную активность заведения. Знаменатель ограничен снизу единицей, потому что у маленькой организации в большинстве дней отзывов может не быть вообще. Медиана и MAD тогда равны нулю, и без такой защиты расчёт ломается.

Порог modified z-score сейчас 3.5. Дополнительно проверяется минимальное количество отзывов для события. Первые дни, пока не накоплена историческая база, в анализ всплесков не входят.

Во время тестирования я наткнулся на забавный момент. В одном сценарии интерфейс показывал, что отзывов стало в 30 раз больше относительно исторической медианы 0.0. Причина была в отдельном расчёте коэффициента роста:

multiplier = observed / max(median, 1.0)

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

За один день появилось 22 отзыва при практически нулевой исторической активности. Аномалия обнаружена на синтетических данных.

За один день появилось 22 отзыва при практически нулевой исторической активности. Аномалия обнаружена на синтетических данных.

Сдвиг рейтинга

Нашли день с необычно большим числом отзывов. А что в нём было? Все пятёрки, все единицы или распределение оценок вообще не изменилось?

Для этого я добавил анализ через Jensen–Shannon divergence. Это способ измерить различие между двумя распределениями вероятностей. Здесь сравниваются доли оценок от одной до пяти звёзд за исторический период и вокруг обнаруженного события.

До события:

5★  55%
4★  25%
3★  10%
2★   5%
1★   5%

Во время события:

5★   4%
4★   2%
3★   0%
2★   4%
1★  90%

Пример условный. Если смотреть только на средний рейтинг, часть информации теряется, а Jensen–Shannon divergence измеряет именно изменение структуры оценок. Алгоритм работает одинаково для положительных и отрицательных всплесков: считать подозрительными одни пятёрки я не хотел.

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

В окне обнаруженного события доля однозвёздочных оценок достигла 90%. Пример из синтетического датасета.

В окне обнаруженного события доля однозвёздочных оценок достигла 90%. Пример из синтетического датасета.

Поиск похожих отзывов

Следующая задача — повторяющиеся тексты. В первых версиях всё просто: нормализуем текст, сравниваем строки и собираем совпадения. Это перестаёт работать, как только отзывы совпадают не буквально. Например:

Отличная пекарня, вкусная выпечка, персонал очень приятный.

И другой отзыв:

Хорошее место, сотрудники приветливые, а выпечка действительно вкусная.

Слова разные, смысл почти одинаковый. Поэтому в ReviewScope четыре уровня совпадений:

  • Exact — одинаковые нормализованные тексты.

  • Fuzzy — лексически близкие формулировки, которые находятся через RapidFuzz.

  • Near — ещё один класс сильного лексического сходства. Для больших наборов кандидаты отбираются в том числе через MinHash/LSH.

  • Semantic — смысловое сходство, для которого используются embeddings.

Мне казалось, что первых трёх хватит. Но если автор полностью перефразировал предложение, лексические методы работают заметно хуже, и я пришёл к embeddings. После разработки семантического поиска для Obsidian идея была знакомой, только вместо заметок теперь короткие отзывы, часто из нескольких слов.

В качестве модели я выбрал sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2. Она создаёт векторы размерности 384 и подходит для русского и английского. Упрощённо всё выглядит так:

Review A
    |
    v
Embedding A ──┐
              ├── Cosine similarity
Embedding B ──┘
    ^
    |
Review B

Модель превращает каждый текст в набор чисел, а cosine similarity оценивает близость этих векторов. Если векторы близки, тексты могут быть похожи по смыслу, даже если слова разные. В текущей конфигурации ReviewScope фиксирует семантическую связь при cosine similarity от 0.88.

С короткими похвалами тут сразу возникает проблема: «Очень вкусно» и «Еда отличная» тоже близки по смыслу. Способов выразить эту мысль немного. Поэтому семантические совпадения я показываю для дальнейшего разбора: сама по себе близость векторов ещё ничего не говорит о происхождении отзывов.

Почему я не стал сравнивать всё со всем

Если у организации тысяча отзывов, возможных пар уже почти полмиллиона. На небольших наборах это терпимо, но с ростом числа отзывов число сравнений увеличивается квадратично. Поэтому стратегия в ReviewScope зависит от размера выборки: на небольших наборах прямое попарное сравнение, на больших для лексического поиска отбор кандидатов через MinHash/LSH, для семантического ограниченное число ближайших соседей через scikit-learn.

Нынешний NearestNeighbors использует algorithm="brute". Мы сохраняем и обрабатываем меньше связей, но расстояния всё равно считаются с квадратичной сложностью. ANN/HNSW-индекса пока нет. На практике помогает то, что сравнение идёт внутри одной организации: все отзывы всех заведений друг с другом сопоставлять не приходится.

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

Embeddings сохраняются в DuckDB. Ключ кэша включает идентификатор отзыва, отпечаток его содержимого и имя модели. Если текст не менялся, вектор не пересчитывается. Если текст изменился или выбрана другая модель, старый embedding не используется. На больших датасетах это экономит много времени при повторных запусках.

Три обнаруженных семейства объединяют 26 из 181 отзыва. Сходство текстов само по себе не доказывает накрутку.

Три обнаруженных семейства объединяют 26 из 181 отзыва. Сходство текстов само по себе не доказывает накрутку.

Группа похожих отзывов не обязана состоять из взаимно похожих текстов

Когда детекторы заработали, возник вопрос: как объединять найденные пары в группы? Допустим, A похож на B с оценкой 0.94, B похож на C с оценкой 0.91, а A и C имеют сходство всего 0.72.

A ── 0.94 ── B ── 0.91 ── C

A ── 0.72 ── C
     порог не пройден

Для простоты все значения здесь можно считать семантическим сходством, для которого установлен порог 0.88. Отзывы становятся вершинами графа, а обнаруженные совпадения рёбрами. Семейства в ReviewScope собираются как связные компоненты через Union-Find, и в нашем примере все три отзыва попадут в одну группу, потому что между ними есть цепочка связей. Но назвать их тремя взаимно похожими отзывами нельзя.

Пока не строишь интерфейс, это кажется придиркой. Если просто показать карточку «найдено три похожих отзыва», пользователь решит, что все три прошли проверку друг с другом, а это не так. Поэтому я добавил отображение прямых связей: можно посмотреть, какие пары действительно прошли порог, каким методом найдено совпадение и насколько близки сами тексты.

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

Граф из 18 отзывов: обнаружены 54 прямые связи из 153 возможных. Остальные участники могут быть связаны транзитивно.

Граф из 18 отзывов: обнаружены 54 прямые связи из 153 возможных. Остальные участники могут быть связаны транзитивно.

Два отзыва находятся в одном семействе, хотя между ними нет обнаруженной прямой связи. Их объединяют промежуточные совпадения.

Два отзыва находятся в одном семействе, хотя между ними нет обнаруженной прямой связи. Их объединяют промежуточные совпадения.

Так гораздо проще разобраться, почему конкретный отзыв оказался в группе.

Четыре упавших теста и 1.0000002

При подготовке публичного релиза в GitHub Actions упали четыре теста Investigation Workspace. Группы строились правильно, но полностью одинаковые отзывы иногда получали тип semantic вместо exact.

Причина оказалась в погрешности float32. Cosine similarity двух одинаковых текстов иногда получалась чуть больше единицы:

Semantic similarity: 1.0000002

Exact similarity:    1.0

Логика выбирала связь с максимальным score и оставляла semantic, хотя тексты совпадали буквально. Исправление небольшое:

# Exact pairs always take precedence.
for key, score in exact_pairs.items():
    uf.union(*key)
    pair_scores[key] = score
    pair_types[key] = "exact"

Теперь точное совпадение всегда имеет приоритет, а для этого случая появился регрессионный тест с 1.0000002. После исправления CI прошёл целиком.

Нашёл я эту ошибку быстро, но она банальная, а именно такие обычно съедают больше всего времени, по крайней мере у меня. Интерфейс при этом уверенно объяснял буквальное совпадение как семантическое. Мелочь, конечно, но для инструмента, в котором я предлагаю разбирать каждую связь, это уже баг.

Можно ли определить, что отзыв написал ChatGPT?

После истории с булочной этот вопрос напрашивается сам. Но классический AI-детектор я делать не стал. Мне интереснее искать повторяющиеся тексты и разбирать связи между ними. К тому же шаблонно может писать и человек, а нейросетевой отзыв можно отредактировать так, что по одному стилю уже мало что поймёшь.

Вместо этого ReviewScope смотрит на наблюдаемые свойства текста: повторяющиеся словосочетания, структуру предложений, сходство с другими отзывами той же организации, количество уникальных деталей и однообразие стиля. По ним рассчитывается Templated Score от 0 до 100. Если двадцать отзывов построены почти одинаково, а различаются только названиями блюд и парой прилагательных, система должна обратить на это внимание. Но один хорошо написанный отзыв не должен получать высокий балл только потому, что его автор умеет красиво формулировать мысли.

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

Метрики авторов

Первоначальная идея анализа авторов никуда не исчезла. В ReviewScope появились три основных показателя.

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

Local Familiarity показывает историю отзывов в городе или регионе.

Reviewer Relevance объединяет несколько характеристик, в том числе категорийный опыт, информативность текстов автора, согласованность оценок и глубину доступной истории.

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

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

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

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

Weighted Rating

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

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

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

Упрощённая формула качества на данный момент:

quality =
    0.35 * specificity
  + 0.20 * category_experience
  + 0.25 * reviewer_relevance
  + 0.20 * recency

Компоненты нормализуются в диапазон от 0 до 1. Нейтральная точка — 0.50, и если качество выше неё, вес отзыва может увеличиваться.

excess = max(0, quality - 0.50)

weight = clamp(
    1 + 2 * excess - penalty,
    0.25,
    2.0
)

После этого рейтинг рассчитывается как обычное взвешенное среднее:

weighted_rating =
    sum(rating_i * weight_i)
    ------------------------
         sum(weight_i)

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

Показатель

Коэффициент

Предполагаемая логика

Specificity

0.35

Конкретность самого текста получила наибольший приоритет

Category Experience

0.20

Опыт автора в категории учитывается, но не доминирует

Reviewer Relevance

0.25

Общая релевантность истории автора

Recency

0.20

Более свежие отзывы получают дополнительное преимущество

Как я случайно штрафовал отзывы дважды

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

Duplicate signal
      |
      +---- Direct duplicate penalty
      |
      +---- Coordinated probability
                    |
                    +---- Another penalty

Я сравнил несколько вариантов формулы на корпусе из 21 831 отзыва. Повторный учёт текстовых признаков составлял около 18% суммарной штрафной массы исходной модели. Под штрафной массой здесь понимается сумма рассчитанных штрафов по всем отзывам: это не доля накрученных отзывов и не изменение рейтинга на 18%.

Просто убрать coordinated-компонент было бы странно: вместе с повторным учётом исчезли бы и самостоятельные признаки, например участие во временных событиях. Поэтому из coordinated probability вычитается уже учтённый текстовый вклад:

text_reuse =
    0.30 * max(duplicate_prob, templated_prob)

coord_resid =
    max(0, coordinated_prob - text_reuse)

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

После исправления пересчитал результаты для 250 организаций. Видимый Weighted Rating изменился у 31, максимум на 0.01. Получилось скромно: повторный штраф убрал, а на итоговом рейтинге это почти не сказалось. Коэффициенты ради более заметного эффекта менять не стал.

После этого я стал проверять и остальные показатели на пересечения. На синтетике они выглядели самостоятельными, а на реальных отзывах несколько детекторов нередко срабатывали на одни и те же тексты.

Синтетика оказалась слишком удобной

Сначала я проверял всё на синтетических отзывах: создавал одинаковые положительные тексты, добавлял негативный review bombing, распределял публикации по организациям. Для отладки удобно, ведь заранее знаешь, что и где должно сработать. Но отзывы и аномалии я придумывал сам, под собственное представление о задаче. Нужно было проверить, что алгоритмы найдут в данных, которые я для них не готовил.

Первым реальным набором стал Geo Reviews Dataset 2023 от Яндекса: корпус из 500 тысяч русскоязычных отзывов об организациях. Я подготовил из него выборку на 10 454 отзыва. Но там быстро обнаружилось ограничение: есть тексты, оценки и информация об организациях, а полноценной истории отдельных авторов и точных дат публикации нет. Для поиска повторяющихся текстов и тематической кластеризации такой набор подходит, а временные аномалии и историю авторов на нём полноценно не проверить. Пришлось искать другой источник.

21 тысяча настоящих отзывов вместо тысячи придуманных

Следующим источником стал Google Local Data (2021) от исследователей UCSD. В нём есть идентификаторы пользователей и организаций, временные метки, оценки и метаданные заведений. Для исследования я подготовил подмножество по штату Vermont.

Vermont выбрал не потому, что он мне особенно нравится среди штатов США. Нужна была выборка, где есть разные категории организаций, достаточно отзывов об отдельных заведениях и история авторов, писавших о нескольких местах. А по объёму её можно было многократно прогонять и разбираться с результатами.

Полный проход AnalysisEngine по подготовленному корпусу занимал около четырёх с половиной минут. Embeddings к этому моменту уже лежали в кэше; подготовка датасета в это время тоже не входит. Даже с готовыми векторами анализ был далеко не мгновенным.

После очистки получилось:

Reviews:    21 831
Places:        250
Reviewers:   4 071

На этом корпусе я запустил основные алгоритмы ReviewScope и получил:

Repeated-text families:   611
Reviews in families:     1 793
Templated HIGH:            131

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

В корпусе Google Local Vermont медианная длина отзыва составляет 11 слов, а среди участников повторяющихся семейств — всего 4 слова.

В корпусе Google Local Vermont медианная длина отзыва составляет 11 слов, а среди участников повторяющихся семейств — всего 4 слова.

В самих парах часто встречалось что-нибудь вроде «Great food» и «Really good food». Сходство настоящее, но называть это накруткой было бы абсурдно.

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

Оценки и время

Другой интересный результат касался самих рейтингов. Среди найденных семейств в 46,5% случаев все участники поставили одинаковую оценку. В сопоставимых случайных группах, подобранных с учётом организации, размера группы и длины отзывов, одинаковые оценки встречались примерно в 35% случаев. Разница составила около 11,5 процентного пункта.

Доля семейств с одинаковыми оценками: 46,5% против 35% в сопоставимых контрольных группах. Разница не является доказательством накрутки.

Доля семейств с одинаковыми оценками: 46,5% против 35% в сопоставимых контрольных группах. Разница не является доказательством накрутки.

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

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

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

Результат, который оказался ошибкой

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

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

Авторы отмеченных отзывов:

1.41 отмеченного отзыва
на пользователя

Остальные авторы:

4.71 отзыва всего
на пользователя

То есть сравнивались два разных показателя. После исправления:

Авторы отмеченных отзывов:

6.78 отзыва всего
на пользователя

Остальные авторы:

4.71 отзыва всего
на пользователя

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

После такого стал внимательнее относиться к красивым аналитическим таблицам. Особенно своим.

Шаблонность и повторяющиеся тексты почти совпали

На том же корпусе 131 отзыв получил высокий Templated Score, и 120 из них уже входили в найденные семейства повторяющихся текстов. То есть отдельный показатель шаблонности добавил к объединённому множеству отмеченных текстов всего 11 новых отзывов.

После такого просто складывать срабатывания детекторов уже не хочется. Semantic similarity, шаблонность и тематическая кластеризация часто замечают близкие свойства текста. Три сработавших алгоритма вполне могут указывать на одну и ту же особенность. Поэтому мне удобнее рассматривать показатели по отдельности и проверять, что стоит за каждым.

Что получилось

В ReviewScope постепенно набралось несколько разделов. Discover показывает весь датасет: можно сравнить организации по доле повторяющихся отзывов, размеру семейств, разнице обычного и взвешенного рейтингов, информативности текстов. Оттуда перейти к заведению, открыть временные аномалии, историю авторов или тематические кластеры. Для разбора совпадений есть Investigation Workspace, отдельно доступны Reviewers, Topics, Reviewed Places и Data Quality.

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

Больше всего мне нравится раздел Anomalies, так как там легче всего отследить и понять какие-либо аномалии, вы и сами сможете это попробовать на приложенном тестовом датасете, там есть парочка таких мест. А поиск этих мест я оставлю уже вам.

Публичное демо

Локальная версия использует PyTorch и sentence-transformers. Загружать тяжёлую модель и рассчитывать embeddings с нуля при каждом запуске на бесплатном Streamlit Community Cloud мне не хотелось. Поэтому для демо я сделал отдельный синтетический датасет на 1 058 искусственных отзывов с разными сценариями активности и заранее сохранил embeddings в DuckDB. В облачной версии готовые векторы загружаются из базы, а модель для пересчёта каждого текста не нужна.

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

Локальная версия остаётся отдельным инструментом: она принимает собственные CSV и JSON, проводит анализ и при необходимости пересчитывает embeddings для изменившихся данных.

Тесты и ограничения

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

После проверки на реальных данных я стал спокойнее относиться к зелёному CI. Тесты ловят ошибки вроде 1.0000002 и проверяют, что алгоритмы выполняют заданные правила. А вот насколько удачно выбраны сами правила, по тестам не узнаешь. Для этого я добавил workflow с человеческой разметкой: аннотатор сначала оценивает данные, не видя результатов системы, а затем оценки можно сравнить. Точность детекторов я пока не подтвердил, но теперь её можно измерять.

Есть и другие ограничения:

  • ReviewScope находит необычные закономерности, а не доказывает накрутку. У него нет данных о фактических посещениях, чеках и договорённостях между авторами.

  • Результаты зависят от полноты данных. Нет дат — нет полноценной временной аналитики. Нет истории авторов — нельзя обоснованно оценивать их опыт.

  • На очень коротких отзывах семантических совпадений больше, и многие из них неоднозначны.

  • Weighted Rating — аналитическая модель с настраиваемыми коэффициентами, а не исправленная версия официального рейтинга.

  • Проверка сдвига распределения оценок пока привязана к найденным всплескам активности.

  • Метрики опыта авторов ещё недостаточно проверены на независимых данных.

  • На анализ миллионов отзывов текущая реализация не рассчитана.

Пока я использую ReviewScope как инструмент для исследования: открываю находку, смотрю исходные отзывы и разбираюсь, откуда она взялась. Автоматическому вердикту о накрутке на этом этапе я бы и сам не доверял.

Что я понял по дороге

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

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

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

Попробовать

GitHub: https://github.com/zinverno/reviewscope

Публичное демо: https://reviewscope-demo.streamlit.app/

Если будете тестировать, мне интересны две вещи. Первая: насколько полезным окажется Investigation Workspace и в каких случаях система объединяет отзывы, которые, по вашему мнению, вообще не должны попадать в одну группу. Вторая: какие из показателей вы действительно использовали бы при выборе заведения, а какие кажутся лишним шумом.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.

Хотел выбрать булочную по отзывам, а написал анализатор аномалий с embeddings и графами — KioskNews