赛题
新闻推荐比赛链接
赛题任务
赛题以预测用户未来点击新闻文章为任务,数据集报名后可见并可下载,该数据来自某新闻APP平台的用户交互数据,包括30万用户,近300万次点击,共36万多篇不同的新闻文章,同时每篇新闻文章有对应的embedding向量表示。为了保证比赛的公平性,将会从中抽取20万用户的点击日志数据作为训练集,5万用户的点击日志数据作为测试集A,5万用户的点击日志数据作为测试集B。
数据表
train_click_log.csv
:训练集用户点击日志
testA_click_log.csv
:测试集用户点击日志
articles.csv
:新闻文章信息数据表
articles_emb.csv
:新闻文章embedding向量表示
sample_submit.csv
:提交样例文件
字段表
user_id
用户id
click_article_id
点击文章id
click_timestamp
点击时间戳
click_environment
点击环境
click_deviceGroup
点击设备组
click_os
点击操作系统
click_country
点击城市
click_region
点击地区
click_referrer_type
点击来源类型
article_id
文章id,与click_article_id相对应
category_id
文章类型id
created_at_ts
文章创建时间戳
words_count
文章字数
emb_1,emb_2,…,emb_249
文章embedding向量表示
赛题理解
此次比赛是新闻推荐场景下的用户行为预测挑战赛,该赛题是以新闻APP中的新闻推荐为背景,目的是要求我们根据用户历史浏览点击新闻文章的数据信息预测用户未来的点击行为, 即用户的最后一次点击的新闻文章 。
明确此次比赛的目标: 根据用户历史浏览点击新闻的数据信息预测用户最后一次点击的新闻文章。从这个目标上看,此次比赛和普通的结构化比赛不太一样, 主要有两点:
首先是目标上,要预测最后一次点击的新闻文章,也就是我们给用户推荐的是新闻文章, 并不是像之前那种预测一个数或者预测数据哪一类那样的问题。
数据上,通过给出的数据我们会发现,这种数据也不是特征+标签的数据,而是基于了真实的业务场景,拿到的用户的点击日志。
所以思考方向就是结合目标,把该预测问题转成一个监督学习的问题(特征+标签),然后才能进行ML,DL等建模预测。转成一个什么样的监督学习问题呢? 由于我们是预测用户最后一次点击的新闻文章,从36万篇文章中预测某一篇的话,首先可能会想到这可能是一个多分类的问题(36万类里面选1), 但是如此庞大的分类问题做起来可能比较困难,那么能不能转化一下? 既然是要预测最后一次点击的文章, 那么如果能预测出某个用户最后一次对于某一篇文章会进行点击的概率, 是不是就间接性的解决了这个问题呢,概率最大的那篇文章不就是用户最后一次可能点击的新闻文章吗,这样就把原问题变成了一个点击率预测的问题(用户, 文章) --> 点击的概率(软分类)。
(接下来的问题:哪些模型可以尝试呢?能利用的特征又有哪些呢? 面对36万篇文章,20多万用户的推荐,又有哪些策略来缩减问题的规模?如何进行最后的预测?)
评分方式及理解
最后提交的格式是针对每个用户给出五篇文章的推荐结果,按照点击概率从前往后排序。 而每个用户最后一次点击的文章只会有一篇的真实答案, 所以就看提交结果中推荐的这五篇里面是否有命中真实答案的。比如对于user1来说,提交格式为:
user1, article1, article2, article3, article4, article5
评价指标的公式如下: \(score(user) = \sum_{k=1}^5 \frac{s(user,k)}{k}\)
假如article1就是真实的用户点击文章,也就是article1命中, 则s(user1,1)=1, s(user1,2-4)都是0, 如果article2是用户点击的文章, 则s(user,2)=1/2,s(user,1,3,4,5)都是0。也就是score(user)=命中第几条的倒数。如果都没中, 则score(user1)=0。命中的结果越靠前分数越高。
Baseline
下面为在jupyter notebook上写的baseline代码。baseline采用基于Item的协同过滤的方法。 > 有关协同过滤在我的另一篇博客里有介绍。博客
导入包
1 2 3 4 5 6 7 8 9 10 11 12 13 import time, math, os from tqdm import tqdm import gc import pickle import randomfrom datetime import datetimefrom operator import itemgetter import numpy as npimport pandas as pdimport warningsfrom collections import defaultdictimport collectionswarnings.filterwarnings('ignore' )
1 2 3 data_path = './raw_data/' save_path = './results/'
节省内存函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 def reduce_mem (df ): start_time = time.time() numerics = ['int16' , 'int32' , 'int64' , 'float16' , 'float32' , 'float64' ] start_mem = df.memory_usage().sum () / 1024 **2 for col in df.columns: col_type = df[col].dtypes if col_type in numerics: c_min = df[col].min () c_max = df[col].max () if pd.isnull(c_min) or pd.isnull(c_max): continue if str (col_type)[:3 ] == 'int' : if c_min > np.iinfo(np.int8).min and c_max < np.iinfo(np.int8).max : df[col] = df[col].astype(np.int8) elif c_min > np.iinfo(np.int16).min and c_max < np.iinfo(np.int16).max : df[col] = df[col].astype(np.int16) elif c_min > np.iinfo(np.int32).min and c_max < np.iinfo(np.int32).max : df[col] = df[col].astype(np.int32) elif c_min > np.iinfo(np.int64).min and c_max < np.iinfo(np.int64).max : df[col] = df[col].astype(np.int64) else : if c_min > np.iinfo(np.float16).min and c_max < np.iinfo(np.float16).max : df[col] = df[col].astype(np.float16) elif c_min > np.iinfo(np.float32).min and c_max < np.iinfo(np.float32).max : df[col] = df[col].astype(np.float32) else : df[col] = df[col].astype(np.float64) end_mem = df.memory_usage().sum () / 1024 **2 print("-- Mem. usage decreased to {:5.2f} Mb ({:.1f}% reduction),time spend:{:2.2f} min" .format ( end_mem, 100 *(start_mem-end_mem)/start_mem, (time.time()-start_time)/60 )) return df
读取数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def get_all_click_df (data_path, offline=True ): if offline: all_click = pd.read_csv(data_path + 'train_click_log.csv' ) else : train_click = pd.read_csv(data_path + 'train_click_log.csv' ) test_click = pd.read_csv(data_path + 'testA_click_log.csv' ) all_click = train_click.append(test_click) all_click = all_click.drop_duplicates((['user_id' , 'click_article_id' , 'click_timestamp' ])) return all_click def get_all_click_sample (data_path, sample_nums=10000 ): all_click = pd.read_csv(data_path + 'train_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
1 2 all_click_df = get_all_click_df(data_path, offline=False )
构建用户的(文章,点击时间)的字典
为了预测最后一次点击的文章,需要知道用户时间上先后点击文章的顺序
1 2 3 4 5 6 7 8 9 10 11 def get_user_article_time (click_df ): click_df = click_df.sort_values('click_timestamp' ) def make_article_time_pair (df ): return list (zip (df['click_article_id' ], df['click_timestamp' ])) user_article_time_df = click_df.groupby('user_id' )['click_article_id' , 'click_timestamp' ].apply( lambda x:make_article_time_pair(x)).reset_index().rename(columns={0 : 'article_time_list' }) user_article_time_dict = dict (zip (user_article_time_df['user_id' ], user_article_time_df['article_time_list' ])) return user_article_time_dict
获取点击Top k的文章
1 2 3 4 def get_article_topk_click (click_df, k ): topk_click = click_df['click_article_id' ].value_counts().index[:k] return topk_click
基于物品的协同过滤(ItemCF)的物品相似度计算
推荐系统的推荐算法包含基于人口统计学的推荐 、基于内容的推荐 、基于协同过滤的推荐 ,其中基于协同过滤(Collaborative Filtering, CF)的推荐又包含基于用户(user)的协同过滤 和基于物品(item)的协同过滤 ,此处为ItemCF。
ItemCF的Item相似度计算
Caution:setdefault函数:如果键存在,返回值;如果键不存在,设置其(默认)值并返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 def itemcf_sim (df ): user_item_time_dict = get_user_article_time(df) i2i_sim = {} item_cnt = defaultdict(int ) for user, item_time_list in tqdm(user_item_time_dict.items()): for i, i_click_time in item_time_list: item_cnt[i] += 1 i2i_sim.setdefault(i, {}) for j, j_click_time in item_time_list: if i == j: continue i2i_sim[i].setdefault(j, 0 ) i2i_sim[i][j] += 1 /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 (save_path + 'itemcf_i2i_sim.pkl' , 'wb' )) return i2i_sim_
1 i2i_sim = itemcf_sim(all_click_df)
100%|████████████████████████████████████████████████████████████████████████| 250000/250000 [00:29<00:00, 8436.37it/s]
ItemCF的召回
召回(match)是指从全量信息集合中得到用户可能感兴趣的一小部分候选集。召回 后则是排序 ,排序是将召回阶段得到的候选集内容进行打分排序,选出得分高的几个结果(It depends.)推荐给用户。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 def item_based_recommend (user_id, user_item_time_dict, i2i_sim, sim_item_topk, recall_item_num, item_topk_click ): """ user_id: int, 用户id user_item_time_dict: dict, 用户-文章-点击时间字典 i2i_sim: dict, 文章相似性矩阵 sim_item_topk: int, 选择与当前文章最相似的前k篇 recall_item_num: int, 最后的召回文章整数 item_topk_click: list, 点击次数最多的文章列表,用户召回补全 """ 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, clikc_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 item_rank.setdefault(j, 0 ) item_rank[j] += wij if len (item_rank) < recall_item_num: for i, item in enumerate (item_topk_click): if item in item_rank.items(): 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
向每个用户基于上述方法进行文章的推荐
1 2 3 4 5 6 7 8 9 10 user_recall_items_dict = collections.defaultdict(dict ) user_item_time_dict = get_user_article_time(all_click_df) i2i_sim = pickle.load(open (save_path + 'itemcf_i2i_sim.pkl' , 'rb' )) sim_item_topk = 10 recall_item_num = 10 item_topk_click = get_article_topk_click(all_click_df, k=50 ) for user in tqdm(all_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)
100%|██████████████████████████████████████████████████████████████████████████| 250000/250000 [55:44<00:00, 74.75it/s]
1 2 3 4 5 6 user_item_score_list = [] for user, items in tqdm(user_recall_items_dict.items()): for item, score in items: user_item_score_list.append([user, item, score]) recall_df = pd.DataFrame(user_item_score_list, columns=['user_id' , 'click_article_id' , 'pred_score' ])
100%|███████████████████████████████████████████████████████████████████████| 250000/250000 [00:04<00:00, 58856.32it/s]
利用submit函数生成提交文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def submit (recall_df, topk=5 , model_name=None ): recall_df = recall_df.sort_values(by=['user_id' , 'pred_score' ]) recall_df['rank' ] = recall_df.groupby(['user_id' ])['pred_score' ].rank(ascending=False , method='first' ) tmp = recall_df.groupby('user_id' ).apply(lambda x: x['rank' ].max ()) assert tmp.min () >= topk del recall_df['pred_score' ] submit = recall_df[recall_df['rank' ] <= topk].set_index(['user_id' , 'rank' ]).unstack(-1 ).reset_index() submit.columns = [int (col) if isinstance (col, int ) else col for col in submit.columns.droplevel(0 )] submit = submit.rename(columns={'' : 'user_id' , 1 : 'article_1' , 2 : 'article_2' , 3 : 'article_3' , 4 : 'article_4' , 5 : 'article_5' }) save_name = save_path + model_name + '_' + datetime.today().strftime('%m-%d' ) + '.csv' submit.to_csv(save_name, index=False , header=True )
1 2 3 4 5 6 7 test_click = pd.read_csv(data_path + 'testA_click_log.csv' ) test_users = test_click['user_id' ].unique() test_recall = recall_df[recall_df['user_id' ].isin(test_users)] submit(test_recall, topk=5 , model_name='itemcf_baseline' )
最后生成文件的部分内容