什么TM的是召回
先来看推荐系统架构图
对于多路召回
召回层是对原始的,大规模的数据,通过简单的特征与模型进行粗排,降低数据的规模而不失主要数据信息,返回待排序的候选集。多路召回每条路径可以采用不同特征与算法模型,路径之间是独立的。召回层是实际工程的产物,在实际生产中也会有相适应算法模型。最后在导入排序层时,可以分配不同的权重进行路径融合。
本新闻推荐数据总共有25万行之多,对于建立召回层是有必要的。比赛中会涉及到三种调试模式:
- debug模式:从训练集中抽取一部分数据进行调试,最低保证模型能够跑的通(baseline)
- 线下模式:加载整个训练集进行训练
- 线上模式:加载整个数据集(训练集+测试集),得出最后的提交结果
召回层的内容包括:
- 不同模式的数据加载
- 各召回路径数据的加载
- 各召回路径算法的设计
- 召回路径的融合
debug模式的数据加载
#debug模式,获取训练数据
def get_train_sample(path, samples=10000):
#获取不重复的user_id
all_click = pd.read_csv(data_path + 'testA_click_log.csv')
all_user_ids = all_click.user_id.unique()
#可能会选到同样的
sample_user_ids = np.random.choice(all_user_ids, size=sample_nums, replace=False)
all_click = all_click[all_click['user_id'].isin(sample_user_ids)]
#按行去重
all_click = all_click.drop_duplicates((['user_id', 'click_article_id', 'click_timestamp']))
return all_click
#获取文章属性数据
def get_item_info(path):
item_info = pd.read_csv(path + 'articles.csv')
item_info = item_info.rename(columns={'article_id': 'click_article_id'})
return item_info
#读取文章embeding内容
def get_item_emb_dict(path):
item_emb = pd.read_csv(path + 'articles_emb.csv')
#因为文章id属性
item_emb_cols = [x for x in item_emb.columns if 'emb' in x]
# 进行归一化,值除以二范
# axis=1 :按行求二范
# keepdims:是否保持原来数据的维度
item_emb_np = item_emb[item_emb_cols].values
item_emb_np = item_emb_np / np.linalg.norm(item_emb_np, axis=1, keepdims=True)
#压缩可迭代数据为字典
item_emb_dict = dict(zip(item_emb_df['article_id'], item_emb_np))
#保存可迭代对象到本地,读取用pickle.load()
pickle.dump(item_emb_dict, open(path + 'item_content_emb.pkl', 'wb'))
return item_emb_dict
召回策略罗列
先罗列召回策略的目的一是召回方法的原理,二是寻找所需策略的数据需求是什么样子。
本文章涉及的召回策略:
- Youtube DNN 召回
- 文章的协同过滤
- 用户的协同过滤
Youtube DNN 召回层重点:
重读Youtube深度学习推荐系统论文,字字珠玑,惊为神文
词向量word2vec模型解读
- 用word2vec方法对视频做了embeding处理
- 特征工程后新加入的特征与视频embeding拼接作为输入,并要求每位用户的输入特征长度相同
- 通过RELU神经网路训练
- 转化成了分类问题,通过负采样加快计算,输入softmax预测类别
文章的协同过滤重点:
- 给用户召回历史文章相似的文章
- 召回相似文章需要关联以下规则:
- 考虑相似文章与历史点击文章顺序的权重
- 考虑相似文章与历史点击文章创建时间差的权重
- 文章内容相似度权重(相似的文章与历史点击文章不存在相似度)
用户的协同过滤重点:
- 给用户推荐与其相似的用户历史点击文章
- 召回相似文章需要关联以下规则:
- 计算被推荐用户历史点击文章与相似用户历史点击文章的相似度
- 计算被推荐用户历史点击文章与相似用户历史点击文章创建时间差
- 计算被推荐用户历史点击文章与相似用户历史点击相对位置的总和
根据以上的需求与规则,大概知道了需要什么样的数据格式,下面是数据格式的准备。
各召回路径的数据准备
- 想要相似文章就需要相似物品矩阵
#据点击时间获取用户的点击文章序列 {user1: [(item1, time1), (item2, time2)..]...}
#历史物品之间是不计算相似度的,即在user1这里,item1与item2不计算相似度
'''
假如循环到了user1的item1,则计算item1与下一个用户的其它物品的相似度,计算相似度需要考虑到上方陈述的3方面的权重,即(2.1,2.2,2.3)
'''
def get_user_item_time(click_df):
click_df = click_df.sort_values('click_timestamp')
def make_item_time_pair(df):
return list(zip(df['click_article_id'], df['click_timestamp']))
user_item_time_df = click_df.groupby('user_id')['click_article_id', 'click_timestamp'].apply(lambda x: make_item_time_pair(x))\
.reset_index().rename(columns={0: 'item_time_list'})
user_item_time_dict = dict(zip(user_item_time_df['user_id'], user_item_time_df['item_time_list']))
return user_item_time_dict
#文章创建时间
def item_created_time(df):
item_created_time_dict = dict(zip(df["click_article_id"] , df["created_at_ts"]))
return item_created_time_dict
#难点
def itemcf_sim(df, item_created_time_dict):
"""
文章与文章之间的相似性矩阵计算
:param df: 数据表
:item_created_time_dict: 文章创建时间的字典
return : 文章与文章的相似性矩阵
"""
user_item_time_dict = get_user_item_time(df)
# 计算物品相似度
i2i_sim = {}
item_cnt = defaultdict(int)
for user,(item_time_list) in user_item_time_dict.items():
#{user1: [(item1, time1), (item2, time2)..]
#忽略user_id,获得后面的列表
for loc1 , (i , i_click_time) in enumerate(item_time_list):
# loc1 是item所在的索引就是列表中的位置,列表中item按照用户点击顺序排列
item_cnt[i] += 1
i2i_sim.setdefault(i, {}) #原字典中没有i则加入并设置初始值{},没有则返回原值
for loc2 , (j , j_click_time) in enumerate(item_time_list):
#相同物品不计算相似度
if i == j:
continue
#一堆堆计算权重,不知道怎么来的也没关系
# 考虑文章的正向顺序点击和反向顺序点击
loc_alpha = 1.0 if loc2 > loc1 else 0.7
# 位置信息权重,
loc_weight = loc_alpha * (0.9 ** (np.abs(loc2 - loc1) - 1))
# 点击时间权重
click_time_weight = np.exp(0.7 ** np.abs(i_click_time - j_click_time))
# 两篇文章创建时间的权重
created_time_weight = np.exp(0.8 ** np.abs(item_created_time_dict[i] - item_created_time_dict[j]))
i2i_sim[i].setdefault(j, 0)
# 考虑多种因素的权重计算最终的文章之间的相似度
i2i_sim[i][j] += loc_weight * click_time_weight * created_time_weight / math.log(len(item_time_list) + 1)
i2i_sim_ = i2i_sim.copy()
for i, related_items in i2i_sim.items():
for j, wij in related_items.items():
i2i_sim_[i][j] = wij / math.sqrt(item_cnt[i] * item_cnt[j])
# 将得到的相似性矩阵保存到本地
pickle.dump(i2i_sim_, open(path + 'itemcf_i2i_sim.pkl', 'wb'))
return i2i_sim_
'''
最后i2i_sim_的结果格式如下
{195839: {191971: 0.17002324109946493,
194300: 0.14028226677401606,
166581: 0.004738731071607025,
272143: 0.0030777894292628007,
285298: 0.014267230873702937,
39857: 0.03787405668573889,
194717: 0.0762200687996933,
233717: 0.010576962590031045,
36399: 0.00891114964092883,
195868: 0.0353609868198151,
...
}
'''
- 按照物品相似矩阵的逻辑计算用户相似矩阵
#逻辑相同,权重计算有所不同
#根据时间获取商品被点击的用户序列 {item1: [(user1, time1), (user2, time2)...]...}
def get_item_user_time_dict(click_df):
def make_user_time_pair(df):
return list(zip(df['user_id'], df['click_timestamp']))
click_df = click_df.sort_values('click_timestamp')
item_user_time_df = click_df.groupby('click_article_id')['user_id', 'click_timestamp'].apply(lambda x: make_user_time_pair(x))\
.reset_index().rename(columns={0: 'user_time_list'})
item_user_time_dict = dict(zip(item_user_time_df['click_article_id'], item_user_time_df['user_time_list']))
return item_user_time_dict
#计算用户的活跃度,一种权重的计算
def get_user_activate_degree_dict(all_click_df):
all_click_df_ = all_click_df.groupby('user_id')['click_article_id'].count().reset_index()
# 用户活跃度归一化
mm = MinMaxScaler()
all_click_df_['click_article_id'] = mm.fit_transform(all_click_df_[['click_article_id']])
user_activate_degree_dict = dict(zip(all_click_df_['user_id'], all_click_df_['click_article_id']))
return user_activate_degree_dict
#逻辑和上面是相同的
def usercf_sim(click_df, user_activate_degree_dict):
"""
用户相似性矩阵计算
:param all_click_df: 数据表
:param user_activate_degree_dict: 用户活跃度的字典
return 用户相似性矩阵
"""
item_user_time_dict = get_item_user_time_dict(click_df)
u2u_sim = {}
user_cnt = defaultdict(int)
for item, user_time_list in item_user_time_dict.items():
for u, click_time in user_time_list:
user_cnt[u] += 1
u2u_sim.setdefault(u, {})
for v, click_time in user_time_list:
u2u_sim[u].setdefault(v, 0)
if u == v:
continue
# 用户平均活跃度作为活跃度的权重
activate_weight = 100 * 0.5 * (user_activate_degree_dict[u] + user_activate_degree_dict[v])
u2u_sim[u][v] += activate_weight / math.log(len(user_time_list) + 1)
u2u_sim_ = u2u_sim.copy()
for u, related_users in u2u_sim.items():
for v, wij in related_users.items():
u2u_sim_[u][v] = wij / math.sqrt(user_cnt[u] * user_cnt[v])
# 将得到的相似性矩阵保存到本地
pickle.dump(u2u_sim_, open(path + 'usercf_u2u_sim.pkl', 'wb'))
return u2u_sim_
# %%time = 54.1 s
'''
最后结果u2u_sim_格式:
{203172: {203172: 0.0,
238503: 0.14830159613688895,
230262: 0.43069329645723503,
228301: 0.1689511714607187,
242286: 0.1329408251817319,
245198: 0.15484630639404126,
227057: 0.41426036891152124,
238944: 0.07430792178193243,
241560: 0.08650412384989443,
228013: 0.08088853325519581,
,,,
}
'''
- YoutubeDNN召回所需要的数据
要求每个用户的数据长度是一样的,将每个用户的数据维度转化为1*N维即一行。
'''
构造YoutubeDNN输入数据
通过gen_data_set函数筛选数据特征,构造正负样本,输出训练测试集
通过gen_model_input函数将数据维度调整一致,输出模型的输入训练集与训练集标签
'''
def gen_data_set(data, negsample=2):
data.sort_values("click_timestamp", inplace=True)
#获取所有点击文章的id
item_ids = data['click_article_id'].unique()
train_set = [] #训练集
test_set = [] #测试集
#每次遍历用户id和该用户的行动
for reviewerID, hist in data.groupby('user_id'):
#用户过点击文章列表
pos_list = hist['click_article_id'].tolist()
#负样本
if negsample > 0:
# 用户没看过的文章里面选择负样本
candidate_set = list(set(item_ids) - set(pos_list))
# 对于每个正样本,选择n个负样本
neg_list = np.random.choice(candidate_set,size=len(pos_list)*negsample,replace=True)
# 长度只有一个的时候,需要把这条数据也放到训练集中,不然的话最终学到的embedding就会有缺失
if len(pos_list) == 1:
train_set.append((reviewerID, [pos_list[0]], pos_list[0],1,len(pos_list)))
test_set.append((reviewerID, [pos_list[0]], pos_list[0],1,len(pos_list)))
# 滑窗构造正负样本,补充训练样本
for i in range(1, len(pos_list)):
hist = pos_list[:i]
if i != len(pos_list) - 1:
# 正样本 [user_id, his_item, pos_item, label, len(his_item)]
# 说明[用户id,历史点击文章,最后一次点击的文章,标签1或者0,历史点击文章数量]
train_set.append((reviewerID, hist[::-1], pos_list[i], 1, len(hist[::-1])))
for negi in range(negsample):
# 负样本 [user_id, his_item, neg_item, label, len(his_item)]
train_set.append((reviewerID, hist[::-1], neg_list[i*negsample+negi], 0,len(hist[::-1])))
else:
# 将最长的那一个序列长度作为测试数据
test_set.append((reviewerID, hist[::-1], pos_list[i],1,len(hist[::-1])))
# 打乱顺序
random.shuffle(train_set)
random.shuffle(test_set)
return train_set, test_set
#获取模型输入
def gen_model_input(train_set,user_profile,seq_max_len):
train_uid = np.array([line[0] for line in train_set])
train_seq = [line[1] for line in train_set]
train_iid = np.array([line[2] for line in train_set])
train_label = np.array([line[3] for line in train_set])
train_hist_len = np.array([line[4] for line in train_set])
#填充数据,将数据维度拉成一致
#没有加入label
train_seq_pad = pad_sequences(train_seq, maxlen=seq_max_len, padding='post', truncating='post', value=0)
train_model_input = {"user_id": train_uid, "click_article_id": train_iid, "hist_article_id": train_seq_pad,
"hist_len": train_hist_len}
return train_model_input, train_label
难点
#
def youtubednn_u2i_dict(data, topk=20):
sparse_features = ["click_article_id", "user_id"]
SEQ_LEN = 30 # 用户点击序列的长度,短的填充,长的截断
user_profile_ = data[["user_id"]].drop_duplicates('user_id')
item_profile_ = data[["click_article_id"]].drop_duplicates('click_article_id')
# 类别编码
features = ["click_article_id", "user_id"]
feature_max_idx = {}
for feature in features:
lbe = LabelEncoder()
data[feature] = lbe.fit_transform(data[feature])
feature_max_idx[feature] = data[feature].max() + 1
# 提取user和item的画像,这里具体选择哪些特征还需要进一步的分析和考虑
user_profile = data[["user_id"]].drop_duplicates('user_id')
item_profile = data[["click_article_id"]].drop_duplicates('click_article_id')
user_index_2_rawid = dict(zip(user_profile['user_id'], user_profile_['user_id']))
item_index_2_rawid = dict(zip(item_profile['click_article_id'], item_profile_['click_article_id']))
# 划分训练和测试集
# 由于深度学习需要的数据量通常都是非常大的,所以为了保证召回的效果,往往会通过滑窗的形式扩充训练样本
train_set, test_set = gen_data_set(data, 0)
# 整理输入数据,具体的操作可以看上面的函数
train_model_input, train_label = gen_model_input(train_set, user_profile, SEQ_LEN)
test_model_input, test_label = gen_model_input(test_set, user_profile, SEQ_LEN)
# 确定Embedding的维度
embedding_dim = 16
# 将数据整理成模型可以直接输入的形式
user_feature_columns = [SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim),
VarLenSparseFeat(SparseFeat('hist_article_id', feature_max_idx['click_article_id'], embedding_dim,
embedding_name="click_article_id"), SEQ_LEN, 'mean', 'hist_len'),]
item_feature_columns = [SparseFeat('click_article_id', feature_max_idx['click_article_id'], embedding_dim)]
# 模型的定义
# num_sampled: 负采样时的样本数量
# YoutubeDNN来自于deepmatch
model = YoutubeDNN(user_feature_columns, item_feature_columns, num_sampled=5, user_dnn_hidden_units=(64, embedding_dim))
# 模型编译
model.compile(optimizer="adam", loss=sampledsoftmaxloss)
# 模型训练,这里可以定义验证集的比例,如果设置为0的话就是全量数据直接进行训练
history = model.fit(train_model_input, train_label, batch_size=256, epochs=1, verbose=1, validation_split=0.0)
# 训练完模型之后,提取训练的Embedding,包括user端和item端
test_user_model_input = test_model_input
all_item_model_input = {"click_article_id": item_profile['click_article_id'].values}
user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
item_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
# 保存当前的item_embedding 和 user_embedding 排序的时候可能能够用到,但是需要注意保存的时候需要和原始的id对应
user_embs = user_embedding_model.predict(test_user_model_input, batch_size=2 ** 12)
item_embs = item_embedding_model.predict(all_item_model_input, batch_size=2 ** 12)
# embedding保存之前归一化一下
user_embs = user_embs / np.linalg.norm(user_embs, axis=1, keepdims=True)
item_embs = item_embs / np.linalg.norm(item_embs, axis=1, keepdims=True)
# 将Embedding转换成字典的形式方便查询
raw_user_id_emb_dict = {user_index_2_rawid[k]: \
v for k, v in zip(user_profile['user_id'], user_embs)}
raw_item_id_emb_dict = {item_index_2_rawid[k]: \
v for k, v in zip(item_profile['click_article_id'], item_embs)}
# 将Embedding保存到本地
pickle.dump(raw_user_id_emb_dict, open(path + 'user_youtube_emb.pkl', 'wb'))
pickle.dump(raw_item_id_emb_dict, open(path + 'item_youtube_emb.pkl', 'wb'))
# faiss紧邻搜索,通过user_embedding 搜索与其相似性最高的topk个item
index = faiss.IndexFlatIP(embedding_dim)
# 上面已经进行了归一化,这里可以不进行归一化了
# faiss.normalize_L2(user_embs)
# faiss.normalize_L2(item_embs)
index.add(item_embs) # 将item向量构建索引
sim, idx = index.search(np.ascontiguousarray(user_embs), topk) # 通过user去查询最相似的topk个item
user_recall_items_dict = collections.defaultdict(dict)
for target_idx, sim_value_list, rele_idx_list in tqdm(zip(test_user_model_input['user_id'], sim, idx)):
target_raw_id = user_index_2_rawid[target_idx]
# 从1开始是为了去掉商品本身, 所以最终获得的相似商品只有topk-1
for rele_idx, sim_value in zip(rele_idx_list[1:], sim_value_list[1:]):
rele_raw_id = item_index_2_rawid[rele_idx]
user_recall_items_dict[target_raw_id][rele_raw_id] = user_recall_items_dict.get(target_raw_id, {})\
.get(rele_raw_id, 0) + sim_value
user_recall_items_dict = {k: sorted(v.items(), key=lambda x: x[1], reverse=True) for k, v in user_recall_items_dict.items()}
# 将召回的结果进行排序
# 保存召回的结果
# 这里是直接通过向量的方式得到了召回结果,相比于上面的召回方法,上面的只是得到了i2i及u2u的相似性矩阵,还需要进行协同过滤召回才能得到召回结果
# 可以直接对这个召回结果进行评估,为了方便可以统一写一个评估函数对所有的召回结果进行评估
pickle.dump(user_recall_items_dict, open(path + 'youtube_u2i_dict.pkl', 'wb'))
return user_recall_items_dict
各路径召回
三种召回方式,需要评定召回效果,所以添加召回函数metrics_recall
# 依次评估召回的前10, 20, 30, 40, 50个文章中的击中率
def metrics_recall(user_recall_items_dict, trn_last_click_df, topk=5):
last_click_item_dict = dict(zip(trn_last_click_df['user_id'], trn_last_click_df['click_article_id']))
user_num = len(user_recall_items_dict)
for k in range(10, topk+1, 10):
hit_num = 0
for user, item_list in user_recall_items_dict.items():
# 获取前k个召回的结果
tmp_recall_items = [x[0] for x in user_recall_items_dict[user][:k]]
if last_click_item_dict[user] in set(tmp_recall_items):
hit_num += 1
hit_rate = round(hit_num * 1.0 / user_num, 5)
print(' topk: ', k, ' : ', 'hit_num: ', hit_num, 'hit_rate: ', hit_rate, 'user_num : ', user_num)
# 获取当前数据的历史点击和最后一次点击
def get_hist_and_last_click(all_click):
all_click = all_click.sort_values(by=['user_id', 'click_timestamp'])
click_last_df = all_click.groupby('user_id').tail(1)
# 如果用户只有一个点击,hist为空了,会导致训练的时候这个用户不可见,此时默认泄露一下
def hist_func(user_df):
if len(user_df) == 1:
return user_df
else:
return user_df[:-1]
click_hist_df = all_click.groupby('user_id').apply(hist_func).reset_index(drop=True)
return click_hist_df, click_last_df
#获取热门商品
def get_item_topk_click(click_df, k):
topk_click = click_df['click_article_id'].value_counts().index[:k]
return topk_click
#Youtube DNN召回
trn_hist_click_df, trn_last_click_df = get_hist_and_last_click(all_click_df)
user_multi_recall_dict['youtubednn_recall'] = youtubednn_u2i_dict(trn_hist_click_df, topk=20)
# 召回效果评估
metrics_recall(user_multi_recall_dict['youtubednn_recall'], trn_last_click_df, topk=20)
#itemCF召回
def item_based_recommend(user_id, user_item_time_dict, i2i_sim, sim_item_topk, recall_item_num, item_topk_click, item_created_time_dict, emb_i2i_sim):
"""
基于文章协同过滤的召回
:param user_id: 用户id
:param user_item_time_dict: 字典, 根据点击时间获取用户的点击文章序列 {user1: [(item1, time1), (item2, time2)..]...}
:param i2i_sim: 字典,文章相似性矩阵
:param sim_item_topk: 整数, 选择与当前文章最相似的前k篇文章
:param recall_item_num: 整数, 最后的召回文章数量
:param item_topk_click: 列表,点击次数最多的文章列表,用户召回补全
:param emb_i2i_sim: 字典基于内容embedding算的文章相似矩阵
return: 召回的文章列表 [(item1, score1), (item2, score2)...]
"""
# 获取用户历史交互的文章
user_hist_items = user_item_time_dict[user_id]
user_hist_items_ = {user_id for user_id, _ in user_hist_items}
item_rank = {}
for loc, (i, click_time) in enumerate(user_hist_items):
for j, wij in sorted(i2i_sim[i].items(), key=lambda x: x[1], reverse=True)[:sim_item_topk]:
if j in user_hist_items_:
continue
# 文章创建时间差权重
created_time_weight = np.exp(0.8 ** np.abs(item_created_time_dict[i] - item_created_time_dict[j]))
# 相似文章和历史点击文章序列中历史文章所在的位置权重
loc_weight = (0.9 ** (len(user_hist_items) - loc))
content_weight = 1.0
if emb_i2i_sim.get(i, {}).get(j, None) is not None:
content_weight += emb_i2i_sim[i][j]
if emb_i2i_sim.get(j, {}).get(i, None) is not None:
content_weight += emb_i2i_sim[j][i]
item_rank.setdefault(j, 0)
item_rank[j] += created_time_weight * loc_weight * content_weight * wij
# 不足10个,用热门商品补全
if len(item_rank) < recall_item_num:
for i, item in enumerate(item_topk_click):
if item in item_rank.items(): # 填充的item应该不在原来的列表中
continue
item_rank[item] = - i - 100 # 随便给个负数就行
if len(item_rank) == recall_item_num:
break
item_rank = sorted(item_rank.items(), key=lambda x: x[1], reverse=True)[:recall_item_num]
return item_rank
user_recall_items_dict = collections.defaultdict(dict)
user_item_time_dict = get_user_item_time(trn_hist_click_df)
i2i_sim = pickle.load(open(path + 'emb_i2i_sim.pkl','rb'))
sim_item_topk = 20
recall_item_num = 10
item_topk_click = get_item_topk_click(trn_hist_click_df, k=50)
for user in trn_hist_click_df['user_id'].unique():
user_recall_items_dict[user] = item_based_recommend(user, user_item_time_dict, i2i_sim, sim_item_topk,
recall_item_num, item_topk_click, item_created_time_dict, emb_i2i_sim)
user_multi_recall_dict['embedding_sim_item_recall'] = user_recall_items_dict
pickle.dump(user_multi_recall_dict['embedding_sim_item_recall'], open(path + 'embedding_sim_item_recall.pkl', 'wb'))
#召回评估
metrics_recall(user_multi_recall_dict['embedding_sim_item_recall'], trn_last_click_df, topk=recall_item_num)
#UserCF
def user_based_recommend(user_id, user_item_time_dict, u2u_sim, sim_user_topk, recall_item_num,
item_topk_click, item_created_time_dict, emb_i2i_sim):
"""
基于文章协同过滤的召回
:param user_id: 用户id
:param user_item_time_dict: 字典, 根据点击时间获取用户的点击文章序列 {user1: [(item1, time1), (item2, time2)..]...}
:param u2u_sim: 字典,文章相似性矩阵
:param sim_user_topk: 整数, 选择与当前用户最相似的前k个用户
:param recall_item_num: 整数, 最后的召回文章数量
:param item_topk_click: 列表,点击次数最多的文章列表,用户召回补全
:param item_created_time_dict: 文章创建时间列表
:param emb_i2i_sim: 字典基于内容embedding算的文章相似矩阵
return: 召回的文章列表 [(item1, score1), (item2, score2)...]
"""
# 历史交互
user_item_time_list = user_item_time_dict[user_id] # [(item1, time1), (item2, time2)..]
user_hist_items = set([i for i, t in user_item_time_list]) # 存在一个用户与某篇文章的多次交互, 这里得去重
items_rank = {}
for sim_u, wuv in sorted(u2u_sim[user_id].items(), key=lambda x: x[1], reverse=True)[:sim_user_topk]:
for i, click_time in user_item_time_dict[sim_u]:
if i in user_hist_items:
continue
items_rank.setdefault(i, 0)
loc_weight = 1.0
content_weight = 1.0
created_time_weight = 1.0
# 当前文章与该用户看的历史文章进行一个权重交互
for loc, (j, click_time) in enumerate(user_item_time_list):
# 点击时的相对位置权重
loc_weight += 0.9 ** (len(user_item_time_list) - loc)
# 内容相似性权重
if emb_i2i_sim.get(i, {}).get(j, None) is not None:
content_weight += emb_i2i_sim[i][j]
if emb_i2i_sim.get(j, {}).get(i, None) is not None:
content_weight += emb_i2i_sim[j][i]
# 创建时间差权重
created_time_weight += np.exp(0.8 * np.abs(item_created_time_dict[i] - item_created_time_dict[j]))
items_rank[i] += loc_weight * content_weight * created_time_weight * wuv
# 热度补全
if len(items_rank) < recall_item_num:
for i, item in enumerate(item_topk_click):
if item in items_rank.items(): # 填充的item应该不在原来的列表中
continue
items_rank[item] = - i - 100 # 随便给个复数就行
if len(items_rank) == recall_item_num:
break
items_rank = sorted(items_rank.items(), key=lambda x: x[1], reverse=True)[:recall_item_num]
return items_rank
user_recall_items_dict = collections.defaultdict(dict)
user_item_time_dict = get_user_item_time(trn_hist_click_df)
u2u_sim = pickle.load(open(path + 'usercf_u2u_sim.pkl', 'rb'))
sim_user_topk = 20
recall_item_num = 10
item_topk_click = get_item_topk_click(trn_hist_click_df, k=50)
for user in tqdm(trn_hist_click_df['user_id'].unique()):
user_recall_items_dict[user] = user_based_recommend(user, user_item_time_dict, u2u_sim, sim_user_topk, \
recall_item_num, item_topk_click, item_created_time_dict, emb_i2i_sim)
pickle.dump(user_recall_items_dict, open(path + 'usercf_u2u2i_recall.pkl', 'wb'))
#召回评估
metrics_recall(user_recall_items_dict, trn_last_click_df, topk=recall_item_num)
召回合并
def combine_recall_results(user_multi_recall_dict, weight_dict=None, topk=25):
final_recall_items_dict = {}
# 对每一种召回结果按照用户进行归一化,方便后面多种召回结果,相同用户的物品之间权重相加
def norm_user_recall_items_sim(sorted_item_list):
# 如果冷启动中没有文章或者只有一篇文章,直接返回,出现这种情况的原因可能是冷启动召回的文章数量太少了,
# 基于规则筛选之后就没有文章了, 这里还可以做一些其他的策略性的筛选
if len(sorted_item_list) < 2:
return sorted_item_list
min_sim = sorted_item_list[-1][1]
max_sim = sorted_item_list[0][1]
norm_sorted_item_list = []
for item, score in sorted_item_list:
if max_sim > 0:
norm_score = 1.0 * (score - min_sim) / (max_sim - min_sim) if max_sim > min_sim else 1.0
else:
norm_score = 0.0
norm_sorted_item_list.append((item, norm_score))
return norm_sorted_item_list
print('多路召回合并...')
for method, user_recall_items in tqdm(user_multi_recall_dict.items()):
print(method + '...')
# 在计算最终召回结果的时候,也可以为每一种召回结果设置一个权重
if weight_dict == None:
recall_method_weight = 1
else:
recall_method_weight = weight_dict[method]
for user_id, sorted_item_list in user_recall_items.items(): # 进行归一化
user_recall_items[user_id] = norm_user_recall_items_sim(sorted_item_list)
for user_id, sorted_item_list in user_recall_items.items():
# print('user_id')
final_recall_items_dict.setdefault(user_id, {})
for item, score in sorted_item_list:
final_recall_items_dict[user_id].setdefault(item, 0)
final_recall_items_dict[user_id][item] += recall_method_weight * score
final_recall_items_dict_rank = {}
# 多路召回时也可以控制最终的召回数量
for user, recall_item_dict in final_recall_items_dict.items():
final_recall_items_dict_rank[user] = sorted(recall_item_dict.items(), key=lambda x: x[1], reverse=True)[:topk]
# 将多路召回后的最终结果字典保存到本地
pickle.dump(final_recall_items_dict_rank, open(os.path.join(path, 'final_recall_items_dict.pkl'),'wb'))
return final_recall_items_dict_rank
参考资料:
天池零基础入门推荐系统