加速“最接近”字符串匹配算法

Speeding up a "closest" string match algorithm

提问人:LBes 提问时间:8/27/2018 最后编辑:LBes 更新时间:8/28/2018 访问量:1905

问:

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

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

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

  1. 上周,从我在挪威Jotunheimen的露营地看到的Slettmarkmountains
  2. 在英国苏格兰斯凯岛仙女谷冒险
  3. 加利福尼亚州移民荒野的早晨

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

  1. Slettmarkmountains, Jotunheimen, 挪威
  2. Fairy Glen Skye,苏格兰,英国
  3. 加州移民荒野
  4. 优胜美地国家公园
  5. 半穹顶优胜美地国家公园

geoname 数据集包含以下内容:

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

我正在应用此算法来在我的条目和包含 4M 条目的地理名称 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 兼容的解决方案。

提前致谢:)。

编辑:

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

编辑 2:

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

Python 算法 性能 语言无关的 字符串匹配

评论


答:

3赞 PM 2Ring 8/27/2018 #1

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

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

# 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)

计算 bigram 中有多少是集合的成员。pairs1pairs2

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

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())

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


这是实现 juvian 在评论中提出的建议的一个版本。这样可以节省一点时间。此版本还避免了在任一二元框为空时进行测试。new_first_match

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

一个更简单的变化是不打扰计算,只是与 进行比较。hiscoreboundthresh

评论

0赞 juvian 8/27/2018
这涵盖了我本来建议的所有主要内容,很好。较小的优化是避免计算new_first_match,如果它的上限 (2 * len(pairs1) / (pairs1_len + pairs2_len)) 没有达到当前最佳脱粒。
0赞 PM 2Ring 8/27/2018
@juvian好的,我已经做到了。但我不确定它会有多大的好处,因为除非是巨大的,否则会很快。sum(x in pairs2 for x in pairs1)pairs1
1赞 juvian 8/27/2018
好吧,如果按长度顺序处理数据集,则可以停止搜索,因为以下所有内容都将具有下限上限(2 * pairs1_len是一个常数,与pairs1_len相同。唯一增加的是pairs2_len,这将使上限降低)。
0赞 LBes 8/28/2018
感谢您的更新版本将尽快尝试。忘了点赞,所以这里是顺便说一句:)
0赞 LBes 8/28/2018
您的new_best_match在不到 10 秒的时间内给出结果,平均可能为 5 秒,这已经比我的效率高出大约 6 倍。但是,您编辑的 new_first_match 版本给了我一个错误,因为除以 0
1赞 juvian 8/28/2018 #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()

评论

0赞 LBes 8/28/2018
谢谢你。我必须尝试一下,看看它与下面给出的其他解决方案相比表现如何。适应我的代码来测试它可能有点困难(我没有字典,而是一个列表,而不仅仅是来自 geoname 的单个字符串,而是多个信息 [lat, long...]),但我会尝试让你知道。谢谢
0赞 juvian 8/28/2018
@LBes什么词典?我在示例中使用了列表。Geonames 可以是包含所有信息的类或对象的列表,只需更改代码即可访问其名称信息。
0赞 LBes 8/28/2018
我的坏我读得很快(在电话里),实际上它似乎几乎马上就起作用了。但是,loadSpellChecker() 似乎无法正常工作......它开始运行该功能,但从未停止过,似乎我的计算机没有做任何事情
0赞 juvian 8/28/2018
@LBes load_dictionary处理可能需要很长时间,因为有很多单词。也许使用较小的数据集来测试或尝试减少prefix_length和max_edit_distance_dictionary。频率.txt有多大?频率有多少个条目?
0赞 LBes 8/28/2018
我还没有花时间看所有这些,但会确保尽快完成。这只是一个副业,所以我没有那么多时间去做:D