逻辑回归并行化

2017/6/21 posted in  机器学习

Github地址为https://github.com/zhuangqh/LR-x

数学原理

逻辑回归与线性回归非常类似,区别就是预测函数不同。预测函数表示预测结果取1的概率。

\[h_\theta=g(\theta^Tx)=\frac{1}{1+e^{-\theta^Tx}}\]

定义损失函数

\[Cost(h_\theta(x),y)=\]

对损失函数做最大似然估计,然后取对数并乘与 -1/m 得到

\[J(\theta)=\frac{1}{m} \sum^n_{i=1}Cost(h_\theta(x_i), y_i)=-\frac{1}{m}[\sum^n_{i=1}y_i\log h_\theta(x_i)+(1-y_i)\log(1-h_\theta(x_i))]\]

通过梯度下降的方法来得到\(J(\theta)\)的最小值。

对\(J(\theta)\)求导数得到:

\[\frac{\partial J(\theta)}{\partial \theta_j} =-\frac{1}{m}\sum^n_{i=1}(y_i\frac{1}{h_\theta(x_i)}\frac{\partial h_\theta(x_i)}{\partial \theta_j}-(1-y_i)\frac{1}{1-h_\theta(x_i)} \frac{\partial h_\theta(x_i)}{\partial \theta_j})\\
= -\frac{1}{m}\sum^m_{i=1}(y_i\frac{1}{g(\theta^Tx_i)}-(1-y_i)\frac{1}{1-g(\theta^Tx_i)})\frac{\partial g(\theta^Tx_i)}{\partial \theta_j} \\
= -\frac{1}{m}\sum^m_{i=1}(y_i\frac{1}{g(\theta^Tx_i)}-(1-y_i)\frac{1}{1-g(\theta^Tx_i)})g(\theta^Tx_i)(1-g(\theta^Tx_i))\frac{\partial \theta^Tx_i}{\partial \theta_j} \\
= -\frac{1}{m}\sum^m_{i=1}(y_i(1-g(\theta^Tx_i))-(1-y_i)g(\theta^Tx_i))x_i^j\\
= -\frac{1}{m}\sum^m_{i=1}(y_i-g(\theta^Tx_i))x_i^j\\
= \frac{1}{m}\sum^m_{i=1}(h_\theta(x_i)-y_i)x_i^j\]

那么参数更新的过程为
\[\theta_j := \theta_j - \alpha \frac{1}{m}\sum^m_{i=1}(h_\theta(x_i)-y_i)x^j_i\]

加入正则化项,防止过拟合

\[\theta_j := \theta_j - \frac{\alpha}{m}\sum^m_{i=1}(h_\theta(x_i)-y_i)x^j_i-\frac{\lambda}{m}\theta_j\]

下面着手用代码实现

代码实现

实现的时候,我使用的是文档比较友好的eigen来辅助矩阵计算。使用OpenMP做并行化。

各个版本的实现非常相似,区别在于计算梯度的部分。伪代码如下

initial theta
for each iteration do
    gw <- get_gradient_from_train_data()
    theta <- theta - alpha * gw
    if convergence
        then break
end

以下假设theta个数为n, 训练集数目为m

简单版本

第一个版本是直接计算。对于训练集的每一行,计算 与的内积,然后减去,再乘与,得到常数coeff。常数coeff与相乘便得到这一个训练样本的梯度向量。将所有m个向量相加便得到最终的梯度。

VectorXd gw;
gw.setZero(n);
for (size_t i = 0; i < m; i++) {
    double coeff = train.first.row(i) * theta;
    // h(xi) = 1/m * (g(xi * theta) - yi)
    coeff = 1.0 / m * (g(coeff) - train.second(i));
    // gwi = h(xi) * xi
    VectorXd gwi = coeff * train.first.row(i);
    
    gw += gwi;
}

向量化

观察式(1),发现可以转化为矩阵乘法。也即向量化。

\[记 \mathbf{E} = g(x \cdot \theta) - y\]

\(\theta_j\)化为:\(\theta_j := \theta_j - \alpha \cdot (x_j^{(1)}, x_j^{(2)},...,x_j^{(m)}) \cdot \mathbf{E}\)
 
那么综合起来就是:

\[\theta := \theta - \alpha \cdot x^T \cdot \mathbf{E}\]

用代码实现起来并不难。eigen帮我们实现快速的矩阵乘法。
c++代码做了重载,使得代码就像数学表达式一样直白。

// A = x * theta
VectorXd inner = train.first * theta;
// E = g(A)
VectorXd ga = inner.unaryExpr(std::ptr_fun(g));
// gw = 1/m * xT * (E - y)
VectorXd gw = 1.0 / this->m * (train.first.transpose() * (ga - train.second));

并行化

我的并行化是在简单版本的基础上进行改进的。可以看到,在对每一行计算梯度的时候,行与行之间是相互独立的。然后再将所有这些梯度相加。这天然就是一个可以被并行优化的问题。

我使用的是OpenMP,只要在需要并行话的地方写上对应的宏,编译器就会帮我们做并行化。
在做并行化的时候,会有这样一个问题。在简单版中,用于迭代计算梯度的 i 在并行化会被处理器竞争。导致结果跟我们预想的不符。

我们用openmp的函数获取处理器的数量,为每个处理器分配一个迭代器,独立使用。

int coreNum = omp_get_num_procs();

// temp sum in each processor
std::vector<VectorXd> sumInCore(coreNum);

// iterator in each processor
std::vector<size_t> iters(coreNum);

并且分配空间用于存放各个处理计算得到的梯度和。各个处理独立使用的迭代器和临时和变量,这样在并行化中便不存在竞争。

然后将训练样本均匀分配给每个处理器。在一个处理器中,计算这些训练样本的梯度和。在并行化结束后,将各个处理器的梯度和相加得到总的梯度。

结果验证与性能比较

并行化了之后不能简单使用CPU clock来衡量运算的快慢。因为并行版本会使用多个核。
我使用OpenMP提供的omp_get_wtime函数,他会返回从任意一个一致点所经过的时间。我用这个来衡量各版本的计算速度。

void timer_wrapper(std::function<void()> func) {
  double startTime = omp_get_wtime();
  func();
  double stopTime = omp_get_wtime();

  std::cout << stopTime - startTime << std::endl;
}

使用这样函数timer来计算一个函数的运行时间。timer接受一个类型为function的函数对象,然后打印这个函数的运行时间。

性能对比

测试数据是我从网上找的一个垃圾邮件的数据。样本数2760,feature数量57。我们迭代1000次来看运行时间的差别。
并行版比简单版快了4倍多。向量化版是最快的,这可能与eigen的实现有关,因为向量化的乘法完全使用eigen的函数。

预测结果 naive.txt, vectorize.txt, parallel.txt 三者的结果一模一样。这说明算法是正确的。

参考资料