【问题标题】:How to find the best way to distribute N observations into M groups?如何找到将 N 个观测值分配到 M 个组中的最佳方法?
【发布时间】:2019-11-12 03:54:34
【问题描述】:

我会尽量以最清晰的方式解释我的问题。假设我们有 df 数据框:

import pandas as pd

users = ['a','b','c','d','e','f','g','h', 'a','b','c','g','h', 'b','c','d','e']
groups = ['g1']*8 + ['g2']*5 + ['g3']*4
scores = [0.54, 0.02, 0.78, 0.9 , 0.98, 0.27, 0.25, 0.98, 0.47, 0.02, 0.8, 0.51, 0.28, 0.53, 0.01, 0.51, 0.6 ]
df = pd.DataFrame({'user': users,
                   'group': groups,
                   'score': scores}).sort_values('score', ascending=False)

这将返回如下内容:

   user group  score
7     h    g1   0.98
4     e    g1   0.98
3     d    g1   0.90
10    c    g2   0.80
2     c    g1   0.78
16    e    g3   0.60
0     a    g1   0.54
13    b    g3   0.53
11    g    g2   0.51
15    d    g3   0.51
8     a    g2   0.47
12    h    g2   0.28
5     f    g1   0.27
6     g    g1   0.25
1     b    g1   0.02
9     b    g2   0.02
14    c    g3   0.01

每个用户在属于每个组时都有一定的分数。问题是每个组可以有有限数量的成员。这些数字存储在字典中:

members = {'g1': 3,
           'g2': 2,
           'g3': 1}

问题来了:我必须选择将用户分组的最佳方式,同时考虑到他们的分数和每个组可以托管的用户数量。

如果我们看一下上面的数据框,将用户分配到组的最佳方法是:

  1. 最高分是分配给hed 属于g1 的分数。鉴于g1 最多可容纳 3 个成员,则将这三个用户分配给它。现在g1 不能再招收会员了。
  2. 以下最好成绩是分配给属于g2c 的成绩。因此g2 现在还剩一个空位。
  3. 观察到下面的分数也是指c,但是这个用户已经被分配了,所以不能分配两次。因此,它必须被忽略。下面的情况也是如此,它把e(已经分配给g1)的用户与g3相关联。
  4. 以下一个将ag1 相关联,但该组已满。因此,它也必须被忽略。
  5. 该过程必须继续进行,直到所有组都已满,或者直到没有更多的行可以填充组(在这种情况下,一些组将有空闲插槽)。

我找到的解决方案是这样的:

final = pd.DataFrame([])
# As long as there are non-assigned users and groups with free slots...
while len(df):
    # Take the first row (i.e. the best score of the rows left)
    i = df.first_valid_index()
    # If there are free slots...
    if members[df.loc[i,'group']] > 0:
        # Subtract 1 from the slots left of this group
        members[df.loc[i,'group']] -= 1
        # Append this row to the 'final' DataFrame
        final = final.append(df.loc[i])
        # Delete all rows belonging to this user, as it was already assigned
        df = df.loc[df.user != df.loc[i,'user']]
    # If the group has no free slots left...
    else:
        # Delete all rows belonging to this group, as it is already full
        df = df.loc[df.group != df.loc[i,'group']]
final = final.groupby('group').agg({'user': ['unique','count']})

这将返回以下 DataFrame:

            user      
          unique count
group                 
g1     [h, d, f]     3
g2        [c, g]     2
g3           [b]     1

问题出在这里:这段代码在现实生活中需要很长时间才能运行。我有超过 2000 万不同的用户,大约有 10 个不同的组需要填充。所以这种方法真的不可行。

有没有更有效的方法来做到这一点? 如有必要,我愿意采用次优解决方案。也就是说,将几乎最佳的用户分配给每个组......如果这有意义的话。

【问题讨论】:

  • 用户出现在组中的概率在您的情况下真正意味着什么?它是一种偏好,按降序排列吗?因为无论“概率”如何,您都将返回单个选择...
  • @MadPhysicist 嗯,它们是某种分数。也许“概率”这个词不是最好的术语。我会在问题中改变它。不过,无论score 列的实际含义如何,都应按照问题中的说明进行选择。
  • 有道理。澄清一下,您的问题是如何优化现有算法?
  • @MadPhysicist 是的。当与小组一起工作时,我在 OP 末尾编写的循环可以正常工作。这 我的问题的最佳解决方案。问题是当有数百万行时这不起作用。因此,我正在寻找另一种解决方案,即使输出不是实际的最佳值,而是接近它的值。
  • @גלעדברקן 所有信息都已在问题中。请仔细阅读,因为“最佳分发方式”在 OP 中有很好的描述。

标签: python pandas algorithm optimization


【解决方案1】:

不完全是答案,但评论太长了。

对 2000 万个数据集进行排序不应该花费那么那么长时间,并且它之后的所有内容都应该以线性时间运行。我有一种预感,删除会变得非常昂贵,特别是 df = df.loc[...] 行。让我们假设您有 20M 用户,每个用户出现两次,因此有 40M 行。每个用户将被删除一次。如果每个用户删除扫描整个 DataFrame,那就是 20M 删除,平均剩余 20M 行,所以 400*10^12 操作。

您可以在不删除任何内容的情况下实现相同的算法,每扫描行的时间为 O(1)。只需为每个用户保留一个“分配”位(在较低级别的语言中,您将拥有一个布尔数组)。当您分配一个用户时,将其位设置为 1。对于每一行,检查该组是否有剩余位置并且该用户未分配。现在不需要删除;自然会跳过分配了用户的行。

抱歉,我的 Python 不够流利,无法提供代码。

【讨论】:

  • 即使您没有提供任何代码,但您的想法是正确的。我实现了它并且效果很好。
【解决方案2】:

这就是我会做的:

df = df.pivot_table(index='user', columns='group', values='score').reset_index().fillna(0)
final = {}
df['sum'] = df.loc[:, 'g1':].sum(axis=1)
for group in members.keys():
    df[group] = df[group] / df['sum']
for group in members.keys():
    df = df.sort_values(group, ascending=False)
    final[group] = list(df.head(members[group])['user'])
    df = df.iloc[members[group]:, :]
final

输出:

{'g1': ['f', 'h', 'd'], 'g2': ['g', 'c'], 'g3': ['b']}

说明:对于每个用户,我都在计算他与任何组的相关程度,以及所有组的相关性。然后每个组获取最适合该组的用户,然后我删除这些用户,并对其他组执行相同操作。

【讨论】:

  • 这似乎工作正常,但这只是偶然。如果您沿 reversed(['g1', 'g2', 'g3']) 而不是 ['g1', 'g2', 'g3'] 迭代,结果会发生变化。因此,您的输出取决于组的列出顺序,这是不正确的。
  • @Tendero 因为您自己没有严格的算法来“最大化回报”,我认为结果可能存在差异是合法的,正如您自己所说的“我是如有必要,愿意采取次优解决方案”。既然你不能确定什么是“正确”的答案,那么只要有意义,你怎么能确定任何解决方案是“不正确的”呢?例如,您会说整个knn 模型是“不正确的”,因为它的最终结果会因起点不同而变化?
  • 我明白你的意思。问题是当我说我可以采取次优解决方案时,我期待的是相当随机或启发式的东西。这个过程似乎相当确定,因此我不相信它的结果可能会有差异。我的解决方案总是返回相同的输出,与字典 members 的定义方式无关。
【解决方案3】:

以下是 Cătălin Frâncu 建议的尝试(使用 numpy 代替 pandas)

  • 在 OP 中

这是根据您的要求显示调度的简化版本。

  • 测试中

可以直接访问ref 数组(而不是使用映射(user_idOP))

我没有按分数排序(很少有人感兴趣)

调度似乎减慢了大约 900 万,很可能是因为所有用户都已调度

n_users = 1e5 占用了 3s1e7 我不知道我之前退出了。


def OP():
    groups = [0,3,2,1] #respectively group
    ref = []
    users = ['a','b','c','d','e','f','g','h']
    user_id = {}
    for i in range(len(users)):
        user_id[users[i]] = i
        ref.append(False)
    entries = []
    entries.append(('h',1,'0.98'))
    entries.append(('e',1,'0.98'))
    entries.append(('d',1,'0.90'))
    entries.append(('c',2,'0.80'))
    entries.append(('c',1,'0.78'))
    entries.append(('e',3,'0.60'))
    entries.append(('a',1,'0.54'))
    entries.append(('b',3,'0.53'))
    entries.append(('g',2,'0.51'))
    entries.append(('d',3,'0.51'))
    entries.append(('a',2,'0.47'))
    entries.append(('h',2,'0.28'))
    entries.append(('f',1,'0.27'))
    entries.append(('g',1,'0.25'))
    entries.append(('b',1,'0.02'))
    entries.append(('b',2,'0.02'))
    entries.append(('c',3,'0.01'))

    out = []
    for u,g,s in entries:
        if ref[user_id[u]] == True:
            continue
        if groups[g] > 0:
            groups[g]-=1
            out.append((u,g,s))
            ref[user_id[u]] = True

    print(out)
    #[('h', 1, '0.98'), ('e', 1, '0.98'), ('d', 1, '0.90'), ('c', 2, '0.80'), ('b', 3, '0.53'), ('g', 2, '0.51')]

def test():
    import numpy as np
    n_users = int(1e7)
    n_groups = 10
    groups = [3,1e6,1e7,1e6,1e6,1e6,1e6,1e6,1e6,1e6]

    print('allocating array')
    N = n_users * n_groups
    dscores = np.random.random((N,1))
    dusers = np.random.randint(0, n_users, (N,1))
    dgroups = np.random.randint(0, n_groups, (N,1))

    print('building ref')
    ref = np.zeros(n_users, dtype=int)

    print('hstack')
    entries = np.hstack((dusers, dgroups, dscores))

    print('dispatching')
    out = np.zeros((n_users, 3))
    z = 0
    counter = 0
    for e in entries:
        counter += 1
        if counter % 1e6 == 0:
            print('ccc', counter)
        u,g,s = e
        u = int(u)
        g = int(g)
        if ref[u] == 1:
            continue
        if groups[g] > 0:
            groups[g]-=1
            out[z][0] = u
            out[z][1] = g
            out[z][2] = s
            ref[u] = 1
            z += 1
            if z % 1e5==0:
                print('z : ', z)
    print('done')

OP()
test()


【讨论】:

    【解决方案4】:

    我绝不是 Numba 方面的专家,这可能会慢一些。但我过去曾成功使用 Numba 和循环编写复杂的算法。如果您有大量数据,您可能需要将 int8 更改为更大的数据类型。

    import pandas as pd
    import numpy as np
    import numba
    
    # Basic setup:
    users = ['a','b','c','d','e','f','g','h', 'a','b','c','g','h', 'b','c','d','e']
    groups = ['g1']*8 + ['g2']*5 + ['g3']*4
    scores = [0.54, 0.02, 0.78, 0.9 , 0.98, 0.27, 0.25, 0.98, 0.47, 0.02, 0.8, 0.51, 0.28, 0.53, 0.01, 0.51, 0.6 ]
    df = pd.DataFrame({'user': users,
                       'group': groups,
                       'score': scores}).sort_values('score', ascending=False)
    
    # Convert user, groups and limits to numbers:
    df['user'] = df.user.astype('category')
    df['group'] = df.group.astype('category')
    df['usercat'] = df.user.cat.codes
    df['groupcat'] = df.group.cat.codes
    
    member_mapping_temp = dict( enumerate(df['group'].cat.categories ) )
    
    members = {'g1': 3,
               'g2': 2,
               'g3': 1}
    
    member_map = np.array([(x,members.get(y)) for x,y in member_mapping_temp.items()])
    
    # Define numba njit function to solve problem:
    from numba import types
    from numba.typed import Dict, List
    int_array = types.int8[:]
    
    @numba.njit()
    def calc_scores(numpy_array, member_map):
        member_map_limits = Dict.empty(
          key_type=types.int8,
          value_type=types.int8,
        )
        member_count = Dict.empty(
          key_type=types.int8,
          value_type=types.int8,
        )
        memeber_list = []
        for ix in range(len(member_map)):
            group = member_map[ix,0]
            limit = member_map[ix,1]
            member_map_limits[group] = limit
            member_count[group] = 0
    
        seen_users = set()
    
        for ix in range(len(numpy_array)):
            user = numpy_array[ix,0]
            group = numpy_array[ix,1]
            if user in seen_users:
                continue
            if member_map_limits[group] == member_count[group]:
                continue
            member_count[group] = member_count[group] + 1
            memeber_list.append((group,user))
            seen_users.add(user)
    
        return memeber_list
    
    # Call function:
    res = calc_scores(df[['usercat','groupcat']].to_numpy(), member_map)
    
    # Add result to DF
    res = pd.DataFrame(res, columns=['group','member'])
    
    # Map back to values
    res['group'] = pd.Categorical.from_codes(codes=res['group'], dtype=df['group'].dtype)
    res['member'] = pd.Categorical.from_codes(codes=res['member'], dtype=df['user'].dtype)
    

    如果这在真实数据集上更快,请告诉我。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2022-01-19
      • 1970-01-01
      • 1970-01-01
      • 2014-09-04
      • 1970-01-01
      • 2020-09-06
      相关资源
      最近更新 更多