libffm源码解读

一: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

计算代码段执行时间,在这里记录模型训练时间

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值