寻找最佳子字符串匹配

20

我正在寻找一个库或方法,使用现有的库(difflibfuzzywuzzypython-levenshtein),在文本(corpus)中查找与字符串(query)最接近的匹配项。

我基于 difflib 开发了一种方法,其中我将我的 corpus 拆分成大小为 n (即 query 的长度)的 ngram。

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: 实际使用此脚本是为了将查询(字符串)与混乱的 OCR 输出进行匹配。 如我在问题中所说,OCR 可以混淆字符,甚至漏掉字符。 如果可能,请考虑单词之间缺少空格的情况。 最佳匹配是不包括查询中不存在的其他单词字符的匹配。
编辑2: 我现在使用的解决方案是扩展ngrams,用(n-k)-grams for k = {1,2,3}来防止3个删除。 这比第一个版本要好得多,但在速度方面并不高效,因为我们需要检查超过3倍的ngrams。 它也是一种非通用的解决方案。

ngrams 在处理你的示例中的双重删除 "dlr" 时可能会有困难,使用莱文斯坦距离算法可能会得到更好的结果? - Daniel Slater
3
看起来你需要的数据结构可能是Levenshtein自动机,但是很遗憾,我不知道有任何现成的Python实现。更新: 这里有另一篇博客文章提供了一些Python代码,虽然不是一个可以直接使用的库,但这仍然可以作为一个良好的起点。链接在这里:http://julesjacobs.github.io/2015/06/17/disqus-levenshtein-simple-and-fast.html - Sergei Lebedev
如果我正确理解自动机的工作方式,它将语料库视为单独单词的列表?在这种情况下,它无法解决 OP 的问题,因为他正在将查询与 OCR 输出(我想是从图像识别中解析出的文本)进行匹配,不能期望正确地分离单词。不过算法真的很酷,我会记在我的书里! - Ulf Aslak
4个回答

15

此函数寻找最佳匹配的子字符串,其长度是可变的。

实现中将语料库视为一个长字符串,因此避免了空格和未分隔单词的问题。

代码概述: 1.step 的步长扫描语料库以查找匹配值,并找到最高匹配值的近似位置 pos2. 调整子字符串的左/右位置,在 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(range(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)

一些好的启发式建议是始终保持步骤 < 查询长度 * 3/4弹性 < 查询长度 / 3。我还添加了大小写敏感性,以防这很重要。当您开始调整步长和弹性值时,它的效果相当不错。较小的步长值可以获得更好的结果,但计算时间更长。弹性控制生成的子字符串的长度所允许的灵活程度。

需要注意的是:这只会找到第一个最佳匹配项,因此如果有多个同样好的匹配项,则只返回第一个。要允许多个匹配项,请将index_max()更改为返回输入列表中前n个最高值的索引列表,并针对该列表中的值循环执行adjust_left_right_positions()


谢谢你的回答。这是一个很好的函数,因为它有参数。我已经进行了测试,与我的方法进行了比较。你的函数通常会在匹配的单词末尾保留一个空格,但这是我可以轻松解决的问题。我认为我可以结合两种方法来改进结果。+1 +悬赏 - Ghilas BELHADJ
谢谢,很高兴能帮忙。在不改变编辑距离的情况下,可以保留末尾的空格,因为替换和插入的成本相同。我编辑了答案,在输出上调用.strip()函数以进行更正。 - Ulf Aslak
@UlfAslak的回答是否假定corpus必须比query更长? - Rho Phi
@RhoPhi 不,它适用于比语料库更长的查询。不确定为什么会这样做,但如果您考虑在代码中使用它并担心它会出现错误,那么不会。它只会返回语料库。 - Ulf Aslak
2
有Python 3版本可用吗? - Iddo weiner
显示剩余3条评论

3
主要解决方案的路径使用某种有限状态自动机(FSA)。如果您想了解该主题的详细概述,请查看此dissertation(PDF链接)。基于错误的模型(包括Levenshtein自动机和传输器,前者Sergei提到过)是有效的方法。然而,随机模型,包括各种类型的机器学习方法与FSAs集成,目前非常流行。
由于我们正在研究编辑距离(实际上是拼写错误的单词),因此Levenshtein方法很好且相对简单。本文(以及论文;也是PDF)提供了基本思想的体面概述,并明确提到了OCR任务的应用。但是,我将在下面回顾一些关键点。
基本思路是要构建一个有限状态自动机(FSA),它可以计算有效字符串以及所有误差距离(k)内的所有字符串。在一般情况下,这个k可能是无限大或文本的大小,但对于OCR来说这基本上是不相关的(如果你的OCR甚至可能返回bl*h,其中*是整个文本的其余部分,我建议找到更好的OCR系统)。因此,我们可以将像bl*h这样的正则表达式从搜索字符串blah的有效答案集合中排除。对于您的上下文来说,一个通用的、简单和直观的k可能是字符串长度(w)减去2。这允许b--h成为blah的有效字符串。它也允许bla--h,但没关系。此外,请记住,错误可以是您指定的任何字符,包括空格(因此“多词”输入是可解决的)。
下一个基本任务是设置一个简单的加权传感器。任何OpenFST Python端口都可以做到这一点( 这里有一个)。逻辑很简单:插入和删除增加权重,而相等则增加输入字符串中的索引。你也可以像Sergei评论链接中的那个人一样手工编码它。
一旦你有了权重和相关的索引,你只需要排序并返回。计算复杂度应该是O(n(w+k)),因为我们将在最坏情况下每个字符(n)中向前查看w+k个字符。
从这里开始,你可以做各种各样的事情。你可以将传感器转换为DFA。你可以通过将文本分成w+k-grams并将其发送到不同的进程来并行化系统。你可以开发一种语言模型或混淆矩阵,定义每个输入集合中的字母存在哪些常见错误(从而限制有效转换的空间和相关FSA的复杂性)。文献广泛且仍在增长,因此可能会有与解决方案一样多的修改(如果不是更多)。
希望这回答了你的一些问题,没有提供任何代码。

1
我会尝试从查询字符串中构建正则表达式模板。该模板可以用于在语料库中搜索可能与查询匹配的子字符串。然后使用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]

我认为正则表达式模板不是解决这种问题的好方法。你的解决方案将'ixxxxxxxr'输出为一个num_edits = 1的好解决方案。 - Ghilas BELHADJ
@Ghilas 谢谢你发现了这个错误。正则表达式只是用来找到可能匹配查询的地方。然后使用difflib.SequenceMatcher进行比较。我之前计算编辑距离时出错了。已经修复。 - RootTwo

0
使用Levenshtein距离来找到最接近的子字符串是在语料库中找到最佳子字符串匹配的好方法。在大多数情况下,使用Levenshtein距离比difflib的ratio函数要快得多。
此代码需要python-Levenshtein模块。
pip install levenshtein

下面的代码在两个阶段中找到最佳子字符串匹配。
第一阶段使用滑动窗口在语料库上构建与查询相同大小的ngram(滑动窗口的步长=len(query)//2)。找到与查询的Levenshtein距离最小的ngram。这个ngram是语料库周围的区域,最佳匹配存在于该区域内。
第二阶段对确定的区域(narrowed_corpus)进行更彻底的搜索。这是通过在narrowed_corpus上以步长1构建不同长度的ngram来完成的。与查询的Levenshtein距离最小的ngram就是最佳的子字符串匹配。构建的ngram的长度取决于参数step_factor。考虑的长度为:
(len(query)//2 - 1), (len(query)//2 - 1 + step), (len(query)//2 - 1 + 2*step) ... (2 * len(query) + 2)

这里的`step = len(query)//step_factor`。增加步长因子会增加考虑的ngram长度的数量,从而增加找到最小编辑距离的最优子字符串的机会。然而,增加步长因子会导致运行时间增加(因为需要检查更多的ngram)和内存使用量增加(需要存储更多的ngram)。
from Levenshtein import distance as ld
from math import inf

def get_best_match(query, corpus, case_sensitive=False, step_factor=128, favour_smallest=False):
'''
Returns the substring of the corpus with the least Levenshtein distance from the query
(May not always return optimal answer).

Arguments
- query: str
- corpus: str
- case_sensitive: bool
- step_factor: int  
    Influences the resolution of the thorough search once the general region is found.
    The increment in ngrams lengths used for the thorough search is calculated as len(query)//step_factor.
    Increasing this increases the number of ngram lengths used in the thorough search and increases the chances 
    of getting the optimal solution at the cost of runtime and memory.
- favour_smaller: bool
    Once the region of the best match is found, the search proceeds from larger to smaller ngrams or vice versa.
    If two or more ngrams have the same minimum distance then this flag controls whether the largest or smallest
    is returned.

Returns  
{
    'best_match': Best matching substring of corpus,
    'min_ld': Levenshtein distance of closest match
}
'''

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

    corpus_len = len(corpus)
    query_len = len(query)
    query_len_by_2 = max(query_len // 2, 1)
    query_len_by_step_factor = max(query_len // step_factor, 1)

    closest_match_idx = 0
    min_dist = inf
    # Intial search of corpus checks ngrams of the same length as the query
    # Step is half the length of the query.
    # This is found to be good enough to find the general region of the best match in the corpus
    corpus_ngrams = [corpus[i:i+query_len] for i in range(0, corpus_len-query_len+1, query_len_by_2)]
    for idx, ngram in enumerate(corpus_ngrams):
        ngram_dist = ld(ngram, query)
        if ngram_dist < min_dist:
            min_dist = ngram_dist
            closest_match_idx = idx

    closest_match_idx = closest_match_idx * query_len_by_2
    closest_match = corpus[closest_match_idx: closest_match_idx + query_len]
    left = max(closest_match_idx - query_len_by_2 - 1, 0)
    right = min((closest_match_idx+query_len-1) + query_len_by_2 + 2, corpus_len)
    narrowed_corpus = corpus[left: right]
    narrowed_corpus_len = len(narrowed_corpus)

    # Once we have the general region of the best match we do a more thorough search in the region
    # This is done by considering ngrams of various lengths in the region using a step of 1
    ngram_lens = [l for l in range(narrowed_corpus_len, query_len_by_2 - 1, -query_len_by_step_factor)]
    if favour_smallest:
        ngram_lens = reversed(ngram_lens)
    # Construct sets of ngrams where each set has ngrams of a particular length made over the region with a step of 1
    narrowed_corpus_ngrams = [
        [narrowed_corpus[i:i+ngram_len] for i in range(0, narrowed_corpus_len-ngram_len+1)] 
        for ngram_len in ngram_lens
    ]

    # Thorough search of the region in which the best match exists
    for ngram_set in narrowed_corpus_ngrams:
        for ngram in ngram_set:
            ngram_dist = ld(ngram, query)
            if ngram_dist < min_dist:
                min_dist = ngram_dist
                closest_match = ngram

    return {
        'best_match': closest_match,
        'min_ld': min_dist
    }

query = "ipsum dolor"
corpus = "lorem 1psum dlr sit amet"
match = get_best_match(query, corpus)
print("Best match")
print(match['best_match'])
print(f"Minimum Levenshtein distance: {match['min_ld']}")

输出

Best match
1psum dlr
Minimum Levenshtein distance: 3

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接