推薦系統-新聞推薦之推薦

特徵工程

咱們製做特徵和標籤,將推薦問題轉成監督學習問題。
咱們先回顧一下現有數據,有哪些特徵能夠直接利用:html

  1. 文章的自身特徵: category_id表示這文章的類型, created_at_ts表示文章創建的時間, 這個關係着文章的時效性, words_count是文章的字數, 通常字數太長咱們不太喜歡點擊, 也不排除有人就喜歡讀長文。
  2. 文章的內容embedding特徵: 這個召回的時候用過, 這裏能夠選擇使用, 也能夠選擇不用, 也能夠嘗試其餘類型的embedding特徵, 好比W2V等
  3. 用戶的設備特徵信息

上面這些直接能夠用的特徵, 待作完特徵工程以後, 直接就能夠根據article_id或者是user_id把這些特徵加入進去。 可是咱們須要先基於召回的結果,構造一些特徵,而後製做標籤,造成一個監督學習的數據集。python

構造監督數據集的思路, 根據召回結果, 咱們會獲得一個{user_id: [可能點擊的文章列表]}形式的字典。 那麼咱們就能夠對於每一個用戶, 每篇可能點擊的文章構造一個監督測試集, 好比對於用戶user1, 假設獲得的他的召回列表{user1: [item1, item2, item3]}, 咱們就能夠獲得三行數據(user1, item1), (user1, item2), (user1, item3)的形式, 這就是監督測試集時候的前兩列特徵。算法

構造特徵的思路是這樣, 咱們知道每一個用戶的點擊文章是與其歷史點擊的文章信息是有很大關聯的, 好比同一個主題, 類似等等。 因此特徵構造這塊很重要的一系列特徵是要結合用戶的歷史點擊文章信息。咱們已經獲得了每一個用戶及點擊候選文章的兩列的一個數據集, 而咱們的目的是要預測最後一次點擊的文章, 比較天然的一個思路就是和其最後幾回點擊的文章產生關係, 這樣既考慮了其歷史點擊文章信息, 又得離最後一次點擊較近,由於新聞很大的一個特色就是注重時效性。 每每用戶的最後一次點擊會和其最後幾回點擊有很大的關聯。 因此咱們就能夠對於每一個候選文章, 作出與最後幾回點擊相關的特徵以下:數組

  1. 候選item與最後幾回點擊的類似性特徵(embedding內積) — 這個直接關聯用戶歷史行爲
  2. 候選item與最後幾回點擊的類似性特徵的統計特徵 — 統計特徵能夠減小一些波動和異常
  3. 候選item與最後幾回點擊文章的字數差的特徵 — 能夠經過字數看用戶偏好
  4. 候選item與最後幾回點擊的文章創建的時間差特徵 — 時間差特徵能夠看出該用戶對於文章的實時性的偏好

還須要考慮一下
5. 若是使用了youtube召回的話, 咱們還能夠製做用戶與候選item的類似特徵app

固然, 上面只是提供了一種基於用戶歷史行爲作特徵工程的思路, 你們也能夠思惟風暴一下,嘗試一些其餘的特徵。 下面咱們就實現上面的這些特徵的製做, 下面的邏輯是這樣:dom

  1. 咱們首先得到用戶的最後一次點擊操做和用戶的歷史點擊, 這個基於咱們的日誌數據集作
  2. 基於用戶的歷史行爲製做特徵, 這個會用到用戶的歷史點擊表, 最後的召回列表, 文章的信息表和embedding向量
  3. 製做標籤, 造成最後的監督學習數據集

好了,廢話很少說機器學習

導入庫

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

數據存放位置以及結果輸出位置函數

data_dir = './data'
save_dir = './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(click_trn_data , 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.png
在使用gensim訓練word2vec的時候,有幾個比較重要的參數

  • size: 表示詞向量的維度。
  • window:決定了目標詞會與多遠距離的上下文產生關係。
  • sg: 若是是0,則是CBOW模型,是1則是Skip-Gram模型。
  • workers: 表示訓練時候的線程數量
  • min_count: 設置最小的
  • iter: 訓練時遍歷整個數據集的次數

注意

  1. 訓練的時候輸入的語料庫必定要是字符組成的二維數組,如:[[‘北’, ‘京’, ‘你’, ‘好’], [‘上’, ‘海’, ‘你’, ‘好’]]
  2. 使用模型的時候有一些默認值,能夠經過在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的詳細原理能夠參考下面的博客:

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)

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)的形式,觀察發現正負樣本差距極度不平衡,咱們能夠先對負樣本進行下采樣,下采樣的目的一方面緩解了正負樣本比例的問題,另外一方面也減少了咱們作排序特徵的壓力,咱們在作負採樣的時候又有哪些東西是須要注意的呢?

  1. 只對負樣本進行下采樣(若是有比較好的正樣本擴充的方法其實也是能夠考慮的)
  2. 負採樣以後,保證全部的用戶和文章仍然出如今採樣以後的數據中
  3. 下采樣的比例能夠根據實際狀況人爲的控制
  4. 作完負採樣以後,更新此時新的用戶召回文章列表,由於後續作特徵的時候可能用到相對位置的信息。

其實負採樣也能夠留在後面作完特徵在進行,這裏因爲作排序特徵太慢了,因此把負採樣的環節提到前面了。

# 將召回列表轉換成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)
# 給訓練驗證數據打標籤,並負採樣(這一部分時間比較久)
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)
將召回數據轉換成字典
# 將最終的召回的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)
# 獲取訓練驗證及測試數據中召回列文章相關特徵
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)
# 保存一份省的每次都要從新跑,每次跑的時間都比較長
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)

用戶和文章特徵

用戶相關特徵

這一塊,正式進行特徵工程,既要拼接上已有的特徵, 也會作更多的特徵出來,咱們來梳理一下已有的特徵和可構造特徵:

  1. 文章自身的特徵, 文章字數,文章建立時間, 文章的embedding (articles表中)
  2. 用戶點擊環境特徵, 那些設備的特徵(這個在df中)
  3. 對於用戶和商品還能夠構造的特徵:

    • 基於用戶的點擊文章次數和點擊時間構造能夠表現用戶活躍度的特徵
    • 基於文章被點擊次數和時間構造能夠反映文章熱度的特徵
    • 用戶的時間統計特徵: 根據其點擊的歷史文章列表的點擊時間和文章的建立時間作統計特徵,好比求均值, 這個能夠反映用戶對於文章時效的偏好
    • 用戶的主題愛好特徵, 對於用戶點擊的歷史文章主題進行一個統計, 而後對於當前文章看看是否屬於用戶已經點擊過的主題
    • 用戶的字數愛好特徵, 對於用戶點擊的歷史文章的字數統計, 求一個均值
# 讀取文章特徵
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')

分析一下點擊時間和點擊文章的次數,區分用戶活躍度
若是某個用戶點擊文章之間的時間間隔比較小, 同時點擊的文章次數不少的話, 那麼咱們認爲這種用戶通常就是活躍用戶, 固然衡量用戶活躍度的方式可能多種多樣, 這裏咱們只提供其中一種,咱們寫一個函數, 獲得能夠衡量用戶活躍度的特徵,邏輯以下:

首先根據用戶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'])
分析一下點擊時間和被點擊文章的次數, 衡量文章熱度特徵

和上面一樣的思路, 若是一篇文章在很短的時間間隔以內被點擊了不少次, 說明文章比較熱門,實現的邏輯和上面的基本一致, 只不過這裏是按照點擊的文章進行分組:

  1. 根據文章進行分組, 對於每篇文章的用戶, 計算點擊的時間間隔
  2. 將用戶的數量取倒數, 而後用戶的數量和時間間隔歸一化, 而後相加獲得熱度特徵, 該值越小, 說明被點擊的次數越大且時間間隔越短, 文章比較熱
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的那種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)
用戶的時間習慣
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')
文章的特徵直接讀入
articles =  pd.read_csv(data_path+'articles.csv')
articles = reduce_mem(articles)
# 拼上文章特徵
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']
保存特徵

特徵工程和數據清洗轉換是比賽中相當重要的一塊, 由於數據和特徵決定了機器學習的上限,而算法和模型只是逼近這個上限而已,因此特徵工程的好壞每每決定着最後的結果,特徵工程能夠一步加強數據的表達能力,經過構造新特徵,咱們能夠挖掘出數據的更多信息,使得數據的表達能力進一步放大。 在本節內容中,咱們主要是先經過製做特徵和標籤把預測問題轉成了監督學習問題,而後圍繞着用戶畫像和文章畫像進行一系列特徵的製做, 此外,爲了保證正負樣本的數據均衡,咱們還學習了負採樣就技術等。

相關文章
相關標籤/搜索