赛题

新闻推荐比赛链接

赛题任务

赛题以预测用户未来点击新闻文章为任务,数据集报名后可见并可下载,该数据来自某新闻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:提交样例文件

字段表

Field Description
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 random
from datetime import datetime
from operator import itemgetter #获取某位置上的数据
import numpy as np
import pandas as pd
import warnings
from collections import defaultdict
import collections
warnings.filterwarnings('ignore')
1
2
3
data_path = './raw_data/'  #自己本地的位置
save_path = './results/' #自己本地的存储
# data_path = '/home/admin/jupyter/data' #在天池实验室关联完数据后的数据路径

节省内存函数

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
#reduce_mem为节约内存的一个标配函数
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
#根据点击时间获取用户的点击文章序列{user: [(article1: time1), (article2: time2), (article3: time3) ...] ...}
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
#获取近期点击最多的前k个文章
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 # 记录i文章出现的次数
i2i_sim.setdefault(i, {}) # 记录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
# 在sorted函数中key=lambda x:x[1]是指对字典的第二列也就是值进行排序。

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 # -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]
#返回召回的文章列表 {item1: score1, item2: score2, ...}
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 # 召回文章的数量(肯定要比要求的5个设置的大一点)
#热门点击
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
# 将字典转换成cf
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
#rank函数中method参数为first代表根据出现的位置顺序设置rank的值
#unstack(-1)将行索引转变成列索引
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')
# 判断是不是每个用户都有5篇文章以上
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')

最后生成文件的部分内容