Эффективный поиск по общим словам
У меня есть список имен (строк), разделенных на слова. Есть 8 миллионов имен, каждое имя состоит из 20 слов (токенов). Количество уникальных токенов - 2,2 миллиона. Мне нужен эффективный способ найти все имена, содержащие хотя бы одно слово из запроса (которое может содержать также до 20 слов, но обычно только несколько).
Мой текущий подход использует Python Pandas и выглядит так (далее называемый original
):
>>> df = pd.DataFrame([['foo', 'bar', 'joe'],
['foo'],
['bar', 'joe'],
['zoo']],
index=['id1', 'id2', 'id3', 'id4'])
>>> df.index.rename('id', inplace=True) # btw, is there a way to include this into prev line?
>>> print df
0 1 2
id
id1 foo bar joe
id2 foo None None
id3 bar joe None
id4 zoo None None
def filter_by_tokens(df, tokens):
# search within each column and then concatenate and dedup results
results = [df.loc[lambda df: df[i].isin(tokens)] for i in range(df.shape[1])]
return pd.concat(results).reset_index().drop_duplicates().set_index(df.index.name)
>>> print filter_by_tokens(df, ['foo', 'zoo'])
0 1 2
id
id1 foo bar joe
id2 foo None None
id4 zoo None None
В настоящее время такой поиск (по полному набору данных) занимает 5.75 с на моей (довольно мощной) машине. Я бы хотел ускорить его, по крайней мере, 10 раз.
Мне удалось добраться до 5.29s, сжимая все столбцы в один и выполнив поиск по нему (далее называемый original, squeezed
):
>>> df = pd.Series([{'foo', 'bar', 'joe'},
{'foo'},
{'bar', 'joe'},
{'zoo'}],
index=['id1', 'id2', 'id3', 'id4'])
>>> df.index.rename('id', inplace=True)
>>> print df
id
id1 {foo, bar, joe}
id2 {foo}
id3 {bar, joe}
id4 {zoo}
dtype: object
def filter_by_tokens(df, tokens):
return df[df.map(lambda x: bool(x & set(tokens)))]
>>> print filter_by_tokens(df, ['foo', 'zoo'])
id
id1 {foo, bar, joe}
id2 {foo}
id4 {zoo}
dtype: object
Но это еще не достаточно быстро.
Другим решением, которое, как представляется, легко реализовать, является использование многопроцессорности Python (потоки не должны здесь помочь из-за GIL, и нет ввода-вывода, правильно?). Но проблема заключается в том, что большой файл данных необходимо скопировать в каждый процесс, который занимает всю память. Другая проблема заключается в том, что мне нужно много раз вызывать filter_by_tokens
в цикле, поэтому он копирует данные в каждый вызов, что неэффективно.
Обратите внимание, что слова могут встречаться много раз в именах (например, наиболее популярное слово имеет место в 600 тыс. раз в именах), поэтому обратный индекс будет огромным.
Каков хороший способ написать это эффективно? Решение Python предпочтительнее, но я также открыт для других языков и технологий (например, баз данных).
UPD:
Я измерил время выполнения своих двух решений и 5 решений, предложенных @piRSquared в ответе . Вот результаты (tl; dr лучше всего - улучшение 2x):
+--------------------+----------------+
| method | best of 3, sec |
+--------------------+----------------+
| original | 5.75 |
| original, squeezed | 5.29 |
| zip | 2.54 |
| merge | 8.87 |
| mul+any | MemoryError |
| isin | IndexingError |
| query | 3.7 |
+--------------------+----------------+
mul+any
дает MemoryError на d1 = pd.get_dummies(df.stack()).groupby(level=0).sum()
(на компьютере с оперативной памятью 128 ГБ).
isin
дает IndexingError: Unalignable boolean Series key provided
на s[d1.isin({'zoo', 'foo'}).unstack().any(1)]
, по-видимому, потому что форма df.stack().isin(set(tokens)).unstack()
немного меньше формы исходного кадра данных (8.39M против 8.41M строк), не знаю, почему и как это исправить.
Обратите внимание, что машина, которую я использую, имеет 12 ядер (хотя я упомянул некоторые проблемы с распараллеливанием выше). Все решения используют одно ядро.
Заключение (на данный момент): есть улучшение 2.1x с помощью решения zip
(2.54s) vs original squeezed
(5.29s). Это хорошо, хотя я старался хотя бы на 10 раз улучшить, если это возможно. Поэтому я оставляю (по-прежнему замечательный) ответ @piRSquared неприемлемым на данный момент, чтобы приветствовать дополнительные предложения.
Ответы
Ответ 1
идея 0
zip
def pir(s, token):
return s[[bool(p & token) for p in s]]
pir(s, {'foo', 'zoo'})
идея 1
merge
token = pd.DataFrame(dict(v=['foo', 'zoo']))
d1 = df.stack().reset_index('id', name='v')
s.ix[d1.merge(token).id.unique()]
идея 2
mul
+ any
d1 = pd.get_dummies(df.stack()).groupby(level=0).sum()
token = pd.Series(1, ['foo', 'zoo'])
s[d1.mul(token).any(1)]
идея 3
isin
d1 = df.stack()
s[d1.isin({'zoo', 'foo'}).unstack().any(1)]
идея 4
query
token = ('foo', 'zoo')
d1 = df.stack().to_frame('s')
s.ix[d1.query( in @token').index.get_level_values(0).unique()]
Ответ 2
Я сделал аналогичные вещи со следующими инструментами
Hbase - Key can have Multiple columns (Very Fast)
ElasticSearch - Nice easy to scale. You just need to import your data as JSON
Apache Lucene - будет очень хорошо для 8 миллионов записей
Ответ 3
Вы можете сделать это с обратным индексом; приведенный ниже код в pypy строит индекс за 57 секунд, выполняет ли запрос или 20 слов 0,00018 секунд и использует 3,2 ГБ памяти. Python 2.7 строит индекс за 158 секунд и делает запрос в 0,0013 секунды, используя около 3,41 ГБ памяти.
Самый быстрый способ сделать это - с растровыми обратными индексами, сжатыми для экономии места.
"""
8m records with between 1 and 20 words each, selected at random from 100k words
Build dictionary of sets, keyed by word number, set contains nos of all records
with that word
query merges the sets for all query words
"""
import random
import time records = 8000000
words = 100000
wordlists = {}
print "build wordlists"
starttime = time.time()
wordlimit = words - 1
total_words = 0
for recno in range(records):
for x in range(random.randint(1,20)):
wordno = random.randint(0,wordlimit)
try:
wordlists[wordno].add(recno)
except:
wordlists[wordno] = set([recno])
total_words += 1
print "build time", time.time() - starttime, "total_words", total_words
querylist = set()
query = set()
for x in range(20):
while 1:
wordno = (random.randint(0,words))
if wordno in wordlists: # only query words that were used
if not wordno in query:
query.add(wordno)
break
print "query", query
starttime = time.time()
for wordno in query:
querylist.union(wordlists[wordno])
print "query time", time.time() - starttime
print "count = ", len(querylist)
for recno in querylist:
print "record", recno, "matches"
Ответ 4
Возможно, мой первый ответ был немного абстрактным; в отсутствие реальных данных он генерировал случайные данные к ок. объем. чтобы узнать время запроса. Этот код практичен.
data =[['foo', 'bar', 'joe'],
['foo'],
['bar', 'joe'],
['zoo']]
wordlists = {}
print "build wordlists"
for x, d in enumerate(data):
for word in d:
try:
wordlists[word].add(x)
except:
wordlists[word] = set([x])
print "query"
query = [ "foo", "zoo" ]
results = set()
for q in query:
wordlist = wordlists.get(q)
if wordlist:
results = results.union(wordlist)
l = list(results)
l.sort()
for x in l:
print data[x]
Стоимость во времени и памяти строит списки слов (инвертированные индексы); запрос почти свободен. У вас 12 основных машин, поэтому, по-видимому, у него много памяти. Для повторяемости создайте списки слов, рассоедините каждый список слов и напишите в sqlite или любую базу данных key/value со словом в качестве ключа, а маринованный набор - как двоичный blob. то все, что вам нужно, это:
initialise_database()
query = [ "foo", "zoo" ]
results = set()
for q in query:
wordlist = get_wordlist_from_database(q) # get binary blob and unpickle
if wordlist:
results = results.union(wordlist)
l = list(results)
l.sort()
for x in l:
print data[x]
альтернативно, используя массивы, которые более эффективны с точки зрения памяти и, вероятно, быстрее для создания индекса. pypy больше, чем в 10 раз быстрее, чем 2.7
import array
data =[['foo', 'bar', 'joe'],
['foo'],
['bar', 'joe'],
['zoo']]
wordlists = {}
print "build wordlists"
for x, d in enumerate(data):
for word in d:
try:
wordlists[word].append(x)
except:
wordlists[word] = array.array("i",[x])
print "query"
query = [ "foo", "zoo" ]
results = set()
for q in query:
wordlist = wordlists.get(q)
if wordlist:
for i in wordlist:
results.add(i)
l = list(results)
l.sort()
for x in l:
print data[x]
Ответ 5
Если вы знаете, что количество уникальных токенов, которые вы увидите, относительно невелико,
вы можете легко создать эффективную битмаску для запроса совпадений.
Наивный подход (в исходном сообщении) позволит до 64 отдельных токенов.
Улучшенный код ниже использует битовую маску, например, фильтр цветения (модульная арифметика при установке бит вокруг 64). Если имеется более 64 уникальных токенов, будут появляться ложные срабатывания, которые будут автоматически проверяться кодом (с использованием исходного кода).
Теперь наихудшая производительность ухудшится, если количество уникальных токенов (намного) больше 64, или если вам особенно не повезло. Хеширование может уменьшить это.
Что касается производительности, используя приведенные ниже контрольные данные, я получаю:
Оригинальный код: 4.67 секунд
Битмакс-код: 0,30 секунд
Однако, когда количество уникальных токенов увеличивается, битмакс-код остается эффективным, в то время как исходный код значительно замедляется. Имея около 70 уникальных токенов, я получаю что-то вроде:
Оригинальный код: ~ 15 секунд
Битмакс-код: 0,80 секунд
Примечание: для этого последнего случая, построение массива битмаски из предоставленного списка, занимает примерно столько же времени, сколько и создание фрейма данных. Вероятно, нет реальной причины для построения фреймворка данных; оставил его в основном для удобства сравнения с исходным кодом.
class WordLookerUpper(object):
def __init__(self, token_lists):
tic = time.time()
self.df = pd.DataFrame(token_lists,
index=pd.Index(
data=['id%d' % i for i in range(len(token_lists))],
name='index'))
print('took %d seconds to build dataframe' % (time.time() - tic))
tic = time.time()
dii = {}
iid = 0
self.bits = np.zeros(len(token_lists), np.int64)
for i in range(len(token_lists)):
for t in token_lists[i]:
if t not in dii:
dii[t] = iid
iid += 1
# set the bit; note that b = dii[t] % 64
# this 'wrap around' behavior lets us use this
# bitmask as a probabilistic filter
b = dii[t]
self.bits[i] |= (1 << b)
self.string_to_iid = dii
print('took %d seconds to build bitmask' % (time.time() - tic))
def filter_by_tokens(self, tokens, df=None):
if df is None:
df = self.df
tic = time.time()
# search within each column and then concatenate and dedup results
results = [df.loc[lambda df: df[i].isin(tokens)] for i in range(df.shape[1])]
results = pd.concat(results).reset_index().drop_duplicates().set_index('index')
print('took %0.2f seconds to find %d matches using original code' % (
time.time()-tic, len(results)))
return results
def filter_by_tokens_with_bitmask(self, search_tokens):
tic = time.time()
bitmask = np.zeros(len(self.bits), np.int64)
verify = np.zeros(len(self.bits), np.int64)
verification_needed = False
for t in search_tokens:
bitmask |= (self.bits & (1<<self.string_to_iid[t]))
if self.string_to_iid[t] > 64:
verification_needed = True
verify |= (self.bits & (1<<self.string_to_iid[t]))
if verification_needed:
results = self.df[(bitmask > 0 & ~verify.astype(bool))]
results = pd.concat([results,
self.filter_by_tokens(search_tokens,
self.df[(bitmask > 0 & verify.astype(bool))])])
else:
results = self.df[bitmask > 0]
print('took %0.2f seconds to find %d matches using bitmask code' % (
time.time()-tic, len(results)))
return results
Сделайте несколько тестовых данных
unique_token_lists = [
['foo', 'bar', 'joe'],
['foo'],
['bar', 'joe'],
['zoo'],
['ziz','zaz','zuz'],
['joe'],
['joey','joe'],
['joey','joe','joe','shabadoo']
]
token_lists = []
for n in range(1000000):
token_lists.extend(unique_token_lists)
Запустите исходный код и битмакс-код
>>> wlook = WordLookerUpper(token_lists)
took 5 seconds to build dataframe
took 10 seconds to build bitmask
>>> wlook.filter_by_tokens(['foo','zoo']).tail(n=1)
took 4.67 seconds to find 3000000 matches using original code
id7999995 zoo None None None
>>> wlook.filter_by_tokens_with_bitmask(['foo','zoo']).tail(n=1)
took 0.30 seconds to find 3000000 matches using bitmask code
id7999995 zoo None None None