特征工程(制作特征和標簽, 轉成監督學習問題)
我們先捋一下基于原始的給定數據, 有哪些特征可以直接利用:
- 文章的自身特征, 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
warnings.filterwarnings('ignore')
df節省內存函數
# 節省內存的一個函數
# 減少內存
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
定義數據路徑
data_path = 'data/'
save_path = 'tmp_results/'
數據讀取
訓練和驗證集的劃分
劃分訓練和驗證集的原因是為了在線下驗證模型參數的好壞,為了完全模擬測試集,我們這里就在訓練集中抽取部分用戶的所有信息來作為驗證集。提前做訓練驗證集劃分的好處就是可以分解制作排序特征時的壓力,一次性做整個數據集的排序特征可能時間會比較長。
# all_click_df指的是訓練集
# sample_user_nums 采樣作為驗證集的用戶數量
def trn_val_split(all_click_df, sample_user_nums):
all_click = all_click_df
all_user_ids = all_click.user_id.unique()
# replace=True表示可以重復抽樣,反之不可以
sample_user_ids = np.random.choice(all_user_ids, size=sample_user_nums, 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中某些用戶只有一個點擊數據的情況,如果該用戶只有一個點擊數據,又被分到ans中,
# 那么訓練集中就沒有這個用戶的點擊數據,出現用戶冷啟動問題,給自己模型驗證帶來麻煩
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(all_click_df, sample_user_nums)
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 + 'itemcf_emb_dict.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'))
讀取各種Embedding
Word2Vec訓練及gensim的使用
Word2Vec主要思想是:一個詞的上下文可以很好的表達出詞的語義。通過無監督學習產生詞向量的方式。word2vec中有兩個非常經典的模型:skip-gram和cbow。
- skip-gram:已知中心詞預測周圍詞。
-
cbow:已知周圍詞預測中心詞。
image-20201106225233086
在使用gensim訓練word2vec的時候,有幾個比較重要的參數
- size: 表示詞向量的維度。
- window:決定了目標詞會與多遠距離的上下文產生關系。
- sg: 如果是0,則是CBOW模型,是1則是Skip-Gram模型。
- workers: 表示訓練時候的線程數量
- min_count: 設置最小的
- iter: 訓練時遍歷整個數據集的次數
注意
- 訓練的時候輸入的語料庫一定要是字符組成的二維數組,如:[['北', '京', '你', '好'], ['上', '海', '你', '好']]
- 使用模型的時候有一些默認值,可以通過在Jupyter里面通過
Word2Vec??
查看
下面是個簡單的測試樣例:
from gensim.models import Word2Vec
doc = [['30760', '157507'],
['289197', '63746'],
['36162', '168401'],
['50644', '36162']]
w2v = Word2Vec(docs, size=12, sg=1, window=2, seed=2020, workers=2, min_count=1, iter=1)
# 查看'30760'表示的詞向量
w2v['30760']
skip-gram和cbow的詳細原理可以參考下面的博客:
- word2vec原理(一) CBOW與Skip-Gram模型基礎
- word2vec原理(二) 基于Hierarchical Softmax的模型
- word2vec原理(三) 基于Negative Sampling的模型
def trian_item_word2vec(click_df, embed_size=64, save_name='item_w2v_emb.pkl', split_char=' '):
click_df = click_df.sort_values('click_timestamp')
# 只有轉換成字符串才可以進行訓練
click_df['click_article_id'] = click_df['click_article_id'].astype(str)
# 轉換成句子的形式
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)
# 這里的參數對訓練得到的向量影響也很大,默認負采樣為5
w2v = Word2Vec(docs, size=16, sg=1, window=5, seed=2020, workers=24, min_count=1, iter=1)
# 保存成字典的形式
item_w2v_emb_dict = {k: w2v[k] for k in click_df['click_article_id']}
pickle.dump(item_w2v_emb_dict, open(save_path + 'item_w2v_emb.pkl', 'wb'))
return item_w2v_emb_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 = trian_item_word2vec(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_path + '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_path, offline=False)
-- 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
對訓練數據做負采樣
通過召回我們將數據轉換成三元組的形式(user1, item1, label)的形式,觀察發現正負樣本差距極度不平衡,我們可以先對負樣本進行下采樣,下采樣的目的一方面緩解了正負樣本比例的問題,另一方面也減小了我們做排序特征的壓力,我們在做負采樣的時候又有哪些東西是需要注意的呢?
- 只對負樣本進行下采樣(如果有比較好的正樣本擴充的方法其實也是可以考慮的)
- 負采樣之后,保證所有的用戶和文章仍然出現在采樣之后的數據中
- 下采樣的比例可以根據實際情況人為的控制
- 做完負采樣之后,更新此時新的用戶召回文章列表,因為后續做特征的時候可能用到相對位置的信息。
其實負采樣也可以留在后面做完特征在進行,這里由于做排序特征太慢了,所以把負采樣的環節提到前面了。
# 將召回列表轉換成df的形式
def recall_dict_2_df(recall_list_dict):
df_row_list = [] # [user, item, score]
for user, recall_list in tqdm(recall_list_dict.items()):
for item, score in recall_list:
df_row_list.append([user, item, score])
col_names = ['user_id', 'sim_item', 'score']
recall_list_df = pd.DataFrame(df_row_list, columns=col_names)
return recall_list_df
# 負采樣函數,這里可以控制負采樣時的比例, 這里給了一個默認的值
def neg_sample_recall_data(recall_items_df, sample_rate=0.001):
pos_data = recall_items_df[recall_items_df['label'] == 1]
neg_data = recall_items_df[recall_items_df['label'] == 0]
print('pos_data_num:', len(pos_data), 'neg_data_num:', len(neg_data), 'pos/neg:', len(pos_data)/len(neg_data))
# 分組采樣函數
def neg_sample_func(group_df):
neg_num = len(group_df)
sample_num = max(int(neg_num * sample_rate), 1) # 保證最少有一個
sample_num = min(sample_num, 5) # 保證最多不超過5個,這里可以根據實際情況進行選擇
return group_df.sample(n=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', 'sim_item'], keep='last')
# 將正樣本數據合并
data_new = pd.concat([pos_data, neg_data_new], ignore_index=True)
return data_new
# 召回數據打標簽
def get_rank_label_df(recall_list_df, label_df, is_test=False):
# 測試集是沒有標簽了,為了后面代碼同一一些,這里直接給一個負數替代
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 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_items_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_items_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
# 讀取召回列表
recall_list_dict = get_recall_list(save_path, single_recall_model='i2i_itemcf') # 這里只選擇了單路召回的結果,也可以選擇多路召回結果
# 將召回數據轉換成df
recall_list_df = recall_dict_2_df(recall_list_dict)
100%|██████████| 250000/250000 [00:12<00:00, 20689.39it/s]
# 給訓練驗證數據打標簽,并負采樣(這一部分時間比較久)
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_num: 64190 neg_data_num: 1935810 pos/neg: 0.03315924600038227
trn_user_item_label_df.label
將召回數據轉換成字典
# 將最終的召回的df數據轉換成字典的形式做排序特征
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
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]))
用戶歷史行為相關特征
對于每個用戶召回的每個商品, 做特征。 具體步驟如下:
- 對于每個用戶, 獲取最后點擊的N個商品的item_id,
- 對于該用戶的每個召回商品, 計算與上面最后N次點擊商品的相似度的和(最大, 最小,均值), 時間差特征,相似性特征,字數差特征,與該用戶的相似性特征
# 下面基于data做歷史相關的特征
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
"""
# 建立一個二維列表保存結果, 后面要轉成DataFrame
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_words_count = articles_info[articles_info['article_id']==article_id]['words_count'].values[0]
single_user_fea = [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_words_count = 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_words_count-b_words_count))
single_user_fea.extend(sim_fea) # 相似性特征
single_user_fea.extend(time_fea) # 時間差特征
single_user_fea.extend(word_fea) # 字數差特征
single_user_fea.extend([max(sim_fea), min(sim_fea), sum(sim_fea), sum(sim_fea) / len(sim_fea)]) # 相似性的統計特征
if user_emb: # 如果用戶向量有的話, 這里計算該召回文章與用戶的相似性特征
single_user_fea.append(np.dot(user_emb[user_id], articles_emb[article_id]))
single_user_fea.extend([score, rank, label])
# 加入到總的表中
all_user_feas.append(single_user_fea)
# 定義列名
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
# 轉成DataFrame
df = pd.DataFrame( all_user_feas, columns=cols)
return df
article_info_df = get_article_info_df()
all_click = click_trn.append(click_tst)
item_content_emb_dict, item_w2v_emb_dict, item_youtube_emb_dict, user_youtube_emb_dict = get_embedding(save_path, 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, article_info_df, 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, article_info_df, 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, article_info_df, item_content_emb_dict)
100%|██████████| 200000/200000 [50:16<00:00, 66.31it/s]
100%|██████████| 50000/50000 [1:07:21<00:00, 12.37it/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()
# 讀取文章特征
articles = pd.read_csv(data_path+'articles.csv')
articles = reduce_mem(articles)
# 日志數據,就是前面的所有數據
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)
# 拼上文章信息
all_data = all_data.merge(articles, left_on='click_article_id', right_on='article_id')
all_data.shape
分析一下點擊時間和點擊文章的次數,區分用戶活躍度
如果某個用戶點擊文章之間的時間間隔比較小, 同時點擊的文章次數很多的話, 那么我們認為這種用戶一般就是活躍用戶, 當然衡量用戶活躍度的方式可能多種多樣, 這里我們只提供其中一種,我們寫一個函數, 得到可以衡量用戶活躍度的特征,邏輯如下:
- 首先根據用戶user_id分組, 對于每個用戶,計算點擊文章的次數, 兩兩點擊文章時間間隔的均值
- 把點擊次數取倒數和時間間隔的均值統一歸一化,然后兩者相加合并,該值越小, 說明用戶越活躍
- 注意, 上面兩兩點擊文章的時間間隔均值, 會出現如果用戶只點擊了一次的情況,這時候時間間隔均值那里會出現空值, 對于這種情況最后特征那里給個大數進行區分
這個的衡量標準就是先把點擊的次數取到數然后歸一化, 然后點擊的時間差歸一化, 然后兩者相加進行合并, 該值越小, 說明被點擊的次數越多, 且間隔時間短。
def active_level(all_data, cols):
"""
制作區分用戶活躍度的特征
:param all_data: 數據集
:param cols: 用到的特征列
"""
data = all_data[cols]
data.sort_values(['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:
return np.mean([j-i for i, j in list(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']
# 兩者歸一化
user_act['click_size'] = (user_act['click_size'] - user_act['click_size'].min()) / (user_act['click_size'].max() - user_act['click_size'].min())
user_act['time_diff_mean'] = (user_act['time_diff_mean'] - user_act['time_diff_mean'].min()) / (user_act['time_diff_mean'].max() - user_act['time_diff_mean'].min())
user_act['active_level'] = user_act['click_size'] + user_act['time_diff_mean']
user_act['user_id'] = user_act['user_id'].astype('int')
del user_act['click_timestamp']
return user_act
user_act_fea = active_level(all_data, ['user_id', 'click_article_id', 'click_timestamp'])
user_act_fea.head()
分析一下點擊時間和被點擊文章的次數, 衡量文章熱度特征
和上面同樣的思路, 如果一篇文章在很短的時間間隔之內被點擊了很多次, 說明文章比較熱門,實現的邏輯和上面的基本一致, 只不過這里是按照點擊的文章進行分組:
- 根據文章進行分組, 對于每篇文章的用戶, 計算點擊的時間間隔
- 將用戶的數量取倒數, 然后用戶的數量和時間間隔歸一化, 然后相加得到熱度特征, 該值越小, 說明被點擊的次數越大且時間間隔越短, 文章比較熱
當然, 這只是給出一種判斷文章熱度的一種方法, 這里大家也可以頭腦風暴一下
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)
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())
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, ['user_id', 'click_article_id', 'click_timestamp'])
article_hot_fea.head()
用戶的系列習慣
這個基于原來的日志表做一個類似于article的那種DataFrame, 存放用戶特有的信息, 主要包括點擊習慣, 愛好特征之類的
- 用戶的設備習慣, 這里取最常用的設備(眾數)
- 用戶的時間習慣: 根據其點擊過得歷史文章的時間來做一個統計(這個感覺最好是把時間戳里的時間特征的h特征提出來,看看用戶習慣一天的啥時候點擊文章), 但這里先用轉換的時間吧, 求個均值
- 用戶的愛好特征, 對于用戶點擊的歷史文章主題進行用戶的愛好判別, 更偏向于哪幾個主題, 這個最好是multi-hot進行編碼, 先試試行不
- 用戶文章的字數差特征, 用戶的愛好文章的字數習慣
這些就是對用戶進行分組, 然后統計即可
用戶的設備習慣
def device_fea(all_data, cols):
"""
制作用戶的設備特征
:param all_data: 數據集
:param 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.head()
用戶的時間習慣
def user_time_hob_fea(all_data, cols):
"""
制作用戶的時間習慣特征
:param all_data: 數據集
:param cols: 用到的特征列
"""
user_time_hob_info = all_data[cols]
# 先把時間戳進行歸一化
mm = MinMaxScaler()
user_time_hob_info['click_timestamp'] = mm.fit_transform(user_time_hob_info[['click_timestamp']])
user_time_hob_info['created_at_ts'] = mm.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
user_time_hob_cols = ['user_id', 'click_timestamp', 'created_at_ts']
user_time_hob_info = user_time_hob_fea(all_data, user_time_hob_cols)
用戶的主題愛好
這里先把用戶點擊的文章屬于的主題轉成一個列表, 后面再總的匯總的時候單獨制作一個特征, 就是文章的主題如果屬于這里面, 就是1, 否則就是0。
def user_cat_hob_fea(all_data, cols):
"""
用戶的主題愛好
:param all_data: 數據集
:param 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
user_category_hob_cols = ['user_id', 'category_id']
user_cat_hob_info = user_cat_hob_fea(all_data, user_category_hob_cols)
用戶的字數偏好特征
user_wcou_info = all_data.groupby('user_id')['words_count'].agg('mean').reset_index()
user_wcou_info.rename(columns={'words_count': 'words_hbo'}, inplace=True)
用戶的信息特征合并保存
# 所有表進行合并
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.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')
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', 'cate_list',
'words_hbo'],
dtype='object')
文章的特征直接讀入
articles = pd.read_csv(data_path+'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 not 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)
總結
特征工程和數據清洗轉換是比賽中至關重要的一塊, 因為數據和特征決定了機器學習的上限,而算法和模型只是逼近這個上限而已,所以特征工程的好壞往往決定著最后的結果,特征工程可以一步增強數據的表達能力,通過構造新特征,我們可以挖掘出數據的更多信息,使得數據的表達能力進一步放大。 在本節內容中,我們主要是先通過制作特征和標簽把預測問題轉成了監督學習問題,然后圍繞著用戶畫像和文章畫像進行一系列特征的制作, 此外,為了保證正負樣本的數據均衡,我們還學習了負采樣就技術等。當然本節內容只是對構造特征提供了一些思路,也請學習者們在學習過程中開啟頭腦風暴,嘗試更多的構造特征的方法,也歡迎我們一塊探討和交流。
這一節先欠著,后續補上,立貼為證