Методы распознавания матерных (и не только) языков
Всем привет! Меня зовут Миша, я работаю Backend-разработчиком в Doubletapp. В одном из проектов появилась фича по добавлению тегов по интересам. Любой пользователь может создать интерес, и он будет виден всем остальным. Неожиданно (!!!) появились интересы с не очень хорошими словами, которые обычно называют матерными. Встала задача по распознаванию языка с матерными словами, чтобы исключить возможность добавления гадости в наш огород!

Проблема
Распознавание языков — не новая задача в computer science. Синтаксические анализаторы успешно распознают код языков программирования. С разговорными языками сложнее — их труднее формализовать, слова и предложения могут восприниматься по-разному.
Задачи анализа текстов решаются довольно успешно.
Например:
идентификация языка;
анализ поисковых запросов;
алгоритмы голосовых помощников;
алгоритмы больших языковых моделей для анализа одновременно лексики, синтаксиса и семантики.
Лексика — слова. Пример: [яблоко, стол, кушать]
Синтаксис — правила построения предложений. Пример: [Она съела яблоко] подлежащее идёт перед сказуемым, окончание соответствует женскому роду
Семантика — смысловое содержание предложения. Пример: предложение [Девушка съела яблоко] означает, что девушка решила перекусить яблоком
Я остановился на распознавании подъязыка в рамках русского языка, который формируется некоторым словарём русских слов. Решение этой задачи может быть полезно для:
фильтрации ненормативной лексики;
фильтрации спам-сообщений;
поддержания формального тона в рабочих чатах и документах
Будем рассуждать на примере фильтра ненормативной лексики. В задаче не надо беспокоиться о синтаксисе и семантике, что упрощает дело, так как остаётся только лексика (слова). Я опишу разные подходы и выберу удобные инструменты для реализации в коде.
Идеи в статье не привязаны к конкретному ЯП и БД, можно использовать любые. Для простоты буду писать на Python + django REST framework + Postgresql. Если сразу хочется потыкать, то вот ссылка на открытый репозиторий с демо фильтра.
Делаем фильтр ненормативной лексики
Задача
Вход: текстовая строка не более K символов.
Выход: булевое значение (True — текст содержит ненормативную лексику, False — не содержит).
Выходной ответ будет различаться для разных людей. Представим, что над нами стоит оракул (например, заказчик фильтра), и он в точности знает ответ на любую строку. Дальше считаем, что оракул — это автор статьи, то есть я.
Задача — максимизировать долю правильных ответов.
Параметры решения
Точность ответов.
Детерминированность — на один и тот же вход всегда выдает один и тот же ответ.
Размер словаря, требующийся для работы.
Время работы.
Сложность поддержки — добавление нетипичных слов и обработка ложных срабатываний.
Регулярные расходы и стоимость разработки.
Закинем в нейросеть?
ChatGPT
Сразу удовлетворю непреодолимое желание и спроектирую фильтр на основе нейросетей.
Начнём с простого: постучимся в ChatGPT с промптом: «содержит ли текст ненормативную лексику: [искомый текст], выведи true, если да, и false, если нет».
Плюсы:
Простота решения.
Словарь неявно хранится внутри ChatGPT.
Работает при большом K (количестве символов).
Анализ семантики — смысла предложения.
Минусы:
Сомнительная точность.
Разные ответы на один и тот же текст.
Большое время отклика.
Сложное управление в случае ошибок: добавлять слова-исключения в промпт неудобно и дорого.
Дорого при большом количестве запросов.
Своя нейросеть
Напишем свою нейросеть и обучим на словаре ненормативной лексики. Эта модель, в отличии от GPT, может быть детерминированной. Распарсим текст на слова и проверим каждое слово в отдельности
Плюсы:
Детерминированность.
Точность зависит от полноты словаря.
Возможно сделать в перспективе дешевле, чем в GPT.
Время работы зависит от нейросети.
Минусы:
Проблема обучаемости и переобучаемости — нужен большой и точный словарь.
Сложно найти хороший датасет по этическим соображениям, придется создавать самим.
Составим словарик?
После баловства с нейросетями можно задуматься, насколько задача сложна. Разбрасывать маты не будем, заменим их на кухонно-фруктовую тематику. То есть задача — отловить слова, связанные с готовкой и фруктами.
Построим грамматику
Распознаем язык, в котором встречаются существительные без суффиксов и приставок, фрукты в единственном числе в именительном падеже: «банан», «яблоко», «груша». Для решения достаточно перечислить все фрукты в словаре и проверять текст на их наличие.
Расширим задачу: добавим глаголы и прилагательные, оставив ограничения на падеж, приставки и суффиксы. Добавить прилагательные и глаголы в словарь недостаточно — окончания могут меняться: вкуснОЕ яблоко, вкуснАЯ груша, он ел яблоко, она елА яблоко. Добавим в словарь все строки вида: RE:
R (root) — множество корней [яблок, груш, рез (ать), …]
E (ending) — множество окончаний [а, и, ый, ий, ть …]
R и E содержат пустую строку.
Появляется проблема с тем, что распознается язык бОльший, чем требуется. Допустим, что строка «он готовилЫЙ спелА яблокИЙ» тоже подходит, хотя она лексически и синтаксически составлена неверно.
Теперь разрешим падежи, суффиксы и приставки. По аналогии с окончаниями добавим в словарь все слова вида PRSE:
P (prefix) — приставки [при, по, о, …]
R (root) — корень [яблок, груш, рез (ать), …]
S (suffix) — суффиксы [а, оньк, к, …]
E (ending) — окончания [а, и, ый, ий, ть …]
Размер словаря существенно увеличится, добавление любой из составных частей влечет множество новых слов. Поиск по такому словарю — непростая задача. Этим методом будет сложно распознать сложносоставные слова: фрукторез, грушевидная.
Отчасти проблемы устраняются синтаксическими анализаторами. Простейший анализатор — регулярное выражение. Но такие анализаторы тяжело поддерживать — они созданы для распознавания ЯП, которые не так часто меняются При изменении языка анализатор может потребовать большой доработки. К тому же анализаторы плохо справляются с ошибками и намеренными заменами в словах: бОнан, ябл0к0, grusha и так далее.
Триграммы
Мы выяснили, что точное распознавание слов делает словарь слишком большим. Хотелось бы уменьшить словарь и на входное слово получать наиболее похожее слово из словаря и значение, насколько они похожи. Помогут метрики:
N-граммная схожесть (в частности триграммная).
Расстояние Левенштейна.
Коэффициент Отиаи.
Коэффициент Жаккара.
Фонетическая схожесть.
Коэффициенты Отиаи и Жаккара нативно поддерживаются только в Elasticsearch, поэтому я не стал подробно их изучать.
Расстояние Левенштейна чувствительно к разнице в один символ: для слов кокос и покос схожесть будет наивысшей. В триграммах это не так: чем меньше слово, тем большей считается разница от различий. Я решил использовать триграммы.
Советую использовать PostgreSQL. В PostgreSQL легко настроить индексы для поиска по триграммам, что ускоряет запросы.
Экспериментируем
Загрузим в БД фрукты: яблоко, груша, апельсин, банан, виноград, манго, ананас, киви, персик, абрикос, слива, грейпфрут, лимон, лайм, хурма, кокос, авокадо, черешня, вишня, гранат.
Найдем схожесть для вишни:
get_similarity_words("вишня")
1.0 — полное совпадение, 0.0 — не совпадает никак.
Установим индикатор совпадения 0.4: если схожесть больше или равна индикатору, то слово распознается фильтром.
Найдем схожесть для производных от вишни: вишневый, вишенка, вишеночка:
get_similarity_words("вишневый")
get_similarity_words("вишенка")
get_similarity_words("вишненочка")
Ответы меньше чем 0.4, значит фильтр не смог распознать эти формы. Есть 2 решения:
увеличить словарь
уменьшить индикатор.
Помним, что при уменьшении индикатора растет количество ложных срабатываний.
Ложные срабатывания
get_similarity_words("покос")
get_similarity_words("граната")
get_similarity_words("сливаться")
«Граната» — это та, которая взрывается. Без контекста (семантики) не понятно, что имелось в виду, поэтому ответ засчитаем. А вот «сливаться» определилось ошибочно.
Для ложных срабатываний можно завести дополнительный словарь — слова, распознаваемые фильтром, но не попадающие в нужный язык. Назовём изначальный словарь «черным», а новый — «белым».
Для «белого» словаря введем аналогичную схожесть по триграммам и индикатор совпадения. Если фильтр распознает слово, то дополнительно проверим схожесть со словами из «белого» словаря. Если есть слово со схожестью больше, чем индикатор совпадения, то считаем слово ложным.
Обработка намеренных ошибок
Из исходного слова создадим новые слова и предложения, помня о том, какие символы или группы символов могут быть похожи на те, что используются в «плохих» словах.
Например:
ябл0к0 похоже на яблоко.
ллииммоонн похоже на лимон.
«грyша» с латинской «y» похожа на «груша» с русской «y».
Запустим фильтр на новых словах.
Нормализуем строку?
Преобразуем входную строку так, чтобы её было легче распознать. Зафиксируем, что нормализованный текст — это текст с нормализованными формами слов. Нормализованная форма слова — та, к которой сводится изначальное по каким-либо правилам. Правила могут разными.
Простейшие методы
удаляем все неалфавитные и нецифровые символы [«åß≈ƒ´ß ЯблОко3000» → » ЯблОко3000»]
преобразуем в нижний регистр [«ЯблОко3000» → «яблоко3000»]
Сделаем транслитерацию кириллических букв в латинские эквиваленты [«яблоко3000 → yabloko3000»]
Теперь в словарь надо добавлять нормализованные формы, а не сами слова. Если раньше для распознавания были нужны «ЯБЛОКО», «яблоко» и «yabloko», то теперь можно использовать только yabloko. Получилось уменьшить словарь.
Основа слова
Пусть нормальная форма — это основа слова. Например:
Яблоко [яблок]
Вкусный [вкусн]
Кушать [куша]
Важно, что основа — это не корень. Для определения основы можно использовать pymorphy2 и DeepPavlov. Эти библиотеки умеют определять окончания и приставки, а затем оставлять только основу слова. То есть в словарь можно добавлять только основу.
Корень слова
Идея сделать нормальную форму корнем слова заманчива: это позволит в разы уменьшить словарь и упростит управление фильтром.
Корень, в отличии от основы, — неформальное определение, поэтому его выделение классическими методами сложнее. Я не нашел достойного решения по выделению корня слова, но поэкспериментировал с ChatGPT. В 90% случаев нейросеть справляется с задачей и выдаёт строку, близкую к корню. В остальных случаях она отдает строку, близкую к основе.
Определение корня особенно полезно в сложносоставных словах. Заполнять словарь изящно придуманными словами — долгое и неблагодарное дело (фрукторезоточенная, грушевиднопродолговатая). Аналоги матерных слов тоже несложно придумать :) ChatGPT отдаст несколько корней, и их можно анализировать по отдельности.
Но есть и минус: ChatGPT создает недетерминированность и непредсказуемость при ложных срабатываниях.
Последовательное применение
Находим основу классическими методами.
Находим корень нейросетями.
Делаем транслитерацию.

Объединяем в одно целое?
Рассмотрим точность работы методов кругами Эйлера.

2 + 1 — язык ненормативной лексики.
1 + 3 — язык, распознающийся методом триграмм на конкретном словаре.
1 — язык, успешно распознанный фильтром.
2 — язык, не распознающийся фильтром.
3 — язык ложных срабатываний.
Хочется, чтобы площади 2 и 3 стремились к нулю, а площадь 1 стремилась к 2 + 1.
Можно надвигать круг триграмм (правый круг) на язык ненормативной лексики (левый круг), уточняя «черный» словарь, при этом площадь ложных срабатываний (область 3) можно контролировать белым словарём. Хотелось бы автоматизировать процесс добавления новых слов.
Добавим в схему язык ненормативной лексики, который распознается ChatGPT.

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

Итого
Плюсы:
Точность ответов зависит от словаря.
Детерминированность.
Размер словаря не так велик.
Ответ на вопрос в моменте — быстр (поиск по триграммам), анализ нейросетями можно запускать асинхронно.
Поддержка частично автоматизирована, гибкая настройка через «черный» и «белый» словарь.
Минусы:
Нужен администратор, проверяющий подозрительные слова.
Дешевле, чем простое использование ChatGPT, но всё равно дороже остальных решений
Еще одно преимущество: минусы могут быть активны только первое время. После основного наполнения админ может побольше отдыхать, а анализ нейросетями можно отключить.
Демо
Воплотил в код идею из последнего топика. Использовал расширение для PostgreSQL и удобную ORM из Django для работы с БД. Пригодилась джанговская админка, в которой можно редактировать словарь. Ответ на то, является ли строка ненормативной, можно получить через swagger.