新闻推荐之特征工程
问题导向:
- 如何将召回阶段的结果转化为有监督的数据?
- 如何挖掘更多有用的特征?
- 结合新闻推荐业务场景,怎么构建有价值的特征组合形式?
目标:制作特征和标签, 转成监督学习问题
我们先捋一下基于原始的给定数据, 有哪些特征可以直接利用:
- 文章的自身特征, category_id表示这文章的类型, created_at_ts表示文章建立的时间, 这个关系着文章的时效性, words_count是文章的字数, 一般字数太长我们不太喜欢点击, 也不排除有人就喜欢读长文。
- 文章的内容embedding特征, 这个召回的时候用过, 这里可以选择使用, 也可以选择不用, 也可以尝试其他类型的embedding特征, 比如W2V等
- 用户的设备特征信息
上面这些直接可以用的特征, 待做完特征工程之后, 直接就可以根据article_id或者是user_id把这些特征加入进去。 但是我们需要先基于召回的结果, 构造一些特征,然后制作标签,形成一个监督学习的数据集。
构造监督数据集的思路, 根据召回结果, 我们会得到一个{user_id: [可能点击的文章列表]}形式的字典。 那么我们就可以对于每个用户, 每篇可能点击的文章构造一个监督测试集, 比如对于用户user1, 假设得到的他的召回列表{user1: [item1, item2, item3]}, 我们就可以得到三行数据(user1, item1), (user1, item2), (user1, item3)的形式, 这就是监督测试集时候的前两列特征。
构造特征的思路是这样, 我们知道每个用户的点击文章是与其历史点击的文章信息是有很大关联的, 比如同一个主题, 相似等等。 所以特征构造这块很重要的一系列特征是要结合用户的历史点击文章信息。我们已经得到了每个用户及点击候选文章的两列的一个数据集, 而我们的目的是要预测最后一次点击的文章, 比较自然的一个思路就是和其最后几次点击的文章产生关系, 这样既考虑了其历史点击文章信息, 又得离最后一次点击较近,因为新闻很大的一个特点就是注重时效性。 往往用户的最后一次点击会和其最后几次点击有很大的关联。 所以我们就可以对于每个候选文章, 做出与最后几次点击相关的特征如下:
- 候选item与最后几次点击的相似性特征(embedding内积) — 这个直接关联用户历史行为
- 候选item与最后几次点击的相似性特征的统计特征 — 统计特征可以减少一些波动和异常
- 候选item与最后几次点击文章的字数差的特征 — 可以通过字数看用户偏好
- 候选item与最后几次点击的文章建立的时间差特征 — 时间差特征可以看出该用户对于文章的实时性的偏好
还需要考虑一下
5. 如果使用了youtube召回的话, 我们还可以制作用户与候选item的相似特征
当然, 上面只是提供了一种基于用户历史行为做特征工程的思路, 大家也可以思维风暴一下,尝试一些其他的特征。 下面我们就实现上面的这些特征的制作, 下面的逻辑是这样:
- 我们首先获得用户的最后一次点击操作和用户的历史点击, 这个基于我们的日志数据集做
- 基于用户的历史行为制作特征, 这个会用到用户的历史点击表, 最后的召回列表, 文章的信息表和embedding向量
- 制作标签, 形成最后的监督学习数据集
数据准备
import numpy as np
import pandas as pd
import pickle
from tqdm import tqdm
import gc, os
import logging
import time
import lightgbm as lgb
from gensim.models import Word2Vec
from sklearn.preprocessing import MinMaxScaler
import warnings
save_path = "./temp_results/"
warnings.filterwarnings('ignore')
节省内存函数
# 节省内存的一个函数
# 减少内存
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
划分训练集和验证集
def trn_val_split(all_click_df, sample_user_num):
"""划分数据集"""
all_click = all_click_df
all_user_ids = all_click.user_id.unique()
sample_user_ids = np.random.choice(all_user_ids, size=sample_user_num, replace=False) # 无放回抽样数据
# 采样数据作为验证集
click_val = all_click[all_click["user_id"].isin(sample_user_ids)]
click_trn = all_click[~all_click["user_id"].isin(sample_user_ids)]
# 抽取验证集中最后一次点击的数据作为预测答案
click_val = click_val.sort_values(["user_id", "click_timestamp"])
val_ans = click_val.groupby("user_id").tail(1) # 每一个分组的最后一个数据
click_val = click_val.groupby("user_id").apply(lambda x:x[:-1]).reset_index(drop=True)
# 验证集的答案和历史数据中的用户,双方互存在
val_ans = val_ans[val_ans.user_id.isin(click_val.user_id.unique())]
click_val = click_val[click_val.user_id.isin(val_ans.user_id.unique())]
return click_trn, click_val, val_ans
获取历史点击和最后一次点击的情况
# 获取当前数据的历史点击和最后一次点击
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_trn_val_tst_data(data_path, offline=True):
if offline:
click_trn_data = pd.read_csv(data_path+'train_click_log.csv') # 训练集用户点击日志
click_trn_data = reduce_mem(click_trn_data)
click_trn, click_val, val_ans = trn_val_split(click_trn_data, 20000)
else:
click_trn = pd.read_csv(data_path+'train_click_log.csv')
click_trn = reduce_mem(click_trn)
click_val = None
val_ans = None
click_tst = pd.read_csv(data_path+'testA_click_log.csv')
return click_trn, click_val, click_tst, val_ans
返回多路召回列表或者单路召回
def get_recall_list(save_path, single_recall_model=None, multi_recall=False):
if multi_recall:
return pickle.load(open(save_path + 'final_recall_items_dict.pkl', 'rb'))
if single_recall_model == 'i2i_itemcf':
return pickle.load(open(save_path + 'itemcf_recall_dict.pkl', 'rb'))
elif single_recall_model == 'i2i_emb_itemcf':
return pickle.load(open(save_path + 'embedding_sim_item_recall.pkl', 'rb'))
elif single_recall_model == 'user_cf':
return pickle.load(open(save_path + 'youtubednn_usercf_dict.pkl', 'rb'))
elif single_recall_model == 'youtubednn':
return pickle.load(open(save_path + 'youtube_u2i_dict.pkl', 'rb'))
Word2Vec
def train_item_word2vect(click_df, embed_size = 64, save_path="./data/", split_char=" "):
"""训练文章的向量"""
click_df = click_df.sort_values(by=["click_timestamp"])
# str
click_df["click_article_id"] = click_df["click_article_id"].astype(str)
# sentences
docs = click_df.groupby("user_id")["click_article_id"].apply(lambda x:list(x)).reset_index()
docs = docs["click_article_id"].values.tolist()
# 打印log信息
logging.basicConfig(format='%(asctime)s:%(levelname)s:%(message)s', level=logging.INFO)
w2v = Word2Vec(sentences=docs, size=16, window=5, seed=2020, workers=8, min_count=1, iter=1)
item_w2v_dict = {k:w2v[k] for k in click_df["click_article_id"]}
pickle.dump(item_w2v_dict, open(save_path + "item_w2v_emb.pkl", "wb"))
return item_w2v_dict
# 可以通过字典查询对应的item的Embedding
def get_embedding(save_path, all_click_df):
if os.path.exists(save_path + 'item_content_emb.pkl'):
item_content_emb_dict = pickle.load(open(save_path + 'item_content_emb.pkl', 'rb'))
else:
print('item_content_emb.pkl 文件不存在...')
# w2v Embedding是需要提前训练好的
if os.path.exists(save_path + 'item_w2v_emb.pkl'):
item_w2v_emb_dict = pickle.load(open(save_path + 'item_w2v_emb.pkl', 'rb'))
else:
item_w2v_emb_dict = train_item_word2vect(all_click_df)
if os.path.exists(save_path + 'item_youtube_emb.pkl'):
item_youtube_emb_dict = pickle.load(open(save_path + 'item_youtube_emb.pkl', 'rb'))
else:
print('item_youtube_emb.pkl 文件不存在...')
if os.path.exists(save_path + 'user_youtube_emb.pkl'):
user_youtube_emb_dict = pickle.load(open(save_path + 'user_youtube_emb.pkl', 'rb'))
else:
print('user_youtube_emb.pkl 文件不存在...')
return item_content_emb_dict, item_w2v_emb_dict, item_youtube_emb_dict, user_youtube_emb_dict
获取数据集
def get_article_info_df():
article_info_df = pd.read_csv("./data/" + 'articles.csv')
article_info_df = reduce_mem(article_info_df)
return article_info_df
# 这里offline的online的区别就是验证集是否为空
click_trn, click_val, click_tst, val_ans = get_trn_val_tst_data("./data/", offline=True)
-- Mem. usage decreased to 23.34 Mb (69.4% reduction),time spend:0.00 min
# 数据集划分的情况
click_trn_hist, click_trn_last = get_hist_and_last_click(click_trn)
# 划分验证集(历史、最后一次点击)
if click_val is not None:
click_val_hist, click_val_last = click_val, val_ans
else:
click_val_hist, click_val_last = None, None
# 测试集, 无标签
click_tst_hist = click_tst
样本不平衡(负采样)
转换数据格式
# 将召回列表转换为DF的形式
def recall_dict_2_df(recall_list_dict):
"""装换数据类型"""
df_row_list = []
for user, item_list in tqdm(recall_list_dict.items()):
for item, score in item_list:
df_row_list.append([user, item, score])
# 转为DF
df_cols = ["user_id", "sim_item", "score"]
recall_list_df = pd.DataFrame(df_row_list, columns=df_cols)
return recall_list_df
召回数据打标签(做成监督数据)
重点:主要实现数据的有标签化,为精排做准备。
做法:将训练用户最后一次点击作为正样本,测试集用户的标签设置为负值(无实际意义)。
def get_rank_label_df(recall_list_df, label_df, is_test=False):
"""
构建有监督数据集, 数据格式:user_id, sim_item, label
"""
if is_test:
recall_list_df["label"] = -1
return recall_list_df
label_df = label_df.rename(columns={"click_article_id":"sim_item"})
# 非非标签值则使用空值填补
recall_list_df_ = recall_list_df.merge(label_df[["user_id", "sim_item", "click_timestamp"]], how="left", on=["user_id", "sim_item"])
# 打标签,最后一次点击作为正样本
recall_list_df_["label"] = recall_list_df_["click_timestamp"].apply(lambda x: 0.0 if np.isnan(x) else 1.0)
del recall_list_df_["click_timestamp"] # 删除冗余列
return recall_list_df_
负采样(样本不均衡)
def neg_sample_recall_data(recall_list_df, sample_rate=0.001):
"""负采样数据集,维持数据样本平衡"""
pos_data = recall_list_df[recall_list_df["label"] == 1]
neg_data = recall_list_df[recall_list_df["label"] == 0]
print("pos_data nums:", len(pos_data), "neg_data_nums:", len(neg_data), "pos_num/neg_num:", len(pos_data)/len(neg_data))
def neg_sample_func(groupby_df):
"""采样"""
neg_num = len(groupby_df)
sample_num = max(int(neg_num*sample_rate), 1) # 最少采样1个
sample_num = min(sample_num, 5) # 最多采样5个
return groupby_df.sample(sample_num, replace=True) # 有放回
# 保证每一位用户都能被采到
neg_data_user_sample = neg_data.groupby("user_id", group_keys=False).apply(neg_sample_func)
# 保证每一篇新闻都能被采到
neg_data_item_sample = neg_data.groupby("sim_item", group_keys=False).apply(neg_sample_func)
# 数据合并
neg_data_new = neg_data_user_sample.append(neg_data_item_sample)
neg_data_new = neg_data_new.sort_values(["user_id", "score"]).drop_duplicates(["user_id", "score"], keep="last")
data_new = pd.concat([pos_data, neg_data_new], ignore_index=True)
return data_new
获取指定数据集
def get_user_recall_item_label_df(click_trn_hist, click_val_hist, click_tst_hist, click_trn_last, click_val_last, recall_list_df):
""""""
# 构建训练集的召回列表(从召回列表中选择出在候选列表中的用户)
trn_user_item_df = recall_list_df[recall_list_df["user_id"].isin(click_trn_hist["user_id"].unique())]
# 打标签
trn_user_item_label_df = get_rank_label_df(trn_user_item_df, click_trn_last, is_test=False)
# 负采样(处理样本不平衡的问题)
trn_user_item_label_df = neg_sample_recall_data(trn_user_item_label_df)
if click_val is not None:
val_user_items_df = recall_list_df[recall_list_df['user_id'].isin(click_val_hist['user_id'].unique())]
val_user_item_label_df = get_rank_label_df(val_user_items_df, click_val_last, is_test=False)
val_user_item_label_df = neg_sample_recall_data(val_user_item_label_df)
else:
val_user_item_label_df = None
# 测试数据不需要进行负采样,直接对所有的召回商品进行打-1标签
tst_user_items_df = recall_list_df[recall_list_df['user_id'].isin(click_tst_hist['user_id'].unique())]
tst_user_item_label_df = get_rank_label_df(tst_user_items_df, None, is_test=True)
return trn_user_item_label_df, val_user_item_label_df, tst_user_item_label_df
def get_recall_list(save_path, single_recall_model=None, multi_recall=False):
if multi_recall:
return pickle.load(open(save_path + 'final_recall_items_dict.pkl', 'rb'))
if single_recall_model == 'i2i_itemcf':
return pickle.load(open(save_path + 'itemcf_recall_dict.pkl', 'rb'))
elif single_recall_model == 'i2i_emb_itemcf':
return pickle.load(open(save_path + 'embedding_sim_item_recall.pkl', 'rb'))
elif single_recall_model == 'user_cf':
return pickle.load(open(save_path + 'youtubednn_usercf_recall.pkl', 'rb'))
elif single_recall_model == 'youtubednn':
return pickle.load(open(save_path + 'youtube_u2i_dict.pkl', 'rb'))
# 读取召回列表
recall_list_dict = get_recall_list("./temp_results/", single_recall_model='youtubednn') # 这里只选择了单路召回的结果,也可以选择多路召回结果
# recall_list_dict = get_recall_list("./data/", multi_recall=True)
# 将召回数据转换成df
# recall_list_dict = recall_list_dict["itemcf_sim_itemcf_recall"]
# recall_list_dict = pickle.load(open("./source code/itemcf_recall_dict.pkl", "rb"))
recall_list_df = recall_dict_2_df(recall_list_dict)
100%|██████████| 250000/250000 [00:09<00:00, 25608.87it/s]
recall_list_df.tail()
user_id | sim_item | score | |
---|---|---|---|
4749995 | 167154 | 350849 | 0.998230 |
4749996 | 167154 | 351470 | 0.998138 |
4749997 | 167154 | 72567 | 0.996951 |
4749998 | 167154 | 358433 | 0.996091 |
4749999 | 167154 | 191890 | 0.995206 |
click_trn_last.head()
user_id | click_article_id | click_timestamp | click_environment | click_deviceGroup | click_os | click_country | click_region | click_referrer_type | |
---|---|---|---|---|---|---|---|---|---|
1112620 | 0 | 157507 | 1508211702520 | 4 | 1 | 17 | 1 | 25 | 2 |
1112600 | 2 | 168401 | 1508211468695 | 4 | 3 | 20 | 1 | 25 | 2 |
1112598 | 3 | 36162 | 1508211389672 | 4 | 3 | 2 | 1 | 25 | 2 |
1112587 | 4 | 39894 | 1508211655466 | 4 | 1 | 12 | 1 | 16 | 1 |
1112574 | 5 | 234481 | 1508211273884 | 4 | 4 | 2 | 1 | 25 | 2 |
# 给训练验证数据打标签,并负采样(这一部分时间比较久)
trn_user_item_label_df, val_user_item_label_df, tst_user_item_label_df = get_user_recall_item_label_df(click_trn_hist,
click_val_hist,
click_tst_hist,
click_trn_last,
click_val_last,
recall_list_df)
pos_data nums: 21 neg_data_nums: 3419979 pos_num/neg_num: 6.140388581333394e-06
pos_data nums: 3 neg_data_nums: 379997 pos_num/neg_num: 7.894799169467127e-06
trn_user_item_label_df.head()
user_id | sim_item | score | label | |
---|---|---|---|---|
0 | 88619 | 258007 | inf | 1.0 |
1 | 191660 | 207111 | inf | 1.0 |
2 | 35684 | 272266 | 0.998765 | 1.0 |
3 | 31650 | 258007 | inf | 1.0 |
4 | 178495 | 207111 | inf | 1.0 |
# 正负样本比例
sum(trn_user_item_label_df.label == 1.0)/sum(trn_user_item_label_df.label==0.0)
0.00011649515990347545
sum(trn_user_item_label_df.label == 1.0)
21
sum(trn_user_item_label_df.label==0.0)
180265
def make_tuple_func(group_df):
row_data = []
for name, row_df in group_df.iterrows():
row_data.append((row_df["sim_item"], row_df["score"], row_df["label"]))
return row_data
# TODO:为什么要存储为这种格式的数据
trn_user_item_label_tuples = trn_user_item_label_df.groupby('user_id').apply(make_tuple_func).reset_index()
trn_user_item_label_tuples_dict = dict(zip(trn_user_item_label_tuples['user_id'], trn_user_item_label_tuples[0]))
if val_user_item_label_df is not None:
val_user_item_label_tuples = val_user_item_label_df.groupby('user_id').apply(make_tuple_func).reset_index()
val_user_item_label_tuples_dict = dict(zip(val_user_item_label_tuples['user_id'], val_user_item_label_tuples[0]))
else:
val_user_item_label_tuples_dict = None
tst_user_item_label_tuples = tst_user_item_label_df.groupby('user_id').apply(make_tuple_func).reset_index()
tst_user_item_label_tuples_dict = dict(zip(tst_user_item_label_tuples['user_id'], tst_user_item_label_tuples[0]))
trn_user_item_label_tuples.head()
user_id | 0 | |
---|---|---|
0 | 0 | [(191890.0, 0.9962208867073059, 0.0)] |
1 | 2 | [(169138.0, inf, 0.0)] |
2 | 3 | [(169138.0, inf, 0.0)] |
3 | 4 | [(350849.0, 0.998528003692627, 0.0)] |
4 | 5 | [(68719.0, inf, 0.0)] |
特征工程(重点)
方法:特征创造、特征组合和特征提取
文章特征信息构建
目标:根据召回的文章为其根据历史点击文章构建新的特征属性。
用户历史行为特征构建:
# 下面基于data做历史相关的特征,现有数据类型为:{user_id:sim_item, score, label}
"""
{1: [(207111.0, inf, 0.0)],
17: [(258007.0, inf, 0.0)],
25: [(127088.0, inf, 0.0)],
50: [(127088.0, inf, 0.0)],
51: [(358433.0, 0.9962038993835449, 0.0)],
65: [(142300.0, inf, 0.0)],
69: [(258007.0, inf, 0.0)],
71: [(68719.0, inf, 0.0)],
79: [(272266.0, 0.9982268214225769, 0.0)],
80: [(358433.0, 0.9965305924415588, 0.0)]......
}
"""
def create_feature(users_id, recall_list, click_hist_df, articles_info, articles_emb, user_emb=None, N=1):
"""
基于用户的历史行为做相关特征(通过计算召回用户的新闻与其历史点击文章计算相关关系,从而增加新的特征数据(特征创造))
:param users_id: 用户id
:param recall_list: 对于每个用户召回的候选文章列表
:param click_hist_df: 用户的历史点击信息
:param articles_info: 文章信息
:param articles_emb: 文章的embedding向量, 这个可以用item_content_emb, item_w2v_emb, item_youtube_emb
:param user_emb: 用户的embedding向量, 这个是user_youtube_emb, 如果没有也可以不用, 但要注意如果要用的话, articles_emb就要用item_youtube_emb的形式, 这样维度才一样
:param N: 最近的N次点击 由于testA日志里面很多用户只存在一次历史点击, 所以为了不产生空值,默认是1
"""
all_user_feas = []
i = 0
for user_id in tqdm(users_id):
# 用户历史N次点击的文章
hist_user_items = click_hist_df[click_hist_df["user_id"] == user_id]["click_article_id"][-N:]
# 遍历召回数据,即候选推荐文章
for rank, (article_id, score, label) in enumerate(recall_list[user_id]):
# 计算召回新闻的信息
a_create_time = articles_info[articles_info["article_id"] == article_id]["created_at_ts"].values[0]
a_create_word = articles_info[articles_info["article_id"] == article_id]["words_count"].values[0]
# 存储特征
sigle_user_feas = [user_id, article_id]
sim_fea = []
time_fea = []
word_fea = []
# 计算候选推荐新闻与历史用户新闻的相似度(N)
for hist_item in hist_user_items:
b_create_time = articles_info[articles_info["article_id"] == hist_item]["created_at_ts"].values[0]
b_create_word = articles_info[articles_info["article_id"] == hist_item]["words_count"].values[0]
# 计算文章相似度
sim_fea.append(np.dot(articles_emb[hist_item], articles_emb[article_id]))
# 计算创建时间差
time_fea.append(abs(a_create_time - b_create_time))
# 计算文章字数的差值
word_fea.append(abs(a_create_word - b_create_word))
sigle_user_feas.extend(sim_fea)
sigle_user_feas.extend(time_fea)
sigle_user_feas.extend(word_fea)
#加入相似性的统计变量
sigle_user_feas.extend([max(sim_fea), min(sim_fea), sum(sim_fea), sum(sim_fea)/len(sim_fea)])
# 计算用户与文章的相似度
if user_emb:
sigle_user_feas.append(np.dot(user_emb[user_id], articles_emb[article_id]))
sigle_user_feas.extend([score, rank, label])
# 候选文章
all_user_feas.append(sigle_user_feas) #追加一个list[[候选商品的特征信息]]
# 构建个DataFrame
id_cols = ["user_id", "click_article_id"]
sim_cols = ["sim" + str(i) for i in range(N)]
time_cols = ["time_diff" + str(i) for i in range(N)]
word_cols = ["word_diff" + str(i) for i in range(N)]
sat_cols = ["sim_max", "sim_min", "sim_sum", "sim_mean"]
user_item_sim_cols = ["user_item_sim"] if user_emb else []
user_score_rank_label = ["score", "rank", "label"]
cols = id_cols + sim_cols + time_cols + word_cols + sat_cols + user_item_sim_cols + user_score_rank_label
df = pd.DataFrame(all_user_feas, columns=cols)
return df
# 加载文章信息
articles_info = get_article_info_df()
all_click = click_trn.append(click_tst)
# 加载embedding向量,计算文章embedding相似度
item_content_emb_dict, item_w2v_emb_dict, item_youtube_emb_dict, user_youtube_emb_dict = get_embedding("./data/", all_click)
-- Mem. usage decreased to 5.56 Mb (50.0% reduction),time spend:0.00 min
# 获取训练验证及测试数据中召回列文章相关特征
trn_user_item_feats_df = create_feature(trn_user_item_label_tuples_dict.keys(), trn_user_item_label_tuples_dict, \
click_trn_hist, articles_info, item_content_emb_dict)
if val_user_item_label_tuples_dict is not None:
val_user_item_feats_df = create_feature(val_user_item_label_tuples_dict.keys(), val_user_item_label_tuples_dict, \
click_val_hist, articles_info, item_content_emb_dict)
else:
val_user_item_feats_df = None
tst_user_item_feats_df = create_feature(tst_user_item_label_tuples_dict.keys(), tst_user_item_label_tuples_dict, \
click_tst_hist, articles_info, item_content_emb_dict)
100%|██████████| 180000/180000 [11:20<00:00, 264.48it/s]
100%|██████████| 20000/20000 [01:11<00:00, 278.88it/s]
100%|██████████| 50000/50000 [47:48<00:00, 17.43it/s]
# 保存一份省的每次都要重新跑,每次跑的时间都比较长
trn_user_item_feats_df.to_csv(save_path + 'trn_user_item_feats_df.csv', index=False)
if val_user_item_feats_df is not None:
val_user_item_feats_df.to_csv(save_path + 'val_user_item_feats_df.csv', index=False)
tst_user_item_feats_df.to_csv(save_path + 'tst_user_item_feats_df.csv', index=False)
用户与文章特征
这一块,正式进行特征工程,既要拼接上已有的特征, 也会做更多的特征出来,我们来梳理一下已有的特征和可构造特征:
- 文章自身的特征, 文章字数,文章创建时间, 文章的embedding (articles表中)
- 用户点击环境特征, 那些设备的特征(这个在df中)
- 对于用户和商品还可以构造的特征:
- 基于用户的点击文章次数和点击时间构造可以表现用户活跃度的特征
- 基于文章被点击次数和时间构造可以反映文章热度的特征
- 用户的时间统计特征: 根据其点击的历史文章列表的点击时间和文章的创建时间做统计特征,比如求均值, 这个可以反映用户对于文章时效的偏好
- 用户的主题爱好特征, 对于用户点击的历史文章主题进行一个统计, 然后对于当前文章看看是否属于用户已经点击过的主题
- 用户的字数爱好特征, 对于用户点击的历史文章的字数统计, 求一个均值
用户相关特征
click_tst.head()
user_id | click_article_id | click_timestamp | click_environment | click_deviceGroup | click_os | click_country | click_region | click_referrer_type | |
---|---|---|---|---|---|---|---|---|---|
0 | 249999 | 160974 | 1506959142820 | 4 | 1 | 17 | 1 | 13 | 2 |
1 | 249999 | 160417 | 1506959172820 | 4 | 1 | 17 | 1 | 13 | 2 |
2 | 249998 | 160974 | 1506959056066 | 4 | 1 | 12 | 1 | 13 | 2 |
3 | 249998 | 202557 | 1506959086066 | 4 | 1 | 12 | 1 | 13 | 2 |
4 | 249997 | 183665 | 1506959088613 | 4 | 1 | 17 | 1 | 15 | 5 |
articles = pd.read_csv("data/articles.csv")
articles = reduce_mem(articles)
articles.head()
-- Mem. usage decreased to 5.56 Mb (50.0% reduction),time spend:0.00 min
article_id | category_id | created_at_ts | words_count | |
---|---|---|---|---|
0 | 0 | 0 | 1513144419000 | 168 |
1 | 1 | 1 | 1405341936000 | 189 |
2 | 2 | 1 | 1408667706000 | 250 |
3 | 3 | 1 | 1408468313000 | 230 |
4 | 4 | 1 | 1407071171000 | 162 |
# 获取全量数据集
if click_val is not None:
all_data = click_trn.append(click_val)
all_data = click_trn.append(click_tst)
all_data = reduce_mem(all_data)
-- Mem. usage decreased to 43.44 Mb (62.5% reduction),time spend:0.00 min
# 拼接文章信息 TODO:拼接数据的方法
all_data = all_data.merge(articles, left_on="click_article_id", right_on="article_id")
分析一下点击时间和点击文章的次数,区分用户活跃度
如果某个用户点击文章之间的时间间隔比较小, 同时点击的文章次数很多的话, 那么我们认为这种用户一般就是活跃用户, 当然衡量用户活跃度的方式可能多种多样, 这里我们只提供其中一种,我们写一个函数, 得到可以衡量用户活跃度的特征,逻辑如下:
- 首先根据用户user_id分组, 对于每个用户,计算点击文章的次数, 两两点击文章时间间隔的均值
- 把点击次数取倒数和时间间隔的均值统一归一化,然后两者相加合并,该值越小, 说明用户越活跃
- 注意, 上面两两点击文章的时间间隔均值, 会出现如果用户只点击了一次的情况,这时候时间间隔均值那里会出现空值, 对于这种情况最后特征那里给个大数进行区分
这个的衡量标准就是先把点击的次数取到数然后归一化, 然后点击的时间差归一化, 然后两者相加进行合并, 该值越小, 说明被点击的次数越多, 且间隔时间短。
def activate_level(all_data, cols):
"""
计算用户活跃等级(关注用户在一段时间内的点击行为)
"""
data = all_data[cols]
data.sort_values(by=["user_id", "click_timestamp"], inplace=True)
# 统计用户的点击次数和点击次数所在的时间
user_act = pd.DataFrame(data.groupby("user_id", as_index=False)[["click_article_id", "click_timestamp"
]].agg({"click_article_id":np.size, "click_timestamp":{list}}).values, columns=["user_id", "click_size", "click_timestamp"])
def time_diff_mean(l):
"""计算平均时间差值"""
if len(l) == 1:
return 1
else:
# i:[0, 1, 2...]
# j:[1, 2, 3...]
return np.mean([j - i for i, j in zip(l[:-1], l[1:])]) # 错位时间相减
# 点击时间差值
user_act["time_diff_mean"] = user_act["click_timestamp"].apply(lambda x: time_diff_mean(x))
user_act["click_size"] = 1 / user_act["click_size"]
# 归一化数据, 用户点击次数
MM = MinMaxScaler()
user_act["click_size"] = MM.fit_transform(user_act["click_size"].values.reshape(-1, 1))
# 归一化数据, 用户点击时间差值
MM = MinMaxScaler()
user_act["time_diff_mean"] = MM.fit_transform(user_act["time_diff_mean"].values.reshape(-1, 1))
# 用户活跃级别,数值越小,其相对应的活跃度越高,即短时间内点击的文章数目较多
user_act["active_level"] = user_act["time_diff_mean"] + user_act["click_size"]
user_act["user_id"] = user_act["user_id"].astype("int")
del user_act["click_timestamp"]
return user_act
user_act_fea = activate_level(all_data, cols=['user_id', 'click_article_id', 'click_timestamp'])
user_act_fea.tail()
user_id | click_size | time_diff_mean | active_level | |
---|---|---|---|---|
229995 | 249995 | 0.028376 | 0.034594 | 0.062970 |
229996 | 249996 | 1.000000 | 0.000000 | 1.000000 |
229997 | 249997 | 0.141942 | 0.117898 | 0.259840 |
229998 | 249998 | 0.199146 | 0.496823 | 0.695969 |
229999 | 249999 | 0.051621 | 0.068288 | 0.119909 |
分析一下点击时间和被点击文章的次数, 衡量文章热度特征
和上面同样的思路, 如果一篇文章在很短的时间间隔之内被点击了很多次, 说明文章比较热门,实现的逻辑和上面的基本一致, 只不过这里是按照点击的文章进行分组:
- 根据文章进行分组, 对于每篇文章的用户, 计算点击的时间间隔
- 将用户的数量取倒数, 然后用户的数量和时间间隔归一化, 然后相加得到热度特征, 该值越小, 说明被点击的次数越大且时间间隔越短, 文章比较热
当然, 这只是给出一种判断文章热度的一种方法, 这里大家也可以头脑风暴一下
def hot_level(all_data, cols):
"""
制作衡量文章热度的特征
:param all_data: 数据集
:param cols: 用到的特征列
"""
data = all_data[cols]
data.sort_values(['click_article_id', 'click_timestamp'], inplace=True)
# 按照文章id进行分组
article_hot = pd.DataFrame(data.groupby('click_article_id', as_index=False)[['user_id', 'click_timestamp']].\
agg({'user_id':np.size, 'click_timestamp': {list}}).values, columns=['click_article_id', 'user_num', 'click_timestamp'])
# 计算被点击时间间隔的均值
def time_diff_mean(l):
if len(l) == 1:
return 1
else:
return np.mean([j-i for i, j in list(zip(l[:-1], l[1:]))])
article_hot['time_diff_mean'] = article_hot['click_timestamp'].apply(lambda x: time_diff_mean(x))
# 点击次数取倒数
article_hot['user_num'] = 1 / article_hot['user_num']
# 两者归一化
article_hot['user_num'] = (article_hot['user_num'] - article_hot['user_num'].min()) / (article_hot['user_num'].max() - article_hot['user_num'].min())
article_hot['time_diff_mean'] = (article_hot['time_diff_mean'] - article_hot['time_diff_mean'].min()) / (article_hot['time_diff_mean'].max() - article_hot['time_diff_mean'].min())
# hot_level值越小级别越高,即短时间内文章被用户所点击数目占比较大
article_hot['hot_level'] = article_hot['user_num'] + article_hot['time_diff_mean']
article_hot['click_article_id'] = article_hot['click_article_id'].astype('int')
del article_hot['click_timestamp']
return article_hot
article_hot_fea = hot_level(all_data, cols=['user_id', 'click_article_id', 'click_timestamp'])
article_hot_fea.tail()
click_article_id | user_num | time_diff_mean | hot_level | |
---|---|---|---|---|
33757 | 364015 | 1 | 0.000000 | 1 |
33758 | 364017 | 0.0624395 | 0.057046 | 0.119485 |
33759 | 364028 | 1 | 0.000000 | 1 |
33760 | 364043 | 0.249952 | 0.234179 | 0.484131 |
33761 | 364046 | 0.499968 | 0.772532 | 1.2725 |
用户的系列习惯
这个基于原来的日志表做一个类似于article的那种DataFrame, 存放用户特有的信息, 主要包括点击习惯, 爱好特征之类的
- 用户的设备习惯, 这里取最常用的设备(众数)
- 用户的时间习惯: 根据其点击过得历史文章的时间来做一个统计(这个感觉最好是把时间戳里的时间特征的h特征提出来,看看用户习惯一天的啥时候点击文章), 但这里先用转换的时间吧, 求个均值
- 用户的爱好特征, 对于用户点击的历史文章主题进行用户的爱好判别, 更偏向于哪几个主题, 这个最好是multi-hot进行编码, 先试试行不
- 用户文章的字数差特征, 用户的爱好文章的字数习惯
这些就是对用户进行分组, 然后统计即可
用户设备习惯
def device_fea(all_data, cols):
"""获取每一个用户经常使用的设备信息特征"""
user_device_info = all_data[cols]
# 分组逐列进行
user_device_info = user_device_info.groupby("user_id").agg(lambda x :x.value_counts().index[0]).reset_index()
return user_device_info
device_cols = ["user_id",'click_environment',
'click_deviceGroup', 'click_os', 'click_country', 'click_region',
'click_referrer_type']
user_device_info = device_fea(all_data, device_cols)
user_device_info.tail()
user_id | click_environment | click_deviceGroup | click_os | click_country | click_region | click_referrer_type | |
---|---|---|---|---|---|---|---|
229995 | 249995 | 4 | 1 | 17 | 1 | 13 | 2 |
229996 | 249996 | 4 | 3 | 2 | 1 | 20 | 2 |
229997 | 249997 | 4 | 1 | 17 | 1 | 15 | 5 |
229998 | 249998 | 4 | 1 | 12 | 1 | 13 | 2 |
229999 | 249999 | 4 | 1 | 17 | 1 | 13 | 2 |
用户时间习惯
def user_time_hob_fea(all_data, cols):
user_time_hob_info = all_data[cols]
MMS = MinMaxScaler()
user_time_hob_info["click_timestamp"] = MMS.fit_transform(user_time_hob_info[["click_timestamp"]])
user_time_hob_info["created_at_ts"] = MMS.fit_transform(user_time_hob_info[["created_at_ts"]])
# 每一位用户访问文章的时间均值
user_time_hob_info = user_time_hob_info.groupby("user_id").agg("mean").reset_index() #有区别
user_time_hob_info.rename(columns={"click_timestamp":"user_time_hob1", "created_at_ts":"user_time_hob2"}, inplace=True)
return user_time_hob_info
time_hob_cols = ["user_id", "click_timestamp", "created_at_ts"]
user_time_hob_info = user_time_hob_fea(all_data, time_hob_cols)
user_time_hob_info.tail()
user_id | user_time_hob1 | user_time_hob2 | |
---|---|---|---|
229995 | 249995 | 0.080467 | 0.989999 |
229996 | 249996 | 0.000011 | 0.989092 |
229997 | 249997 | 0.085789 | 0.987338 |
229998 | 249998 | 0.095746 | 0.990157 |
229999 | 249999 | 0.093899 | 0.989754 |
用户的主题爱好
def user_cat_hot_fea(all_data, cols):
"""统计用户的主题爱好"""
user_category_hob_info = all_data[cols]
user_category_hob_info = user_category_hob_info.groupby("user_id").agg(list).reset_index()
user_cat_hob_info = pd.DataFrame()
user_cat_hob_info["user_id"] = user_category_hob_info["user_id"]
user_cat_hob_info["cate_list"] = user_category_hob_info["category_id"]
return user_cat_hob_info
cat_hob_cols = ["user_id", "category_id"]
user_cat_hob_info = user_cat_hot_fea(all_data, cat_hob_cols)
user_cat_hob_info.tail()
user_id | cate_list | |
---|---|---|
229995 | 249995 | [281, 7, 437, 421, 399, 323, 281, 281, 428, 30... |
229996 | 249996 | [281] |
229997 | 249997 | [250, 301, 301, 209, 142, 250, 250] |
229998 | 249998 | [281, 327, 375, 375, 375] |
229999 | 249999 | [281, 281, 431, 281, 375, 375, 281, 348, 421, ... |
用户字数偏爱
# 每一位用户的阅读子数的均值
user_wcou_info = all_data.groupby("user_id")["words_count"].agg("mean").reset_index()
user_wcou_info.rename(columns={"words_count":"word_hbo"}, inplace=True)
user_wcou_info.tail()
user_id | word_hbo | |
---|---|---|
229995 | 249995 | 202.176471 |
229996 | 249996 | 259.000000 |
229997 | 249997 | 211.714286 |
229998 | 249998 | 212.400000 |
229999 | 249999 | 209.210526 |
合并特征信息并保存
user_info = pd.merge(user_act_fea, user_device_info, on="user_id")
user_info = user_info.merge(user_time_hob_info, on="user_id")
user_info = user_info.merge(user_cat_hob_info, on="user_id")
user_info = user_info.merge(user_wcou_info, on="user_id")
user_info.head()
user_id | click_size | time_diff_mean | active_level | click_environment | click_deviceGroup | click_os | click_country | click_region | click_referrer_type | user_time_hob1 | user_time_hob2 | cate_list | word_hbo | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0.499466 | 0.000048 | 0.499515 | 4 | 1 | 17 | 1 | 25 | 2 | 0.343715 | 0.992865 | [281, 26] | 266.0 |
1 | 2 | 0.499466 | 0.000048 | 0.499515 | 4 | 3 | 20 | 1 | 25 | 2 | 0.343651 | 0.992020 | [297, 43] | 210.0 |
2 | 3 | 0.499466 | 0.000048 | 0.499515 | 4 | 3 | 2 | 1 | 25 | 2 | 0.343629 | 0.992774 | [99, 43] | 196.5 |
3 | 4 | 0.499466 | 0.000048 | 0.499515 | 4 | 1 | 12 | 1 | 16 | 1 | 0.343702 | 0.992688 | [66, 67] | 220.0 |
4 | 5 | 0.499466 | 0.000048 | 0.499515 | 4 | 4 | 2 | 1 | 25 | 2 | 0.343598 | 0.992789 | [340, 375] | 226.0 |
# 保存数据
user_info.to_csv(save_path + "user_info.csv", index=False)
合并特征数据
读取已存数据
user_info = pd.read_csv(save_path + 'user_info.csv')
# 对召回数据的特征进行填补,构造更多有用的特征数据类型
if os.path.exists(save_path + 'trn_user_item_feats_df.csv'):
trn_user_item_feats_df = pd.read_csv(save_path + 'trn_user_item_feats_df.csv')
if os.path.exists(save_path + 'tst_user_item_feats_df.csv'):
tst_user_item_feats_df = pd.read_csv(save_path + 'tst_user_item_feats_df.csv')
if os.path.exists(save_path + 'val_user_item_feats_df.csv'):
val_user_item_feats_df = pd.read_csv(save_path + 'val_user_item_feats_df.csv')
else:
val_user_item_feats_df = None
拼接构建的用户特征和文章特征
用户信息
trn_user_item_feats_df = trn_user_item_feats_df.merge(user_info, on='user_id', how='left')
if val_user_item_feats_df is not None:
val_user_item_feats_df = val_user_item_feats_df.merge(user_info, on='user_id', how='left')
else:
val_user_item_feats_df = None
tst_user_item_feats_df = tst_user_item_feats_df.merge(user_info, on='user_id',how='left')
文章信息
articles = pd.read_csv("./data/"+'articles.csv')
articles = reduce_mem(articles)
-- Mem. usage decreased to 5.56 Mb (50.0% reduction),time spend:0.00 min
# 拼上文章特征
trn_user_item_feats_df = trn_user_item_feats_df.merge(articles, left_on='click_article_id', right_on='article_id')
if val_user_item_feats_df is not None:
val_user_item_feats_df = val_user_item_feats_df.merge(articles, left_on='click_article_id', right_on='article_id')
else:
val_user_item_feats_df = None
tst_user_item_feats_df = tst_user_item_feats_df.merge(articles, left_on='click_article_id', right_on='article_id')
召回文章的主题是否在用户的爱好里面
目的:查看召回结果是否优良
trn_user_item_feats_df['is_cat_hab'] = trn_user_item_feats_df.apply(lambda x: 1 if x.category_id in set(x.cate_list) else 0, axis=1)
if val_user_item_feats_df is None:
val_user_item_feats_df['is_cat_hab'] = val_user_item_feats_df.apply(lambda x: 1 if x.category_id in set(x.cate_list) else 0, axis=1)
else:
val_user_item_feats_df = None
tst_user_item_feats_df['is_cat_hab'] = tst_user_item_feats_df.apply(lambda x: 1 if x.category_id in set(x.cate_list) else 0, axis=1)
# 线下验证
del trn_user_item_feats_df['cate_list']
if val_user_item_feats_df is not None:
del val_user_item_feats_df['cate_list']
else:
val_user_item_feats_df = None
del tst_user_item_feats_df['cate_list']
del trn_user_item_feats_df['article_id']
if val_user_item_feats_df is not None:
del val_user_item_feats_df['article_id']
else:
val_user_item_feats_df = None
del tst_user_item_feats_df['article_id']
# 训练验证特征
trn_user_item_feats_df.to_csv(save_path + 'trn_user_item_feats_df.csv', index=False)
if val_user_item_feats_df is not None:
val_user_item_feats_df.to_csv(save_path + 'val_user_item_feats_df.csv', index=False)
tst_user_item_feats_df.to_csv(save_path + 'tst_user_item_feats_df.csv', index=False)
trn_user_item_feats_df.columns
Index(['user_id', 'click_article_id', 'sim0', 'time_diff0', 'word_diff0',
'sim_max', 'sim_min', 'sim_sum', 'sim_mean', 'score', 'rank', 'label',
'click_size', 'time_diff_mean', 'active_level', 'click_environment',
'click_deviceGroup', 'click_os', 'click_country', 'click_region',
'click_referrer_type', 'user_time_hob1', 'user_time_hob2', 'word_hbo',
'category_id', 'created_at_ts', 'words_count', 'is_cat_hab'],
dtype='object')
tst_user_item_feats_df.columns
Index(['user_id', 'click_article_id', 'sim0', 'time_diff0', 'word_diff0',
'sim_max', 'sim_min', 'sim_sum', 'sim_mean', 'score', 'rank', 'label',
'click_size', 'time_diff_mean', 'active_level', 'click_environment',
'click_deviceGroup', 'click_os', 'click_country', 'click_region',
'click_referrer_type', 'user_time_hob1', 'user_time_hob2', 'word_hbo',
'category_id', 'created_at_ts', 'words_count', 'is_cat_hab'],
dtype='object')