推荐系统处理数据总结

因为推荐系统中各个子领域的数据处理过程各不相同, 为防止遗忘, 作此笔记(本文应该会不停更新)

纯CTR预估

我的模型的数据处理过程, 根据 AutoInt 修改

首先原始数据大概是长这样的, 以下数据是我编造用来讲解的

1
2
3
1 a1b paq 1 2
0 7h3 100 0
1 a1b -2 -0.5

原始数据集会告诉你每列所代表的意思以及该列是否为实值数据

以上述数据为例, 数据集提供方会告知第一列代表用户是否点击了, 0代表未点击, 1代表点击了, 其中第二三列为稀疏特征 ( sparse features ) , 值为特征 hash 后的值, 第四列为实值特征 ( dense feature )

在 AutoInt 中, 稀疏特征和实值特征最后都会变成向量表示, 即 embedding, 这是最通用的做法, 如果你不需要实值特征的 embedding, 那么你可以使用下面讲解的方法进行数据处理但最后不使用实值特征的 embedding 即可

对于实值特征, 每一列对于一个 embedding

对于稀疏特征, 因为每一列是独立不相关的, 所以需要先对每一列进行 hash 到 index 的映射

对于第二列, 我们可以得到以下映射 ( 用 python 的 dict 实现 )

1
2
3
None:0
a1b:1
7h3:2

第三列我们可以得到以下映射

1
2
3
None:0
paq:1
a1b:2

于是原始数据变成了下面这样

1
2
3
1 1 1 1 2
0 2 0 100 0
1 0 2 -2 -0.5

为了将第二列和第三列都存储在同一个 embedding 中, 我们可以在第三列的映射上加上一个偏移量offset, 第三列前面所有的列 ( field ) 共有 3 个特征, 于是 offset 为 3

1
2
3
1 1 4 1 2
0 2 3 100 0
1 0 5 -2 -0.5

这样处理后, 我们就可以将不同列 ( field ) 的数据存储在同一个field中了 ( 因为不同列的数据通过 offset 实现了隔离 )

对于实值特征, 你可以将它们的 embedding 的 index 放在最前面, 也可以放在最后面, 下面用放最前面来讨论

此时对于两个实值特征的 embedding, 我们需要让它们对应列的 index 分别为 0, 1, 于是就需要处理后面 embedding 的 index, 即 offset 全部加2

即第二列的 index 从 2 开始, 第三列的 index 从 5 ( 2 + 3 ) 开始, 于是数据变成了以下这样

1
2
3
1 3 6 1 2
0 4 5 100 0
1 2 7 -2 -0.5

如果将实值特征的 embedding 放在最后面, 那么就不需要添加相应的 offset

自此数据就处理完毕, 直接输入模型使用即可

模型最后的激活函数使用 sigmoid

划分数据集

打乱后 train : valid : test = 8 : 1 : 1

训练阶段

损失函数使用 logloss ( tf.compat.v1.losses.log_loss ) 进行训练

测试阶段

使用 logloss 和 AUC 作为评测标准

召回

ComiRec

amazon book

生成用户点击数据

下面用 amazon book 的数据进行讨论, 使用的具体文件为 reviews_Books_5.json

原始数据每行长这样

{"reviewerID": "A2WVHIRDMLM82E", "asin": "000100039X", "reviewerName": "Amazon Customer", "helpful": [0, 0], "reviewText": "This book has so much you can take out of it to use in your real life. Amazing, and one of my favorite reads of all time.", "overall": 5.0, "summary": "Amazing", "unixReviewTime": 1394928000, "reviewTime": "03 16, 2014"}

格式化后长这样

1
2
3
4
5
6
7
8
9
10
11
{
"reviewerID": "A2WVHIRDMLM82E",
"asin": "000100039X",
"reviewerName": "Amazon Customer",
"helpful": [0, 0],
"reviewText": "This book has so much you can take out of it to use in your real life. Amazing, and one of my favorite reads of all time.",
"overall": 5.0,
"summary": "Amazing",
"unixReviewTime": 1394928000,
"reviewTime": "03 16, 2014"
}

这里附上官方对各个字段的解释

1
2
3
4
5
6
7
8
9
reviewerID - ID of the reviewer, e.g. A2SUAM1J3GNN3B
asin - ID of the product, e.g. 0000013714
reviewerName - name of the reviewer
helpful - helpfulness rating of the review, e.g. 2/3
reviewText - text of the review
overall - rating of the product
summary - summary of the review
unixReviewTime - time of the review (unix time)
reviewTime - time of the review (raw)

主要用到 reviewerID, asin, unixReviewTime 这三个字段

首先收集所有物品 id , 然后删除所有冷门物品 ( 至少出现过 filter_size 次 ) , 并进行重映射到数字 id

然后收集所有用户 id , 只要那些至少浏览过 ( 去冷门 ) 物品集 filter_size 个的用户 , 并进行重映射到数字 id

将每个用户序列按时间排序后以 <user_id>,<item_id>,<time_stamp> 格式写入文件即可

处理完有五个文件

1
2
3
4
5
book_user_map.txt
book_item_map.txt
book_test.txt
book_train.txt
book_valid.txt

前两个文件保留了原始 id 到新 id 的对应关系, 训练过程中用不到

后三个文件包含 <user_id>,<item_id>,<time_stamp> 格式数据

对于每个用户来说, 可能被分到 train, valid, test中的任意一个

生成物品类别数据

下面依旧用 amazon book 的数据进行讨论, 使用的具体文件为 meta_Books.json 以及上一节生成的 book_item_map.txt

每行数据格式以后长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"asin": "0000031852",
"title": "Girls Ballet Tutu Zebra Hot Pink",
"price": 3.17,
"imUrl": "http://ecx.images-amazon.com/images/I/51fAmVkTbyL._SY300_.jpg",
"related":
{
"also_bought": ["B00JHONN1S", "B002BZX8Z6", "B00D2K1M3O", "0000031909", "B00613WDTQ", "B00D0WDS9A", "B00D0GCI8S", "0000031895", "B003AVKOP2", "B003AVEU6G", "B003IEDM9Q", "B002R0FA24", "B00D23MC6W", "B00D2K0PA0", "B00538F5OK", "B00CEV86I6", "B002R0FABA", "B00D10CLVW", "B003AVNY6I", "B002GZGI4E", "B001T9NUFS", "B002R0F7FE", "B00E1YRI4C", "B008UBQZKU", "B00D103F8U", "B007R2RM8W"],
"also_viewed": ["B002BZX8Z6", "B00JHONN1S", "B008F0SU0Y", "B00D23MC6W", "B00AFDOPDA", "B00E1YRI4C", "B002GZGI4E", "B003AVKOP2", "B00D9C1WBM", "B00CEV8366", "B00CEUX0D8", "B0079ME3KU", "B00CEUWY8K", "B004FOEEHC", "0000031895", "B00BC4GY9Y", "B003XRKA7A", "B00K18LKX2", "B00EM7KAG6", "B00AMQ17JA", "B00D9C32NI", "B002C3Y6WG", "B00JLL4L5Y", "B003AVNY6I", "B008UBQZKU", "B00D0WDS9A", "B00613WDTQ", "B00538F5OK", "B005C4Y4F6", "B004LHZ1NY", "B00CPHX76U", "B00CEUWUZC", "B00IJVASUE", "B00GOR07RE", "B00J2GTM0W", "B00JHNSNSM", "B003IEDM9Q", "B00CYBU84G", "B008VV8NSQ", "B00CYBULSO", "B00I2UHSZA", "B005F50FXC", "B007LCQI3S", "B00DP68AVW", "B009RXWNSI", "B003AVEU6G", "B00HSOJB9M", "B00EHAGZNA", "B0046W9T8C", "B00E79VW6Q", "B00D10CLVW", "B00B0AVO54", "B00E95LC8Q", "B00GOR92SO", "B007ZN5Y56", "B00AL2569W", "B00B608000", "B008F0SMUC", "B00BFXLZ8M"],
"bought_together": ["B002BZX8Z6"]
},
"salesRank": {"Toys & Games": 211836},
"brand": "Coxlures",
"categories": [["Sports & Outdoors", "Other Sports", "Dance"]]
}

生成 asincategories[0][-1] 间的对应关系, 将结果保存到book_item_cate.txt

其中 book_item_cate.txt 包含 <item_id>,<cate_id> 格式数据

划分数据集

所有用户中的 80% 当作 train 用户

所有用户中的 10% 当作 valid 用户

所有用户中的 10% 当作 test 用户

train阶段

以下讨论只针对采样中的某个用户讨论

为讨论方便, 我们假设某个用户按时间顺序访问过的序列为 item_0, item_1, ..., item_n

一次采样会在 k = range(4, len(item_list)) 中采样得到需要预测的物品, 即第 k 个物品是需要预测的物品

同时模型能看到的历史记录长度是有限制的

k < self.maxlen 时, 就需要用 0 进行填充, 考虑到 0 又的确代表某样商品, 于是 0 填充的地方和真实历史物品序列需要分别用一个 mask 数组用 0 和 1 进行遮挡 ( mask 为 1 的下标是模型能看到的, 感觉作者 mask 定义搞反了)

k >= self.maxlen 时, 则只取 k 前面的 maxlen 的物品当作用户历史

具体到代码中

user_id_list 代表用户 id 列表, 数据结构为 list[int]

item_id_list 代表每个用户需要预测的物品 id, 数据结构为 list[int]

hist_item_list 代表每个用户需要预测的物品之前的历史点击物品序列, 数据结构为 list[list[int]]

hist_mask_list 代表 hist_item_list 中的历史点击物品序列是否为真实物品还是 0 填充, 数据结构为 list[list[int]]

每个模型最终会输出一个代表用户兴趣的向量 ( 有些模型有多个用户兴趣向量, 则输出与要预测物品内积最大的向量 ), 然后使用负采样进行训练 ( tf.nn.sampled_softmax_loss ), 希望最大化要预测物品的概率, 目标函数为最小化负似然函数

valid和test阶段

与 train 阶段的不同, k稳定取 int(len(item_list) * 0.8)

  1. item_id_list 不再代表需要预测的物品, 而是代表测试集物品集 (即第 k 后所有物品)

  2. hist_item_list 代表第 k 前的 maxlen 个物品

指标介绍

对于点击率, 需要统计有多少比例用户的 TopN 集合和 item_id_list 有相同的物品

对于召回率, 模型最终会输出 TopN 集合, 只需要比对 TopN 集合和 item_id_list 有多少物品是一样的, 然后将结果除以 item_id_list 的大小, 最后对所有用户取平均

对于 NDCG, 详见公式和公式, 伪代码如下

对每一个用户:
recall = 0
dcg = 0.0
true_item_set = set(iid_list)
for no, iid in enumerate(I[i]):
if iid in true_item_set:
recall += 1
dcg += 1.0 / math.log(no+2, 2) # 加2是因为代码从0开始数, 公式是从1开始数
idcg = 0.0
for no in range(recall):
idcg += 1.0 / math.log(no+2, 2)
total_recall += recall * 1.0 / len(iid_list)
if recall > 0:
total_ndcg += dcg / idcg # recall 为 0 时, dcg, idcg 都是 0
total_hitrate += 1

ndcg = total_ndcg / total # 对所有用户求平均

知识图谱CTR

具体数据是怎么预处理的我不清楚, 但是处理完是什么样的网上有很详细的数据集

KGAT

KGAT Dataset

在上面这个页面中有很多文件, 但是在真正训练过程中只需要使用 3 个文件, 分别是 train.txt, test.txt, kg_final.txt

train.txt, test.txt 每行开头数字为重映射后的用户 id , 后面的数字为用户访问的重映射后的物品 id

kg_final.txt 每行为三元组, 第一个和第三个数字代表重映射后的图谱结点 id, 第二个数字为关系重映射后的 id

划分数据集

TODO

训练过程

TODO

测试过程

TODO

序列预测CTR

DIN

预处理数据集

在官方代码的 utils 文件夹下有三个文件, 依次执行后可以得到包含后续操作所需的数据文件 remap.pkl

remap.pkl 文件中包含了四部分数据, 分别如下

1
2
3
4
reviews_df : 按 reviewerID, unixReviewTime 排序, 用户点击了什么物品在什么时候
cate_list : 商品id -> 商品类别id
user_count: 用户数量, item_count: 商品数量, cate_count: 商品类别数量, example_count: 数据总共多少条
asin_key, cate_key, revi_key, 重映射前原来的值所组成的list

生成训练所需数据集

1
2
3
4
5
为每个用户的每个历史正样本序列生成一个对应的负样本序列, 负样本序列中的样本不得出现在正样本序列中
比如id为10的用户 正样本序列为 [1,2,3,4] 那么假设生成对应的负样本序列 [9,7,8,6]
最后的正训练数据为 (10, [1], 2, 1) (10, [1,2], 3, 1)
负训练数据为 (10, [1], 7, 0) (10, [1,2], 8, 0)
最后的 测试数据为 (10, [1,2,3], (4,6))

训练数据第一个数字代表用户 id, 第二个列表代表采样的历史, 第三个数据代表下一个物品, 第四个数字决定是点击还是未点击 ( label )

测试数据前两个意义与训练数据相同, 第三个元组的第一个为正样本, 第二个为负样本

最后会将数据保存到 dataset.pkl

1
2
3
4
5
with open('dataset.pkl', 'wb') as f:
pickle.dump(train_set, f, pickle.HIGHEST_PROTOCOL) # 包含正训练数据和负训练数据
pickle.dump(test_set, f, pickle.HIGHEST_PROTOCOL)
pickle.dump(cate_list, f, pickle.HIGHEST_PROTOCOL) # 商品id -> 商品类别id
pickle.dump((user_count, item_count, cate_count), f, pickle.HIGHEST_PROTOCOL)

划分数据集

训练数据所采用的是从长度 1 到 len(pos_list) - 2 (含) 的历史数据, 比如上述例子中, 用户历史数据长度为 4, 那么训练数据长度为从 1 到 2 (4-2)

历史数据的最后一个物品永远被当作测试集

训练阶段

对数据进行 padding 确保长度一致

计算训练样本的点击率, 损失函数使用 logloss

因为没有验证集, 所以通过计算测试集上的指标来保存最好的模型

测试阶段

对数据进行 padding 确保长度一致

有两个指标, 一个是 gauc, 另一个是 auc

论文和代码中只用到 gauc, auc在代码中只是程序输出看一下而已

AUC 计算过程

对每个用户来说, 有一个正测试样本和负测试样本, 比如上面例子中 10 号用户的正样本为 4, 负样本为 6

于是让模型分别计算正负样本的点击概率, 然后用所有用户的 predictions 和 labels 计算总 AUC 即可

GAUC 计算过程

先介绍一下 GAUC

推荐模型目前比较成熟的模式是训练分类模型, 这个分类模型的任务是预测用户是否会点击给定的商品, 因此, 推荐系统的核心, 仍然是一个二分类问题, 但是是更细力度的二分类

传统的AUC有时表现不好, AUC 反映的是整体样本间的一个排序能力, 而在计算广告领域, 我们实际要衡量的是不同用户对不同广告之间的排序能力, 因此实际应该更关注的是同一个用户对不同广告间的排序能力

GAUC(group auc)实际是计算每个用户的 AUC, 然后加权平均, 最后得到 group auc, 这样就能减少不同用户间的排序结果不太好比较这一影响

因为每个用户的测试数据只有一条, 所以用 Wilcoxon-Mann-Whitney U-statistic 来代替 AUC 计算

举例来说, 比如 100 个测试用户数据输入模型, 模型预测其中 72 个用户的正样本点击概率大于负样本点击概率, 于是此时 GAUC = 72 / 100 = 0.72

注: 虽然在 DIN 的论文中对每个用户有一个 impression 的权重, 但是在实际的代码里每个用户权重都是 1