零基礎入門新聞推薦系統(多路召回)

科研YJ發表於2020-11-30

多路召回

所謂的“多路召回”策略,就是指採用不同的策略、特徵或簡單模型,分別召回一部分候選集,然後把候選集混合在一起供後續排序模型使用,可以明顯的看出,“多路召回策略”是在“計算速度”和“召回率”之間進行權衡的結果。其中,各種簡單策略保證候選集的快速召回,從不同角度設計的策略保證召回率接近理想的狀態,不至於損傷排序效果。如下圖是多路召回的一個示意圖,在多路召回中,每個策略之間毫不相關,所以一般可以寫併發多執行緒同時進行,這樣可以更加高效。
在這裡插入圖片描述
傳統的標準召回結構一般是多路召回,如上圖所示。如果我們根據召回路是否有使用者個性化因素存在來劃分,可以分成兩大類:一類是無個性化因素的召回路,比如熱門商品或者熱門文章或者歷史點選率高的物料的召回;另外一類是包含個性化因素的召回路,比如使用者興趣標籤召回。我們應該怎麼看待包含個性化因素的召回路呢?其實吧,你可以這麼看,可以把某個召回路看作是:單特徵模型排序的排序結果。意思是,可以把某路召回,看成是某個排序模型的排序結果,只不過,這個排序模型,在使用者側和物品側只用了一個特徵。比如說,標籤召回,其實就是用使用者興趣標籤和物品標籤進行排序的單特徵排序結果;再比如協同召回,可以看成是隻包含UID和ItemID的兩個特徵的排序結果….諸如此類。

獲取到的使用者全量資料部分展示(1630633 rows × 9 columns)

在這裡插入圖片描述
這裡將使用者點選新聞文章的時間戳進行了歸一化操作。對應的函式是

max_min_scaler = lambda x : (x-np.min(x))/(np.max(x)-np.min(x))

獲取的文章資訊展示(364047 rows × 4 columns)

在這裡插入圖片描述

獲取的文章embedding資訊

在這裡插入圖片描述
key表示的是文章ID,value就是文章對應的embedding向量。

獲取使用者-文章-點選時間字典

# 根據點選時間獲取使用者的點選文章序列   {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_item_time_dict = dict(zip(user_item_time_df['user_id'], user_item_time_df['item_time_list']))
    
    return user_item_time_dict

在這裡插入圖片描述

獲取文章-使用者-點選時間字典

# 根據時間獲取商品被點選的使用者序列  {item1: [(user1, time1), (user2, time2)...]...}
# 這裡的時間是使用者點選當前商品的時間,好像沒有直接的關係。
def get_item_user_time_dict(click_df):
    def make_user_time_pair(df):
        return list(zip(df['user_id'], df['click_timestamp']))
    
    click_df = click_df.sort_values('click_timestamp')
    item_user_time_df = click_df.groupby('click_article_id')['user_id', 'click_timestamp'].apply(lambda x: make_user_time_pair(x))\
                                                            .reset_index().rename(columns={0: 'user_time_list'})
    
    item_user_time_dict = dict(zip(item_user_time_df['click_article_id'], item_user_time_df['user_time_list']))
    return item_user_time_dict

在這裡插入圖片描述

獲取歷史和最後一次點選

這個在評估召回結果, 特徵工程和製作標籤轉成監督學習測試集的時候回用到

# 根據時間獲取商品被點選的使用者序列  {item1: [(user1, time1), (user2, time2)...]...}
# 這裡的時間是使用者點選當前商品的時間,好像沒有直接的關係。
# 獲取當前資料的歷史點選和最後一次點選
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

在這裡插入圖片描述
在這裡插入圖片描述

獲取文章屬性特徵

# 獲取文章id對應的基本屬性,儲存成字典的形式,方便後面召回階段,冷啟動階段直接使用
def get_item_info_dict(item_info_df):
    max_min_scaler = lambda x : (x-np.min(x))/(np.max(x)-np.min(x))
    item_info_df['created_at_ts'] = item_info_df[['created_at_ts']].apply(max_min_scaler)
    
    item_type_dict = dict(zip(item_info_df['click_article_id'], item_info_df['category_id']))
    item_words_dict = dict(zip(item_info_df['click_article_id'], item_info_df['words_count']))
    item_created_time_dict = dict(zip(item_info_df['click_article_id'], item_info_df['created_at_ts']))
    
    return item_type_dict, item_words_dict, item_created_time_dict

在這裡插入圖片描述
在這裡插入圖片描述
在這裡插入圖片描述

獲取點選數量最多的top-k個文章

# 獲取近期點選最多的文章
def get_item_topk_click(click_df, k):
    topk_click = click_df['click_article_id'].value_counts().index[:k]
    return topk_click

文章embedding相似

# 向量檢索相似度計算
# topk指的是每個item, faiss搜尋後返回最相似的topk個item
def embdding_sim(click_df, item_emb_df, save_path, topk):
    """
        基於內容的文章embedding相似性矩陣計算
        :param click_df: 資料表
        :param item_emb_df: 文章的embedding
        :param save_path: 儲存路徑
        :patam topk: 找最相似的topk篇
        return 文章相似性矩陣
        
        思路: 對於每一篇文章, 基於embedding的相似性返回topk個與其最相似的文章, 只不過由於文章數量太多,這裡用了faiss進行加速
    """
    
    # 文章索引與文章id的字典對映
    item_idx_2_rawid_dict = dict(zip(item_emb_df.index, item_emb_df['article_id']))
    
    item_emb_cols = [x for x in item_emb_df.columns if 'emb' in x]
    item_emb_np = np.ascontiguousarray(item_emb_df[item_emb_cols].values, dtype=np.float32)
    # 向量進行單位化
    item_emb_np = item_emb_np / np.linalg.norm(item_emb_np, axis=1, keepdims=True)
    
    # 建立faiss索引
    item_index = faiss.IndexFlatIP(item_emb_np.shape[1])
    item_index.add(item_emb_np)
    # 相似度查詢,給每個索引位置上的向量返回topk個item以及相似度
    sim, idx = item_index.search(item_emb_np, topk) # 返回的是列表
    
    # 將向量檢索的結果儲存成原始id的對應關係
    item_sim_dict = collections.defaultdict(dict)
    for target_idx, sim_value_list, rele_idx_list in tqdm(zip(range(len(item_emb_np)), sim, idx)):
        target_raw_id = item_idx_2_rawid_dict[target_idx]
        # 從1開始是為了去掉商品本身, 所以最終獲得的相似商品只有topk-1
        for rele_idx, sim_value in zip(rele_idx_list[1:], sim_value_list[1:]): 
            rele_raw_id = item_idx_2_rawid_dict[rele_idx]
            item_sim_dict[target_raw_id][rele_raw_id] = item_sim_dict.get(target_raw_id, {}).get(rele_raw_id, 0) + sim_value
    
    # 儲存i2i相似度矩陣
    pickle.dump(item_sim_dict, open(save_path + 'emb_i2i_sim.pkl', 'wb'))   
    
    return item_sim_dict

在這裡插入圖片描述
召回
這個就是我們開篇提到的那個問題, 面的36萬篇文章, 20多萬使用者的推薦, 我們又有哪些策略來縮減問題的規模? 我們就可以再召回階段篩選出使用者對於點選文章的候選集合, 從而降低問題的規模。召回常用的策略:

Youtube DNN 召回
基於文章的召回
文章的協同過濾
基於文章embedding的召回
基於使用者的召回
使用者的協同過濾
使用者embedding

上面的各種召回方式一部分在基於使用者已經看得文章的基礎上去召回與這些文章相似的一些文章, 而這個相似性的計算方式不同, 就得到了不同的召回方式, 比如文章的協同過濾, 文章內容的embedding等。還有一部分是根據使用者的相似性進行推薦,對於某使用者推薦與其相似的其他使用者看過的文章,比如使用者的協同過濾和使用者embedding。 還有一種思路是類似矩陣分解的思路,先計算出使用者和文章的embedding之後,就可以直接算使用者和文章的相似度, 根據這個相似度進行推薦, 比如YouTube DNN。 我們下面詳細來看一下每一個召回方法:

# 獲取雙塔召回時的訓練驗證資料
# negsample指的是通過滑窗構建樣本的時候,負樣本的數量
def gen_data_set(data, negsample=0):
    data.sort_values("click_timestamp", inplace=True)
    item_ids = data['click_article_id'].unique()

    train_set = []
    test_set = []
    for reviewerID, hist in tqdm(data.groupby('user_id')):
        pos_list = hist['click_article_id'].tolist()
        
        if negsample > 0:
            candidate_set = list(set(item_ids) - set(pos_list))   # 使用者沒看過的文章裡面選擇負樣本
            neg_list = np.random.choice(candidate_set,size=len(pos_list)*negsample,replace=True)  # 對於每個正樣本,選擇n個負樣本
            
        # 長度只有一個的時候,需要把這條資料也放到訓練集中,不然的話最終學到的embedding就會有缺失
        if len(pos_list) == 1:
            train_set.append((reviewerID, [pos_list[0]], pos_list[0],1,len(pos_list)))
            test_set.append((reviewerID, [pos_list[0]], pos_list[0],1,len(pos_list)))
            
        # 滑窗構造正負樣本
        for i in range(1, len(pos_list)):
            hist = pos_list[:i]
            
            if i != len(pos_list) - 1:
                train_set.append((reviewerID, hist[::-1], pos_list[i], 1, len(hist[::-1])))  # 正樣本 [user_id, his_item, pos_item, label, len(his_item)]
                for negi in range(negsample):
                    train_set.append((reviewerID, hist[::-1], neg_list[i*negsample+negi], 0,len(hist[::-1]))) # 負樣本 [user_id, his_item, neg_item, label, len(his_item)]
            else:
                # 將最長的那一個序列長度作為測試資料
                test_set.append((reviewerID, hist[::-1], pos_list[i],1,len(hist[::-1])))
                
    random.shuffle(train_set)
    random.shuffle(test_set)
    
    return train_set, test_set

# 將輸入的資料進行padding,使得序列特徵的長度都一致
def gen_model_input(train_set,user_profile,seq_max_len):

    train_uid = np.array([line[0] for line in train_set])
    train_seq = [line[1] for line in train_set]
    train_iid = np.array([line[2] for line in train_set])
    train_label = np.array([line[3] for line in train_set])
    train_hist_len = np.array([line[4] for line in train_set])

    train_seq_pad = pad_sequences(train_seq, maxlen=seq_max_len, padding='post', truncating='post', value=0)
    train_model_input = {"user_id": train_uid, "click_article_id": train_iid, "hist_article_id": train_seq_pad,
                         "hist_len": train_hist_len}

    return train_model_input, train_label
def youtubednn_u2i_dict(data, topk=20):    
    sparse_features = ["click_article_id", "user_id"]
    SEQ_LEN = 30 # 使用者點選序列的長度,短的填充,長的截斷
    
    user_profile_ = data[["user_id"]].drop_duplicates('user_id')
    item_profile_ = data[["click_article_id"]].drop_duplicates('click_article_id')  
    
    # 類別編碼
    features = ["click_article_id", "user_id"]
    feature_max_idx = {}
    
    for feature in features:
        lbe = LabelEncoder()
        data[feature] = lbe.fit_transform(data[feature])
        feature_max_idx[feature] = data[feature].max() + 1
    
    # 提取user和item的畫像,這裡具體選擇哪些特徵還需要進一步的分析和考慮
    user_profile = data[["user_id"]].drop_duplicates('user_id')
    item_profile = data[["click_article_id"]].drop_duplicates('click_article_id')  
    
    user_index_2_rawid = dict(zip(user_profile['user_id'], user_profile_['user_id']))
    item_index_2_rawid = dict(zip(item_profile['click_article_id'], item_profile_['click_article_id']))
    
    # 劃分訓練和測試集
    # 由於深度學習需要的資料量通常都是非常大的,所以為了保證召回的效果,往往會通過滑窗的形式擴充訓練樣本
    train_set, test_set = gen_data_set(data, 0)
    # 整理輸入資料,具體的操作可以看上面的函式
    train_model_input, train_label = gen_model_input(train_set, user_profile, SEQ_LEN)
    test_model_input, test_label = gen_model_input(test_set, user_profile, SEQ_LEN)
    
    # 確定Embedding的維度
    embedding_dim = 16
    
    # 將資料整理成模型可以直接輸入的形式
    user_feature_columns = [SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim),
                            VarLenSparseFeat(SparseFeat('hist_article_id', feature_max_idx['click_article_id'], embedding_dim,
                                                        embedding_name="click_article_id"), SEQ_LEN, 'mean', 'hist_len'),]
    item_feature_columns = [SparseFeat('click_article_id', feature_max_idx['click_article_id'], embedding_dim)]
    
    # 模型的定義 
    # num_sampled: 負取樣時的樣本數量
    model = YoutubeDNN(user_feature_columns, item_feature_columns, num_sampled=5, user_dnn_hidden_units=(64, embedding_dim))
    # 模型編譯
    model.compile(optimizer="adam", loss=sampledsoftmaxloss)  
    
    # 模型訓練,這裡可以定義驗證集的比例,如果設定為0的話就是全量資料直接進行訓練
    history = model.fit(train_model_input, train_label, batch_size=256, epochs=1, verbose=1, validation_split=0.0)
    
    # 訓練完模型之後,提取訓練的Embedding,包括user端和item端
    test_user_model_input = test_model_input
    all_item_model_input = {"click_article_id": item_profile['click_article_id'].values}

    user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
    item_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
    
    # 儲存當前的item_embedding 和 user_embedding 排序的時候可能能夠用到,但是需要注意儲存的時候需要和原始的id對應
    user_embs = user_embedding_model.predict(test_user_model_input, batch_size=2 ** 12)
    item_embs = item_embedding_model.predict(all_item_model_input, batch_size=2 ** 12)
    
    # embedding儲存之前歸一化一下
    user_embs = user_embs / np.linalg.norm(user_embs, axis=1, keepdims=True)
    item_embs = item_embs / np.linalg.norm(item_embs, axis=1, keepdims=True)
    
    # 將Embedding轉換成字典的形式方便查詢
    raw_user_id_emb_dict = {user_index_2_rawid[k]: \
                                v for k, v in zip(user_profile['user_id'], user_embs)}
    raw_item_id_emb_dict = {item_index_2_rawid[k]: \
                                v for k, v in zip(item_profile['click_article_id'], item_embs)}
    # 將Embedding儲存到本地
    pickle.dump(raw_user_id_emb_dict, open(save_path + 'user_youtube_emb.pkl', 'wb'))
    pickle.dump(raw_item_id_emb_dict, open(save_path + 'item_youtube_emb.pkl', 'wb'))
    
    # faiss緊鄰搜尋,通過user_embedding 搜尋與其相似性最高的topk個item
    index = faiss.IndexFlatIP(embedding_dim)
    # 上面已經進行了歸一化,這裡可以不進行歸一化了
#     faiss.normalize_L2(user_embs)
#     faiss.normalize_L2(item_embs)
    index.add(item_embs) # 將item向量構建索引
    sim, idx = index.search(np.ascontiguousarray(user_embs), topk) # 通過user去查詢最相似的topk個item
    
    user_recall_items_dict = collections.defaultdict(dict)
    for target_idx, sim_value_list, rele_idx_list in tqdm(zip(test_user_model_input['user_id'], sim, idx)):
        target_raw_id = user_index_2_rawid[target_idx]
        # 從1開始是為了去掉商品本身, 所以最終獲得的相似商品只有topk-1
        for rele_idx, sim_value in zip(rele_idx_list[1:], sim_value_list[1:]): 
            rele_raw_id = item_index_2_rawid[rele_idx]
            user_recall_items_dict[target_raw_id][rele_raw_id] = user_recall_items_dict.get(target_raw_id, {})\
                                                                    .get(rele_raw_id, 0) + sim_value
            
    user_recall_items_dict = {k: sorted(v.items(), key=lambda x: x[1], reverse=True) for k, v in user_recall_items_dict.items()}
    # 將召回的結果進行排序
    
    # 儲存召回的結果
    # 這裡是直接通過向量的方式得到了召回結果,相比於上面的召回方法,上面的只是得到了i2i及u2u的相似性矩陣,還需要進行協同過濾召回才能得到召回結果
    # 可以直接對這個召回結果進行評估,為了方便可以統一寫一個評估函式對所有的召回結果進行評估
    pickle.dump(user_recall_items_dict, open(save_path + 'youtube_u2i_dict.pkl', 'wb'))
    return user_recall_items_dict
# 由於這裡需要做召回評估,所以講訓練集中的最後一次點選都提取了出來
if not metric_recall:
    user_multi_recall_dict['youtubednn_recall'] = youtubednn_u2i_dict(all_click_df, topk=20)
else:
    trn_hist_click_df, trn_last_click_df = get_hist_and_last_click(all_click_df)
    user_multi_recall_dict['youtubednn_recall'] = youtubednn_u2i_dict(trn_hist_click_df, topk=20)
    # 召回效果評估
    metrics_recall(user_multi_recall_dict['youtubednn_recall'], trn_last_click_df, topk=20)

相關文章