通过常用词进行高效查找

Posted

技术标签:

【中文标题】通过常用词进行高效查找【英文标题】:Efficient lookup by common words 【发布时间】:2017-03-01 19:58:28 【问题描述】:

我有一个分为单词的名称(字符串)列表。有 800 万个名称,每个名称最多包含 20 个单词(标记)。唯一令牌的数量为 220 万。我需要一种有效的方法来从查询中找到至少包含一个单词的所有名称(其中也可能包含最多 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 倍。

通过将所有列压缩为一个并对其执行查找(后来称为original, squeezed),我能够达到 5.29 秒:

>>> 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 并且没有 I/O,对吧?)。但是它的问题是需要将大数据帧复制到每个进程中,这会占用所有内存。另一个问题是我需要在循环中多次调用filter_by_tokens,所以它会在每次调用时复制数据帧,效率很低。

请注意,单词可能在名称中出现多次(例如,最流行的单词在名称中出现 60 万次),因此反向索引会很大。

有什么好的方法可以有效地编写这个?首选 Python 解决方案,但我也对其他语言和技术(例如数据库)持开放态度。


统一更新: 我测量了我的两个解决方案和@piRSquared 在他的answer 中建议的5 个解决方案的执行时间。以下是结果(tl;dr 最好是 2 倍改进):

+--------------------+----------------+
|       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+anyd1 = pd.get_dummies(df.stack()).groupby(level=0).sum() 上给出 MemoryError(在 128Gb RAM 机器上)。

isins[d1.isin('zoo', 'foo').unstack().any(1)] 上给出IndexingError: Unalignable boolean Series key provided,显然是因为df.stack().isin(set(tokens)).unstack() 的形状略小于原始数据框的形状(8.39M 与 8.41M 行),不知道为什么以及如何解决这个问题。

请注意,我使用的机器有 12 个内核(尽管我在上面提到了并行化的一些问题)。所有解决方案都使用一个内核。

结论(截至目前):zip (2.54s) 与 original squeezed 解决方案 (5.29s) 相比有 2.1 倍的改进。这很好,尽管如果可能的话,我的目标是至少提高 10 倍。所以我暂时不接受(仍然很棒)@piRSquared 的答案,欢迎更多建议。

【问题讨论】:

multiprocessing 模块是为了绕过多核机器中的 GIL 限制而编写的。 您应该考虑 dask 将您当前的算法扩展到核心/集群之外并继续使用数据帧。 我更新了我的帖子,idea 0 应该是对你的改进 @sisanared 这就是我在帖子中的意思,还说多处理存在内存问题(每次调用时都需要将数据复制到每个进程)。 你真的尝试过反向索引吗?怎么可能比原来的数据结构大?也许整数 ID 会节省一些空间。 【参考方案1】:

想法 0zip

def pir(s, token):
    return s[[bool(p & token) for p in s]]

pir(s, 'foo', 'zoo')

想法 1merge

token = pd.DataFrame(dict(v=['foo', 'zoo']))
d1 = df.stack().reset_index('id', name='v')
s.ix[d1.merge(token).id.unique()]

想法 2mul + any

d1 = pd.get_dummies(df.stack()).groupby(level=0).sum()
token = pd.Series(1, ['foo', 'zoo'])
s[d1.mul(token).any(1)]

想法 3isin

d1 = df.stack()
s[d1.isin('zoo', 'foo').unstack().any(1)]

想法 4query

token = ('foo', 'zoo')
d1 = df.stack().to_frame('s')
s.ix[d1.query('s in @token').index.get_level_values(0).unique()]

【讨论】:

感谢您提供很好的建议和(大部分)工作代码的答案!我已经尝试了所有这些并将timeit 结果发布为原始问题的更新。非常欢迎改进! zip 解决方案可以简化为s[s & token],但它虽然看起来不错,但由于某种原因,它增加了从2.54s6.39s 的时间。【参考方案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 - 非常适合 800 万条记录

【讨论】:

感谢您的想法。非常感谢使用这些工具的代码的最小工作示例。【参考方案3】:

您可以使用反向索引来做到这一点;下面在 pypy 中运行的代码在 57 秒内构建索引,查询或 20 个单词是否需要 0.00018 秒并使用大约 3.2Gb 内存。 Python 2.7 在 158 秒内构建索引,并在 0.0013 秒内使用大约 3.41Gb 内存进行查询。

最快的方法是使用位图反向索引,压缩以节省空间。

"""
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 或任何键/值数据库,单词作为键,腌制集作为二进制 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 比 2.7 快 10 倍以上

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

【讨论】:

有 220 万个独特的令牌...我已经更新了帖子以包含它。 那么您的原始解决方案会生成一个包含 220 万列的数据框? 不,列数等于每个名称的最大令牌数 (20)。每行包含一个由标记组成的名称。列并不对应所有可能的标记,而是对应名称中的标记位置(token1、token2、token3、...)。 谢谢 - 我现在明白了。 尝试了大约 2m 个唯一键;结果大约是原始解决方案的两倍。所以并不比其他解决方案更好。

以上是关于通过常用词进行高效查找的主要内容,如果未能解决你的问题,请参考以下文章

使用通配符进行单词查找的高效数据结构

Visual Studio最常用最高效快捷键

3.索引与string进行映射实现高效查找

查找所有连接对之和的高效算法

Ops: 高效组合命令集合

C 语言高效编程与代码优化