【问题标题】:Speeding up a "closest" string match algorithm加速“最接近”的字符串匹配算法
【发布时间】:2018-08-27 13:19:40
【问题描述】:

我目前正在处理一个非常大的位置数据库,并尝试将它们与它们的真实世界坐标相匹配。

为此,我下载了the geoname dataset,其中包含很多条目。它给出了可能的名称和纬度/经度坐标。为了尝试加快这个过程,我通过删除对我的数据集没有意义的条目,设法将巨大的 csv 文件(1.6 GB)减少到 0.450 GB。然而,它仍然包含 400 万个条目。

现在我有很多条目,例如:

  1. 上周从我在挪威尤通黑门的露营地看到的斯莱特马克山脉
  2. 在英国苏格兰斯凯岛的 Fairy Glen 探险
  3. 加利福尼亚州移民荒野的早晨

知道字符串与如此长的字符串匹配,我通过 NLTK 使用 Standford's NER 来获得更好的字符串来限定我的位置。现在我有这样的字符串:

  1. 挪威尤通黑门斯莱特马克山脉
  2. Fairy Glen Skye 苏格兰英国
  3. 加州移民荒野
  4. 优胜美地国家公园
  5. 半圆顶优胜美地国家公园

geoname 数据集包含以下内容:

  1. Jotunheimen 挪威 Lat Long
  2. Slettmarkmountains Jotunheimen Norway Lat Long
  3. 布莱斯峡谷拉特隆
  4. Lat Long 半圆顶
  5. ...

我正在应用此algorithm 以在我的条目和包含 4M 条目的 geoname csv 之间获得良好的匹配。我首先阅读 geoname_cleaned.csv 文件并将所有数据放入一个列表中。对于我拥有的每个条目,然后在当前条目和 geoname_list 的所有条目之间调用我的每个条目 string_similarity()

def get_bigrams(string):
    """
    Take a string and return a list of bigrams.
    """
    s = string.lower()
    return [s[i:i+2] for i in list(range(len(s) - 1))]

def string_similarity(str1, str2):
    """
    Perform bigram comparison between two strings
    and return a percentage match in decimal form.
    """
    pairs1 = get_bigrams(str1)
    pairs2 = get_bigrams(str2)
    union  = len(pairs1) + len(pairs2)
    hit_count = 0
    for x in pairs1:
        for y in pairs2:
            if x == y:
                hit_count += 1
                break
    return (2.0 * hit_count) / union

我已经在我的原始数据集的一个子集上测试了该算法,它运行良好,但它显然非常慢(单个位置最多需要 40 秒)。由于我有超过一百万个条目要处理,这将需要 10000 小时或更长时间。我想知道你们是否对如何加快速度有任何想法。我显然想到了并行处理,但我没有任何可用的 HPC 解决方案。也许简单的想法可以帮助我加快速度。

我对你们可能有的任何想法持开放态度,但会更喜欢与 python 兼容的解决方案。

提前致谢:)。

编辑:

我用fuzz.token_set_ratio(s1, s2) 尝试过fuzzywuzzy,它的性能最差(运行时间更差,结果也不是那么好)。比赛不如以前使用我的自定义技术时那么好,并且单个条目的运行时间增加了 15 秒。

编辑2:

我也想过在开始时使用某种排序来帮助匹配,但我的幼稚实现不起作用。但我确信有一些方法可以加快速度,也许可以删除 geoname 数据集中的一些条目,或者以某种方式对它们进行排序。我已经做了很多清理以删除无用的条目,但无法获得低于 4M 的数字

【问题讨论】:

    标签: python algorithm performance language-agnostic string-matching


    【解决方案1】:

    我们可以通过几种方式加快匹配速度。我假设在您的代码中,str1 是您数据集中的名称,str2 是地理名称字符串。为了测试代码,我从您问题中的数据中制作了两个小数据集。我写了两个匹配函数best_matchfirst_match,它们使用你当前的string_similarity 函数,所以我们可以看到我的策略给出了相同的结果。 best_match 检查所有地理名称字符串,如果超过给定的阈值分数,则返回得分最高的字符串,否则返回 Nonefirst_match (可能)更快:它只返回第一个超过阈值的地理名称字符串,或者 None 如果它找不到一个,所以如果它没有找到匹配项,那么它仍然必须搜索整个地名列表。

    在我的改进版本中,我们为每个str1 生成一次二元组,而不是为我们与之比较的每个str2 重新生成str1 的二元组。我们提前计算了所有地名二元组,将它们存储在由字符串索引的字典中,这样我们就不必为每个str 重新生成它们。此外,我们将地名二元组存储为集合。这使得计算hit_count 的速度要快得多,因为集合成员资格测试比对字符串列表进行线性扫描要快得多。 geodict 还需要存储每个二元组的长度:一个集合不包含重复项,因此二元组的长度可能小于二元组的列表,但我们需要列表长度才能正确计算分数。

    # Some fake data
    geonames = [
        'Slettmarkmountains Jotunheimen Norway',
        'Fairy Glen Skye Scotland UK',
        'Emigrant Wilderness California',
        'Yosemite National Park',
        'Half Dome Yosemite National Park',
    ]
    
    mynames = [
        'Jotunheimen Norway',
        'Fairy Glen',
        'Slettmarkmountains Jotunheimen Norway',
        'Bryce Canyon',
        'Half Dome',
    ]
    
    def get_bigrams(string):
        """
        Take a string and return a list of bigrams.
        """
        s = string.lower()
        return [s[i:i+2] for i in range(len(s) - 1)]
    
    def string_similarity(str1, str2):
        """
        Perform bigram comparison between two strings
        and return a percentage match in decimal form.
        """
        pairs1 = get_bigrams(str1)
        pairs2 = get_bigrams(str2)
        union  = len(pairs1) + len(pairs2)
        hit_count = 0
        for x in pairs1:
            for y in pairs2:
                if x == y:
                    hit_count += 1
                    break
        return (2.0 * hit_count) / union
    
    # Find the string in geonames which is the best match to str1
    def best_match(str1, thresh=0.2):
        score, str2 = max((string_similarity(str1, str2), str2) for str2 in geonames)
        if score < thresh:
            str2 = None
        return score, str2
    
    # Find the 1st string in geonames that matches str1 with a score >= thresh
    def first_match(str1, thresh=0.2):
        for str2 in geonames:
            score = string_similarity(str1, str2)
            if score >= thresh:
                return score, str2
        return None
    
    print('Best')
    for mystr in mynames:
        print(mystr, ':', best_match(mystr))
    print()
    
    print('First')
    for mystr in mynames:
        print(mystr, ':', best_match(mystr))
    print()
    
    # Put all the geoname bigrams into a dict
    geodict = {}
    for s in geonames:
        bigrams = get_bigrams(s)
        geodict[s] = (set(bigrams), len(bigrams))
    
    def new_best_match(str1, thresh=0.2):
        pairs1 = get_bigrams(str1)
        pairs1_len = len(pairs1)
    
        score, str2 = max((2.0 * sum(x in pairs2 for x in pairs1) / (pairs1_len + pairs2_len), str2)
            for str2, (pairs2, pairs2_len) in geodict.items())
        if score < thresh:
            str2 = None
        return score, str2
    
    def new_first_match(str1, thresh=0.2):
        pairs1 = get_bigrams(str1)
        pairs1_len = len(pairs1)
    
        for str2, (pairs2, pairs2_len) in geodict.items():
            score = 2.0 * sum(x in pairs2 for x in pairs1) / (pairs1_len + pairs2_len)
            if score >= thresh:
                return score, str2
        return None
    
    print('New Best')
    for mystr in mynames:
        print(mystr, ':', new_best_match(mystr))
    print()
    
    print('New First')
    for mystr in mynames:
        print(mystr, ':', new_first_match(mystr))
    print()
    

    输出

    Best
    Jotunheimen Norway : (0.6415094339622641, 'Slettmarkmountains Jotunheimen Norway')
    Fairy Glen : (0.5142857142857142, 'Fairy Glen Skye Scotland UK')
    Slettmarkmountains Jotunheimen Norway : (1.0, 'Slettmarkmountains Jotunheimen Norway')
    Bryce Canyon : (0.1875, None)
    Half Dome : (0.41025641025641024, 'Half Dome Yosemite National Park')
    
    First
    Jotunheimen Norway : (0.6415094339622641, 'Slettmarkmountains Jotunheimen Norway')
    Fairy Glen : (0.5142857142857142, 'Fairy Glen Skye Scotland UK')
    Slettmarkmountains Jotunheimen Norway : (1.0, 'Slettmarkmountains Jotunheimen Norway')
    Bryce Canyon : (0.1875, None)
    Half Dome : (0.41025641025641024, 'Half Dome Yosemite National Park')
    
    New Best
    Jotunheimen Norway : (0.6415094339622641, 'Slettmarkmountains Jotunheimen Norway')
    Fairy Glen : (0.5142857142857142, 'Fairy Glen Skye Scotland UK')
    Slettmarkmountains Jotunheimen Norway : (1.0, 'Slettmarkmountains Jotunheimen Norway')
    Bryce Canyon : (0.1875, None)
    Half Dome : (0.41025641025641024, 'Half Dome Yosemite National Park')
    
    New First
    Jotunheimen Norway : (0.6415094339622641, 'Slettmarkmountains Jotunheimen Norway')
    Fairy Glen : (0.5142857142857142, 'Fairy Glen Skye Scotland UK')
    Slettmarkmountains Jotunheimen Norway : (1.0, 'Slettmarkmountains Jotunheimen Norway')
    Bryce Canyon : None
    Half Dome : (0.41025641025641024, 'Half Dome Yosemite National Park')
    

    new_first_match 相当直截了当。线

    for str2, (pairs2, pairs2_len) in geodict.items():
    

    循环遍历geodict 中的每个项目,提取每个字符串、二元组和真正的二元组长度。

    sum(x in pairs2 for x in pairs1)
    

    计算pairs1 中有多少二元组是pairs2 集合的成员。

    因此,对于每个地理名称字符串,我们计算相似度分数,如果它 >= 阈值,则返回它,默认值为 0.2。你可以给它一个不同的默认thresh,或者在你调用它时传递一个thresh

    new_best_match 有点复杂。 ;)

    ((2.0 * sum(x in pairs2 for x in pairs1) / (pairs1_len + pairs2_len), str2)
        for str2, (pairs2, pairs2_len) in geodict.items())
    

    是一个生成器表达式。它遍历 geodict 项目并为每个地理名称字符串创建一个 (score, str2) 元组。然后,我们将该生成器表达式提供给 max 函数,该函数返回得分最高的元组。


    这是 new_first_match 的一个版本,它实现了 juvian 在 cmets 中提出的建议。它可能会节省一点时间。此版本还避免测试任一二元组是否为空。

    def new_first_match(str1, thresh=0.2):
        pairs1 = get_bigrams(str1)
        pairs1_len = len(pairs1)
        if not pairs1_len:
            return None
    
        hiscore = 0
        for str2, (pairs2, pairs2_len) in geodict.items():
            if not pairs2_len:
                continue
            total_len = pairs1_len + pairs2_len
            bound = 2.0 * pairs1_len / total_len
            if bound >= hiscore:
                score = 2.0 * sum(x in pairs2 for x in pairs1) / total_len
                if score >= thresh:
                    return score, str2
                hiscore = max(hiscore, score)
        return None
    

    一个更简单的变化是不计算hiscore,只需将boundthresh 进行比较。

    【讨论】:

    • 这涵盖了我建议的所有主要内容,很好。如果 new_first_match 的上限 (2 * len(pairs1) / (pairs1_len +pairs2_len)) 未达到当前最佳阈值,则较小的优化将避免计算 new_first_match。
    • @juvian 好的,我已经做到了。但我不确定它会有多大好处,因为sum(x in pairs2 for x in pairs1) 会很快,除非pairs1 很大。
    • 好吧,如果按长度顺序处理数据集,有一点可以停止搜索,因为以下所有内容都会有一个下限(2 *pairs1_len 是一个常数,与pair1_len。唯一增加的是pairs2_len,这将形成一个下限)。
    • 感谢更新版本会尽快尝试。忘记投票了,顺便说一句:)
    • 您的 new_best_match 在不到 10 秒的时间内给出结果,平均可能是 5 秒,这已经比我的效率高了大约 6 倍。但是,由于除以 0,您编辑的 new_first_match 版本给了我一个错误
    【解决方案2】:

    我使用SymSpell 端口到python 进行拼写检查。如果你想试试processInput,需要添加代码,最好使用2Ring调整。

    from symspellpy.symspellpy import SymSpell, Verbosity  # import the module
    import csv
    
    
    geonames = [
        'Slettmarkmountains Jotunheimen Norway',
        'Fairy Glen Skye Scotland UK',
        'Emigrant Wilderness California',
        'Yosemite National Park',
        'Half Dome Yosemite National Park',
    ]
    
    mynames = [
        'Jotuheimen Noway',
        'Fairy Gen',
        'Slettmarkmountains Jotnheimen Norway',
        'Bryce Canyon',
        'Half Domes',
    ]
    
    frequency = {}
    buckets = {}
    
    def generateFrequencyDictionary():
    
        for geo in geonames:
            for word in geo.split(" "):
                if word not in frequency:
                    frequency[word] = 0
                frequency[word] += 1
    
    
        with open("frequency.txt", "w") as f:
            w = csv.writer(f, delimiter = ' ',lineterminator='\r')
            w.writerows(frequency.items())      
    
    
    def loadSpellChecker():
        global sym_spell
        initial_capacity = len(frequency)
        # maximum edit distance per dictionary precalculation
        max_edit_distance_dictionary = 4
        prefix_length = 7
        sym_spell = SymSpell(initial_capacity, max_edit_distance_dictionary,
                             prefix_length)
        # load dictionary
        dictionary_path = "frequency.txt"
        term_index = 0  # column of the term in the dictionary text file
        count_index = 1  # column of the term frequency in the dictionary text file
        if not sym_spell.load_dictionary(dictionary_path, term_index, count_index):
            print("Dictionary file not found")
            return
    
    def splitGeoNamesIntoBuckets():
        for idx, geo in enumerate(geonames):
            for word in geo.split(" "):
                if word not in buckets:
                    buckets[word] = set()
                buckets[word].add(idx)  
    
    
    def string_similarity(str1, str2):
        pass
    
    def processInput():
        for name in mynames:
            toProcess = set()
            for word in name.split(" "):
                if word not in buckets: # fix our word with a spellcheck
                    max_edit_distance_lookup = 4
                    suggestion_verbosity = Verbosity.CLOSEST  # TOP, CLOSEST, ALL
                    suggestions = sym_spell.lookup(word, suggestion_verbosity, max_edit_distance_lookup)
                    if len(suggestions):
                        word = suggestions[0].term
                if word in buckets:
                    toProcess.update(buckets[word])
            for index in toProcess: # process only sentences from related buckets
                string_similarity(name, geonames[index])                    
    
    
    
    generateFrequencyDictionary()
    loadSpellChecker()
    splitGeoNamesIntoBuckets()
    processInput()
    

    【讨论】:

    • 谢谢。我将不得不尝试一下,看看它与下面给出的其他解决方案相比如何。可能有点难以适应我的代码来测试它(我没有字典,但有一个列表,而不仅仅是来自 geoname 的单个字符串,而是多个信息 [lat, long ...]),但我我会试着让你知道。谢谢
    • @LBes 什么字典?我在示例中使用了列表。 Geonames 可以是包含所有信息的类或对象的列表,只需更改代码即可访问它的名称信息。
    • 我的错误是我读得很快(正在打电话),实际上它似乎几乎马上就可以工作了。但是,loadSpellChecker() 似乎无法正常工作......它开始运行该功能,但根本不会停止,而且似乎我的计算机没有做任何事情
    • @LBes load_dictionary 处理可能需要很长时间,因为有很多单词。也许使用较小的数据集来测试或尝试减少 prefix_length 和 max_edit_distance_dictionary。 frequency.txt 有多大?频率有多少个条目?
    • 我还没有时间来看看这一切,但会确保尽快完成。这只是一个附带项目,所以我没有太多时间来处理它:D
    猜你喜欢
    • 1970-01-01
    • 2017-03-29
    • 2010-09-08
    • 2011-08-17
    • 2012-07-24
    • 2015-04-14
    • 2017-12-23
    • 1970-01-01
    • 2023-03-29
    相关资源
    最近更新 更多