【问题标题】:Find most similar sets efficiently (Python, data structures)有效地查找最相似的集合(Python、数据结构)
【发布时间】:2020-02-27 03:01:49
【问题描述】:

假设我在名为my_sets 的列表中有几千个 Python 集。对于my_sets 中的每个集合“A”,我想在my_sets 中找到包含集合 A 成员百分比最高的五个集合(集合“B”)。

我目前将数据存储为集合并循环两次以计算重叠...

from random import randint
from heapq import heappush, heappop

my_sets = []

for i in range(20):
    new_set = set()

    for j in range(20):
        new_set.add(randint(0, 50))

    my_sets.append(new_set)

for i in range(len(my_sets)):
    neighbor_heap = []

    for j in range(len(my_sets)):
        if i == j:
            continue

        heappush(neighbor_heap, (1 / len(my_sets[i] & my_sets[j]), j))

    results = []

    while len(results) < 5:
        results.append(heappop(neighbor_heap)[1])

    print('Closest neighbors to set {} are sets {}'.format(i, results))

但是,这显然是一个 O(N**2) 算法,所以当 my_sets 变长时它会爆炸。是否有更好的数据结构或算法可以在基础 Python 中实现来解决这个问题?没有理由 my_sets 必须是一个列表,或者每个单独的集合实际上都必须是一个 Python 集合。存储每个集合是否包含来自有限选项列表的成员的任何方式都可以(例如,以标准化顺序排列的一堆布尔值或位)。并且构建一个更奇特的数据结构来节省时间也很好。

(正如某些人可能想要指出的那样,我当然可以将其构造为一个 Numpy 数组,其中行是集合,列是元素,单元格是 1/0,具体取决于该元素是否在其中设置。然后,您只需执行一些 Numpy 操作。这无疑会更快,但我并没有真正改进我的算法,我只是将复杂性转移到其他人优化的 C/Fortran/其他东西上。)

编辑经过全面测试,我最初发布的算法在约定的测试条件下运行大约 586 秒。

【问题讨论】:

  • 当你让my_sets更长时,你是否也让集合或随机范围更大?还是他们会保持这么小?
  • 好问题。 my_sets 的长度理论上是无限的,但在实践中可能会达到 10k(这将是一个极端情况)。每个集合包含不超过 ~200 个项目(在极端情况下),所有集合的并集可能不超过 ~1000 个项目(同样,在极端情况下)。
  • 为什么不呢,@StefanPochmann?我愿意更正。
  • 嗯,或者至少不是很明显 O(N**2)。它需要heappush 为 O(1),我认为不是。也许如果您将集合大小限制为一个固定常数,那么您不仅可以将交集运算视为 O(1),而且还可以使 heappush O(1),但至少这并不明显。
  • 你只对路口感兴趣吗?如果您愿意使用对称的度量,并且还满足三角不等式,那么您可能会使用 BK 树。

标签: python algorithm data-structures


【解决方案1】:

你能:

  1. 反转集合,为每个集合元素生成包含它的集合列表(或集合)。

    这是 O(n*m) -- 对于 n 个集合和平均 每组 m 个元素。

  2. 对于每个集合S,考虑它的元素,并(使用1)构造其他集合的列表(或堆)以及每个集合与S -- 选择“最好的” 5.

    这是O(n*m*a),其中a 是每个元素所属的平均集合数。

O(n*n) 相距多远,这显然取决于 m一个

编辑:在我的机器上运行 Python 中的朴素实现需要 103 秒...

old_time = clock()

my_sets = []

for i in range(10000):
    new_set = set()

    for j in range(200):
        new_set.add(randint(0, 999))

    my_sets.append(new_set)

my_inv_sets = [[] for i in range(1000)]

for i in range(len(my_sets)):
    for j in range(1000):
        if j in my_sets[i]:
            my_inv_sets[j].append(i)

for i in range(len(my_sets)):
    counter = Counter()

    for j in my_sets[i]:
        counter.update(my_inv_sets[j])

    print(counter.most_common(6)[1:])

print(clock() - old_time)

【讨论】:

  • 编辑了 Chris 的答案以在代码中包含一个幼稚的实现,因为我不想发布不同的答案,也不想让他的想法得到赞誉。不确定这是否是这里的最佳实践,如果不是,请道歉。我能够在 2 分钟内获得测试结果,这大约是 10 倍的加速。
  • @JohnChrysostom:10 倍的加速令人欣慰 :-) 它给出了正确的答案吗?编辑没问题 :-) 但是——假设它确实有效——你能补充一下你以前的最佳时间吗?
  • 经过全面测试,原来的算法是586秒。所以,更像是 6 倍的加速。仍然,令人印象深刻,谢谢!我要出去吃午饭了,但当我回来时,我可能会接受这个答案,除非没有额外的输入。
  • @JohnChrysostom:FWIW,我突然想到,在第 2 步中,每个集合 S 上的操作是完全独立的,因此运行尽可能多的线程很简单你有 CPU。
  • 我还发现了SimHash算法的a Python implementation,可以用来快速估计集合之间的相似度。
【解决方案2】:

您可以通过构建与每个值关联的集合索引列表来减少通过集合列表的次数。然后,再通过集合列表,您可以通过编译集合的每个值的索引数来确定哪些集合具有最常见的值。

这在某些情况下会提高性能,但取决于数据的密度,差异可能不会很大。

这是一个使用集合模块中的 defaultdict 和 Counter 的示例。

from collections import defaultdict,Counter
def top5Matches(setList):
    valueSets = defaultdict(list)
    for i,aSet in enumerate(setList):
        for v in aSet: valueSets[v].append(i)
    results = []
    for i,aSet in enumerate(setList):
        counts = Counter()
        for v in aSet: counts.update(valueSets[v])
        counts[i] = 0
        top5      = [setList[j] for j,_ in counts.most_common(5)]
        results.append((aSet,top5))
    return results

为了比较执行时间,我冒昧地将您的解决方案嵌入到一个函数中。我还必须修复两组根本没有交集的情况:

from heapq import heappush, heappop
def OPSolution(my_sets):
    results = []
    for i in range(len(my_sets)):
        neighbor_heap = []
        for j in range(len(my_sets)):
            if i == j: continue
            heappush(neighbor_heap, (1 / max(1,len(my_sets[i] & my_sets[j])), j))
        top5 = []
        while len(top5) < 5:
            j = heappop(neighbor_heap)[1]
            top5.append(my_sets[j])
        results.append((my_sets[i],top5))    
    return results

这两个函数都返回一个包含原始集合的元组列表和一个基于公共值数量的前 5 个集合的列表。

当第 6 个(或更多)附加集合的交集计数相同时,尽管前 5 个集合可能不同,但这两个函数产生相同的结果。

from random import randrange

my_sets = [ set(randrange(50) for _ in range(20)) for _ in range(20) ]
opResults = OPSolution(my_sets)
print("OPSolution: (matching counts)")
for i,(aSet,top5) in enumerate(opResults):
    print(i,"Top 5:",[len(aSet&otherSet) for otherSet in top5])
print("")

print("top5Matches: (matching counts)")
t5mResults = top5Matches(my_sets)
for i,(aSet,top5) in enumerate(t5mResults):
    print(i,"Top 5:",[len(aSet&otherSet) for otherSet in top5])
print("")

输出:

OPSolution: (matching counts)
0 Top 5: [8, 7, 7, 7, 6]
1 Top 5: [7, 6, 6, 6, 6]
2 Top 5: [8, 7, 6, 6, 6]
3 Top 5: [8, 7, 7, 6, 6]
4 Top 5: [9, 8, 8, 8, 8]
5 Top 5: [7, 6, 6, 6, 6]
6 Top 5: [8, 8, 8, 7, 6]
7 Top 5: [8, 8, 7, 7, 7]
8 Top 5: [9, 7, 7, 7, 6]
9 Top 5: [8, 8, 8, 7, 7]
10 Top 5: [8, 8, 7, 7, 7]
11 Top 5: [8, 8, 7, 7, 6]
12 Top 5: [8, 7, 7, 7, 7]
13 Top 5: [8, 8, 8, 6, 6]
14 Top 5: [9, 8, 8, 6, 6]
15 Top 5: [6, 6, 5, 5, 5]
16 Top 5: [9, 7, 7, 6, 6]
17 Top 5: [8, 7, 7, 7, 7]
18 Top 5: [8, 8, 7, 6, 6]
19 Top 5: [7, 6, 6, 6, 6]

top5Matches: (matching counts)
0 Top 5: [8, 7, 7, 7, 6]
1 Top 5: [7, 6, 6, 6, 6]
2 Top 5: [8, 7, 6, 6, 6]
3 Top 5: [8, 7, 7, 6, 6]
4 Top 5: [9, 8, 8, 8, 8]
5 Top 5: [7, 6, 6, 6, 6]
6 Top 5: [8, 8, 8, 7, 6]
7 Top 5: [8, 8, 7, 7, 7]
8 Top 5: [9, 7, 7, 7, 6]
9 Top 5: [8, 8, 8, 7, 7]
10 Top 5: [8, 8, 7, 7, 7]
11 Top 5: [8, 8, 7, 7, 6]
12 Top 5: [8, 7, 7, 7, 7]
13 Top 5: [8, 8, 8, 6, 6]
14 Top 5: [9, 8, 8, 6, 6]
15 Top 5: [6, 6, 5, 5, 5]
16 Top 5: [9, 7, 7, 6, 6]
17 Top 5: [8, 7, 7, 7, 7]
18 Top 5: [8, 8, 7, 6, 6]
19 Top 5: [7, 6, 6, 6, 6]

比较各种设置组合的执行时间表明,按值索引在较大的数据集上表现更好(尽管在某些情况下效果并不好):

[编辑] 添加了 Chris Hall 的解决方案,通过将功能限制为连续范围内的值集来衡量速度改进。我还必须将它嵌入一个函数并测试结果是否相同。我意识到在这样做的同时,我们基本上采用了相同的方法。主要区别在于 Chris 使用列表而不是字典,它将值限制为必须提供大小的 range()。

def chrisHall(my_sets,valueRange):
    results = []
    my_inv_sets = [[] for i in range(valueRange)]
    for i in range(len(my_sets)):
        for j in range(valueRange):
            if j in my_sets[i]:
                my_inv_sets[j].append(i)

    for i in range(len(my_sets)):
        counter = Counter()

        for j in my_sets[i]:
            counter.update(my_inv_sets[j])

        top5 = [my_sets[j] for j,_ in counter.most_common(6)[1:]]
        results.append((my_sets[i],top5))
    return results

性能测试也嵌入到函数中以避免重复样板代码:

from random import randrange
from timeit import timeit

def compareSolutions(title,setCount,setSize,valueRange,count=1):

    print("-------------------")
    print(title,setCount,"sets of",setSize,"elements in range 0 ...",valueRange)
    testSets = [ set(randrange(valueRange) for _ in range(setSize)) for _ in range(setCount) ]

    t = timeit(lambda: chrisHall(testSets,valueRange),number=count)
    print("chrisHall",t)

    t = timeit(lambda: top5Matches(testSets),number=count)
    print("top5Matches",t)

    t = timeit(lambda: OPSolution(testSets),number=count)
    print("OPSolution",t)

compareSolutions("SIMPLE TEST SET",20,20,50,count=100)
compareSolutions("MORE SETS:",2000,20,50)
compareSolutions("FEWER INTERSECTIONS:",2000,20,500)
compareSolutions("LARGER SETS:",2000,200,500)
compareSolutions("SETTING FROM COMMENTS:",10000,200,1000)

结果:

-------------------
SIMPLE TEST SET 20 sets of 20 elements in range 0 ... 50
chrisHall 0.0766431910000005
top5Matches 0.07549873900000037
OPSolution 0.05089954700000021
-------------------
MORE SETS: 2000 sets of 20 elements in range 0 ... 50
chrisHall 1.274499733999999
top5Matches 1.2646208220000013
OPSolution 3.796912927000001
-------------------
FEWER INTERSECTIONS: 2000 sets of 20 elements in range 0 ... 500
chrisHall 0.4685694170000012
top5Matches 0.42844527900000173
OPSolution 3.5187148479999983
-------------------
LARGER SETS: 2000 sets of 200 elements in range 0 ... 500
chrisHall 8.538208329
top5Matches 8.51855685
OPSolution 23.192823251999997
-------------------
SETTING FROM COMMENTS: 10000 sets of 200 elements in range 0 ... 1000
chrisHall 190.55364428999997
top5Matches 176.066835327
OPSolution 829.934181724

【讨论】:

    【解决方案3】:

    我已经使用集合交集来查找公共元素,然后根据它们包含的公共元素的数量对这些元素进行排序, 这是您可能想尝试的代码:

    from random import randint
    from heapq import heappush, heappop
    
    my_sets = []
    for i in range(20):
        new_set = set()
    
        for j in range(20):
            new_set.add(randint(0, 50))
    
        my_sets.append(new_set)
    
    
    for i in range(len(my_sets)):
        temp = dict()
        for j in range(len(my_sets)):
            if i == j:
                continue
    
            diff = my_sets[i] & my_sets[j]
            temp[j] = diff
    
        five = sorted(temp.items(), reverse=True, key=lambda s: len(s[1]))[:5]
        five_indexes = [t[0] for t in five]
        print('Closest neighbors to set {} are sets {}'.format(i, five_indexes))
    

    【讨论】:

    • 感谢您的建议。我不能 100% 确定排序或使用堆是否更快(所以这可能是一个非常小的加速),但它并不能解决执行 N**2 设置交叉点的根本问题,这仍然会很慢大集合和大 N 值的糖蜜。
    【解决方案4】:

    使用heapq.nlargest 更简单,速度更快(在限制为 10000、200、1000 的大机箱上看起来像 5-10%):

    for i in range(len(my_sets)):
        results = nlargest(5,
                           (j for j in range(len(my_sets)) if j != i),
                           key=lambda j: len(my_sets[i] & my_sets[j]))
        print('Closest neighbors to set {} are sets {}'.format(i, results))
    

    这不会用 N-1 个元素构建堆,而是用 just 5 elements

    【讨论】:

    • 使用sum(1 for x in my_sets[i] if x in my_sets[j]) 而不是len(my_sets[i] &amp; my_sets[j]) 可能更快?
    • @tobias_k 似乎要 慢 3-4 倍。为什么你认为它可能会更快?原来的方式可以选择迭代哪个集合,检查哪个集合,都是C语言编写的。
    • @tobias_k 啊,我猜是因为它不需要建立一个集合。无论如何,更快的版本sum(map(my_sets[j].__contains__, my_sets[i])) 仍然比原来慢了大约 2.3 倍。
    • 是的,猜想用 C 语言实现比新集合创建更重要......
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2016-10-16
    • 1970-01-01
    • 2012-03-16
    • 2018-12-23
    • 2018-02-03
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多