【學習排序】 Learning to Rank中Pointwise關於PRank演算法原始碼實現
最近終於忙完了Learning to Rank的作業,同時也學到了很多東西.我準備寫幾篇相關的文章簡單講述自己對它的理解和認識.第一篇準備講述的就是Learning to Rank中Pointwise的認識及PRank演算法的實現.主要從以下四個方面進行講述: 1.學習排序(Learning to Rank)概念 2.基於點的排序演算法(Pointwise)介紹 3.基於順序迴歸(Ordinal Regression-based)的PRank排序演算法 4.PRank演算法Java\C++實現及總結
一. 學習排序(Learning to Rank)概念
學習排序概念推薦轉載的文章:
3.那麼,學習排序的資料集是怎樣的一個東西呢?也就是上圖中x、y、h分別代表著什麼呢?
資料集可參考微軟136維資料——MSLR-WEB10K 它是2010年的資料.形如:
=============================================================
0 qid:1 1:3 2:0 3:2 4:2 ... 135:0 136:0
2 qid:1 1:3 2:3 3:0 4:0 ... 135:0 136:0
=============================================================
其資料格式: label qid:id feaid:feavalue feaid:feavalue ...
每行表示一個樣本,相同的查詢請求的樣本qid相同,上面就是兩個對qid為“1”的查詢;label表示該樣本和該查詢請求的相關程度,該label等級劃分方式為 {Perfect, Excellent,Good, Fair, Bad} 共五個類別,後面對應的是特徵和特徵值,我們通常使用的<X,Y>即是<特徵量,人工標註>.
同樣你也可以使用比較經典的2007的資料集——
比如,現在你在Google瀏覽器中輸入"Learning to Rank",它就相當於一個qid.而下面列出的各個連結就是多個樣本集合,其中每一個都有200多種影響因素(如其中一種PageRank).在學習過程中需要找到一個模型來預測新查詢文件的得分,並排序計算出使用者最想要的結果. PS:這是我的個人理解,如果有錯誤或不足之處,歡迎提出!
二. 基於點的排序演算法(Pointwise)介紹
機器學習解決排序學習問題可分為3類: 1.基於迴歸排序學習(regression-based algorithms):序列轉為實數 2.基於分類排序學習(classification-based algorithms):二值分類 3.基於順序迴歸排序學習(ordinal regression-based algorithms) 但是這裡我想講述的是最常見的分類,它們應該與上面是交叉的: 1.基於點的LTR演算法——Pointwise Approach 2.基於對的LTR演算法——Pairwise Approach 3.基於列的LTR演算法——Listwise Approach
Pointwise處理物件是一篇文件,將文件轉化為特徵向量後,機器學習系統根據訓練得出的模型對文件進行打分(注意:訓練集學習出權重模型去給測試集文件打分是LTR中非常經典的用法),打分的順序即為搜尋排序的結果. Score(x)=w1*F1+w2*F2+w3*F3+...+w136*F136 其中w1-w136為136維對應權重引數,由訓練集訓練得到;F1-F136為測試文件給出136個特徵值. 原資料有5個類標(0-4代表相關程度:Perfect>Excellent>Good>Fair>Bad),則設定5個閾值來區分所得分數的分類.如果得分大於相關閾值,則劃分為相應的類.常見演算法包括:Prank、McRank 下面是我自己畫的一張圖,其中四根紅線是四個閾值,它把這些文件集劃分為了五個不同類.每當一個新的文件來測試,它都會根據已有模型計算出相應分數,再根據分數和閾值劃分類即可.
三. PRank演算法介紹
PRank演算法是基於點的排序學習,順序迴歸學習問題.其演算法主要參考Kolby Crammer & Yoram Singer(From:The HeBrew University,以色列希伯來大學)論文《Pranking with Ranking》.網址如下:http://papers.nips.cc/paper/2023-pranking-with-ranking.pdf演算法過程如下:
演算法描述:(感覺演算法一目瞭然,但是我功力不夠描述不清楚) 對於46維資料而言,它存在3個類標(0-2).故上述演算法中初始閾值b[0]=b[1]=b[2]=0,b[3]=正無窮. 注意它只有一層迴圈For(1...T)表示樣本集的總行數,而沒有進行迭代(CSDN三國那個例子含迭代錯誤);它主要是通過預測標號y~和實際標號y進行對比,來更新權重和閾值. 在H排序決策函式中,它通過K個閾值b把空間劃分為K個連續的子空間,每個子空間對應一個序列號,即滿足所有的樣本x都有相同的排序結果.對每個樣本,先計算權重w與xi的內積w·x,找出所有滿足w·x-br中最小的br,並將此br對應的序標號xi作為排序模型對樣本的預測排序結果. 推薦中文資料:南開大學論文《基於PRank演算法的主動排序學習演算法》
四. PRank演算法Java\C++實現及總結
1.Java程式碼實現 程式碼中有詳細註釋,每個步驟都是按照上面的演算法進行設計的.左圖是主函式,它主要包括:讀取檔案並解析資料、寫資料(該函式可註釋掉,它是我用於驗證讀取是否正確時寫的)、學習排序模型和打分預測.右圖是預測排序結果的演算法.
程式碼如下:
-
package com.example.pointwise;
-
import java.io.BufferedReader;
-
import java.io.File;
-
import java.io.FileInputStream;
-
import java.io.FileReader;
-
import java.io.FileWriter;
-
import java.io.IOException;
-
import java.io.InputStreamReader;
-
import java.util.ArrayList;
-
import java.util.List;
-
/**
-
* Pointwise基於點學習排序(Learning to Rank)的Prank演算法
-
* @author Eastmount YXZ
-
* 參考資料
-
* 該演算法從136維資料集改成46維資料集,中間可能有註釋不一致現象
-
* (原始論文) http://papers.nips.cc/paper/2023-pranking-with-ranking.pdf
-
* (新浪) http://blog.sina.com.cn/s/blog_4c98b960010008xn.html
-
* (CSDN)http://blog.csdn.net/pennyliang/article/details/17333373
-
*/
-
public class Prank {
-
public int RANK_NUM = 10000; //記錄總樣本數 (總行數)
-
public int RANK_CATA = 46; //排序的特徵維數 (資料集136維 後改為46維)
-
public int RANK_ITER = 1; //排序的迭代次數 (原文迭代1次)
-
public int RANK_LABEL= 3; //排序劃分的閾值 (微軟資料集劃分5類 0-4) 3維全相關,部分相關,不相關
-
//採用該方法實現動態陣列新增資料
-
List<Float> weight = null; //特徵值的權重向量 (46個 136個)
-
//訓練集資料 每行共48個數據 (46個特徵值 二維陣列-feature[行號][46] + 真實Label值0-2 + qid值)
-
List<List<Float>> x = null;
-
Float [] b = null; //閾值數 K+1個(RANK_LABEL+1)
-
public int sumLabel = 0; //檔案總行數 (標記數)
-
/**
-
* 函式功能 讀取檔案
-
* 引數 String filePath 檔案路徑
-
*/
-
public void ReadTxtFile(String filePath) throws IOException {
-
String encoding="GBK";
-
File file = new File(filePath); //檔案
-
BufferedReader bufferedReader = null;
-
try {
-
//判斷檔案是否存在
-
if(file.isFile() && file.exists()) {
-
//輸入流
-
InputStreamReader read = new InputStreamReader(new FileInputStream(file), encoding);
-
bufferedReader = new BufferedReader(read);
-
String lineTxt = null;
-
sumLabel =0; //記錄總樣本數
-
x = new ArrayList<List<Float>> ();
-
//按行讀取資料並分解資料
-
while((lineTxt = bufferedReader.readLine()) != null) {
-
String str = null;
-
int lengthLine = lineTxt.length();
-
List<Float> subList=new ArrayList<Float>();
-
x.add(subList);
-
//獲取資料 字串空格分隔
-
String arrays[] = lineTxt.split(" ");
-
for(int i=2; i<arrays.length; i++) {
-
if(i>=48) { //#號後跳出 後面註釋不進行讀取
-
continue;
-
}
-
//獲取特徵:特徵值 如1:0.0004
-
String subArrays[] = arrays[i].split(":");
-
int number = Integer.parseInt(subArrays[0]); //判斷特徵
-
float value = Float.parseFloat(subArrays[1]);
-
subList.add(value);
-
}
-
//獲取每行樣本的Label值 i=0 (五個等級0-4)
-
subList.add(Float.parseFloat(arrays[0]));
-
//獲取qid值 i=1
-
String subArrays[] = arrays[1].split(":");
-
subList.add(Float.parseFloat(subArrays[1]));
-
//總行數+1
-
sumLabel++;
-
} //End 按行讀取
-
read.close();
-
} else {
-
System.out.println("找不到指定的檔案\n");
-
}
-
} catch (Exception e) {
-
System.out.println("讀取檔案內容出錯");
-
e.printStackTrace();
-
} finally {
-
bufferedReader.close();
-
}
-
}
-
/**
-
* 函式 寫檔案
-
* 引數 String filePath 檔案路徑
-
* 注意 該函式還是136維資料,但演算法該成46維 故不使用該函式
-
*/
-
public void WriteTxtFile(String filePath) {
-
try {
-
System.out.println("檔案輸出");
-
String encoding = "GBK";
-
FileWriter fileWriter = new FileWriter(filePath);
-
//按行寫檔案
-
for(int i=0; i<sumLabel; i++) {
-
fileWriter.write("樣本行數"+i+"\r\n");
-
fileWriter.flush();
-
String value;
-
//寫資料特徵值 136
-
for(int j=0;j<136;j++) {
-
value = String.valueOf(x.get(i).get(j)); //輸出第i行 第j個特徵值
-
fileWriter.write(value+" ");
-
}
-
//label等級 qid
-
fileWriter.write("\r\n");
-
value = String.valueOf(x.get(i).get(136)); //label
-
fileWriter.write(value+" ");
-
value = String.valueOf(x.get(i).get(137));
-
fileWriter.write(value+" ");
-
fileWriter.write("\r\n");
-
}
-
fileWriter.close();
-
} catch(Exception e) {
-
e.printStackTrace();
-
}
-
}
-
/**
-
* 學習排序
-
* 主要功能計算136維權重w和劃分五個等級的閾值b
-
*/
-
public void LearningToRank() {
-
int realRank; //真實Label等級
-
int predictRank; //預測Label等級
-
Float[] y= new Float[RANK_LABEL+1]; //new label
-
Float tao [] = new Float[RANK_LABEL+1];
-
//初始化權重 全為0
-
weight = new ArrayList<Float>();
-
for(int i=0; i< RANK_CATA; i++){ //特徵向量的維數
-
weight.add((float) 0.0);
-
}
-
//初始化閾值 b[0]=b[1]=[2]=0 b[3]=正無窮大
-
b=new Float[RANK_LABEL+1];
-
for(int i=0; i<RANK_LABEL; i++){ //b[0] b[1] b[2]
-
b[i] = (float) 0.0;
-
}
-
b[RANK_LABEL] = Float.POSITIVE_INFINITY; //b[3]
-
/*
-
* 開始計算權重
-
* 注意:迭代主要參照CSDN部落格,它沒有退出.同時沒有損失計算,其結果差別不大
-
* 同時原論文中Loop 1...T是總行數 並沒有講述迭代
-
*/
-
for(int iter = 0; iter < RANK_ITER; iter++){ //總的迭代次數 RANK_ITER=1
-
for(int i=0; i< RANK_NUM; i++){ //總樣本數 可以設定讀取txt中部分
-
//測試順序
-
predictRank = 1;
-
//權重*特徵向量-閾值
-
float sumWX = (float) 0.0;
-
for(int z=0; z<46; z++) {
-
sumWX += weight.get(z)*x.get(i).get(z);
-
}
-
//預測排名
-
for(int r=1;r<=RANK_LABEL;r++) { //閾值數 RANK_LABEL=3
-
if(sumWX-b[r]<0) {
-
predictRank = r;
-
break;
-
}
-
}
-
//獲取真實等級 即資料集中第一個Label數字
-
realRank = Math.round(x.get(i).get(46)); //四捨五入並轉整數
-
if(realRank!=predictRank) {
-
for(int r=1; r < RANK_LABEL; r++){//若136維資料 5個值時
-
if(realRank <= r) { // y形如 1 1 -1 -1 -1
-
y[r] = (float)-1;
-
}
-
else {
-
y[r] = (float)1;
-
}
-
}
-
float tao_sum = (float) 0.0; //tau和
-
for(int r=1; r < RANK_LABEL; r++) { //三個等級
-
//權重*特徵向量-閾值
-
if((sumWX - b[r]) * y[r] <= 0) {
-
tao[r] = y[r];
-
} else {
-
tao[r] = (float) 0.0;
-
}
-
tao_sum += tao[r];
-
}
-
//更新資料
-
for(int z=0; z<RANK_CATA; z++) { //136維權重
-
float newWeight = weight.get(z) +tao_sum*x.get(i).get(z);
-
weight.set(z, newWeight);
-
}
-
for(int r=1;r < RANK_LABEL;++r) { //5個閾值
-
b[r] = b[r] - tao[r];
-
}
-
} //End if
-
else {
-
continue;
-
}
-
} //End 樣本總數
-
} //End 迭代次數
-
}
-
/**
-
* 函式 預測排序結果
-
* 主要 通過LearningToRank()函式計算的得分計算分數,再根據閾值劃分等級
-
*/
-
public void PredictNewLabel() {
-
float rightCount = 0;
-
float score = (float) 0.0;
-
for(int i=0; i < RANK_NUM; i++){
-
int predict_r = 1;
-
//權重*特徵向量-閾值 (W*X-B)
-
float sumWX = (float) 0.0;
-
for(int z=0; z<46; z++) {
-
sumWX = sumWX + weight.get(z) * x.get(i).get(z);
-
}
-
for(int r=1; r<= RANK_LABEL; r++){ //5
-
if(sumWX < b[r]){
-
score = sumWX;
-
predict_r = r;
-
break;
-
}
-
}
-
//計算正確概率
-
if(predict_r == Math.round(x.get(i).get(46))) //46維資料 46-label 47-qid 0-45特徵值
-
{
-
rightCount++;
-
}
-
System.out.println("predict="+predict_r+" score="+score+" real="+x.get(i).get(46));
-
}
-
//輸出結果
-
System.out.println("正確率:"+rightCount/(float)RANK_NUM);
-
System.out.println("輸出閾值");
-
for(int i= 1;i<4;i++){
-
System.out.println(b[i]+" ");
-
}
-
}
-
/**
-
* 主函式
-
*/
-
public static void main(String[] args) {
-
String fileInput = "train.txt";
-
String fileOutput = "output.txt";
-
String fileRank = "rank.txt";
-
//例項化
-
Prank prank = new Prank();
-
try {
-
//第一步 讀取檔案並解析資料
-
prank.ReadTxtFile(fileInput);
-
//第二步 輸出解析的基礎資料
-
//prank.WriteTxtFile(fileOutput);
-
//第三步 學習排序訓練模型
-
prank.LearningToRank();
-
//第四步 測試打分排序
-
prank.PredictNewLabel();
-
} catch (Exception e) {
-
// TODO Auto-generated catch block
-
e.printStackTrace();
-
}
-
}
-
/**
-
* End
-
*/
-
}
執行結果如下圖所示,演算法流程分析都很清楚,同時我採用的是下標從0開始取.b[1]和[2]兩個閾值即可劃分為3個不同的類,b[3]=Infinity.但是預測結果總是一個值,不知道為什麼?可能演算法中有些細節錯誤,糾結了我很長時間.如果知道希望告知.下面是採用C++實現.
2.C++程式碼實現 該部分程式碼參考自新浪播客: http://blog.sina.com.cn/s/blog_4c98b960010008xn.html 執行結果過程如下圖所示,通過train.txt資料集得到model.txt,裡面儲存的是46個權重.如: -0.052744 1.886342 1.002179 -6.400005 -1.824795 0.000000 0.000000 .. 然後通過該模型對test.txt進行打分預測,同時計算正確率(已標註Label=預測Label).
-
#include <iostream>
-
#include <fstream>
-
#include <limits>
-
#include <iomanip>
-
using namespace std;
-
#define K 3 //排序的序數,即如排成全相關,部分相關,不相關,序數就是3
-
#define N 46 //特徵的維數
-
double *w; //權值
-
int *b; //偏置項
-
int *y;
-
int *t;
-
//從檔案中獲得特徵值 X 儲存特徵向量 yt 儲存標籤
-
bool getData(double *x,int &yt,ifstream &fin)
-
{
-
if (fin.eof())
-
return false;
-
char data[1024];
-
int index = 1;
-
fin.getline(data,1024);
-
char *p = data;
-
char q[100];
-
q[0] = p[0];
-
q[1] = '\0';
-
yt = atoi(q) + 1; // 標籤
-
p = p+8;//跳過qid:xx的冒號
-
for( ; *p != '\0'; ++p)
-
{
-
if(*p == ':')
-
{
-
++p;
-
int i = 0;
-
for(i=0; *p != ' '; i++, p++)
-
{
-
q[i] = *p;
-
}
-
q[i] = '\0';
-
x[index ++] = atof(q);
-
}
-
}
-
return true;
-
}
-
//各變數進行初始化
-
void Initialize()
-
{
-
w = new double[N+1];
-
b = new int[K+1];
-
y = new int[K+1];
-
t = new int[K+1];
-
int i;
-
int r;
-
for(i=1; i<=N;i++)
-
w[i] = 0 ;
-
for(r=1;r<=K-1;r++)
-
b[r] = 0;
-
b[K] = std::numeric_limits<int>::max();//無窮大
-
}
-
//利用Prank演算法進行訓練
-
void PrankTraining(double *x,int yt)
-
{
-
int i;
-
int r;
-
double wx = 0; //儲存 W*X 的計算結果
-
for(i =1; i<=N; i++) //計算 W*X
-
wx += w[i] * x[i];
-
for(r =1; r<=K; r++) //找到滿足 W*X-b<0 的最小 r
-
{
-
if(wx - b[r] <0 )
-
break;
-
}
-
int yy = r ; //預測值
-
if (yy == yt) //預測正確,直接返回
-
{
-
return;
-
}
-
else //預測錯誤,權值更新
-
{
-
for(r=1; r<K; r++)
-
{
-
if(yt <= r)
-
y[r] = -1;
-
else
-
y[r] = 1;
-
}
-
for(r=1; r<K; r++)
-
{
-
if ((wx-b[r])*y[r] <= 0)
-
{
-
t[r] = y[r];
-
}
-
else
-
t[r] = 0;
-
}
-
//更新 W 和 b
-
int sumt = 0;
-
for(r=1; r<K; r++)
-
sumt = sumt + t[r];
-
for(i=1;i<=N;i++) //更新 W
-
w[i] = w[i] + sumt*x[i];
-
for(r=1; r<K; r++) //更新 b
-
b[r] = b[r] - t[r];
-
}
-
}
-
//利用得到的model進行測試
-
int Pranking(double *x)
-
{
-
int i;
-
int r;
-
double wx = 0;
-
for(i=1; i<=N; i++)
-
wx = wx + w[i] * x[i];
-
for(r=1; r<=K; r++)
-
if(wx - b[r] <0 )
-
{
-
cout<< " "<<wx;
-
break;
-
}
-
return r;
-
}
-
int main(int argc,char **argv)
-
{
-
int right=0,wrong=0;//排正確和錯誤的樣本數
-
//輸入訓練資料檔名
-
string sin_train = "train.txt";
-
ifstream fin_train(sin_train.c_str());
-
if(fin_train.fail())
-
{
-
cout << "can't open the traningsetFile!"<<endl;
-
return -1;
-
}
-
//輸入輸出模型檔名
-
string sout_model = "model.txt";
-
ofstream fout_model(sout_model.c_str());
-
if(fout_model.fail())
-
{
-
cout << "can't open the ModelFile!"<<endl;
-
return -1;
-
}
-
//輸入測試資料檔名
-
string sin_test = "test.txt";
-
ifstream fin_test(sin_test.c_str());
-
if(fin_test.fail())
-
{
-
cout << "can't open the testsetFile!"<<endl;
-
return -1;
-
}
-
// 輸入輸出結果檔名
-
string sout_result = "result.txt";
-
ofstream fout_result(sout_result.c_str());
-
if(fout_result.fail())
-
{
-
cout << "open resultFile failed!"<<endl;
-
return -1;
-
}
-
double *tr = new double[N+1]; // 特徵向量
-
int yt; // 標籤
-
Initialize(); //初始化權值w和偏置項b
-
int i = 0;
-
//讀入訓練資料進行訓練得到model
-
while(true)
-
{
-
if (getData(tr,yt,fin_train))
-
{
-
PrankTraining(tr,yt);//訓練
-
}
-
else
-
break;
-
}
-
//將得到的w和b寫入檔案
-
char buff[128];
-
cout<<"訓練出的w為:\n";
-
for(i=1; i<=N; i++) //寫 w
-
{
-
cout<<setw(8)<<w[i]<<'\t';
-
memset(buff,0,sizeof(buff));
-
sprintf(buff,"%f",w[i]);
-
fout_model << buff << " ";
-
}
-
fout_model<<endl;
-
cout<<"\n\n訓練出的b為:\n";
-
for(i = 1; i<K;i++) //寫 b
-
{
-
cout<<b[i]<<'\t';
-
memset(buff,0,sizeof(buff));
-
sprintf(buff,"%d",b[i]);
-
fout_model << buff << " ";
-
}
-
//讀入測試資料進行測試得到正確率
-
while(true)
-
{
-
if (getData(tr,yt,fin_test))
-
{
-
int yy = Pranking(tr);
-
char p[2];
-
p[0] = yy -1 + 48;
-
p[1] = '\0';
-
fout_result << p << endl;
-
if (yy == yt)
-
right ++;
-
else
-
wrong ++;
-
}
-
else
-
break;
-
}
-
cout<<"\n\n排正確的個數為"<<right<<",錯誤的個數為"<<wrong<<",正確率為%"<<right*100*1.0/(right+wrong)<<endl;
-
cout<<b[0]<<'\t'<<b[1]<<'\t'<<b[2];
-
//釋放申請的空間並關閉檔案
-
delete []w;
-
delete []y;
-
delete []t;
-
delete []b;
-
delete []tr;
-
fin_train.close();
-
fin_test.close();
-
fout_result.close();
-
fout_model.close();
-
system("PAUSE");
-
return 0;
-
}
五. 總結與問題
最後講述在該演算法中你可能遇到的問題和我的體會: 1.由於它是讀取檔案,可能檔案很大(幾百兆或上G).最初我設計的陣列是double feature[10000][136],用來儲存每行特徵值,但是如果行數太大時,What can do?此時我們應該設定動態陣列<List<List<Float>>>x解決. 2.最初閱讀了CSDN的Prank程式碼,它迭代了1萬次,最後檢視原文發現它並沒有迭代.所以你可以參考C++那部分程式碼,每次只需要讀取一行資料處理,並記住上一次的46維權重和閾值即可. 3.為什麼我從136維資料轉變成了46維資料? 你開啟136維特徵值資料時,你會發現它的值特別大,不論是Pointwise,還是Pairwise和Listwise都可能出現越界,一次內積求和可能就10的7次方資料了.但是46維資料,每個特徵值都是非常小的,所以如果用136維資料,你需要對資料進行歸一化處理,即資料縮小至-1到1之間. 4.評價Pointwise、Pairwise和Listwise指標通常是MAP和[email protected],後面講述基於對的學習排序和基於列的學習排序會具體介紹. 5.你可能會發現資料集中存在vail驗證集,以及交叉驗證、交叉熵、梯度下降後面都會講述.但由於相對於演算法,我對開發更感興趣,很多東西也是一知半解的. 6.最後要求該演算法到Hadoop或Spark實現並行化處理,但演算法的機制是序列化.有一定的方法,但我沒有實現.我們做的是一種偽並行化處理,即模型得到權重後進行並行化計算分數排序. 最後簡單附上我們的實驗結果,後面的演算法實驗結果是基於MAP和[email protected]
希望文章對大家有所幫助!主要是現在看到LTR很多都是理論介紹,論文也沒有具體程式碼,而開源的RankLib有點看不懂,所以提出了自己的認識及程式碼執行.我也是才接觸這個一個月,可能過程中存在錯誤或不足之處,歡迎提出建議~同時感謝一起奮鬥的夥伴,尤其是Pu哥.