Фильтр мата: эволюция

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Крупные компании могут это себе позволить, но как быть нам, «простым смертным», владельцам простейших сайтов и рядовым интернет - пользователям?

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

По интернету бродит пара-тройка JS и PHP скриптов с регулярными выражениями. Они частично решают проблему, но являются статичными алгоритмами. Русские пользователи, как известно, очень креативны в «этом вопросе». Каждый раз менять алгоритм не каждый осилит, да и временные затраты будут серьезные.

Читать такие комментарии по многу раз при формировании фильтра и думать о них не очень приятно. Попробуем воспользоваться data science.

Идея

За основу возьмем очень простую идею – фильтр мата в виде массива строк длиной от 2х до 7ми символов.  Применение фильтра по принципу text.contains определяет с некоторой точностью (70 – 80%), что текст сообщения токсичен.

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

Как вы понимаете, текстовые паттерны «плохих» слов, могут отфильтровать и «хорошие». Поэтому нам надо сформировать фильтр так, чтобы максимально уменьшить фильтрацию обычных слов.

Рецепт

Для генерации подобного фильтра нам понадобятся 2 текстовых файла: bad_words.txt и good_words.txt. Базовые знаний Python и генетический алгоритм – библиотека deap.

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

Список bad_words формируйте под вашу задачу. В интернете существует значительное количество различных баз слов с классификацией. Я не выкладывал свой bad_words.txt  по причине политкорректности.

Устанавливаем deap: pip install deap.

Создаем файл toxic_filter_create.py или jupyter notebook. На ваш выбор.

Загрузка данных

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

После загрузки получаем два списка good_words и bad_words.

Код загрузки данных

def load_file(file):

    with open(file, encoding='utf-8-sig') as input_file:

        lst = []

        for line in input_file:

            str = normalize(line.strip())

            if len(str) < 2: 

                lst.append(str)

    return lst

good_words = load_file('good_words.txt')

# print(good_words)

bad_words = load_file('bad_words.txt')

# print(bad_words)

Формируем паттерны

Минимальную длину паттерна min_letter_count = 2 и максимальную длину паттерна max_letter_count = 7 можно регулировать. Обычно 2 и 5 достаточно.

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

Функция percent_in_text() находит, какую часть из набора фраз (bad или good) «видит» фильтр.

Код создания паттернов

# Словарь со статистикой паттернов

patterns = {}

# Добавление в словарь и ведение статистики

def pattern_stat(p):

    # p = p.strip()

    if p in patterns:

        patterns[p] += 1

    else:

        patterns.update({p: 1})

# Создание паттерна (подстрока)

def create_pattern(text, letter_count):

    for pos in range(0, len(text) - letter_count):

        pattern_stat(text[pos: pos + letter_count])

min_letter_count = 2

max_letter_count = 7

for letter_count in range(min_letter_count, max_letter_count):

    for w in bad_words:

        create_pattern(normalize(w), letter_count)

# for letter_count in range(min_letter_count, max_letter_count):

#     for w in good_words:

#         create_pattern(normalize(w), letter_count)

patterns = sorted(patterns.items(), key=lambda patterns: patterns[1], reverse=True)

print('Кол-во паттернов:', len(patterns))

# Переводит бинарный вектор в текстовый массив - фильтр

def decode(individual):

    lst = []

    for pos in range(0, len(individual)):

        if (individual[pos] == 1):

            lst.append(patterns[pos][0])

    return lst

def percent_in_text(word_set, filter):

    count = 0

    for w in word_set:

        for p in filter:

            if p in w:

                count += 1

                break

    return count / len(word_set)

Выбор лучшего

Длина гена равна размеру списка паттернов. One-Hot кодирование.

Строка creator.create("FitnessCompound", base.Fitness, weights=(1.0, -1.0, -0.6)) говорит о том, что целевая (фитнес) функция возвращает 3 значения. Коэффициенты (1.0, -1.0, -0.6) показывают значимость каждого из параметров.

Значения с минусом говорит о том, что параметр должен минимизироваться, если плюс - максимизироваться.

Целевая функция получает на вход бинарный вектор (ген), декодирует его в текстовые паттерны и проверяет percent_in_text по bad_words и good_words. Первый параметр должен быть ближе к 1.0, а второй к 0, потому что мы не хотим фильтровать хорошие слова. Третий параметр - суммарная длина конечного фильтра. Минимизируется.

Описывать подробно работу ГА нет смысла - на эту тему есть множество статей. Сам код с минимальными модификациями взят с сайта модуля deap.

Остановлюсь лишь на некоторых моментах:

  • Чем больше num_generations, тем точнее будет фильтр.

  • Так как паттерны формируются только по bad_words, то первый параметр быстро максимизируется.

  • Процент вхождения в good_words оптимизируется значительно дольше. При 1000 эпохах у меня второй параметр уменьшился до 0,12. Другими словами, с вероятностью в 12% фильтр уберет и хорошее сообщение.

  • По bad_words фильтр 100% отрабатывает.

Код ген. алгоритма

# ГА

start_time = time.time()

# Целевая функция

def eval_func(individual):

    txt = decode(individual)

    return [percent_in_text(bad_words, txt), percent_in_text(good_words, txt), sum(map(len, txt))]

# Создание toolbox

def create_toolbox(num_bits):

    creator.create("FitnessCompound", base.Fitness, weights=(1.0, -1.0, -0.6))

    creator.create("Individual", list, fitness=creator.FitnessCompound)

    toolbox = base.Toolbox()

    toolbox.register("attr_bool", random.randint, 0, 1)

    toolbox.register("individual", tools.initRepeat,

                     creator.Individual, toolbox.attr_bool, num_bits)

    toolbox.register("population", tools.initRepeat, list, toolbox.individual)

    toolbox.register("evaluate", eval_func)

    toolbox.register("mate", tools.cxTwoPoint)

    toolbox.register("mutate", tools.mutFlipBit, indpb=0.05)

    toolbox.register("select", tools.selTournament, tournsize=3)

    return toolbox

population_size = 100

num_generations = 1000

# Вероятности скрещивания и мутации

probab_crossing, probab_mutating = 0.5, 0.3

# Размер гена равен кол-ву паттернов (one hot)

num_bits = len(patterns)

toolbox = create_toolbox(num_bits)

random.seed(7)

population = toolbox.population(n=population_size)

print('--- Старт ГА ---')

fitnesses = list(map(toolbox.evaluate, population))

for ind, fit in zip(population, fitnesses):

    ind.fitness.values = fit

for g in range(num_generations):

    start_gen_time = time.time()

    # Выбор следующего поколения

    offspring = toolbox.select(population, len(population))

    # Клонирование выбранных экземпляров

    offspring = list(map(toolbox.clone, offspring))

    # Применяем скрещивание

    for child1, child2 in zip(offspring[::2], offspring[1::2]):

        if random.random() < probab_crossing:

            toolbox.mate(child1, child2)

            # Удаляем значение ЦФ наследников

            del child1.fitness.values

            del child2.fitness.values

    # Применяем мутацию

    for mutant in offspring:

        if random.random() < probab_mutating:

            toolbox.mutate(mutant)

            del mutant.fitness.values

    # Оценка популяции

    invalid_ind = [ind for ind in offspring if not ind.fitness.valid]

    fitnesses = map(toolbox.evaluate, invalid_ind)

    for ind, fit in zip(invalid_ind, fitnesses):

        ind.fitness.values = fit

    # Статистика текущего поколения

    print("Поколение %i (%s сек)" % (g, (time.time() - start_gen_time)))

    population.sort(key=lambda x: x.fitness, reverse=True)

    print('- Оценено', len(invalid_ind), 'экземпляров')

    print('- Лучший экземпляр: ', population[0].fitness.values)

    print()

    # Заменяем популяцию на следующее поколение

    population[:] = offspring

print('--- Стоп ГА ---')

print("Всего %s сек\n" % (time.time() - start_time))

Что с этим делать?

Пополняя bad_words.txt новыми «всплесками бешеного креатива» и good_words.txt "классикой", вы можете улучшать свой фильтр.

В итоге алгоритм выдает в консоль массив вида (тут оставлена самая цензурная часть - русские «слова» начинаются с 3х букв), отсортированный в порядке убывания длины.

['поте', 'эрек', 'пида', 'осущ', 'деби', 'уро', 'ид', 'ху', 'чм', 'жо', 'ду', 'уе', 'йл']

Сортировка сделана для того, чтобы вначале срабатывали более точные паттерны, хотя для определения мата иногда достаточно и 2х букв. Это можно отрегулировать параметром min_letter_count. Суммарная длина строк фильтра у меня составила 244 символа.

Берете этот массив, вставляется в JS, PHP или иной код и, по принципу text.contains, идете циклом по фильтру до первого совпадения. Если оно есть - значит идет выбраковка слова или текста.

Для увеличения точности фильтра можно вычислять процент "сработавших" паттернов и, если он выше некоторого порога (скажем 20%), то текст считаем токсичным. Этот подход чуть больше нагрузит CPU.

Так стоит подумать над усовершенствованием функцией normalize(). Различные замены символов приведут текст в более удобный для машины вид, уменьшат количество символов и паттернов в итоге. Не забудьте добавить ее аналог в код фильтрации на сайте.

Полный исходник алгоритма и good_words.txt вы можете найти на моем github.

Источник: https://habr.com/ru/post/548156/


Интересные статьи

Интересные статьи

На работе я занимаюсь поддержкой пользователей и обслуживанием коробочной версии CRM Битрикс24, в том числе и написанием бизнес-процессов. Нужно отметить, что на самом деле я не «чист...
Идём в глубь острова сокровищ с названием "Алгоритм". Читать дальше →
В интернет-магазинах, в том числе сделанных на готовых решениях 1C-Битрикс, часто неправильно реализован функционал быстрого заказа «Купить в 1 клик».
В своей предыдущей статье я представил общий обзор видеоигровой экосистемы. Теперь я бы хотел рассмотреть по отдельности каждую категорию игр, описать их свойства, смысл их инноваций в функцион...
Прошло 47 лет с тех пор, как американский инженер Рей Томлинсон отправил первое в мире электронное письмо. Это он придумал использовать символ @ (at-sign) в адресах, который мы сегодня, не за...