使用包含新标签的数据附加 pandas 带有 MultiIndex 的 DataFrame,但保留旧 MultiIndex 的整数位置

Appending pandas DataFrame with MultiIndex with data containing new labels, but preserving the integer positions of the old MultiIndex

基本场景

对于推荐服务,我在一组 user-item 交互上训练矩阵分解模型 (LightFM)。为了使矩阵分解模型产生最佳结果,我需要将我的用户 ID 和商品 ID 映射到从 0 开始的连续整数 ID 范围。

我在这个过程中使用了一个 pandas DataFrame,我发现一个 MultiIndex 非常方便地创建这个映射,就像这样:

ratings = [{'user_id': 1, 'item_id': 1, 'rating': 1.0},
           {'user_id': 1, 'item_id': 3, 'rating': 1.0},
           {'user_id': 3, 'item_id': 1, 'rating': 1.0},
           {'user_id': 3, 'item_id': 3, 'rating': 1.0}]

df = pd.DataFrame(ratings, columns=['user_id', 'item_id', 'rating'])
df = df.set_index(['user_id', 'item_id'])
df

Out:
                 rating
user_id item_id 
1       1        1.0
1       3        1.0
3       1        1.0
3       1        1.0

然后允许我像这样得到连续的地图

df.index.labels[0]    # For users

Out:
FrozenNDArray([0, 0, 1, 1], dtype='int8')

df.index.labels[1]    # For items

Out:
FrozenNDArray([0, 1, 0, 1], dtype='int8')

之后,我可以使用 df.index.levels[0].get_loc 方法将它们映射回来。太棒了!

扩展

但是,现在我正在尝试简化我的模型训练过程,理想情况下是通过对新数据进行增量训练,同时保留旧的 ID 映射。类似于:

new_ratings = [{'user_id': 2, 'item_id': 1, 'rating': 1.0},
               {'user_id': 2, 'item_id': 2, 'rating': 1.0}]

df2 = pd.DataFrame(new_ratings, columns=['user_id', 'item_id', 'rating'])
df2 = df2.set_index(['user_id', 'item_id'])
df2

Out:
                 rating
user_id item_id 
2       1        1.0
2       2        1.0

然后,只需将新评分附加到旧 DataFrame

df3 = df.append(df2)
df3

Out:
                 rating
user_id item_id 
1       1        1.0
1       3        1.0
3       1        1.0
3       3        1.0
2       1        1.0
2       2        1.0

看起来不错,但是

df3.index.labels[0]    # For users

Out:
FrozenNDArray([0, 0, 2, 2, 1, 1], dtype='int8')

df3.index.labels[1]    # For items

Out:
FrozenNDArray([0, 2, 0, 2, 0, 1], dtype='int8')

后面的DataFrame中我特意加了user_id=2和item_id=2,说明我哪里出错了。在 df3 中,标签 3(对于用户和项目)已从整数位置 1 移动到 2。因此映射不再相同。我正在寻找的是分别用于用户和项目映射的 [0, 0, 1, 1, 2, 2][0, 1, 0, 1, 0, 2]

这可能是因为在 pandas 索引 objects 中排序,我不确定使用 MultiIndex 策略是否完全有可能实现我想要的。寻求有关如何最有效地解决此问题的帮助:)

一些注意事项:

解决方案(原创归功于@jpp)

我对@jpp 的回答进行了修改,以满足我稍后添加的附加要求(标记为 EDIT)。这也真正满足了标题中提出的原始问题,因为它保留了旧的索引整数位置,无论出于何种原因重新排序的行。我还将东西包装到函数中:

from itertools import chain
from toolz import unique


def expand_index(source, target, index_cols=['user_id', 'item_id']):

    # Elevate index to series, keeping source with index
    temp = source.reset_index()
    target = target.reset_index()

    # Convert columns to categorical, using the source index and target columns
    for col in index_cols:
        i = source.index.names.index(col)
        col_cats = list(unique(chain(source.index.levels[i], target[col])))

        temp[col] = pd.Categorical(temp[col], categories=col_cats)
        target[col] = pd.Categorical(target[col], categories=col_cats)

    # Convert series back to index
    source = temp.set_index(index_cols)
    target = target.set_index(index_cols)

    return source, target


def concat_expand_index(old, new):
    old, new = expand_index(old, new)
    return pd.concat([old, new])


df3 = concat_expand_index(df, df2)

结果:

df3.index.labels[0]    # For users

Out:
FrozenNDArray([0, 0, 1, 1, 2, 2], dtype='int8')

df3.index.labels[1]    # For items

Out:
FrozenNDArray([0, 1, 0, 1, 0, 2], dtype='int8')

在连接后强制对齐索引标签并不简单,即使有解决方案,也没有很好的记录。

一个可能对您有吸引力的选项是 Categorical Data。通过一些小心的操作,这可以达到相同的目的:一个级别中的每个唯一索引值都具有到整数的一对一映射,并且即使在与其他数据帧连接后此映射仍然存在。

from itertools import chain
from toolz import unique

# elevate index to series
df = df.reset_index()
df2 = df2.reset_index()

# define columns for reindexing
index_cols = ['user_id', 'item_id']

# convert to categorical with merged categories
for col in index_cols:
    col_cats = list(unique(chain(df[col], df2[col])))
    df[col] = pd.Categorical(df[col], categories=col_cats)
    df2[col] = pd.Categorical(df2[col], categories=col_cats)

# convert series back to index
df = df.set_index(index_cols)
df2 = df2.set_index(index_cols)

我使用 toolz.unique 到 return 一个有序的唯一列表,但如果您无权访问此库,则可以使用 itertool docs.

现在让我们看一下第0个索引级别下的类别代码:

for data in [df, df2]:
    print(data.index.get_level_values(0).codes.tolist())

[0, 0, 1, 1]
[2, 2]

然后执行我们的连接:

df3 = pd.concat([df, df2])

最后,检查分类代码是否对齐:

print(df3.index.get_level_values(0).codes.tolist())
[0, 0, 1, 1, 2, 2]

对于每个索引级别,请注意我们必须将跨数据帧的所有索引值并集以形成 col_cats,否则连接将失败。

我认为使用 MultiIndex 会使这个变得过于复杂 objective:

I need to map my user and item IDs to a continuous range of integer IDs starting at 0.

此解决方案属于以下类别:

Alternatives without MultiIndex are completely acceptable.


def add_mapping(df, df2, df3, column_name='user_id'):

    initial = df.loc[:, column_name].unique()
    new = df2.loc[~df2.loc[:, column_name].isin(initial), column_name].unique()
    maps = np.arange(len(initial))
    mapping = dict(zip(initial, maps))
    maps = np.append(maps, np.arange(np.max(maps)+1, np.max(maps)+1+len(new)))
    total = np.append(initial, new)
    mapping = dict(zip(total, maps))

    df3[column_name+'_map'] = df3.loc[:, column_name].map(mapping) 

    return df3

add_mapping(df, df2, df3, column_name='item_id')
add_mapping(df, df2, df3, column_name='user_id')

 user_id    item_id rating  item_id_map user_id_map
0   1          1    1.0         0           0
1   1          3    1.0         1           0
2   3          1    1.0         0           1
3   3          3    1.0         1           1
0   2          1    1.0         0           2
1   2          2    1.0         2           2

说明

这是维护 user_id 值映射的方法。 item_id 值也是如此。

这些是初始 user_id 值(唯一):

initial_users = df['user_id'].unique()
# initial_users = array([1, 3])

user_map 根据您的要求维护 user_id 值的映射:

user_id_maps = np.arange(len(initial_users))
# user_id_maps = array([0, 1])

user_map = dict(zip(initial_users, user_id_maps))
# user_map = {1: 0, 3: 1}

这些是您从 df2 获得的新 user_id 值 - 您在 df:

中没有看到的值
new_users = df2[~df2['user_id'].isin(initial_users)]['user_id'].unique()
# new_users = array([2])

现在我们更新 user_map 新用户的总用户群:

user_id_maps = np.append(user_id_maps, np.arange(np.max(user_id_maps)+1, np.max(user_id_maps)+1+len(new_users)))
# array([0, 1, 2])
total_users = np.append(initial_users, new_users)
# array([1, 3, 2])

user_map = dict(zip(total_users, user_id_maps))
# user_map = {1: 0, 2: 2, 3: 1}

然后,只需将 user_map 的值映射到 df['user_id']:

df3['user_map'] = df3['user_id'].map(user_map)

user_id item_id rating  user_map
0   1   1       1.0          0
1   1   3       1.0          0
2   3   1       1.0          1
3   3   3       1.0          1
0   2   1       1.0          2
1   2   2       1.0          2