Levenshtein和Trigram的替代品

时间:2013-11-23 13:28:30

标签: levenshtein-distance string-metric

假设我的数据库中有以下两个字符串:

(1) 'Levi Watkins Learning Center - Alabama State University'
(2) 'ETH Library'

我的软件从数据源接收自由文本输入,它应该将这些自由文本与数据库中预定义的字符串(上面的那些)相匹配。

例如,如果软件获得字符串 'Alabama University' ,则应认识到这与(1)更相似,而不是(2)。< / p>

起初,我想过使用像Levenshtein-Damerau或Trigrams这样众所周知的字符串指标,但这会导致不必要的结果,如您所见:

http://fuzzy-string.com/Compare/Transform.aspx?r=Levi+Watkins+Learning+Center+-+Alabama+State+University&q=Alabama+University

http://fuzzy-string.com/Compare/Transform.aspx?r=ETH+Library&q=Alabama+University

Difference to (1): 37
Difference to (2): 14

(2)胜出是因为它比(1)短得多,即使(1)包含搜索字符串的单词(AlabamaUniversity)。< / p>

我也尝试过Trigrams(使用Javascript库fuzzySet),但我在那里得到了类似的结果。

是否有字符串指标可识别搜索字符串与(1)的相似性?

6 个答案:

答案 0 :(得分:3)

你应该改变你的方法:

levenshtein距离擅长计算单位的相似性,无论是“人物”还是“单词”。

从概念上讲,你正在考虑将Alabama和大学(2个单词)作为2个单位,并且你想要计算levenshtein距离应该表示阿拉巴马州和大学之间有多少单词应该为1的单词之间的距离。

但是,您正在尝试应用为单词中的字符实现的levenshtein算法。此实现仅适用于匹配单个单词NOT句子。

你应该在顶部和每个匹配中为'单词'实现自己的levenshtein算法(使用BK-Tree),你再次使用levenshtein为'characters'匹配每个单词。

(1)的结果应与该算法的距离1匹配,且(2)不匹配。

答案 1 :(得分:2)

您可以尝试使用Word Mover的距离https://github.com/mkusner/wmd。该算法的一个明显优势是它在计算文档中单词之间的差异时结合了隐含的含义。该论文可以找到here

答案 2 :(得分:2)

我想我的答案不再需要了,但我喜欢这个问题,它让我想到如何结合RegEx和Levenshtein字符串指标的优点,但不太依赖于距离。

到目前为止,我已经提出了一个解析器,它遵循这个前提和逻辑:

  • 它使用Python3和regex module(OP没有提及任何语言/模块要求)
  • 搜索到的任何needle都将从其标点字符
  • 中删除
  • 每个haystack也会被删除其标点字符 - 因此N.A.S.A将是NASA - 就像在needle中一样,如果它原来是N.A.S.A. - 我知道这在某些情况下可能会有问题,但考虑到这个前提,我无法提出更好的解决方案。
  • needle中不超过3个字符的每个字词都将被移除(不需要is,on,at,no等)
  • 匹配不区分大小写
  • needle将被分为wordgroup个包含n项的内容:n在dict 0 < k <= l中定义,其中k是dict key
  • wordgroup中的每个字词必须互相跟随,其间的最大距离为n
  • 每个字(取决于其n长度)可以具有不同的允许误差阈值:总共e个错误,s ubstitions,i nserts和{{可以指定一个元素,再次使用dict将键保持在d
  • 前面提到的dict都包含key / lambda对,这对于他们的上一个/第一个项目进行计算很有用

Online demo here

contextual_fuzzy_matcher.py:

0 < k <= n

main.py:

from collections import OrderedDict
import regex


class ContextualFuzzyMatcher(object):
    maximum_word_distance = 2
    word_distance = r"\s(?:[\w]+\s){{0,{}}}".format(maximum_word_distance)
    punctuation = regex.compile(r"[\u2000-\u206F\u2E00-\u2E7F\\'!\"#$%&\(\)\*\+,\-\.\/:;<=>\?@\[\]\^_`\{\|\}~]")
    groups = OrderedDict((
        (0, lambda l: l),
        (4, lambda l: 3),
        (8, lambda l: 6),
        (10, lambda l: l // 0.75),
    ))
    tolerances = OrderedDict((
        (0, {
            'e': lambda l: 0,
            's': lambda l: 0,
            'i': lambda l: 0,
            'd': lambda l: 0,
        }),
        (3, {
            'e': lambda l: 1,
            's': lambda l: 1,
            'i': lambda l: 1,
            'd': lambda l: 1,
        }),
        (6, {
            'e': lambda l: 2,
            's': lambda l: 1,
            'i': lambda l: 1,
            'd': lambda l: 1,
        }),
        (9, {
            'e': lambda l: 3,
            's': lambda l: 2,
            'i': lambda l: 2,
            'd': lambda l: 2,
        }),
        (12, {
            'e': lambda l: l // 4,
            's': lambda l: l // 6,
            'i': lambda l: l // 6,
            'd': lambda l: l // 6,
        }),
    ))

    def __init__(self, needle):
        self.sentence = needle
        self.words = self.sentence_to_words(sentence)
        self.words_len = len(self.words)
        self.group_size = self.get_group_size()
        self.word_groups = self.get_word_groups()
        self.regexp = self.get_regexp()

    def sentence_to_words(self, sentence):
        sentence = regex.sub(self.punctuation, "", sentence)
        sentence = regex.sub(" +", " ", sentence)
        return [word for word in sentence.split(' ') if len(word) > 2]

    def get_group_size(self):
        return list(value for key, value in self.groups.items() if self.words_len >= key)[-1](self.words_len)

    def get_word_groups(self):
        return [self.words[i:i + self.group_size] for i in range(self.words_len - self.group_size + 1)]

    def get_tolerance(self, word_len):
        return list(value for key, value in self.tolerances.items() if word_len >= key)[-1]

    def get_regexp(self):
        combinations = []
        for word_group in self.word_groups:
            distants = []
            for word in word_group:
                word_len = len(word)
                tolerance = self.get_tolerance(word_len)
                distants.append(r"({}){{e<={},s<={},i<={},d<={}}}".format(
                    word,
                    tolerance['e'](word_len),
                    tolerance['s'](word_len),
                    tolerance['i'](word_len),
                    tolerance['d'](word_len),
                ))
            combinations.append(
                self.word_distance.join(distants)
            )
        return regex.compile(
            r"|".join(combinations),
            regex.MULTILINE | regex.IGNORECASE
        )

    def findall(self, haystack):
        return self.regexp.findall(haystack)

返回:

test_sentences = [
    'Levi Watkins Learning Center - Alabama State University',
    'ETH Library'
]
test_texts = [
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sapien eget mi proin sed libero enim sed. Nec tincidunt praesent semper feugiat nibh sed pulvinar. Habitasse platea dictumst quisque sagittis. Tortor condimentum lacinia quis vel eros donec ac odio. Platea dictumst vestibulum rhoncus est pellentesque elit ullamcorper dignissim. Ultricies tristique nulla aliquet enim tortor at. Mi proin sed libero enim sed faucibus. Fames ac turpis egestas integer eget aliquet nibh. Potenti nullam ac tortor vitae purus faucibus ornare suspendisse. Cras semper auctor neque vitae tempus quam pellentesque nec. Quam lacus suspendisse faucibus interdum posuere. Neque laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt. Viverra tellus in hac habitasse. Nibh nisl condimentum id venenatis a condimentum vitae. Tincidunt dui ut ornare lectus."
    "Mattis aliquam faucibus purus in massa tempor nec feugiat nisl. Amet consectetur adipiscing elit ut aliquam purus. Turpis massa tincidunt dui ut ornare. Suscipit tellus mauris a diam maecenas sed enim ut sem. Id consectetur purus ut faucibus pulvinar elementum. Est velit egestas dui id. Felis imperdiet proin fermentum leo. Faucibus nisl tincidunt eget nullam non nisi est sit. Elit pellentesque habitant morbi tristique. Nisi lacus sed viverra tellus. Morbi tristique senectus et netus et malesuada fames. Id diam vel quam elementum pulvinar. Id nibh tortor id aliquet lectus. Sem integer vitae justo eget magna. Quisque sagittis purus sit amet volutpat consequat. Auctor elit sed vulputate mi sit amet. Venenatis lectus magna fringilla urna porttitor rhoncus dolor purus. Adipiscing diam donec adipiscing tristique risus nec feugiat in fermentum. Bibendum est ultricies integer quis."
    "Interdum posuere lorem ipsum dolor sit. Convallis convallis tellus id interdum velit. Sollicitudin aliquam ultrices sagittis orci a scelerisque purus. Vel quam elementum pulvinar etiam. Adipiscing bibendum est ultricies integer quis. Tellus molestie nunc non blandit. Sit amet porttitor eget dolor morbi non arcu. Scelerisque purus semper eget duis at tellus. Diam maecenas sed enim ut sem viverra. Vulputate odio ut enim blandit volutpat maecenas. Faucibus purus in massa tempor nec. Bibendum ut tristique et egestas quis ipsum suspendisse. Ut aliquam purus sit amet luctus venenatis lectus magna. Ac placerat vestibulum lectus mauris ultrices eros in cursus turpis. Feugiat pretium nibh ipsum consequat nisl vel pretium. Elit pellentesque habitant morbi tristique senectus et.",
    "Found at ETH's own Library", # ' will be a problem - it adds one extra deletion
    "State University of Alabama has a learning center called Levi Watkins",
    "The ETH library is not to be confused with Alabama State university's Levi Watkins Learning center",
    "ETH Library",
    "Alabma State Unversity",
    "Levi Wtkins Learning"
]


for test_sentence in test_sentences:
    parser = ContextualFuzzyMatcher(test_sentence)
    for test_text in test_texts:
        for match in parser.findall(test_text):
            print(match)

我完全清楚这远远不是一个完美的解决方案,我的例子很少而且不具有代表性 - 但可能通过调整配置并进行大量实际测试,它可能能够覆盖相当很多案例都没有产生太多的误报。此外,由于它是基于类的,它可以为不同的来源进行不同的继承和配置 - 也许在科学文本中,最大单词距离为1就足够了,在报纸文章中可能需要3个,等等。

答案 3 :(得分:1)

你可以尝试使用标准化的levenshtein距离:

李玉坚,刘波,“标准化Levenshtein距离度量”,IEEE模式分析和机器智能交易,第一卷。 29,不。 6,pp.1091-1095,2007年6月,doi:10.1109 / TPAMI.2007.1078 http://www.computer.org/csdl/trans/tp/2007/06/i1091-abs.html

他们建议将levenshtein距离标准化。通过这样做,当比较较长的10的序列时,较长的两个权重的序列中的一个字符的差异大于相同的差异。

答案 4 :(得分:0)

关键字计数

你还没有真正定义为什么你认为选项1是一个“更接近”的匹配,至少在任何算法意义上都没有。看起来你的期望是基于选项1具有比选项2更多匹配关键字的概念,那么为什么不根据每个字符串中关键字的数量进行匹配呢?

例如,使用Ruby 2.0:

string1 = 'Levi Watkins Learning Center - Alabama State University'
string2 = 'ETH Library'
strings = [str1, str2]

keywords  = 'Alabama University'.split
keycount  = {}

# Count matching keywords in each string.
strings.each do |str|
  keyword_hits  = Hash.new(0)
  keywords.each { |word| keyword_hits[word] += str.scan(/#{word}/).count }
  keyword_count = keyword_hits.values.reduce :+
  keycount[str] =  keyword_count
end

# Sort by keyword count, and print results.
keycount.sort.reverse.map { |e| pp "#{e.last}: #{e.first}" }

这将打印:

  

“2:Levi Watkins学习中心 - 阿拉巴马州立大学”
  “0:ETH图书馆”

符合您对语料库的期望。您可能希望使用其他算法对结果进行额外的传递以优化结果或打破关系,但这至少应该指向正确的方向。

答案 5 :(得分:0)

首先,您的距离分数需要根据数据库条目和/或输入的长度进行调整。对于10个字符的表达式,距离5比使用100个字符的表达式的距离5要差得多。

但是你的方法的主要问题是普通的Levenshtein不是子串匹配算法。它将所有一个字符串与所有另一个字符串进行比较。情况(1)中的大距离是由于数据库表达式中的大量单词不在输入表达式中。

为了解决这个问题,最好使用能够匹配模糊Bitap或Smith-Waterman等子串的算法。

如果你必须使用Levenshtein或类似物,你可能想用它来比较单词和单词,然后根据匹配单词的数量和匹配的质量产生一些分数。