Вы хотите найти короткие регулярные выражения, полно и точно отделяющие один класс строк от другого? Это статья для вас. Мы поговорим про задачу классификации строк с помощью автоматически определяемых паттернов, а в конце я предоставлю пример такой процедуры с кодом на Python. Пользоваться мы будем небольшой open-source библиотекой strtree – в названии присутствует слово tree, поскольку регулярные выражения в ней располагаются в форме бинарного дерева, у каждой вершины которого всего одно исходящее ребро.

Зачем же вообще нам пользоваться регулярными выражениями для классификации строк? Такая задача может возникать при следующих условиях:

  • Мы имеем дело со строками небольшой длины, состоящие из одного или нескольких слов – токенизировать такие строки для применения стандартных алгоритмов часто не имеет смысла;

  • Порядок символов строки часто оказывается критически важен;

  • Мы предполагаем, что среди строк одного из классов есть что-то общее, что к тому же отличает их от другого класса;

  • Строки по большей части уникальны.

Такими данными могут быть, например, IP-адреса, модели смартфонов, компьютеров, деталей от них, имена пользователей, адреса электронных почт и прочее. В этих случаях привычный bag-of-words с дальнейшей классификацией табличными данных слабо применим: bag-of-words стирает разницу между строками с одними и теми же символами, но разным порядком. Для нас "ABC-1" и "1-CBA" – это две принципиально разные строки, описывающие два разных объекта (например, два разных устройства). Bag-of-words хорошо работает, например, для sentiment analysis, но при классификации чувствительных к перемешиванию символов строк он показывает себя хуже. Хорошо подобранные регулярные выражения, с другой стороны, не обладают такой проблемой.

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

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

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

Алгоритм

Для начала опишем задачу чуть более формально.

Мы имеем два набора строк соответствующих классов: S_0 и S_1. Одна и та же строка может появляться несколько раз; как только в одном, так и в обоих классах одновременно.

Наша цель – найти такой набор регулярных выражений \{r_i\}, который делал бы предположение о принадлежности произвольной строки к классу S_1. Предположение будет происходить с помощью операции match, примененной к каждому регулярному выражению r_i. Если хотя бы для одного регулярного выражения результат операции match(r_i,s_i) положительный, то мы считаем, что строка s_i принадлежит классу S_1.

Вы можете подумать, что ответ классификатора может только абсолютный: строка либо принадлежит классу S_1, либо не принадлежит. Однако, как я покажу далее, мы можем также получать и вероятность принадлежности строки к классу.

Составляем регулярные выражения

Как получить необходимые регулярные выражения? Начнем с того, что нужный нам набор всегда существует. В качестве него мы можем просто использовать все строки из класса  S_1. Если набор слабо пересекается с классом S_0, точность такого классификатора будет высокая – вплоть до 100% при полном отсутствии пересечений. Однако в этом случае набор может оказаться слишком длинным, а классификатор не будет работать верно при виде новых строк, которые не присутствовали в наборе S_1. Кроме того, если вы будете использовать алгоритм для поиска общих паттернов в классе (например, для EDA), то никакой полезной информации из таких “паттернов“ вы не получите – они ничего не обобщают. Чтобы избежать таких проблем, нам нужно искать более короткие, но точные регулярные выражения.

Для этого каждое регулярное выражение мы будем создавать инкрементно, начиная с пустого паттерна и удлиняя его до тех пор, пока мы либо не достигнем минимальной допустимой точности (т.е. доли строк из класса  S_1среди всех подходящих под выражение строк; обозначим это как minPrecision), либо пока регулярное выражение не станет слишком длинным и перестанет покрывать хоть какие-то строки. Каждый раз, когда мы подобрали (или не подобрали) регулярное выражение, соответствующие последнему шагу строки из класса S_1мы будем считать “покрытыми” и не будем рассматривать их для следующих итераций. Так мы будем собирать регулярные выражения до тех пор, пока для каждой строки из класса S_1мы сможем (или не сможем) подобрать паттерн.

Как же мы будем удлинять регулярное выражение? Мы будем добавлять к нему по одному токену (по одному строительному блоку для регулярного выражения; об этом – ниже), и среди комбинаций выражения и токенов находить лучший вариант удлинения с точки зрения f1_score на непокрытых на данный момент строках. Если при добавлении токенов новое выражение не покрывает ни одной строки из непокрытой части класса S_1(т.е. f1_score = 0 для всех токенов), будем считать, что удлинения не существует, и начнем искать паттерн заново.

Немного про токены. Токены – это преобразованные n-граммы длина 1, полученные из строк класса S_1. Он играет роль минимального паттерна, этакого строительного блока при построении регулярного выражения. Посмотрим на процесс составления токенов на примере. 

Допустим, S_1состоит из одной строки "string 1". N-граммы длины 1 для набора – это "s", "t", "r", "i", "n", "g", " ", "1". После преобразования они станут токенами "s.+", ".+t.+", ".+r.+", …, ".+1",  а также такими токенами, как ".+\d" (вместо "1" в конце строки – любая цифра в конце), "[a-z].+" (вместо "s" в начале – любая строчная буква в начале), и прочие. То есть токены – это короткие паттерны, "в общих чертах" описывающие набор, которые будут добавляться к текущему регулярному выражению. Способов преобразовать n-грамму в токен может быть очень много. Чем шире токен после преобразования – тем шире могут быть регулярные выражения в конечном наборе.

Вероятность принадлежности к классу

Я упомянул, что классификатор способен выдавать не только бинарные значения, но и вероятность принадлежности строки к классу S_1. Происходит это очень просто. При добавлении регулярного выражения в список \{r_i\} мне проверяли его precision score, то есть долю строк из класса S_1среди всех строк, подходящих под регулярное выражение. Таким образом, если произвольная строка подходит под какое-то регулярное выражение, мы можем использовать именно precision score этого регулярного выражения как приближение вероятности того, что строка относится к классу S_1.

"Псевдокод"

Повторять, пока все строки из S_1не будут помечены как "покрытые":

  1. Создать пустое регулярное выражение;

  2. Повторять, пока удлинение выражения не перестанет существует или его точность не достигнет minPrecision:

    1. Удлинить регулярное выражение путем перебора токенов и выбора комбинации токена и выражения с самым высоким f1-score на непокрытых строках;

  3. Если последнее регулярное выражение существует и его точность больше или равна minPrecision, добавить выражение в список \{r_i\};

Пометить строки из S1, подходящие под последнее найденное регулярное выражение, как "покрытые".

Пример

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

  • Определяются классифицирующие регулярные выражения

  • Считается precision score и recall score для классификации тренировочных строк

  • Получаются предсказания со сработавшими регулярными выражениями и их точностями для тренировочных строк

Установить библиотеку вы можете с помощью pip: pip install strtree

У библиотеки также есть небольшая документация.

import strtree


strings = ['Samsung X-500', 'Samsung SM-10', 'Samsung X-1100', 'Samsung F-10', 
           'Samsung X-2200', 'AB Nokia 1', 'DG Nokia 2', 'THGF Nokia 3', 
           'SFSD Nokia 4', 'Nokia XG', 'Nokia YO']
labels = [1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0]

tree = strtree.StringTree()
tree.build(strings, labels, min_precision=0.75)

for leaf in tree.leaves:
    print(leaf)
# PatternNode(".+ .+a.+", right=None, left=PatternNode(.+0.+), n_strings=11, 
#              precision=1.0, recall=0.57)
# PatternNode(".+0.+", right=None, left=None, n_strings=7, 
#             precision=1.0, recall=1.0)

print('Precision: {}'.format(tree.precision_score(strings, labels)))
# Precision: 1.0

print('Recall: {}'.format(tree.recall_score(strings, labels)))
# Recall: 1.0

matches, matched_nodes = tree.match(strings, return_nodes=True)
# нou will receive a vector of the same size as strings, 
# containing 0's (no match) or 1's (match), 
# and also the nodes containing regular expression and its precisions for each sample

Если у вас остались вопросы касательно работы или применения алгоритма, библиотеки, или вам просто хочется поболтать, приглашаю вас в комментарии!

Комментарии (3)


  1. S_A
    26.06.2024 06:56
    +1

    Идея крутая. В кода примера есть две ошибки, strtree.StringTree, и recall_score наверное должно быть.


    1. AntonSoroka Автор
      26.06.2024 06:56
      +1

      Всё верно, спасибо! Исправил код.


  1. Ananiev_Genrih
    26.06.2024 06:56

    Спасибо за статью.

    Насколько это решение применимо в классификации для такого рода задач когда классов трейнсета примерно 70000, документов (текстовых наименований продукции ) в среднем по 4 на класс (т.е. документов около 300_000 в трейне) ?

    Это пример из реальности где я в том числе делал стемминг https://habr.com/ru/articles/658501/