一:ffm-train.cpp
这一部分主要就是参数设置,比较简单,一下三个点比较重要:
1:
#if defined USEOMP //是否启用OMP技术
#include <omp.h>
OpenMP多核并行计算。OpenMP是用于共享内存并行系统的多处理器程序设计的编译方案,便于移植和多核扩。FFM的源码采用了OpenMP的API,对参数训练过程SGD进行了多线程扩展,支持多线程编译。因此,OpenMP技术极大地提高了FFM的训练效率和多核CPU的利用率。在训练模型时,输入的训练参数ns_threads指定了线程数量,一般设定为CPU的核心数,便于完全利用CPU资源。
2:
struct Option {
string tr_path; //训练数据路径
string va_path; //测试数据路径
string model_path; //模型路径
ffm_parameter param; //ffm_parameter结构体,在ffm.h中定义
bool quiet = false; //安静模式
ffm_int nr_threads = 1; //OMP开启多线程的数量,一般与cpu核一样
};
3:
#if defined USEOMP //设置多线程数量
omp_set_num_threads(opt.nr_threads);
二:ffm.h
/**
* ffm_node:一个特征
*/
struct ffm_node {
ffm_int f; // 域索引
ffm_int j; // 特征索引
ffm_float v; // 值索引
};
/**
* ffm_model:ffm模型
*/
struct ffm_model {
ffm_int n; // 特征数量
ffm_int m; // 域数量
ffm_int k; // 隐向量长度
ffm_float *W = nullptr; //二次项参数
bool normalization; //是否归一化
~ffm_model();
};
/**
* ffm_parameter:ffm模型参数
*/
struct ffm_parameter {
ffm_float eta = 0.2; // 学习率
ffm_float lambda = 0.00002; // 正则化参数
ffm_int nr_iters = 15; //迭代次数
ffm_int k = 4; // 隐向量长度
bool normalization = true; //是否归一化
bool auto_stop = false; //训练是否自动停止
};
//将txt格式的特征数据转换为二进制格式
void ffm_read_problem_to_disk(string txt_path, string bin_path);
//存储模型到文件中
void ffm_save_model(ffm_model &model, string path);
//加载模型
ffm_model ffm_load_model(string path);
//基于二进制文件训练模型
ffm_model ffm_train_on_disk(string Tr_path, string Va_path, ffm_parameter param);
//模型预测
ffm_float ffm_predict(ffm_node *begin, ffm_node *end, ffm_model &model);
三:ffm.cpp
ffm.cpp为ffm模型的核心代码,内容比较多,按照函数为单位进行解读。
--------------------------------------
#if defined USESSE //是否使用sse指令,包含相应头文件
#include <pmmintrin.h>
#endif
#if defined USEOMP //是否使用omp指令,包含相应头文件
#include <omp.h>
#endif
--------------------------------------
#if defined USESSE //如果使用sse指令集,那么设置“跨度”,每128个字节为单位,在ffm模型中就是四个float数为一组
ffm_int const kALIGNByte = 16;
#else
ffm_int const kALIGNByte = 4;
#endif
--------------------------------------
//读取一个k长度的隐向量需要几次偏移,个人理解k的长度最好为4的倍数
inline ffm_int get_k_aligned(ffm_int k) {
return (ffm_int) ceil((ffm_float)k / kALIGN) * kALIGN;
}
--------------------------------------
//获取二次项参数的数目
ffm_long get_w_size(ffm_model &model) {
ffm_int k_aligned = get_k_aligned(model.k);
return (ffm_long) model.n * model.m * k_aligned * 2;
}
---------------------------------------
wTx函数为计算二次项累加值的函数,分为使用sse指令加速与普通模式两种方式。
由于函数整体较长,挑其中重要的部分解释。
wTx函数参数:
ffm *begin:特征数组的起始索引
ffm *end:特征数组的结尾索引
ffm_float r:归一化参数
ffm_model &model:模型
ffm_float kappa:梯度下降公式中的
ffm_float eta:学习率
ffm_float lambda:正则化参数
bool do_update=false:是否更新参数
---------------------------------------
//这两个参数的意义就是设置定位隐向量时的偏移量,类似于用一维数组表示二维数组时,如何通过a[x][y] x,y的值找到对应元素的下标。
ffm_int align0 = 2 * get_k_aligned(model.k); //乘二的目的跟模型初始化时乘二的目的都一致,体现在代码中就是wTx函数中的ffm_float *wg1 = w1 + kALIGN; wg1[d] += g1 * g1; 主要就是在梯度更新时保存中间值。
ffm_int align1 = model.m * align0; //相对于k的偏移量
----------------------------------------
//计算对应二次项系数的偏移量,准确来说通过w1就可以得到X(i,fj)的值
ffm_float *w1 = model.W + (ffm_long)j1*align1 + f2*align0;
ffm_float *w2 = model.W + (ffm_long)j2*align1 + f1*align0;
-----------------------------------------
//这里就是体现了当初申请二次项参数W空间时为什么乘2的原因,起一个中间变量的作用。
ffm_float *wg1 = w1 + kALIGN;
ffm_float *wg2 = w2 + kALIGN;
------------------------------------------
//这个for循环比较重要,就是更新参数,注意d += kALIGN * 2,其用意与乘2一样
for(ffm_int d = 0; d < align0; d += kALIGN * 2)
{
ffm_float g1 = lambda * w1[d] + kappa * w2[d] * v; //梯度计算公式
ffm_float g2 = lambda * w2[d] + kappa * w1[d] * v;
wg1[d] += g1 * g1; //梯度计算公式
wg2[d] += g2 * g2;
w1[d] -= eta / sqrt(wg1[d]) * g1; //更新参数
w2[d] -= eta / sqrt(wg2[d]) * g2;
}
--------------------------------------------
//隐向量点乘
for(ffm_int d = 0; d < align0; d += kALIGN * 2)
t += w1[d] * w2[d] * v;
以上为普通模式的wTx函数,以下为启用sse指令加速的wTx函数:
SSE指令我就知道它可以一次处理四个数,就是寄存器是128位的,也就是说可以存放四个浮点数,两个寄存器相加的话就可以实现四个浮点数的相加,仅仅只是执行了一次指令,速度理论上来讲会比不使用sse指令快四倍,但实际上由于io耗时,只会比普通模式快2倍左右。如果宁还是没有理解,我贴一段demo,这个demo大概就是实现两个数组相加的操作,其中Other为普通模式,ComputerArrayCPlusPlusSSE为使用了sse指令加速。
#include <xmmintrin.h>
#include <iostream>
#include <math.h>
#include <chrono>
using namespace std;
//疑点,数组长度必须是4的倍数吗?
void ComputerArrayCPlusPlusSSE(float* pArray1,float* pArray2,float* pResult,int nSize){
int nLoop = nSize/4;
__m128 m1,m2,m3,m4;
__m128* pSrc1 = (__m128*)pArray1;
__m128* pSrc2 = (__m128*)pArray2;
__m128* pDest = (__m128*)pResult;
__m128 m0_5 = _mm_set_ps1(0.5f);
for(int i=0;i < nLoop; i++){
m1 = _mm_mul_ps(*pSrc1,*pSrc1);
m2 = _mm_mul_ps(*pSrc2,*pSrc2);
m3 = _mm_add_ps(m1,m2);
m4 = _mm_sqrt_ps(m3);
*pDest = _mm_add_ps(m4,m0_5);
pSrc1++;
pSrc2++;
pDest++;
}
}
void Other(float* pArray1,float* pArray2,float* pResult,int nSize)
{
int i;
float* pSource1 = pArray1;
float* pSource2 = pArray2;
float* pDest = pResult;
for ( i = 0; i < nSize; i++ )
{
*pDest = (float)sqrt((*pSource1) * (*pSource1) + (*pSource2)
* (*pSource2)) + 0.5f;
pSource1++;
pSource2++;
pDest++;
}
}
int main(){
float p1[5] = {1.0,1.0,1.0,1.0,1.0};
float p2[5] = {1.0,1.0,1.0,1.0,1.0};
float result[5] = {0.0,0.0,0.0,0.0,0.0};
ComputerArrayCPlusPlusSSE(p1,p2,result,5);
for(int i=0;i<5;i++){
cout<<result[i]<<" ";
}
return 0;
}
我拿其中_mm_store_ss指令为例,依据官方文档进行解释:
void _mm_store_ss (float* mem_addr, __m128 a)
Description:
Store the lower single-precision (32-bit) floating-point element from a into memory. mem_addr does not need to be aligned on any particular boundary.
Operation:
MEM[mem_addr+31:mem_addr] := a[31:0]
我来给解释一下,a不是存储了四个浮点数嘛,总共128位,_mm_store_ss的意思就是把低32赋值给mem_addr这个float*指针,说白了就是把第一个浮点数赋值给mem_addr指针。
ok,看完上边这些,我只解释sse版本的wTx函数中的最重要的地方,也就是最能体现加速的地方:
for(ffm_int d = 0; d < align0; d += kALIGN * 2) //在启用sse指令后kALIGN的值为4
{
__m128 XMMw1 = _mm_load_ps(w1_base+d); //这个指令一次会把连续的128位地址空间里的值赋值给XMMw1,这里就是整个sse指令加速的地方。
__m128 XMMw2 = _mm_load_ps(w2_base+d);
XMMt = _mm_add_ps(XMMt,
_mm_mul_ps(_mm_mul_ps(XMMw1, XMMw2), XMMv));
}
malloc_aligned_float函数就是申请二次项参数W的地址空间的,分为三种分配方式,未使用sse指令,使用sse指令(win),使用sse指令(类unix)。其中最主要的是类unix下的内存申请,所以只解释这它。
int status = posix_memalign(&ptr, kALIGNByte, size*sizeof(ffm_float));
posix_memalign函数是用来申请内存的,多大呢?第三个参数就是。那么分配好地址后把这个地址给谁呢?第一个参数就是,好吧,它还有其最独特的功能,内存对齐,也就是说,我申请到的这块内存的首地址是第二个参数的整数倍,我们这里的kALIGNByte是16,2^4,所以申请到的首地址肯定是这个样子的:*0000,对,低地址为四个零。好,这不是重点,重点是为什么非要首地址是这个样子的呢?这个时候就会有人说了,内存对齐啊,好,的确是内存对齐,那么内存对齐干嘛啊?我们这里要内存对齐干嘛啊?听我细说:
1:ffm如果使用sse指令的话,一次要处理4个浮点数,四个浮点数占16个字节。
2:那么我就要从内存中读16个字节啊对吧
3:假设两种情况,一种是内存对齐的,一种是不对齐的,我们就让其首地址分别为:0,19.然后我们的机器是32位的,32位机器一次可以从内存中读出4个字节的数据,并且比较重要的一点就是计算机是按照字节为单位进行读数据的!!!
4:那好,我们现在开始读了,先读内存对齐的,从0开始啊,好,跟着我走啊,读了4字节,到了地址4了,又读了四个字节,到了地址8了,又读了四个字节到了12了,又读了四个字节,到了地址16了。ok,大功告成,四个浮点数全部读出来了,中间没拐弯抹角的,完美!!!
5:那好,我们再来读内存不对齐的这4个浮点数,首地址是19啊。但我不能直接从19这个地址读,我只能从16这个地址开始读,你可千万别问我为啥子不能从19开始读,虽说你这个问题很脑残(我也问过这个问题),但实际上,这个问题才是解释内存对齐最关键的一点,计算机读内存,是有单位的,32位机器就是一次读4个字节,并且是从0开始编址的,可以想象出来读内存的过程吗?ok,我觉得关于内存对齐我就解释完了。宁应该理解为什么要0000这样子的首地址了吧。
init_model函数就是初始化模型,这里边最难理解的应该也就是上边反复提到的乘2问题
uniform_real_distribution<ffm_float> distribution(0.0, 1.0); //连续均匀分布
-------------------------------------
for(ffm_int s = 0; s < kALIGN; s++, w++, d++) {
w[0] = (d < model.k)? coef * distribution(generator) : 0.0; //初始化参数
w[kALIGN] = 1; //这里就是初始化为1,并无特别意义。
}
//这个结构体记录了整个特征文件的概览信息
struct disk_problem_meta {
ffm_int n = 0; //特征个数
ffm_int m = 0; //域个数
ffm_int l = 0; //行数,可以理解为样本数
ffm_int num_blocks = 0; //写入块个数,一个块包含kCHUNK_SIZE个样本
ffm_long B_pos = 0; //文件头指针,记录txt格式转换为bin格式后,bin文件的头指针
uint64_t hash1; //记录文件的hash值
uint64_t hash2;
};
-------------------------------------------
//这个结构体是对整个bin特征文件的详细描述
struct problem_on_disk {
disk_problem_meta meta;
vector<ffm_float> Y; //当前行的label
vector<ffm_float> R; //当前行的归一化系数
vector<ffm_long> P; //P[i]代表从第0行开始到第i行的所有ffm_node的数目,就是按行累加的
vector<ffm_node> X; //表示所有特征的数组
vector<ffm_long> B; //表示bin文件所有写入块的块头指针,方便读取
//初始化这个结构体
problem_on_disk(string path) {
f.open(path, ios::in | ios::binary);
if(f.good()) {
f.read(reinterpret_cast<char*>(&meta), sizeof(disk_problem_meta)); //先把概述读出来
f.seekg(meta.B_pos); //找到第一个块的首地址
B.resize(meta.num_blocks); //给B数组分配地址空间
f.read(reinterpret_cast<char*>(B.data()), sizeof(ffm_long) * meta.num_blocks); //将每个块的首地址都读到B数组中,就是初始化。
}
}
//读取每个块的数据
int load_block(int block_index) {
if(block_index >= meta.num_blocks)
assert(false);
f.seekg(B[block_index]); //定位到每个块的首地址
//以下都是从文件流中顺序读取的,这个顺序是在txt2bin中规定的顺序
ffm_int l;
f.read(reinterpret_cast<char*>(&l), sizeof(ffm_int)); //对l进行初始化
Y.resize(l);
f.read(reinterpret_cast<char*>(Y.data()), sizeof(ffm_float) * l);
R.resize(l);
f.read(reinterpret_cast<char*>(R.data()), sizeof(ffm_float) * l);
P.resize(l+1);
f.read(reinterpret_cast<char*>(P.data()), sizeof(ffm_long) * (l+1));
X.resize(P[l]); //P[l]就是所有行的所有ffm_node的数量
f.read(reinterpret_cast<char*>(X.data()), sizeof(ffm_node) * P[l]);
return l;
}
bool is_empty() {
return meta.l == 0;
}
private:
ifstream f;
};
txt2bin函数是将明文格式的特征文件转换为bin文件,这么做的目的是因为便于检索(每个块的结构体占用的内存大小相等),并且二进制文件似乎更加节省内存占用(这我还不确定),txt文件格式由于每个样本所占用的大小不等,所以很难快速检索,我们这里快速检索的目的是为了sgd算法随机抽取样本。
void txt2bin(string txt_path, string bin_path) { //txt_path就是明文特征文件路径
FILE *f_txt = fopen(txt_path.c_str(), "r");
if(f_txt == nullptr)
throw;
ofstream f_bin(bin_path, ios::out | ios::binary); //二进制文件流,关键!!!
vector<char> line(kMaxLineSize); //存储每一行(一个样本)
ffm_long p = 0; //这个p就是记录有多少个node,是累加的,放进P里的
disk_problem_meta meta;
vector<ffm_float> Y; //存储标签数组,Y[1]就是第一行样本的label
vector<ffm_float> R; //归一化参数数组
vector<ffm_long> P(1, 0); //同上,不过这里就是把P[0]填充为了0,与P[l]呼应,不重要
vector<ffm_node> X; //存储的node数据
vector<ffm_long> B; //每个块的头指针
auto write_chunk = [&] () { //向bin文件中写入一个块
B.push_back(f_bin.tellp()); //把当前bin文件指针赋值给B,记录这个块的头指针
ffm_int l = Y.size(); //label数和行数是一致的
ffm_long nnz = P[l]; //这里P[l]就是记录的有多少个非0node
meta.l += l;
//按照顺序写入到文件里,与problem_on_disk读取的顺序是一致的
f_bin.write(reinterpret_cast<char*>(&l), sizeof(ffm_int));
f_bin.write(reinterpret_cast<char*>(Y.data()), sizeof(ffm_float) * l);
f_bin.write(reinterpret_cast<char*>(R.data()), sizeof(ffm_float) * l);
f_bin.write(reinterpret_cast<char*>(P.data()), sizeof(ffm_long) * (l+1));
f_bin.write(reinterpret_cast<char*>(X.data()), sizeof(ffm_node) * nnz); //B不能在这里写入,因为B不属于块量级的信息,属于综述信息,跟disk_problem_meta量级一样
Y.clear();
R.clear();
P.assign(1, 0);
X.clear();
p = 0; //一定要注意这里p重新设置为0
meta.num_blocks++; //块数目+1
};
f_bin.write(reinterpret_cast<char*>(&meta), sizeof(disk_problem_meta)); //这里其实迷惑性很强的,就是你肯定很疑惑为啥子写入一个还没有初始化的meta,其实这个只是起一个占位的作用,到最后才会通过重定向文件指针到文件头,写入初始化的meta
while(fgets(line.data(), kMaxLineSize, f_txt)) { //读取txt特征文件的一行
char *y_char = strtok(line.data(), " \t"); //会获取第一个字符串,也就是label
ffm_float y = (atoi(y_char)>0)? 1.0f : -1.0f; //这个是label
ffm_float scale = 0; //归一化参数
for(; ; p++) { //p记录有多少个node
char *field_char = strtok(nullptr,":"); //获取域id
char *idx_char = strtok(nullptr,":"); //获取特征id
char *value_char = strtok(nullptr," \t"); //获取值
if(field_char == nullptr || *field_char == '\n')
break;
ffm_node N;
N.f = atoi(field_char);
N.j = atoi(idx_char);
N.v = atof(value_char);
X.push_back(N); //将ffm_node追加到X数组中
meta.m = max(meta.m, N.f+1); //找出总共的域个数
meta.n = max(meta.n, N.j+1); //找出总共的特征个数
scale += N.v*N.v; //归一化
}
scale = 1.0 / scale; //归一化
Y.push_back(y); //放入这一行的label
R.push_back(scale); //放入这一行的归一化参数
P.push_back(p); //放入这一行的node个数
if(X.size() > (size_t)kCHUNK_SIZE) //这里其实就是定义了一个块的大小
write_chunk();
}
write_chunk();
write_chunk(); // write a dummy empty chunk in order to know where the EOF is
assert(meta.num_blocks == (ffm_int)B.size()); //如果它的条件返回错误,则终止程序执行
meta.B_pos = f_bin.tellp(); //记录下最后这个块的写入地址
f_bin.write(reinterpret_cast<char*>(B.data()), sizeof(ffm_long) * B.size()); //这里把每个块的起始地址灌进去
fclose(f_txt);
meta.hash1 = hashfile(txt_path, true); //给这个文件赋予hash值,保证不会被重复转换
meta.hash2 = hashfile(txt_path, false);
f_bin.seekp(0, ios::beg);
f_bin.write(reinterpret_cast<char*>(&meta), sizeof(disk_problem_meta)); //与上边的呼应
}
check_same_txt_bin函数就是看看这个txt文件有没有已经转换为bin文件了,不解释。
ffm_read_problem_to_disk函数就是将txt文件转换为bin文件
ffm_train_on_disk函数就是启动训练
//one_epoch
auto one_epoch = [&] (problem_on_disk &prob, bool do_update) {
ffm_double loss = 0;
vector<ffm_int> outer_order(prob.meta.num_blocks); //打乱块顺序
iota(outer_order.begin(), outer_order.end(), 0); //用循序递增的值赋值指定范围内的元素,从0开始赋值
random_shuffle(outer_order.begin(), outer_order.end()); //洗牌,打乱顺序
for(auto blk : outer_order) {
ffm_int l = prob.load_block(blk); //一个块中的样本数目
vector<ffm_int> inner_order(l); //打乱行顺序
iota(inner_order.begin(), inner_order.end(), 0);
random_shuffle(inner_order.begin(), inner_order.end());
#if defined USEOMP
#pragma omp parallel for schedule(static) reduction(+: loss) //这里的意思就是for循环有ii次迭代次数,会被平均分配给每个线程,每个线程的loss的计算过程会依赖其上次迭代的值,所以使用了reduction(+: loss)来解决这个问题。
#endif
for(ffm_int ii = 0; ii < l; ii++) { //开始按行读取了
ffm_int i = inner_order[ii];
ffm_float y = prob.Y[i];
ffm_node *begin = &prob.X[prob.P[i]]; //这里p[i+1]-p[i]就是第i行的ffm_node数量
ffm_node *end = &prob.X[prob.P[i+1]];
ffm_float r = param.normalization? prob.R[i] : 1; //归一化
ffm_double t = wTx(begin, end, r, model); //计算二次项和
ffm_double expnyt = exp(-y*t); //损失函数中的
loss += log1p(expnyt); //损失
if(do_update) {
ffm_float kappa = -y*expnyt/(1+expnyt); //kappa就是梯度中的第一项那个
wTx(begin, end, r, model, kappa, param.eta, param.lambda, true); //这里会进行更新
}
}
}
return loss / prob.meta.l;
};
下边就是控制迭代次数
//是否自动停止训练,best_va_loss来控制
if(auto_stop) {
if(va_loss > best_va_loss) {
memcpy(model.W, prev_W.data(), w_size*sizeof(ffm_float));
cout << endl << "Auto-stop. Use model at " << iter-1 << "thiteration." << endl;
break;
} else {
memcpy(prev_W.data(), model.W, w_size*sizeof(ffm_float));
best_va_loss = va_loss;
}
}
ffm_save_model,ffm_load_model,ffm_predict分别为保存模型,加载模型,预测
四:ffm-predict.cpp
逻辑在上边都有
五:timer.cpp timer.h
计算代码段执行时间,在这里记录模型训练时间