Ускорение «самого близкого» алгоритма совпадения строк - PullRequest
0 голосов
/ 27 августа 2018

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

Для этого я скачал набор данных geoname , который содержит много записей. Это дает возможные имена и координаты широты и долготы. Чтобы попытаться ускорить процесс, мне удалось уменьшить огромный файл CSV (размером 1,6 ГБ) до 0,450 ГБ, удалив записи, которые не имеют смысла для моего набора данных. Однако он по-прежнему содержит 4 миллиона записей.

Теперь у меня много записей, таких как:

  1. Горы Слеттмарк, замеченные на моей стоянке в Йотунхеймене, Норвегия, на прошлой неделе
  2. Приключения в Фейри Глен, остров Скай, Шотландия, Великобритания
  3. Утро в эмигрантской пустыне, Калифорния

Зная, что строка совпадает с такими длинными строками, я использовал NER Standford через NLTK, чтобы получить лучшую строку для определения моего местоположения. Теперь у меня есть строки вроде:

  1. Slettmarkmountains Jotunheimen Норвегия
  2. Фея Глен Скай, Шотландия, Великобритания
  3. Эмигрантская дикая природа Калифорнии
  4. Йосемитский национальный парк
  5. Half Dome Йосемитский национальный парк

Набор данных geoname содержит такие вещи, как:

  1. Jotunheimen Norway Lat Long
  2. Slettmarkmountains Jotunheimen Норвегия Лат Лонг
  3. Брайс Каньон Лат Лонг
  4. Half Dome Lat Long
  5. ...

И я применяю этот алгоритм , чтобы получить хорошее возможное совпадение между моими записями и геонамом csv, содержащим 4M записей. Сначала я читаю файл geoname_cleaned.csv и помещаю все данные в список. Затем для каждой записи, которую я имею, я вызываю каждую из моих записей string_similarity() между текущей записью и всеми записями в списке geoname_list

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-совместимое решение.

Заранее спасибо:).

Edit:

Я пробовал fuzzywuzzy с fuzz.token_set_ratio(s1, s2), и он дает худшие результаты (время работы хуже, а результаты не так хороши). Совпадения не так хороши, как раньше с моей собственной техникой, и время прохождения увеличилось на хорошие 15 секунд для одной записи.

Редактировать 2:

Я также использовал некоторую сортировку в начале, чтобы помочь с сопоставлением, но моя наивная реализация не будет работать. Но я уверен, что есть несколько способов ускорить это, возможно, избавившись от некоторых записей в наборе данных Geoname или отсортировав их каким-либо образом. Я уже сделал много очистки, чтобы удалить ненужные записи, но не могу получить число намного ниже, чем 4M

Ответы [ 2 ]

0 голосов
/ 27 августа 2018

Я использовал 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 голосов
/ 27 августа 2018

Мы можем ускорить сопоставление несколькими способами. Я предполагаю, что в вашем коде str1 - это имя из вашего набора данных, а str2 - строка геонамов. Чтобы проверить код, я сделал два крошечных набора данных из данных вашего вопроса. И я написал две соответствующие функции best_match и first_match, которые используют вашу текущую функцию string_similarity, чтобы мы могли видеть, что моя стратегия дает те же результаты. best_match проверяет все строки геонаме и возвращает строку с наивысшей оценкой, если она превышает заданную пороговую оценку, в противном случае возвращается None. first_match (потенциально) быстрее: он просто возвращает первую строку геонаме, которая превышает пороговое значение, или None, если он не может его найти, поэтому, если он не находит соответствия, он все равно должен искать весь список географических названий.

В моей улучшенной версии мы генерируем биграммы для каждого str1 один раз, вместо того, чтобы заново генерировать биграммы для str1 для каждого str2, с которым мы их сравниваем. И мы заранее вычисляем все биграммы геонамов, сохраняя их в формате dict, проиндексированном строкой, чтобы нам не приходилось их регенерировать для каждого 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, в которой реализовано предположение, высказанное Джувианом в комментариях. Это может сэкономить немного времени. Эта версия также позволяет избежать тестирования, если один из биграммов пуст.

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, а просто сравнить bound с thresh.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...