StreamingLinearRegressionWithSGD核心计算部分源码解读

StreamingLinearRegressionWithSGD核心计算部分源码解读

大家好,我是一拳就能打爆A柱的猛男

经过考虑,我决定接下来的几天把Spark中的一些流式机器学习算法的最核心的代码给大家讲解一下,看看我能扒多深吧。今天给大家讲流式线性回归最核心的部分的代码,接下来就分为三部分:发现核心、源码分析、对数据的影响。

1、发现核心

在之前的博客《StreamingLinearRegressionWithSGD源码分析 流式线性回归源码分析》中提到过最核心的这部分代码的位置。但是由于当时关注的重点在整个流式计算的流程,所以没有深入计算,这回就把这部分内容补上。

因为流式线性回归模型最终是将RDD中的批数据使用mini-batch SGD的方式进行训练的,而且训练的核心是梯度,通过梯度来更新模型的权重向量,所以经过分析知道了具体的训练代码在GradientDescent伴生对象的runMiniBatchSGD中的while部分,具体的整个分析链可以看上面这篇博客。

while部分的代码:

while (!converged && i <= numIterations) {
    val bcWeights = data.context.broadcast(weights)
    // Sample a subset (fraction miniBatchFraction) of the total data
    // compute and sum up the subgradients on this subset (this is one map-reduce)
    val (gradientSum, lossSum, miniBatchSize) = data.sample(false, miniBatchFraction, 42 + i)
    .treeAggregate((BDV.zeros[Double](n), 0.0, 0L))(
        seqOp = (c, v) => {
            // c: (grad, loss, count), v: (label, features)
            val l = gradient.compute(v._2, v._1, bcWeights.value, Vectors.fromBreeze(c._1))
            (c._1, c._2 + l, c._3 + 1)
        },
        combOp = (c1, c2) => {
            // c: (grad, loss, count)
            (c1._1 += c2._1, c1._2 + c2._2, c1._3 + c2._3)
        })
    bcWeights.destroy()

    if (miniBatchSize > 0) {
        /**
         * lossSum is computed using the weights from the previous iteration
         * and regVal is the regularization value computed in the previous iteration as well.
         */
        stochasticLossHistory += lossSum / miniBatchSize + regVal
        val update = updater.compute(
            weights, Vectors.fromBreeze(gradientSum / miniBatchSize.toDouble),
            stepSize, i, regParam)
        weights = update._1
        regVal = update._2

        previousWeights = currentWeights
        currentWeights = Some(weights)
        if (previousWeights != None && currentWeights != None) {
            converged = isConverged(previousWeights.get,
                                    currentWeights.get, convergenceTol)
        }
    } else {
        logWarning(s"Iteration ($i/$numIterations). The size of sampled batch is zero")
    }
    i += 1
}

在if中可以看到若前后两个阶段的weights相差不大的话,converged标志会变化,从而退出循环。所以整个循环都在通过一种方法去计算梯度(gradient),更新权重(weights),判断收敛(converged)。了解大概流程后就可以逐行看源码了。

2、源码分析

接下来我将以GradientDescent.scala文件的行号来注明代码,各位可以打开文件一起看。

229行

这行判断可以看出,退出循环的标志就是converged这个标志为true或者达到了迭代次数(numIteration),从而可以知道while中必然有判断是否收敛的语句。

230行

在Spark Core中存在一种广播变量,即可以将Driver端的数据通过广播的方式分发到各节点,从而方便各节点计算。显然做流式线性回归,各节点都需要此时模型的weight向量。

注意:data.context拿到的是当前RDD的SparkContext,SparkContext通过广播方法broadcast将weights向量发送到各节点。weights向量是存在于driver的model的权重向量。

233 - 243行

233行中的data的类型是RDD,RDD的sample作为采样方法,可以从数据集中随机采样出一定量数据。

// (不重复采样,采样数,随机种子)
.sample(false, miniBatchFraction, 42 + i)

最终返回的还是一个RDD。对于为什么要采样在博客《StreamingLinearRegressionWithSGD源码分析 流式线性回归源码分析》中也有讲解,简单来说就是使用小批量数据替代大批量数据做SGD,既能减少计算时间又能保证收敛。

234行到243行是重点,但是在看代码之前还是要先清楚treeAgreegate函数的运行方式。

.treeAggregate((BDV.zeros[Double](n), 0.0, 0L))(
    seqOp = (c, v) => {
        // c: (grad, loss, count), v: (label, features)
        val l = gradient.compute(v._2, v._1, bcWeights.value, Vectors.fromBreeze(c._1))
        (c._1, c._2 + l, c._3 + 1)
    },
    combOp = (c1, c2) => {
        // c: (grad, loss, count)
        (c1._1 += c2._1, c1._2 + c2._2, c1._3 + c2._3)
    })

treeAggregate方法也是RDD的方法,其作用是通过树形结构将数据聚合到Driver端以便做统一的处理(更新model)。整个聚合的步骤如下:

  • zeroValue是三元元组,(长度为n的0向量,0,0),zeroValue作为初始值参与局部聚合。通过seqOp计算出的l更新三元元组,并参与节点内下一条数据的聚合,最终得到(grand_local,loss_local,count_local)
  • combOp作为全局聚合时的函数,将各节点计算出的(grand_local,loss_local,count_local)聚合成(gradientSum, lossSum, miniBatchSize)

但是上面关键的地方是损失的计算和梯度向量的更新:

val l = gradient.compute(v._2, v._1, bcWeights.value, Vectors.fromBreeze(c._1))

它内部的计算是:

override def compute(
    data: Vector, // 样本向量
    label: Double, // 真实值
    weights: Vector, // 权重向量
    cumGradient: Vector): Double = { // 梯度向量 zeroValue = 0向量
    val diff = dot(data, weights) - label
    axpy(diff, data, cumGradient)
    diff * diff / 2.0
}

通过计算真实值与预测值的误差(diff)来更新梯度向量,并且返回误差平方的一半(l,即损失)。其中axpy(diff, data, cumGradient)表示的是 y += a * x,也就是 梯度 += 误差 * 样本向量,这个就是梯度的计算公式。

到这里,233 - 243行的代码讲完了,简单来说就是首先经过采样(以少量样本替代大量样本,减少计算压力),然后通过聚合,最后在Driver端得到该批次数据的采样样本总梯度、总损失、样本量

244行

销毁广播变量

251 - 256行

// 计算历史随机损失
stochasticLossHistory += lossSum / miniBatchSize + regVal
val update = updater.compute(
    weights, Vectors.fromBreeze(gradientSum / miniBatchSize.toDouble),
    stepSize, i, regParam)
weights = update._1
regVal = update._2

这部分的重点显然在updater.conpute中,传入的参数分别是:权重向量,总梯度向量的均值,步长,本次迭代次数,回归参数。

override def compute(
    weightsOld: Vector,
    gradient: Vector,
    stepSize: Double,
    iter: Int,
    regParam: Double): (Vector, Double) = {
    val thisIterStepSize = stepSize / math.sqrt(iter)
    val brzWeights: BV[Double] = weightsOld.asBreeze.toDenseVector
    brzAxpy(-thisIterStepSize, gradient.asBreeze, brzWeights)

    (Vectors.fromBreeze(brzWeights), 0)
}

步骤如下:

  • 计算出本轮迭代的步长:步长 / 第i轮的开平方。
  • 将上轮权重向量转成DenseVector方便计算。
  • 通过 y += a * x,更新权重。 brzWeights += -thisIterStepSize * gradient.asBreeze。

在compute方法中有一个思想是更新的步长会随着迭代次数的增长而减小,这样模型在移动的过程中会逐渐减速,这也能降低模型完全拟合新批次样本的风险。

258 - 263行

previousWeights = currentWeights
currentWeights = Some(weights)
if (previousWeights != None && currentWeights != None) {
    converged = isConverged(previousWeights.get,
                            currentWeights.get, convergenceTol)
}

3、对数据的影响

线性回归的权重迭代已经结束了,回顾整个流式线性回归算法,**我认为流式线性回归算法的思想就是通过维护一个线性回归模型来做到模型迭代更新的同时还能做实时预测。**kafka的数据按批到达Spark,算法以批为单位去训练模型。

对于批数据量过大,导致训练效率低的问题,Spark的流式线性回归采用的是mini-batch SGD,也就是说从批数据中采样出合理范围的mini-batch,基于mini-batch在分布式环境下计算,再将结果聚合到Driver中。这样做的好处是降低了各个节点的计算负担,同时各节点计算后只需要将结果发送给Driver,降低了集群网络负担。

从整个计算过程来看,流式线性回归面向的对象是每一批新来的样本(也就是说每一次都希望将曲线尽量拟合新批次样本)。对于旧批次的样本对模型的影响可以分为两种情况:

  • 1、迭代次数够大。
  • 2、迭代次数恰当大小。

在迭代次数够大的情况下:旧批次的样本对模型的影响其实并不重要,因为在训练终止的条件中有一个是否收敛的flag,只有前后两次权重相差小于阈值的时候训练才会终止(或者达到指定迭代次数),换句话说只要新批次的样本未拟合到位就不会停止。这种情况下模型就会退化成传统的批式线性模型,来一批数据就相当于换一个模型(因为每个批次的数据量不大),完全失去预测能力。(当然模型移动速度会逐渐减慢,也能起到一定预防作用)

在迭代次数恰当大小的情况下:旧批次的样本对模型有一定的拉扯作用,模型会在新批次样本的影响下向新样本方向移动,但是在迭代次数限制下不会达到完全拟合新批次样本的情况。这样做的好处是面对新样本具有一定的余量去调整,更具有通用性。此时迭代次数的大小就成为一个关键:若迭代次数过小,受旧样本拉扯太强烈,模型变化很慢。若迭代次数过大,会失去预测能力。

总结

看完核心的计算代码,可以发现GradientDescent是一个通用的类,其中定义了很多方法可以适配其他的线性模型。这次针对流式线性回归去看其计算过程,总结下来就是几点:

  • Spark的流式还是基于RDD数据结构,也就是针对批数据处理。
  • 计算梯度的过程Spark的步长动态变化,且公式是给定步长/迭代数开根号。
  • 根据步长的变化可以感知到模型对新旧数据的态度,是否保留旧数据对新模型的影响?保留的程度是多少?等等的这些问题都可以通过参数来设定。

在这里给大家安利一本书《大数据处理框架Apache Spark设计与实现》,这本书将RDD的一些API讲解的很清楚,包括内部数据的组织形式,如何shuffle等等。刚刚这些内容只是其中一小部分,更重要的是在spark框架的设计部分也讲解的很深入,是值得反复学习的好书,之所以安利这本书是因为本来我以为我对Spark的一些概念多少是了解的,但是看了一部分后我越来越觉得这个计算框架深奥。以至于我现在写博客完全没自信了。。。因为我希望能给大家深挖,可是对底层的东西不够深就越不能确定我的看法是不是对的。总之看完这本书,绝对会让你对Spark有更深的理解。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值