Найти лучший подстрочный матч

Я ищу библиотеку или метод с использованием существующих библиотек (difflib, fuzzywuzzy, python-levenshtein), чтобы найти самое близкое соответствие строки (query) в тексте (corpus)

Я разработал метод, основанный на difflib, где я разбил свой corpus на ngrams размером n (длина query).

import difflib
from nltk.util import ngrams

def get_best_match(query, corpus):
    ngs = ngrams( list(corpus), len(query) )
    ngrams_text = [''.join(x) for x in ngs]
    return difflib.get_close_matches(query, ngrams_text, n=1, cutoff=0)

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

query = "ipsum dolor"
corpus = "lorem 1psum d0l0r sit amet"

match = get_best_match(query, corpus)
# match = "1psum d0l0r"

Но когда разница - это удаление символа, это не так.

query = "ipsum dolor"
corpus = "lorem 1psum dlr sit amet"

match = get_best_match(query, corpus)
# match = "psum dlr si"
# expected_match = "1psum dlr"

Есть ли способ получить более гибкий размер результата (как для expected_match)?

РЕДАКТИРОВАТЬ 1:

  • Фактическое использование этого script заключается в совпадении запросов (строк) с грязный выход ocr.
  • Как я уже сказал в вопросе, ocr может смешивать символы и даже пропускать их.
  • Если возможно, рассмотрите также случай, когда пробел отсутствует между словами.
  • Наилучшее совпадение - это тот, который не включает символы из других слов, чем те, что указаны в запросе.

ИЗМЕНИТЬ 2:

Решение, которое я использую сейчас, - это расширить ngrams с помощью (n-k)-grams for k = {1,2,3}, чтобы предотвратить 3 удаления. Это намного лучше, чем первая версия, но неэффективна с точки зрения скорости, поскольку мы имеем более чем в 3 раза больше числа ngrams для проверки. Это также не обобщаемое решение.

Ответы

Ответ 1

Эта функция находит наилучшую совпадающую подстроку переменной длины.

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

Сводка кода:   1.. Сканируйте корпус для значений соответствия с шагом step, чтобы найти приблизительное местоположение с наивысшим значением соответствия, pos.   2. Найдите подстроку в окрестности pos с наивысшим значением соответствия, отрегулировав левую/правую позиции подстроки.

from difflib import SequenceMatcher

def get_best_match(query, corpus, step=4, flex=3, case_sensitive=False, verbose=False):
    """Return best matching substring of corpus.

    Parameters
    ----------
    query : str
    corpus : str
    step : int
        Step size of first match-value scan through corpus. Can be thought of
        as a sort of "scan resolution". Should not exceed length of query.
    flex : int
        Max. left/right substring position adjustment value. Should not
        exceed length of query / 2.

    Outputs
    -------
    output0 : str
        Best matching substring.
    output1 : float
        Match ratio of best matching substring. 1 is perfect match.
    """

    def _match(a, b):
        """Compact alias for SequenceMatcher."""
        return SequenceMatcher(None, a, b).ratio()

    def scan_corpus(step):
        """Return list of match values from corpus-wide scan."""
        match_values = []

        m = 0
        while m + qlen - step <= len(corpus):
            match_values.append(_match(query, corpus[m : m-1+qlen]))
            if verbose:
                print query, "-", corpus[m: m + qlen], _match(query, corpus[m: m + qlen])
            m += step

        return match_values

    def index_max(v):
        """Return index of max value."""
        return max(xrange(len(v)), key=v.__getitem__)

    def adjust_left_right_positions():
        """Return left/right positions for best string match."""
        # bp_* is synonym for 'Best Position Left/Right' and are adjusted 
        # to optimize bmv_*
        p_l, bp_l = [pos] * 2
        p_r, bp_r = [pos + qlen] * 2

        # bmv_* are declared here in case they are untouched in optimization
        bmv_l = match_values[p_l / step]
        bmv_r = match_values[p_l / step]

        for f in range(flex):
            ll = _match(query, corpus[p_l - f: p_r])
            if ll > bmv_l:
                bmv_l = ll
                bp_l = p_l - f

            lr = _match(query, corpus[p_l + f: p_r])
            if lr > bmv_l:
                bmv_l = lr
                bp_l = p_l + f

            rl = _match(query, corpus[p_l: p_r - f])
            if rl > bmv_r:
                bmv_r = rl
                bp_r = p_r - f

            rr = _match(query, corpus[p_l: p_r + f])
            if rr > bmv_r:
                bmv_r = rr
                bp_r = p_r + f

            if verbose:
                print "\n" + str(f)
                print "ll: -- value: %f -- snippet: %s" % (ll, corpus[p_l - f: p_r])
                print "lr: -- value: %f -- snippet: %s" % (lr, corpus[p_l + f: p_r])
                print "rl: -- value: %f -- snippet: %s" % (rl, corpus[p_l: p_r - f])
                print "rr: -- value: %f -- snippet: %s" % (rl, corpus[p_l: p_r + f])

        return bp_l, bp_r, _match(query, corpus[bp_l : bp_r])

    if not case_sensitive:
        query = query.lower()
        corpus = corpus.lower()

    qlen = len(query)

    if flex >= qlen/2:
        print "Warning: flex exceeds length of query / 2. Setting to default."
        flex = 3

    match_values = scan_corpus(step)
    pos = index_max(match_values) * step

    pos_left, pos_right, match_value = adjust_left_right_positions()

    return corpus[pos_left: pos_right].strip(), match_value

Пример:

query = "ipsum dolor"
corpus = "lorem i psum d0l0r sit amet"
match = get_best_match(query, corpus, step=2, flex=4)
print match
('i psum d0l0r', 0.782608695652174)

Некоторый хороший эвристический совет - всегда держать step < len(query) * 3/4 и flex < len(query) / 3. Я также добавил чувствительность к регистру, в случае, если это важно. Он работает очень хорошо, когда вы начинаете играть со значениями шага и гибкости. Значения малых шагов дают лучшие результаты, но для вычисления требуется больше времени. flex определяет, насколько гибкой может быть длина результирующей подстроки.

Важно отметить:. Это найдет только первое совпадение, поэтому, если есть несколько одинаково хороших совпадений, будет возвращено только первое. Чтобы разрешить несколько совпадений, измените index_max(), чтобы вернуть список индексов для n наивысших значений входного списка и перебрать adjust_left_right_positions() для значений в этом списке.

Ответ 2

Основной путь к решению использует конечные автоматы (FSA). Если вы хотите получить подробное резюме по этой теме, проверьте dissertation вне (ссылка PDF). Подход к этому основаны на моделях с ошибками (включая автоматы и преобразователи Левенштейна, первый из которых упоминал Сергей). Тем не менее, стохастические модели, включая различные типы подходов машинного обучения, интегрированные с FSA, очень популярны на данный момент.

Поскольку мы смотрим на расстояния редактирования (буквально с ошибками), подход Левенштейна хорош и относительно прост. Настоящая статья (а также диссертация, а также PDF) дают достойный набросок основной идеи, а также прямо упоминает приложение для задач OCR. Тем не менее, я рассмотрю некоторые из ключевых моментов ниже.

Основная идея заключается в том, что вы хотите построить FSA, который вычисляет как допустимую строку, так и все строки до некоторого расстояния ошибки (k). В общем случае это k может быть бесконечным или размером текста, но это в основном не имеет отношения к OCR (если ваше OCR может даже потенциально вернуть bl*h, где * - остальная часть всего текста, я бы посоветовал найти лучшая система распознавания). Следовательно, мы можем ограничить регулярное выражение, например bl*h, из набора допустимых ответов для строки поиска blah. Общий, простой и интуитивно понятный k для вашего контекста, вероятно, является длиной строки (w) минус 2. Это позволяет b--h быть допустимой строкой для blah. Он также позволяет bla--h, но это хорошо. Кроме того, имейте в виду, что ошибки могут быть любыми указанными вами символами, включая пробелы (следовательно, "многословный" вход разрешимо).

Следующей основной задачей является установка простого взвешенного преобразователя. Любой из портов OpenFST Python может это сделать (здесь один). Логика проста: вставки и удаления увеличивают вес, а равенство увеличивает индекс во входной строке. Вы могли бы просто передать код ему, как парень в ссылке на комментарий Сергея.

Как только у вас есть веса и связанные индексы весов, вы просто сортируете и возвращаете. Сложность вычислений должна быть O (n (w + k)), так как мы будем смотреть на w + k символов в худшем случае для каждого символа (n) в тексте.

Отсюда вы можете делать всевозможные вещи. Вы можете преобразовать преобразователь в DFA. Вы можете распараллелить систему, разбив текст на w + k-граммы, которые отправляются в разные процессы. Вы могли бы разработать модель языка или матрицу замешательства, которая определяет, какие распространенные ошибки существуют для каждой буквы во входном наборе (и тем самым ограничивать пространство допустимых переходов и сложность ассоциированного FSA). Литература обширна и все еще растет, поэтому существует, вероятно, столько изменений, сколько есть решений (если не больше).

Надеюсь, это ответит на некоторые ваши вопросы, не указывая никакого кода.

Ответ 3

Я бы попытался создать шаблон регулярного выражения из строки запроса. Затем шаблон можно будет использовать для поиска corpus для подстрок, которые могут соответствовать запросу. Затем используйте difflib или fuzzywuzzy, чтобы проверить, соответствует ли подстрока запросу.

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

import re

query = "ipsum dolor"
corpus = ["lorem 1psum d0l0r sit amet",
          "lorem 1psum dlr sit amet",
          "lorem ixxxxxxxr sit amet"]

first_letter, second_letter = query[:2]
minimum_gap, maximum_gap = len(query) - 6, len(query) - 3
penultimate_letter, ultimate_letter = query[-2:]

fmt = '(?:{}.|.{}).{{{},{}}}(?:{}.|.{})'.format
pattern = fmt(first_letter, second_letter,
              minimum_gap, maximum_gap,
              penultimate_letter, ultimate_letter)

#print(pattern) # for debugging pattern

m = difflib.SequenceMatcher(None, "", query, False)

for c in corpus:
    for match in re.finditer(pattern1, c, re.IGNORECASE):
        substring = match.group()
        m.set_seq1(substring)
        ops = m.get_opcodes()

        # EDIT fixed calculation of the number of edits
        #num_edits = sum(1 for t,_,_,_,_ in ops if t != 'equal')
        num_edits = sum(max(i2-i1, j2-j1) for op,i1,i2,j1,j2 in ops if op != 'equal' )
        print(num_edits, substring)

Вывод:

3 1psum d0l0r
3 1psum dlr
9 ixxxxxxxr

Другая идея - использовать характеристики ocr при построении регулярного выражения. Например, если ocr всегда получает определенные буквы правильно, тогда, когда какая-либо из этих букв находится в запросе, используйте несколько из них в регулярном выражении. Или, если ocr смешивает "1", "!", "L" и "i", но никогда не заменяет что-то еще, тогда, если одна из этих букв находится в запросе, используйте [1!il] в регулярном выражении.