youtobe召回--双塔模型
实战使用的数据集是 MovieLens 1M,使用其中 5 个 user 特征 'user_id', 'gender', 'age', 'occupation', 'zip',2个 item 特征 "movie_id", "cate_id",一共 7 个sparse特征。
任务描述:已知用户过去的电影观看(评论)行为,预测该用户观看(评论)另外一种电影的可能性(在本文中,用 0/1 的标签表示预测和真实结果,即经典的二分类问题)。
1.1 数据载入和预处理
首先,要载入相关的库,并固定随机数种子便于复现。
1.2 特征工程
我们使用两种类别的特征,分别是稀疏特征(SparseFeature)和序列特征(SequenceFeature)。
-
对于稀疏特征,是一个离散的、有限的值(例如用户ID,一般会先进行LabelEncoding 操作转化为连续整数值),模型将其输入到 Embedding 层,输出一个 Embedding 向量。
-
对于序列特征,每一个样本是一个List[SparseFeature](一般是观看历史、搜索历史等),对于这种特征,默认对于每一个元素取 Embedding 后平均,输出一个 Embedding 向量。此外,除了平均,还有拼接,最值等方式,可以在pooling参数中指定。
特征工程
对电影类型--处理genres特征,取出其第一个作为标签
使用LabelEncoder()进行特征映射--'user_id', 'movie_id', 'gender', 'age', 'occupation', 'zip', "cate_id"分别按照数量进行分类----进行去重--同时保留索引库
通过函数generate_seq_feature_match确定训练集和测试集--df_train--[user_id, movie_id, [正采样],正采样个数,负采样集合]--结合item-user_profile对正采样进行补充
正样本是用户的第一个样本---负采样是将item对于频率做出排序,得到一个集合,此时先对频率做对数+归一化处理,将得到的值做为movie_id出现的概率进行负采样----对于youtobe召回,选择listwise--选择40个样本
得到训练数据之后,进行embeding处理---用户的hist_movie_id特征和物品的movie_id特征共享一个embeding层权重。
Label Encoding
Emebedding 是模型训练出来的,而不是通过预训练模型直接载入的。
user/item tower seting
在 DSSM 中,分为用户塔和物品塔,每一个塔的输出是用户/物品的特征拼接后经过 MLP(多层感知机)得到的。我们需要定义用户塔和物品塔都有哪些特征:
sequence feature 生成----得到训练数据之后,进行embeding处理
本数据集中的序列特征为观看历史,根据timestamp来生成,具体在generate_seq_feature_match函数中实现。参数含义如下:
-
mode表示样本的训练方式(0 - point wise, 1 - pair wise, 2 - list wise)
-
neg_ratio表示每个正样本对应的负样本数量
-
min_item限制每个用户最少的样本量,小于此值将会被抛弃,当做冷启动用户处理(框架中还未添加冷启动的处理,这里直接抛弃)
-
sample_method表示负采样方法
用户的hist_movie_id特征和物品的movie_id特征共享一个embeding层权重。
输入 :[ 正样本,负样本1,负样本2, ... , 负样本40]
label : [1,0,0,...,0] (1个1,40零)
1.3 训练模型
根据之前的x_train字典和y_train等数据生成训练用的Dataloader(train_dl)。
user_features表示用户塔有哪些特征,user_params表示用户塔的MLP的各层维度和激活函数。(Note:在这个样例中激活函数的选取对最终结果影响很大)
定义一个召回训练器 MatchTrainer,进行模型的训练。
YoutubeDNN算法--
user_tower--embedding---MLP
item_tower--embedding[结合了正样本和负样本的]---MLP
-----计算这两个的余弦相似度,再除温度系数------输出y
损失函数-----torch.nn.CrossEntropyLoss()---交叉熵损失函数
1.4 向量化召回-评估
使用trainer获取测试集中每个user的embedding和数据集中所有物品的embedding集合
用annoy构建物品embedding索引,对每个用户向量进行ANN(Approximate Nearest Neighbors)召回K个物品
按照topk进行召回,并返回评估指标,一般看recall、precision、hit
import torch import pandas as pd import numpy as np import os pd.set_option('display.max_rows',500) pd.set_option('display.max_columns',50) pd.set_option('display.width',1000) torch.manual_seed(2022) users_file = 'ml-1m/users.dat' movies_file = 'ml-1m/movies.dat' ratings_file = 'ml-1m/ratings.dat' users_data = pd.read_csv(users_file, sep='::',header=None,engine="python", encoding='ISO-8859-1',names=['user_id','gender','age','occupation','zip']) movies_data = pd.read_csv(movies_file, sep='::',header=None,engine="python", encoding='ISO-8859-1',names=['movie_id','title','genres']) print(users_data.head()) print(movies_data.head()) print(users_data.shape,movies_data.shape) ratings_data = pd.read_csv(ratings_file, sep='::',header=None,engine="python", encoding='ISO-8859-1',names=['user_id','movie_id','rating','timestamp']) print(ratings_data.head()) data = pd.merge(pd.merge(ratings_data, users_data), movies_data) def get_movielens_data(data, load_cache=False): ### 处理genres特征,取出其第一个作为标签 data["cate_id"] = data["genres"].apply(lambda x: x.split("|")[0]) # 指定用户列和物品列的名字、离散和稠密特征,适配框架的接口 sparse_features = ['user_id', 'movie_id', 'gender', 'age', 'occupation', 'zip', "cate_id"] user_col, item_col = "user_id", "movie_id" feature_max_idx = {} for feature in sparse_features: lbe = LabelEncoder() # 删除 0 值 ---- 拟合得到特征映射 data[feature] = lbe.fit_transform(data[feature]) + 1 # 多出来的 1 应该是为了 unseen 类别做保留,比如新商品、新用户-----记录特征的最大数值 feature_max_idx[feature] = data[feature].max() + 1 # lbe.classes_的值会随着 lbe.fit_transform 处理的数据而变化,有对应关系;leb.classes_是类属性 # evaluation时会用到-------暂时不知道有什么用-----*******保留的索引库*****---进行召回 if feature == user_col: #encode user id: raw user id user_map = {encode_id + 1: raw_id for encode_id, raw_id in enumerate(lbe.classes_)} if feature == item_col: #encode item id: raw item id item_map = {encode_id + 1: raw_id for encode_id, raw_id in enumerate(lbe.classes_)} np.save("raw_id_maps.npy", np.array((user_map, item_map),dtype=object)) ##取出数据,并对数据去重 ###---用户数据 user_profile = data[["user_id", "gender", "age", "occupation", "zip"]].drop_duplicates('user_id') ###---内容数据 item_profile = data[["movie_id", "cate_id"]].drop_duplicates('movie_id') if load_cache: #if you have run this script before and saved the preprocessed data x_train, y_train, x_test, y_test = np.load("data_preprocess.npy", allow_pickle=True) else: df_train, df_test = generate_seq_feature_match(data, ##整体数据 user_col, ##用户user_id item_col, ##物品movie_id time_col="timestamp",##时间戳 item_attribute_cols=[],## sample_method=2,###负采样方法 mode=2, ###损失函数---训练方式--- neg_ratio=40,###每个正样本对应负样本的数量 min_item=0) ###限制用户最少样本量 ##取用户样本的第一个作为正样本 ##涉及填充序列,矩阵转字典 x_train = gen_model_input(df_train, user_profile, user_col, item_profile, item_col, seq_max_len=20) #label=0 means the first pred value is positive sample y_train = np.array([0] * df_train.shape[0]) x_test = gen_model_input(df_test, user_profile, user_col, item_profile, item_col, seq_max_len=20) y_test= np.array([0] * df_test.shape[0]) ##保存一个处理过后的文件 np.save("data_preprocess.npy", np.array((x_train, y_train, x_test, y_test), dtype=object)) user_cols = ['user_id', 'gender', 'age', 'occupation', 'zip'] item_cols = ['movie_id', "cate_id"] ##--用户的稀疏特征###--vocab_size即为最大的特征数量,每个生成embeding为16 ###--embeding--[vocab_size,16] user_features = [SparseFeature(name, vocab_size=feature_max_idx[name], embed_dim=16) for name in user_cols] ##--用户的序列特征###--序列特征--即为采取的负样本数量 ###--embeding--[vocab_size,16]--pooling user_features += [ SequenceFeature("hist_movie_id", vocab_size=feature_max_idx["movie_id"], embed_dim=16, pooling="mean", shared_with="movie_id")] ##--物品的稀疏特征 item_features = [SparseFeature('movie_id', vocab_size=feature_max_idx['movie_id'], embed_dim=16)] neg_item_feature = [ SequenceFeature('neg_items', vocab_size=feature_max_idx['movie_id'], embed_dim=16, pooling="concat", shared_with="movie_id")] ###--可以注意,这时,user和item用的是相同的embeding ##--输出作为字典 all_item = df_to_dict(item_profile) ##--测试 test_user = x_test return user_features, item_features, neg_item_feature, x_train, y_train, all_item, test_user user_features, item_features, neg_item_feature, x_train, y_train, all_item, test_user = get_movielens_data(data,load_cache=False) dg = MatchDataGenerator(x=x_train, y=y_train) model_name="yotube" epoch=5 learning_rate=0.0001 batch_size=32 weight_decay=0.0001 device="cuda:0" save_dir="youtobe" seed=1024 if not os.path.exists(save_dir): os.makedirs(save_dir) torch.manual_seed(seed) ##mlp--最后要对应于embeding--16 model = YoutubeDNN(user_features, item_features, neg_item_feature, user_params={"dims": [128, 64, 16]}, temperature=0.5) #mode=2,对应于list-wise trainer = MatchTrainer(model, mode=2, optimizer_params={ "lr": learning_rate, "weight_decay": weight_decay }, n_epoch=epoch, device=device, model_path=save_dir) train_dl, test_dl, item_dl = dg.generate_dataloader(test_user, all_item, batch_size=batch_size) trainer.fit(train_dl) import collections import numpy as np import pandas as pd from torch_rechub.utils.match import Annoy from torch_rechub.basic.metric import topk_metrics from collections import Counter def match_evaluation(user_embedding, item_embedding, test_user, all_item, user_col='user_id', item_col='movie_id', raw_id_maps="raw_id_maps.npy", topk=10): ##user_embedding--用户embedding---item_embedding--物品embedding---test_user--所有特征--all_item--物品特征 print("评估:embedding在测试数据") annoy = Annoy(n_trees=10) annoy.fit(item_embedding) #for each user of test dataset, get ann search topk result print("topk召回") user_map, item_map = np.load(raw_id_maps, allow_pickle=True) match_res = collections.defaultdict(dict) # user id -> predicted item ids for user_id, user_emb in zip(test_user[user_col], user_embedding): if len(user_emb.shape)==2: #多特征召回 items_idx = [] items_scores = [] for i in range(user_emb.shape[0]): ###--ann召回 # the index of topk match items temp_items_idx, temp_items_scores = annoy.query(v=user_emb[i], n=topk)# the index of topk match items items_idx += temp_items_idx items_scores += temp_items_scores temp_df = pd.DataFrame() temp_df['item'] = items_idx temp_df['score'] = items_scores ###通过score排序 temp_df = temp_df.sort_values(by='score', ascending=True) ###得到的进行去重 temp_df = temp_df.drop_duplicates(subset=['item'], keep='first', inplace=False) ###topk选取 recall_item_list = temp_df['item'][:topk].values match_res[user_map[user_id]] = np.vectorize(item_map.get)(all_item[item_col][recall_item_list]) else: #普通召回 ##user_emb--user_id的映射 items_idx, items_scores = annoy.query(v=user_emb, n=topk) #the index of topk match items ##item_map--movie_id的映射 match_res[user_map[user_id]] = np.vectorize(item_map.get)(all_item[item_col][items_idx]) #get ground truth print("取出真实数据") data = pd.DataFrame({user_col: test_user[user_col], item_col: test_user[item_col]}) ##对物品和用户特征进行映射 data[user_col] = data[user_col].map(user_map) data[item_col] = data[item_col].map(item_map) ##按照用户分组,并重新生成列表,重置分组操作后的索引 user_pos_item = data.groupby(user_col).agg(list).reset_index() ground_truth = dict(zip(user_pos_item[user_col], user_pos_item[item_col])) # user id -> ground truth print("计算topk评估值") out = topk_metrics(y_true=ground_truth, y_pred=match_res, topKs=[topk]) print(out) print("inference embedding") user_embedding = trainer.inference_embedding(model=model, mode="user", data_loader=test_dl, model_path=save_dir) item_embedding = trainer.inference_embedding(model=model, mode="item", data_loader=item_dl, model_path=save_dir) match_evaluation(user_embedding, item_embedding, test_user, all_item, topk=100)
DSSM召回--双塔模型
1.1 数据载入和预处理
首先,要载入相关的库,并固定随机数种子便于复现。
1.2 特征工程
我们使用两种类别的特征,分别是稀疏特征(SparseFeature)和序列特征(SequenceFeature)。
-
对于稀疏特征,是一个离散的、有限的值(例如用户ID,一般会先进行LabelEncoding 操作转化为连续整数值),模型将其输入到 Embedding 层,输出一个 Embedding 向量。
-
对于序列特征,每一个样本是一个List[SparseFeature](一般是观看历史、搜索历史等),对于这种特征,默认对于每一个元素取 Embedding 后平均,输出一个 Embedding 向量。此外,除了平均,还有拼接,最值等方式,可以在pooling参数中指定。
Label Encoding
在本案例中,Emebedding 是模型训练出来的,而不是通过预训练模型直接载入的。
user/item tower seting
在 DSSM 中,分为用户塔和物品塔,每一个塔的输出是用户/物品的特征拼接后经过 MLP(多层感知机)得到的。我们需要定义用户塔和物品塔都有哪些特征:
sequence feature 生成
用户的hist_movie_id特征和物品的movie_id特征共享一个embeding层权重。
对电影类型--处理genres特征,取出其第一个作为标签
使用LabelEncoder()进行特征映射--'user_id', 'movie_id', 'gender', 'age', 'occupation', 'zip', "cate_id"分别按照数量进行分类----进行去重--同时保留索引库
通过函数generate_seq_feature_match确定训练集和测试集--df_train--[user_id, movie_id, [正采样],正采样个数,负采样集合]--结合item-user_profile对正采样进行补充
正样本是用户的第一个样本---负采样是将item对于频率做出排序,得到一个集合,此时先对频率做对数+归一化处理,将得到的值做为movie_id出现的概率进行负采样----对于youtobe召回,选择listwise--选择40个样本
得到训练数据之后,进行embeding处理---用户的hist_movie_id特征和物品的movie_id特征共享一个embeding层权重。
1.3 训练模型
根据之前的x_train字典和y_train等数据生成训练用的Dataloader(train_dl)、测试用的Dataloader(test_dl, item_dl)。
定义一个双塔DSSM模型,user_features表示用户塔有哪些特征,user_params表示用户塔的MLP的各层维度和激活函数。(Note:在这个样例中激活函数的选取对最终结果影响很大)
DSSM
user_tower--embedding---MLP
item_tower--embedding[只有item的,即为负样本]---MLP
-----计算这两个的余弦相似度,再除温度系数------输出y
损失函数-----把句子映射为向量,利用距离公式来表示文本间的相似度-----torch.nn.BCELoss()---二分类
1.4 向量化召回-评估
使用trainer获取测试集中每个user的embedding和数据集中所有物品的embedding集合
用annoy构建物品embedding索引,对每个用户向量进行ANN(Approximate Nearest Neighbors)召回K个物品
按照topk进行召回,并返回评估指标,一般看recall、precision、hit
data["cate_id"] = data["genres"].apply(lambda x: x.split("|")[0]) # 指定用户列和物品列的名字、离散和稠密特征,适配框架的接口 user_col, item_col = "user_id", "movie_id" sparse_features = ['user_id', 'movie_id', 'gender', 'age', 'occupation', 'zip', "cate_id"] dense_features = [] # 对SparseFeature进行LabelEncoding from sklearn.preprocessing import LabelEncoder print(data[sparse_features].head()) feature_max_idx = {} for feature in sparse_features: lbe = LabelEncoder() data[feature] = lbe.fit_transform(data[feature]) + 1 # 删除 0 值 feature_max_idx[feature] = data[feature].max() + 1 # 多出来的 1 应该是为了 unseen 类别做保留,比如新商品、新用户 if feature == user_col: # lbe.classes_的值会随着 lbe.fit_transform 处理的数据而变化,有对应关系;leb.classes_是类属性 user_map = {encode_id + 1: raw_id for encode_id, raw_id in enumerate(lbe.classes_)} #encode user id: raw user id if feature == item_col: item_map = {encode_id + 1: raw_id for encode_id, raw_id in enumerate(lbe.classes_)} #encode item id: raw item id np.save("raw_id_maps.npy", (user_map, item_map)) # evaluation时会用到 print('LabelEncoding后:') print(data[sparse_features].head()) user_cols = ["user_id", "gender", "age", "occupation", "zip"] item_cols = ['movie_id', "cate_id"] # 从data中取出相应的数据 user_profile = data[user_cols].drop_duplicates('user_id') # 去重 item_profile = data[item_cols].drop_duplicates('movie_id') from torch_rechub.utils.match import generate_seq_feature_match, gen_model_input df_train, df_test = generate_seq_feature_match(data,user_col,item_col,time_col="timestamp",item_attribute_cols=[],sample_method=1, mode=0,neg_ratio=3,min_item=0) # 该函数将在 1.5 中讲解 print(df_train.head()) print(df_train.shape) print(df_train['hist_movie_id'][0]) x_train = gen_model_input(df_train, user_profile, user_col, item_profile, item_col, seq_max_len=50) # 该函数将在 1.5 中讲解 y_train = x_train["label"] x_test = gen_model_input(df_test, user_profile, user_col, item_profile, item_col, seq_max_len=50) y_test = x_test["label"] #删除y值 del x_train["label"] del x_test["label"] from torch_rechub.basic.features import SparseFeature, SequenceFeature # embed_dim 是指定 LabelEncoder 的维度,会通过训练来自动学习到合适的 Lookup table user_features = [ SparseFeature(feature_name, vocab_size=feature_max_idx[feature_name], embed_dim=16) for feature_name in user_cols ] user_features += [ SequenceFeature("hist_movie_id", vocab_size=feature_max_idx["movie_id"], embed_dim=16, pooling="mean", shared_with="movie_id") # mean pooling,会对历史观影的 embedding 做平均运算 ] item_features = [SparseFeature(feature_name, vocab_size=feature_max_idx[feature_name], embed_dim=16) for feature_name in item_cols] # 将dataframe转为dict from torch_rechub.utils.data import df_to_dict all_item = df_to_dict(item_profile) test_user = x_test from torch_rechub.models.matching import DSSM from torch_rechub.trainers import MatchTrainer from torch_rechub.utils.data import MatchDataGenerator #save_dir = './ml-1m/' save_dir="result" # 根据之前处理的数据拿到Dataloader dg = MatchDataGenerator(x=x_train, y=y_train) train_dl, test_dl, item_dl = dg.generate_dataloader(test_user, all_item, batch_size=128)###使用测试集进行训练,小样本 # 定义模型 model = DSSM(user_features, item_features, temperature=0.02, # 在归一化之后的向量计算內积之后,乘一个固定的超参 r ,论文中命名为温度系数。归一化后如果不乘 temperature,模型无法收敛 user_params={ "dims": [256, 128, 64], "activation": 'prelu', # important!! }, item_params={ "dims": [256, 128, 64], "activation": 'prelu', # important!! }) # 模型训练器 trainer = MatchTrainer(model, mode=0, # 同上面的mode,需保持一致 optimizer_params={ "lr": 1e-4, "weight_decay": 1e-6 }, n_epoch=10, device='cpu', model_path=save_dir) # 开始训练 trainer.fit(train_dl) import collections import numpy as np import pandas as pd from torch_rechub.utils.match import Annoy from torch_rechub.basic.metric import topk_metrics def match_evaluation(user_embedding, item_embedding, test_user, all_item, user_col='user_id', item_col='movie_id', raw_id_maps="raw_id_maps.npy", topk=10): print("evaluate embedding matching on test data") ##用于空间检索近邻的数据--ann annoy = Annoy(n_trees=10) annoy.fit(item_embedding) #for each user of test dataset, get ann search topk result print("matching for topk") user_map, item_map = np.load(raw_id_maps, allow_pickle=True) match_res = collections.defaultdict(dict) # user id -> predicted item ids for user_id, user_emb in zip(test_user[user_col], user_embedding): items_idx, items_scores = annoy.query(v=user_emb, n=topk) #the index of topk match items match_res[user_map[user_id]] = np.vectorize(item_map.get)(all_item[item_col][items_idx]) #get ground truth print("generate ground truth") data = pd.DataFrame({user_col: test_user[user_col], item_col: test_user[item_col]}) data[user_col] = data[user_col].map(user_map) data[item_col] = data[item_col].map(item_map) user_pos_item = data.groupby(user_col).agg(list).reset_index() ground_truth = dict(zip(user_pos_item[user_col], user_pos_item[item_col])) # user id -> ground truth print("compute topk metrics") out = topk_metrics(y_true=ground_truth, y_pred=match_res, topKs=[topk]) return out user_embedding = trainer.inference_embedding(model=model, mode="user", data_loader=test_dl, model_path=save_dir) item_embedding = trainer.inference_embedding(model=model, mode="item", data_loader=item_dl, model_path=save_dir) match_evaluation(user_embedding, item_embedding, test_user, all_item, topk=10, raw_id_maps="raw_id_maps.npy")
训练方式--point wise--list wise--pair wise
point wise---把句子映射为向量,利用距离公式来表示文本间的相似度
推荐系统领域,最常用就是二元分类的Pointwise,比如常见的点击率(CTR)预估问题,之所以用得多,是因为二元分类的 Pointwise 模型的复杂度通常比 Pairwise 和 Listwise 要低,而且可以借助用户的点击反馈自然地完成正负样例的标注,而其他 Pairwise 和 Listwise 的模型标注就没那么容易了。成功地将排序问题转化成分类问题,也就意味着我们机器学习中那些常用的分类方法都可以直接用来解决排序问题,如 LR、GBDT、SVM 等,甚至包括结合深度学习的很多推荐排序模型,都属于这种 Pointwise 的思想范畴。 Pointwise 方法存在的问题:
1.Pointwise 方法通过优化损失函数求解最优的参数,可以看到 Pointwise 方法非常简单,工程上也易实现,但是 Pointwise 也存在很多问题:
2.Pointwise 只考虑单个文档同 query 的相关性,没有考虑文档间的关系,然而排序追求的是排序结果,并不要求精确打分,只要有相对打分即可; 3.通过分类只是把不同的文档做了一个简单的区分,同一个类别里的文档则无法深入区别,虽然我们可以根据预测的概率来区别,但实际上,这个概率只是准确度概率,并不是真正的排序靠前的预测概率; 4.Pointwise 方法并没有考虑同一个 query 对应的文档间的内部依赖性。一方面,导致输入空间内的样本不是 IID 的,违反了 ML 的基本假设,另一方面,没有充分利用这种样本间的结构性。其次,当不同 query 对应不同数量的文档时,整体 loss 将容易被对应文档数量大的 query 组所支配,应该每组 query 都是等价的才合理。 5.很多时候,排序结果的 Top N 条的顺序重要性远比剩下全部顺序重要性要高,因为损失函数没有相对排序位置信息,这样会使损失函数可能无意的过多强调那些不重要的 docs,即那些排序在后面对用户体验影响小的 doc,所以对于位置靠前但是排序错误的文档应该加大惩罚。 数据输入和输出形式:
Pointwise方法是通过近似为回归问题解决排序问题,输入的单条样本为得分-文档,将每个查询-文档对的相关性得分作为实数分数或者序数分数,使得单个查询-文档对作为样本点(Pointwise的由来),训练排序模型。预测时候对于指定输入,给出查询-文档对的相关性得分。
基于神经网络的排序算法 RankProp、基于感知机的在线排序算法 Prank(Perception Rank)/OAP-BPM 和基于 SVM 的排序算法。
工作场景:
如果在推荐场景中,一般会选择下单或者点击为正样本,曝光未点击为负样本。样本构造简单,天然标注,并且优化时,也是单点优化,训练模型速度快。
损失函数形式:
单个样本输入进行优化,这里的损失函数(【ML】目标函数、损失函数、代价函数、结构风险函数分别是什么,怎么用_损失函数就是目标函数吗-CSDN博客)都可以使用,完全套用,形式上也不用做改变。
只考虑给定查询下,单个文档的绝对相关度,而不考虑其他文档和给定查询的相关度。亦即给定查询q的一个真实文档序列,我们只需要考虑单个文档di和该查询的相关程度ci,亦即输入数据应该是如下的形式:
Pairwise
多阅读两边,多理解理解。
配对法的基本思路是对样本进行两两比较,构建偏序文档对,从比较中学习排序,因为对于一个查询关键字来说,最重要的其实不是针对某一个文档的相关性是否估计得准确,而是要能够正确估计一组文档之间的 “相对关系”。
因此,Pairwise 的训练集样本从每一个 “关键字文档对” 变成了 “关键字文档文档配对”。也就是说,每一个数据样本其实是一个比较关系,当前一个文档比后一个文档相关排序更靠前的话,就是正例,否则便是负例,如下图。试想,有三个文档:A、B 和 C。完美的排序是 “B>C>A”。我们希望通过学习两两关系 “B>C”、“B>A” 和 “C>A” 来重构 “B>C>A”。
这里面有几个非常关键的假设。换句话说,标注是一个困难的事情,难点在于:是否存能得到完美关系?是否能重构完美排序?
一,我们可以针对某一个关键字得到一个完美的排序关系。在实际操作中,这个关系可以通过五级相关标签来获得,也可以通过其他信息获得,比如点击率等信息。然而,这个完美的排序关系并不是永远都存在的。试想在电子商务网站中,对于查询关键字 “哈利波特”,有的用户希望购买书籍,有的用户则希望购买含有哈利波特图案的 T 恤,显然,这里面就不存在一个完美排序。
二,我们寄希望能够学习文档之间的两两配对关系从而 “重构” 这个完美排序。然而,这也不是一个有 “保证” 的思路。用刚才的例子,希望学习两两关系 “B>C”、“B>A” 和 “C>A” 来重构完美排序 “B>C>A”。然而,实际中,这三个两两关系之间是独立的。特别是在预测的时候,即使模型能够正确判断 “B>C” 和 “C>A”,也不代表模型就一定能得到 “B>A”。注意,这里的关键是 “一定”,也就是模型有可能得到也有可能得不到。两两配对关系不能 “一定” 得到完美排序,这个结论其实就揭示了这种方法的不一致性。也就是说,我们并不能真正保证可以得到最优的排序。
三,我们能够构建样本来描述这样的两两相对的比较关系。一个相对比较简单的情况,认为文档之间的两两关系来自于文档特征(Feature)之间的差异。也就是说,可以利用样本之间特征的差值当做新的特征,从而学习到差值到相关性差异这样的一组对应关系。人工标注标签怎么转换到 pairwise 类方法的输出空间:
如果标注直接是相关度 𝑠𝑗(这里有sj的定义),则 doc pair (𝑥𝑢,𝑥𝑣) 的真实标签定义为 𝑦𝑢,𝑣=2∗𝐼𝑠𝑢>𝑠𝑣−1 如果标注是 pairwise preference 𝑠𝑢,𝑣,则 doc pair (𝑥𝑢,𝑥𝑣) 的真实标签定义为 𝑦𝑢,𝑣=𝑠𝑢,𝑣 如果标注是整体排序 π,则 doc pair (𝑥𝑢,𝑥𝑣) 的真实标签定义为 𝑦𝑢,𝑣=2∗𝐼π𝑢,π𝑣−1。
Pairwise 方法存在的问题:
Pairwise 方法通过考虑两两文档之间的相关对顺序来进行排序,相比 Pointwise 方法有明显改善。但 Pairwise 方法仍有如下问题:
-
使用的是两文档之间相关度的损失函数,而它和真正衡量排序效果的指标之间存在很大不同,甚至可能是负相关的,如可能出现 Pairwise Loss 越来越低,但 NDCG 分数也越来越低的现象。
-
只考虑了两个文档的先后顺序,且没有考虑文档在搜索列表中出现的位置,导致最终排序效果并不理想。
-
不同的查询,其相关文档数量差异很大,转换为文档对之后,有的查询可能有几百对文档,有的可能只有几十个,这样不加均一化在一起学习,模型会优先考虑文档对数量多的查询,减少这些查询的 loss,最终对机器学习的效果评价造成困难。
-
Pairwise 方法的训练样例是偏序文档对,它将对文档的排序转化为对不同文档与查询相关性大小关系的预测;因此,如果因某个文档相关性被预测错误,或文档对的两个文档相关性均被预测错误,则会影响与之关联的其它文档,进而引起连锁反应并影响最终排序结果。
数据输入和输出形式:
Pairwise方法是通过近似为分类问题解决排序问题,输入的单条样本为标签-文档对。对于一次查询的多个结果文档,组合任意两个文档形成文档对作为输入样本。即学习一个二分类器,对输入的一对文档对AB(Pairwise的由来),根据A相关性是否比B好,二分类器给出分类标签1或0。对所有文档对进行分类,就可以得到一组偏序关系,从而构造文档全集的排序关系。该类方法的原理是对给定的文档全集S,降低排序中的逆序文档对的个数来降低排序错误,从而达到优化排序结果的目的。(降低-1的集合的个数)
(featureid: feature_value) query_id : 1, relevance_score:1, feature_vector 0:0.1, 1:0.2, 2:0.4 #doc0 query_id : 1, relevance_score:2, feature_vector 0:0.3, 1:0.1, 2:0.4 #doc1 query_id : 1, relevance_score:0, feature_vector 0:0.2, 1:0.4, 2:0.1 #doc2 query_id : 2, relevance_score:0, feature_vector 0:0.1, 1:0.4, 2:0.1 #doc0 需要将输入样本转换为Pairwise的输入格式,例如组合生成格式与mq2007 Pairwise格式相同的结构 1 doc1 doc0 1 doc1 doc2 1 doc0 doc2 注意,一般在Pairwise格式的数据中,label=1表示docA和查询的相关性好于docB,事实上label信息隐含在docA和docB组合pair中。如果存在0 docA docB,交换顺序构造1 docB docA即可。
可以看到上述方法没有考虑到排序的一些特征,比如文档之间的排序结果针对的是给定查询下的文档集合,而PairWise则是考虑了任意两个相关度不同的文档之间的相对相关度。
listwise
相对于尝试学习每一个样本是否相关或者两个文档的相对比较关系,列表法排序学习的基本思路是尝试直接优化像 NDCG(Normalized Discounted Cumulative Gain)这样的指标,从而能够学习到最佳排序结果。
列表法的相关研究有很大一部分来自于微软研究院,这其中著名的作者就有微软亚州院的徐君、李航、刘铁岩等人,以及来自微软西雅图的研究院的著名排序算法 LambdaMART 以及 Bing 搜索引擎的主导人克里斯托弗·博格斯(Christopher J.C. Burges)。
列表法排序学习有两种基本思路。第一种称为 Measure-specific,就是直接针对 NDCG 这样的指标进行优化。目的简单明了,用什么做衡量标准,就优化什么目标。第二种称为 Non-measure specific,则是根据一个已经知道的最优排序,尝试重建这个顺序,然后来衡量这中间的差异。 1)Measure-specific,对NDCG 这样的指标进行优化
先来看看直接优化排序指标的难点和核心在什么地方。
难点在于,希望能够优化 NDCG 指标这样的 “理想” 很美好,但是现实却很残酷。NDCG、MAP 以及 AUC 这类排序标准,都是在数学的形式上的 “非连续”(Non-Continuous)和 “非可微分”(Non-Differentiable)。而绝大多数的优化算法都是基于 “连续”(Continuous)和 “可微分”(Differentiable)函数的。因此,直接优化难度比较大。
第一种方法是,既然直接优化有难度,那就找一个近似 NDCG 的另外一种指标。而这种替代的指标是 “连续” 和 “可微分” 的 。只要我们建立这个替代指标和 NDCG 之间的近似关系,那么就能够通过优化这个替代指标达到逼近优化 NDCG 的目的。这类的代表性算法的有 SoftRank 和 AppRank。
第二种方法是,尝试从数学的形式上写出一个 NDCG 等指标的 “边界”(Bound),然后优化这个边界。比如,如果推导出一个上界,那就可以通过最小化这个上界来优化 NDCG。这类的代表性算法有 SVM-MAP 和 SVM-NDCG。
第三种方法则是,希望从优化算法上下手,看是否能够设计出复杂的优化算法来达到优化 NDCG 等指标的目的。对于这类算法来说,算法要求的目标函数可以是 “非连续” 和 “非可微分” 的。这类的代表性算法有 AdaRank 和 RankGP。
2)Non-measure specific,尝试重建最优顺序,衡量其中差异
这种思路的主要假设是,已经知道了针对某个搜索关键字的完美排序,那么怎么通过学习算法来逼近这个完美排序。我们希望缩小预测排序和完美排序之间的差距。值得注意的是,在这种思路的讨论中,优化 NDCG 等排序的指标并不是主要目的。这里面的代表有 ListNet 和 ListMLE。
Listwise 方法存在的问题:
列表法相较单点法和配对法针对排序问题的模型设计更加自然,解决了排序应该基于 query 和 position 问题。
但列表法也存在一些问题:一些算法需要基于排列来计算 loss,从而使得训练复杂度较高,如 ListNet 和 BoltzRank。此外,位置信息并没有在 loss 中得到充分利用,可以考虑在 ListNet 和 ListMLE 的 loss 中引入位置折扣因子。
数据输入和输出形式:
Listwise方法是直接优化排序列表,输入为单条样本为一个文档排列。通过构造合适的度量函数衡量当前文档排序和最优排序差值,优化度量函数得到排序模型。由于度量函数很多具有非连续性的性质,优化困难。
query_id : 1, relevance_score:1, feature_vector 0:0.1, 1:0.2, 2:0.4 #doc0 query_id : 1, relevance_score:2, feature_vector 0:0.3, 1:0.1, 2:0.4 #doc1 query_id : 1, relevance_score:0, feature_vector 0:0.2, 1:0.4, 2:0.1 #doc2 query_id : 2, relevance_score:0, feature_vector 0:0.1, 1:0.4, 2:0.1 #doc0 query_id : 2, relevance_score:2, feature_vector 0:0.1, 1:0.4, 2:0.1 #doc1 ..... 需要转换为Listwise格式,例如 <query_id><relevance_score> <feature_vector> 1 1 0.1,0.2,0.4 1 2 0.3,0.1,0.4 1 0 0.2,0.4,0.1 2 0 0.1,0.4,0.1 2 2 0.1,0.4,0.1 ...... 数据格式注意 数据中每条样本对应的文档数量都必须大于lambda_cost层的NDCG_num 若单条样本对应的文档都为0,文档相关性都为0,NDCG计算无效,那么可以判定该query无效,我们在训练中过滤掉了这样的query。
KL 散度 loss
import torch.nn.functional as F kl_div = F.kl_div(q_tensor.log(), p_tensor, reduction='batchmean') import torch.nn.functional as F import torch.nn as nn class KLDivLoss(nn.Module): def __init__(self): super(KLDivLoss, self).__init__() def forward(self, p, q): p = F.softmax(p, dim=-1) q = F.softmax(q, dim=-1) loss = F.kl_div(q.log(), p, reduction='batchmean') return loss
ListMLE loss
ListNet Loss