一.理论部分
理论部分网上有许多,自己也简单的整理了一份,这几天会贴在这里,先把代码贴出,后续会优化一些写法,这里将训练数据写成dataset,dataloader样式。
排序学习所需的训练样本格式如下:
解释:其中第二列是query id,第一列表示此query id与这条样本的相关度(数字越大,表示越相关),从第三列开始是本条样本的特征向量。
- RankNet:
RankNet是属于pairwise方法,它是将某个query下的所有文档两两组成文档对,每个文档对作为一个样本:
A. 预测相关性概率:
解释:对于任一个doc对(Ui,Uj),模型输出的得分为si和sj,那么根据模型预测Ui比Uj与query更相关的概率。RankNet一般采用神经网络,sigmoid能提供一个较好的概率评估。
B. 真实相关性概率:
解释:真实数据对中的Ui和Uj都包含一个与query相关度的label,比如Ui为3,Uj为1,则Ui比Uj与query更相关,这里是定义Ui比Uj更相关的真实概率。Sij定义为1:Ui比Uj更相关;-1:Uj比Ui更相关;0:Ui与Uj相关度相同。
C. 代价函数:
解释:这里使用交叉熵来拟合真实概率与预测概率,两个分布越接近,交叉熵越小。
D. 问题:
问题一:没有使用排序中的一些评估指标直接作为代价函数,原因是这些指标函数不连续,不好求导,不太好用梯度下降,交叉熵适合梯度下降。
问题二:在正常训练时,对每个样本对{i,j}都会更新一次参数,采用BP时,更新一次需要先前向预测,再误差后反向传播,会很慢。
E.在实际使用中,ranknet采用神经网络方法进行学习,一般采用的是带有隐层的神经网络。学习过程一般使用误差反向传播方法来训练。
如何训练呢?这里提供了两种思路:
1)取一个样本对(Xi, Xj),首先对Xi带入神经网络进行前向反馈,其次将Xj带入神经网络进行前向反馈,
然后计算差分结果并进行误差反向传播,接着取下一个样本对。这种方法很直观,缺点是收敛速度慢。
2)批量训练。我们可以对同一个排序下的所有文档pair全部带入神经网络进行前向反馈,
然后计算总差分并进行误差反向传播,这样将大大减少误差反向传播的次数。
大家可以参考论文《From RankNet to LambdaRank to LambdaMART: An Overview》,这篇论文从RankNet,LambdaRank讲到LambdaMart的这三种排序学习方法,后面的都是在前面的基础上进行改进提出的。基中RankNet来自论文《Learning to Rank using Gradient Descent》,LambdaRank来自论文《Learning to Rank with Non-Smooth Cost Functions》,LambdaMart来自《Selective Gradient Boosting for Effective Learning to Rank》。RankNet与LambdaRank是神经网络模型,LambdaRank加速了计算和引入了排序的评估指标NDCG,提出了lambda概念。而LambdaMart的核心则是利用了GBDT,即MART,这里每棵树拟合的不是残差(平方损失的梯度是残差,其它损失叫负梯度),而是Lambda这个值,这个值代表这篇文档在下次迭代时的方向和强度,lambdamart不需要显式定义损失函数,更加不需要对损失函数求导(因为ndcg非连续),lambda充当了拟合目标,在实际计算时,会为每个文档计算一个lambda值。
......
二.pytorch实现RankNet
1 import torch 2 import torch.utils.data as data 3 import numpy as np 4 5 y_train = [] 6 x_train = [] 7 query_id = [] 8 array_train_x1 = [] 9 array_train_x0 = [] 10 11 def extract_features(toks): 12 # 获取features 13 features = [] 14 for tok in toks: 15 features.append(float(tok.split(":")[1])) 16 return features 17 18 def extract_query_data(tok): 19 #获取queryid documentid 20 query_features = [tok.split(":")[1]] #qid 21 return query_features 22 23 def get_format_data(data_path): 24 with open(data_path, 'r', encoding='utf-8') as file: 25 for line in file: 26 data, _, comment = line.rstrip().partition("#") 27 toks = data.split() 28 y_train.append(int(toks[0])) #相关度 29 x_train.append(extract_features(toks[2:])) # doc features 30 query_id.append(extract_query_data(toks[1])) #qid 31 32 def get_pair_doc_data(y_train, query_id): 33 #两两组合pair 34 pairs = [] 35 tmp_x0 = [] 36 tmp_x1 = [] 37 for i in range(0, len(query_id) - 1): 38 for j in range(i + 1, len(query_id)): 39 #每个query下的文档 40 if query_id[i][0] != query_id[j][0]: 41 break 42 #使用不同相关度的文档pair 43 if (query_id[i][0] == query_id[j][0]) and (y_train[i] != y_train[j]): 44 #将最相关的放在前面,保持文档pair中第一个doc比第二个doc与query更相关 45 if y_train[i] > y_train[j]: 46 pairs.append([i,j]) 47 tmp_x0.append(x_train[i]) 48 tmp_x1.append(x_train[j]) 49 else: 50 pairs.append([j,i]) 51 tmp_x0.append(x_train[j]) 52 tmp_x1.append(x_train[i]) 53 #array_train_x0里和array_train_x1里对应的下标元素,保持前一个元素比后一个元素更相关 54 array_train_x0 = np.array(tmp_x0) 55 array_train_x1 = np.array(tmp_x1) 56 print('fond {} doc pairs'.format(len(pairs))) 57 return len(pairs), array_train_x0, array_train_x1 58 59 class Dataset(data.Dataset): 60 ''' 61 torch.utils.data.Dataset 是一个表示数据集的抽象类. 你自己的数据集一般应该继承Dataset, 并且重写下面的方法: 62 __len__使用len(dataset) 可以返回数据集的大小 63 __getitem__ 支持索引, 以便于使用 dataset[i] 可以 获取第i个样本(0索引) 64 数据集创建一个数据集类. 我们使用 __init__方法来初始化, 使用 __getitem__根据索引读取样本. 65 这样可以使内存高效利用, 因为我们并不需要在内存中一次存储所有图片, 而是按需读取. 66 ''' 67 def __init__(self, data_path): 68 # 解析训练数据 69 get_format_data(data_path) 70 # pair组合 71 self.datasize, self.array_train_x0, self.array_train_x1 = get_pair_doc_data(y_train, query_id) 72 73 def __getitem__(self, index): 74 data1 = torch.from_numpy(self.array_train_x0[index]).float() 75 data2 = torch.from_numpy(self.array_train_x1[index]).float() 76 return data1, data2 77 78 def __len__(self): 79 return self.datasize 80 81 def get_loader(data_path, batch_size, shuffle, num_workers): 82 dataset = Dataset(data_path) 83 data_loader = torch.utils.data.DataLoader( 84 dataset=dataset, 85 batch_size = batch_size, 86 shuffle = shuffle, 87 num_workers=num_workers 88 ) 89 return data_loader
1 import torch 2 import torch.nn as nn 3 import torch.optim as optim 4 import numpy as np 5 import os 6 7 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') 8 9 class RankNet(nn.Module): 10 def __init__(self, inputs, hidden_size, outputs): 11 super(RankNet, self).__init__() 12 self.model = nn.Sequential( 13 nn.Linear(inputs, hidden_size), 14 #nn.Dropout(0.5), 15 nn.ReLU(inplace=True), 16 #nn.LeakyReLU(0.2, inplace=True),#inplace为True,将会改变输入的数据 ,否则不会改变原输入,只会产生新的输出 17 nn.Linear(hidden_size, outputs), 18 #nn.Sigmoid() 19 ) 20 self.sigmoid = nn.Sigmoid() 21 22 def forward(self, input_1, input_2): 23 result_1 = self.model(input_1) #预测input_1得分 24 result_2 = self.model(input_2) #预测input_2得分 25 pred = self.sigmoid(result_1 - result_2) #input_1比input_2更相关概率 26 return pred 27 28 def predict(self, input): 29 result = self.model(input) 30 return result 31 32 def train(): 33 # 超参 34 inputs = 38 35 hidden_size = 10 36 outputs = 1 37 learning_rate = 0.2 38 num_epochs = 100 39 batch_size = 100 40 41 model = RankNet(inputs, hidden_size, outputs).to(device) 42 #损失函数和优化器 43 criterion = nn.BCELoss() 44 optimizer = optim.Adadelta(model.parameters(), lr = learning_rate) 45 46 base_path = os.path.abspath(os.path.join(os.getcwd(), '..')) 47 base_path = os.path.dirname(base_path) 48 data_path = base_path + '/goods_data/train/train_result.txt' 49 50 data_loader = get_loader(data_path, batch_size, False, 4) 51 total_step = len(data_loader) 52 # 这里使用batch size的方式,并非每次传入一对docs进行前向和后向传播 53 # (tips:还有一种是将每个query下的所有docs对作为batch输入到网络中进行前向和后向,但是这里没法用到Dataset和DataLoader) 54 for epoch in range(num_epochs): 55 for i, (data1, data2) in enumerate(data_loader): 56 print('Epoch [{}/{}], Step [{}/{}]'.format(epoch, num_epochs, i, total_step)) 57 data1 = data1.to(device) 58 data2 = data2.to(device) 59 label_size = data1.size()[0] 60 pred = model(data1, data2) 61 loss = criterion(pred, torch.from_numpy(np.ones(shape=(label_size, 1))).float().to(device)) 62 optimizer.zero_grad() 63 loss.bachward() 64 optimizer.step() 65 if i % 10 == 0: 66 print('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}' 67 .format(epoch + 1, num_epochs, i + 1, total_step, loss.item())) 68 69 torch.save(model.state_dict(), 'model.ckpt') 70 71 def test(): 72 #test data 73 base_path = os.path.abspath(os.path.join(os.getcwd(), '..')) 74 base_path = os.path.dirname(base_path) 75 test_path = base_path + '/goods_data/test/test_result.txt' 76 77 # 超参 78 inputs = 38 79 hidden_size = 10 80 outputs = 1 81 model = RankNet(inputs, hidden_size, outputs).to(device) 82 model.load_state_dict(torch.load('model.ckpt')) 83 84 with open(test_path, 'r', encoding='utf-8') as f: 85 features = [] 86 for line in f: 87 toks = line.split() 88 feature = [] 89 for tok in toks[2:]: 90 _, value = tok.split(":") 91 feature.append(float(value)) 92 features.append(feature) 93 features = np.array(features) 94 features = torch.from_numpy(features).float().to(device) 95 predict_score = model.predict(features)