天池推荐系统学习赛Task01打卡:赛题理解+Baseline

天池推荐系统学习赛Task01打卡:赛题理解+Baseline

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

最近报名了天池推荐系统的组队学习,本文参考零基础入门推荐系统【赛题理解+Baseline】Task1,记录一下我对本次赛题和baseline的理解,以及对baseline中部分代码的补充注释


一、赛题理解

1.赛题简介

本次比赛的目的是要求我们根据用户历史浏览点击新闻文章的数据信息预测用户未来的点击行为, 由于我们无法真正获得用户未来的数据,因此问题转化为预测用户最后一次点击的新闻文章

2.数据描述和下载

本次赛事的数据来自某新闻APP平台的用户交互数据,包括30万用户,近300万次点击,共36万多篇不同的新闻文章,同时每篇新闻文章有对应的embedding向量表示。为了保证比赛的公平性,从中抽取20万用户的点击日志数据作为训练集,5万用户的点击日志数据作为测试集A,5万用户的点击日志数据作为测试集B。

竞赛官网上报名后可以下载到一个保存数据下载链接的文档,直接复制链接到浏览器上即可完成数据的下载。

3.问题转化

此次比赛和我们之前遇到的普通的结构化比赛不太一样, 主要有两点:

  1. 目标上, 要预测最后一次点击的新闻文章,也就是我们给用户推荐的是新闻文章, 并不是像之前那种预测一个数或者预测数据哪一类那样的问题
  2. 数据上, 通过给出的数据我们会发现, 这种数据也不是我们之前遇到的那种特征+标签的数据,而是基于了真实的业务场景, 拿到的用户的点击日志

所以拿到这个题目,我们的思考方向就是,如何结合我们的目标,把该预测问题转成一个监督学习的问题(特征+标签)呢?在这之后才能进行ML,DL的建模预测。

既然是要预测最后一次点击的文章, 那么如果我们能预测出某个用户最后一次对于某一篇文章会进行点击的概率, 是不是就间接性的解决了这个问题呢?概率最大的那篇文章不就是用户最后一次可能点击的新闻文章吗? 这样就把原问题变成了一个点击率预测的问题(用户, 文章) --> 点击的概率(软分类), 而这个问题, 就是我们所熟悉的监督学习领域分类问题了。

因此,本次问题中的x为(用户id,文章id),标签y为用户是否点击过该文章。模型的目的是预测用户是否会点击该文章。

二、评价指标

针对单个用户的评价指标的公式如下:
s c o r e ( u s e r ) = ∑ k = 1 5 s ( u s e r , k ) k score(user) = \sum^{5}_{k=1}\frac{s(user,k)}{k} score(user)=k=15ks(user,k)
k是文章的序号,假如article1就是真实的用户点击文章,也就是article1命中, 则s(user1,1)=1, s(user1,2-4)都是0,score=1。假如article2是真实的用户点击文章, 则s(user1,2)=1, s(user1,{1,3-5})都是0,score=1/2。
也就是说推荐的5篇文章中命中的文章排名越靠后,得分越低。没有命中时,该用户的得分为0

三、baseline理解

baseline的整体思路是:基于协同过滤算法得到文章与文章之间的相似度矩阵,然后通过查相似度矩阵得到每一篇文章与用户历史浏览文章的的相似度将相似度作为最终的推荐评分。最后取评分前五的文章进行推荐。似乎并没有用到ML或者DL的方法。

跑通basline基本不需要改代码,只需要改改数据的路径就好了(我是在自己的服务器上跑的,如果用天池的服务器甚至路径都不用改)。下面的代码添加了一些个人理解的注释:

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')

data_path = 'data_raw/'
save_path = 'tmp_results/'


# 节约内存的一个标配函数
def reduce_mem(df):
    starttime = 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.finfo(np.float16).min and c_max < np.finfo(np.float16).max:
                    df[col] = df[col].astype(np.float16)
                elif c_min > np.finfo(np.float32).min and c_max < np.finfo(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() - starttime) / 60))
    return df


# 读取采样或全量数据
# debug模式:从训练集中划出一部分数据来调试代码。这里选了10000个用户来调试
def get_all_click_sample(data_path, sample_nums=10000):
    """
        训练集中采样一部分数据调试
        data_path: 原数据的存储路径
        sample_nums: 采样数目(这里由于机器的内存限制,可以采样用户做)
    """
    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


# 读取点击数据,这里分成线上和线下,如果是为了获取线上提交结果应该讲测试集中的点击数据合并到总的数据中
# 如果是为了线下验证模型的有效性或者特征的有效性,可以只使用训练集
def get_all_click_df(data_path='./data_raw/', offline=True):
    if offline:
        all_click = pd.read_csv(data_path + 'train_click_log.csv')
    else:
        trn_click = pd.read_csv(data_path + 'train_click_log.csv')
        tst_click = pd.read_csv(data_path + 'testA_click_log.csv')

        all_click = trn_click.append(tst_click)

    all_click = all_click.drop_duplicates((['user_id', 'click_article_id', 'click_timestamp']))
    return all_click


# 获取用户-文章-点击时间字典
# 根据点击时间获取用户的点击文章序列   {user1: [ (item1, time1) ,  (item2, time2)..]...}
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_id进行分组
    user_item_time_dict = dict(zip(user_item_time_df['user_id'], user_item_time_df['item_time_list']))

    return user_item_time_dict


# 获取近期点击最多的文章  topK
def get_item_topk_click(click_df, k):
    topk_click = click_df['click_article_id'].value_counts().index[:k]
    return topk_click


# 基于物品的协同过滤进行文章之间的相似度计算,最后文章和文章之间的相似度矩阵
def itemcf_sim(df):
    """
        文章与文章之间的相似性矩阵计算
        :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 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_


# itemcf的文章推荐
# 基于相似度矩阵的召回策略
def item_based_recommend(user_id, user_item_time_dict, i2i_sim, sim_item_topk, recall_item_num, item_topk_click):
    """
        基于文章协同过滤的召回
        :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: 列表,点击次数最多的文章列表,用户召回补全
        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

            item_rank.setdefault(j, 0)
            item_rank[j] += 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


# 根据生成的召回Dataframe生成提交文件
def submit(recall_df, topk=5, model_name=None):
    recall_df = recall_df.sort_values(by=['user_id', 'pred_score'])  # 根据用户id和推荐的评分排序
    # 生成每个用户推荐分数的排名
    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  # tmp.min()表示所有用户中推荐文章最少的用户的推荐文章数,判断是不是每个用户都有5篇文章及以上
    # assert(断言) 用于判断一个表达式,false出发异常 del可以删除变量(而不是数据),可以节约内存
    del recall_df['pred_score']  # 因为上面已经针对每个用户推荐文章的分数生成排名了,所以可以直接删除pred_score
    # 取排名在5名以前的文章进行推荐
    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)


# Press the green button in the gutter to run the script.
if __name__ == '__main__':
    # 获取全量训练集
    all_click_df = get_all_click_df(offline=False)
    i2i_sim = itemcf_sim(all_click_df)

    # 给每个用户根据物品的协同过滤推荐文章
    # 定义
    user_recall_items_dict = collections.defaultdict(dict)

    # 获取 用户 - 文章 - 点击时间的字典
    user_item_time_dict = get_user_item_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_item_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)

    # 将召回字典的形式转换成df
    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'])

    # 获取测试集
    tst_click = pd.read_csv(data_path + 'testA_click_log.csv')
    tst_users = tst_click['user_id'].unique()

    # 从所有的召回数据中将测试集中的用户选出来
    tst_recall = recall_df[recall_df['user_id'].isin(tst_users)]

    # 生成提交文件
    submit(tst_recall, topk=5, model_name='itemcf_baseline')


总结

由于之前没有推荐系统的基础,中间有一些代码暂时还没看懂。如果有哪里说错的地方,欢迎大家在评论区讨论或者指正。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值