spark mllib源码分析之随机森林(Random Forest)

本文深入分析Spark中随机森林算法的实现细节,涵盖决策树、特征处理、样本划分、训练流程及模型保存加载等方面,揭示其高效训练策略。

Spark在mllib中实现了tree相关的算法,决策树DT(DecisionTree),随机森林RF(RandomForest),GBDT(Gradient Boosting Decision Tree),其基础都是RF,DT是RF一棵树时的情况,而GBDT则是循环构建DT,GBDT与DT的代码是非常简单明了的,本文将分成五部分分别对RF的源码进行分析,介绍spark在实现过程中使用的一些技巧。

1. 决策树与随机森林
首先对决策树和随机森林进行简单的回顾。

1.1. 决策树


在决策树的训练中,如上图所示,就是从根节点开始,不断的分裂,直到触发截止条件,在节点的分裂过程中要解决的问题其实就2个

分裂点:一般就是遍历所有特征的所有特征值,选取impurity最大的分成左右孩子节点,impurity的选取有信息熵(分类),最小均方差(回归)等方法
预测值:一般取当前最多的class(分类)或者取均值(回归)
1.2. 随机森林
随机森林就是构建多棵决策树投票,在构建多棵树过程中,引入随机性,一般体现在两个方面,一是每棵树使用的样本进行随机抽样,分为有放回和无放回抽样。二是对每棵树使用的特征集进行抽样,使用部分特征训练。 
在训练过程中,如果单机内存能放下所有样本,可以用多线程同时训练多棵树,树之间的训练互不影响。

2. spark RF优化策略
spark在实现RF时,使用了一些优化技巧,提高训练效率。

2.1. 逐层训练
当样本量过大,单机无法容纳时,只能采用分布式的训练方法,数据是在集群中的多台机器存放,如果按照单机的方法,每棵树完全独立访问样本数据,则样本数据的访问次数为数的个数k*每棵树的节点数N,相当于深度遍历。在spark的实现中,因为数据存放在不同的机器上,频繁的访问数据效率非常低,因此采用广度遍历的方法,每次构造所有树的一层,例如如果要训练10棵树,第一次构造所有树的第一层根节点,第二次构造所有深度为2的节点,以此类推,这样访问数据的次数降为树的最大深度,大大减少了机器之间的通信,提高训练效率。

2.2. 样本抽样
当样本存在连续特征时,其可能的取值可能是无限的,存储其可能出现的值占用较大空间,因此spark对样本进行了抽样,抽样数量

val requiredSamples = math.max(metadata.maxBins * metadata.maxBins, 10000)

最少抽样1万条,当然这样会降低模型精度。

2.3. 特征装箱
其实没什么神秘的,每个离散特征值(对于连续特征,先离散化)称为一个Split,上下限[lowSplit, highSplit]组成一个bin,也就是特征装箱,默认的maxBins是32。对于连续特征,离散化时的bin的个数就是maxBins,采用等频离散化;对于有序的离散特征,bin的个数是特征值个数+1;对于无序离散特征,bin的个数是2^(M-1)-1,M是特征值个数

3. 源码分析
我们从官方给出的分类demo开始,逐层分析其实现

3.1. 训练数据的解析
主要是LabelPoint的构造,官方demo中要求训练数据是LibSVM格式的

parsed.map { case (label, indices, values) =>
      LabeledPoint(label, Vectors.sparse(d, indices, values))
    }

可以看到LabelPoint有两个成员,第一个是样本label,第二个是稀疏向量SparseVector,d是其size,在这里其实是特征数,indices是实际非0特征的index,values里面是实际的特征值,这里需要注意的是,SVN格式的特征index是从0开始的,这里进行了-1,变成从0开始了。

3.2. demo中训练参数说明
官方demo中只设置了部分参数

val model = RandomForest.trainClassifier(trainingData, numClasses, categoricalFeaturesInfo,
      numTrees, featureSubsetStrategy, impurity, maxDepth, maxBins)

categoricalFeaturesInfo:Map[Int, Int],key是特征的index,value为特征值的个数(或者说几种),这里值得注意的是,因为LabelPoint中进行了index-1的变换,这个里面的key也需要-1(参见后面metadata的numBins的计算)。例如性别这个特征在样本中的index为1,特征值男/女两种,则0->2
featureSubsetStrategy:特征子集的抽取方法,支持”auto”, “all”, “sqrt”, “log2”, “onethird”
impurity:不纯度,其实就是节点分裂时的衡量准则,例如信息熵,均方差等,这里支持三种,gini(基尼指数),entripy(信息熵),variance(均方差)
maxDepth:树的最大深度
maxBins:最大装箱数,或者说是特征的最大可能切分数+1。这个值必须大于等于最大的离散特征值数
3.3. 参数封装
spark根据用户提供的参数值,进行实际训练参数的计算,并且将这些参数封装成类,方便传递。

3.3.1. Strategy
class Strategy @Since("1.3.0") (
    @Since("1.0.0") @BeanProperty var algo: Algo,
    @Since("1.0.0") @BeanProperty var impurity: Impurity,
    @Since("1.0.0") @BeanProperty var maxDepth: Int,
    @Since("1.2.0") @BeanProperty var numClasses: Int = 2,
    @Since("1.0.0") @BeanProperty var maxBins: Int = 32,
    @Since("1.0.0") @BeanProperty var quantileCalculationStrategy: QuantileStrategy = Sort,
    @Since("1.0.0") @BeanProperty var categoricalFeaturesInfo: Map[Int, Int] = Map[Int, Int](),
    @Since("1.2.0") @BeanProperty var minInstancesPerNode: Int = 1,
    @Since("1.2.0") @BeanProperty var minInfoGain: Double = 0.0,
    @Since("1.0.0") @BeanProperty var maxMemoryInMB: Int = 256,
    @Since("1.2.0") @BeanProperty var subsamplingRate: Double = 1,
    @Since("1.2.0") @BeanProperty var useNodeIdCache: Boolean = false,
    @Since("1.2.0") @BeanProperty var checkpointInterval: Int = 10)

algo:classification/regression
quantileCalculationStrategy:分位点(Split)策略,目前只支持Sort,对于连续型特征值,先把特征值进行排序,然后按次序取分位点。从代码中可以看到原来可能打算实现的MinMax和ApproxHist目前没有实现。
minInstancesPerNode:每个树节点中最小的样本数,低于将不再对节点进行分裂,默认为1,可作为提前截止条件
minInfoGain:最小增益,节点分裂后的增益如果小于它,将不再进行分裂,可作为提前截止条件
subsamplingRate:样本抽样率,默认为1,每棵树都使用全部样本
isMulticlassClassification:是否是多分类,判断条件为Classification 并且类别>2
isMulticlassWithCategoricalFeatures:是否是带类别特征的多分类,判断条件再上面的基础上加categoricalFeaturesInfo的size大于0
3.3.2. metadata
在buildMetadata中根据strategy计算得到DecisionTreeMetadata的参数。

class DecisionTreeMetadata(
    val numFeatures: Int,
    val numExamples: Long,
    val numClasses: Int,
    val maxBins: Int,
    val featureArity: Map[Int, Int],
    val unorderedFeatures: Set[Int],
    val numBins: Array[Int],
    val impurity: Impurity,
    val quantileStrategy: QuantileStrategy,
    val maxDepth: Int,
    val minInstancesPerNode: Int,
    val minInfoGain: Double,
    val numTrees: Int,
    val numFeaturesPerNode: Int)

部分参数同Strategy,对额外参数和区别说明

numClasses:如为Regression,设为0
maxPossibleBins:取maxBins和样本数量中较小的;必须大于categoricalFeaturesInfo中的最大的离散特征值数
numBins:所有特征及其特征值数,Int数组,维数是特征数,默认大小是maxPossibleBins。对于连续特征,其值就是默认值maxPossibleBins。对于离散特征,如为二分类或回归,此处将categoricalFeaturesInfo中的key特征index作为数组index,value特征个数写入数组中(这里有疑问,SVM格式的index是从1开始的,因此对numBins的index应该是categoricalFeaturesInfo的key-1,这里没有-1,当最大值等于maxBins的时候访问数组会抛异常);如果是多分类,先计算其当做当UnorderedFeature(无序的离散特征)的bin,如果个数小于等于maxPossibleBins,会被当成UnorderedFeature,否则被当成orderedFeatures(为了防止计算指数溢出,实际是把maxPossibleBins取log与特征数比较),因为UnorderedFeature的bin是比较大,这里限制了其特征值不能太多,这里仅仅根据特征值的特殊决定是否是ordered,不太好。每个split要将所有特征值分成2部分,bin的数量也就是2*split,因此bin的个数是2*(2^(M-1)-1)
numFeaturesPerNode:由featureSubsetStrategy决定,如果为“auto”,且为单棵树,则使用全部特征;如为多棵树,分类则是sqrt,回归为1/3;也可以自己指定,支持”all”, “sqrt”, “log2”, “onethird”。 
如果仅对RF的使用感兴趣,了解上述训练参数也就可以了,后面的文章将对其训练代码进行分析。
 

4. 特征处理
这部分主要在DecisionTree.scala的findSplitsBins函数,将所有特征封装成Split,然后装箱Bin。首先对split和bin的结构进行说明

4.1. 数据结构
4.1.1. Split
class Split(
    @Since("1.0.0") feature: Int,
    @Since("1.0.0") threshold: Double,
    @Since("1.0.0") featureType: FeatureType,
    @Since("1.0.0") categories: List[Double])

feature:特征id
threshold:阈值
featureType:连续特征(Continuous)/离散特征(Categorical)
categories:离散特征值数组,离散特征使用。放着此split中所有特征值
4.1.2. Bin
class Bin(
    lowSplit: Split, 
    highSplit: Split, 
    featureType: FeatureType, 
    category: Double)

lowSplit/highSplit:上下界
featureType:连续特征(Continuous)/离散特征(Categorical)
category:离散特征的特征值
4.2. 连续特征处理
4.2.1. 抽样
val continuousFeatures = Range(0, numFeatures).filter(metadata.isContinuous)
val sampledInput = if (continuousFeatures.nonEmpty) {
      // Calculate the number of samples for approximate quantile calculation.
      val requiredSamples = math.max(metadata.maxBins * metadata.maxBins, 10000)
      val fraction = if (requiredSamples < metadata.numExamples) {
        requiredSamples.toDouble / metadata.numExamples
      } else {
        1.0
      }
      logDebug("fraction of data used for calculating quantiles = " + fraction)
      input.sample(withReplacement = false, fraction, new XORShiftRandom().nextInt())
    } else {
      input.sparkContext.emptyRDD[LabeledPoint]
    }

首先筛选出连续特征集,然后计算抽样数量,抽样比例,然后无放回样本抽样;如果没有连续特征,则为空RDD

4.2.2. 计算Split
metadata.quantileStrategy match {
      case Sort =>
        findSplitsBinsBySorting(sampledInput, metadata, continuousFeatures)
      case MinMax =>
        throw new UnsupportedOperationException("minmax not supported yet.")
      case ApproxHist =>
        throw new UnsupportedOperationException("approximate histogram not supported yet.")
    }

分位点策略,这里只实现了Sort这一种,前文有说明,下面的计算在findSplitsBinsBySorting函数中,入参是抽样样本集,metadata和连续特征集(里面是特征id,从0开始,见LabelPoint的构造)

val continuousSplits = {
    // reduce the parallelism for split computations when there are less
    // continuous features than input partitions. this prevents tasks from
    // being spun up that will definitely do no work.
    val numPartitions = math.min(continuousFeatures.length,input.partitions.length)
    input.flatMap(point => continuousFeatures.map(idx =>  (idx,point.features(idx))))
         .groupByKey(numPartitions)
         .map { case (k, v) => findSplits(k, v) }
         .collectAsMap()
    }

特征id为key,value是样本对应的该特征下的所有特征值,传给findSplits函数,其中又调用了findSplitsForContinuousFeature函数获得连续特征的Split,入参为样本,metadata和特征id

def findSplitsForContinuousFeature(
      featureSamples: Array[Double], 
      metadata: DecisionTreeMetadata,
      featureIndex: Int): Array[Double] = {
    require(metadata.isContinuous(featureIndex),
      "findSplitsForContinuousFeature can only be used to find splits for a continuous feature.")

    val splits = {
    //连续特征的split是numBins-1
      val numSplits = metadata.numSplits(featureIndex)
    //统计所有特征值其出现的次数
      // get count for each distinct value
      val valueCountMap = featureSamples.foldLeft(Map.empty[Double, Int]) { (m, x) =>
        m + ((x, m.getOrElse(x, 0) + 1))
      }
      //按特征值排序
      // sort distinct values
      val valueCounts = valueCountMap.toSeq.sortBy(_._1).toArray

      // if possible splits is not enough or just enough, just return all possible splits
      val possibleSplits = valueCounts.length
      if (possibleSplits <= numSplits) {
        valueCounts.map(_._1)
      } else {
      //等频离散化
        // stride between splits
        val stride: Double = featureSamples.length.toDouble / (numSplits + 1)
        logDebug("stride = " + stride)

        // iterate `valueCount` to find splits
        val splitsBuilder = Array.newBuilder[Double]
        var index = 1
        // currentCount: sum of counts of values that have been visited
        var currentCount = valueCounts(0)._2
        // targetCount: target value for `currentCount`.
        // If `currentCount` is closest value to `targetCount`,
        // then current value is a split threshold.
        // After finding a split threshold, `targetCount` is added by stride.
        var targetCount = stride
        while (index < valueCounts.length) {
          val previousCount = currentCount
          currentCount += valueCounts(index)._2
          val previousGap = math.abs(previousCount - targetCount)
          val currentGap = math.abs(currentCount - targetCount)
          // If adding count of current value to currentCount
          // makes the gap between currentCount and targetCount smaller,
          // previous value is a split threshold.
          //每次步进targetCount个样本,取上一个特征值与下一个特征值gap较小的
          if (previousGap < currentGap) {
            splitsBuilder += valueCounts(index - 1)._1
            targetCount += stride
          }
          index += 1
        }

        splitsBuilder.result()
      }
    }

    // TODO: Do not fail; just ignore the useless feature.
    assert(splits.length > 0,
      s"DecisionTree could not handle feature $featureIndex since it had only 1 unique value." +
        "  Please remove this feature and then try again.")

    // the split metadata must be updated on the driver

    splits
  }

在构造split的过程中,如果统计到的值的个数possibleSplits 还不如你设置的numSplits多,那么所有的值都作为分割点;否则,用等频分隔法,首先计算分隔步长stride,然后再循环中每次累加到targetCount中,作为理想分割点,但是理想分割点可能会包含的特征值过多,想取一个里理想分割点尽量近的特征值,例如,理想分割点是100,落在特征值fcfc里,但是当前特征值里面有30个样本,而前一个特征值fpfp只有5个样本,因此我们如果取fcfc作为split,则当前区间实际多25个样本,如果取fpfp,则少5个样本,显然取fpfp更为合理。 
具体到代码实现,在if判断里步进stride个样本,累加在targetCount中。while循环逐次把每个特征值的个数加到currentCount里,计算前一次previousCount和这次currentCount到targetCount的距离,有3种情况,一种是pre和cur都在target左边,肯定是cur小,继续循环,进入第二种情况;第二种一左一右,如果pre小,肯定是pre是最好的分割点,如果cur还是小,继续循环步进,进入第三种情况;第三种就是都在右边,显然是pre小。因此if的判断条件pre<curpre<cur,只要满足肯定就是split。整体下来的效果就能找到离target最近的一个特征值。 
findSplits函数使用本函数得到的离散化点作为threshold,构造Split

val splits = {
    val featureSplits = findSplitsForContinuousFeature(
          featureSamples.toArray,
          metadata,
          featureIndex)
    logDebug(s"featureIndex = $featureIndex, numSplits = ${featureSplits.length}")

    featureSplits.map(threshold => new Split(featureIndex, threshold, Continuous, Nil))
}

这样就得到了连续特征所有的Split 
4.2.3. 计算bin 
得到splits后,即可类似滑窗得到bin的上下界,构造bins

val bins = {
    val lowSplit = new DummyLowSplit(featureIndex, Continuous)
    val highSplit = new DummyHighSplit(featureIndex, Continuous)

    // tack the dummy splits on either side of the computed splits
    val allSplits = lowSplit +: splits.toSeq :+ highSplit

    // slide across the split points pairwise to allocate the bins
    allSplits.sliding(2).map {
         case Seq(left, right) => new Bin(left, right, Continuous, Double.MinValue)
    }.toArray
}

在计算splits的时候,个数是bin的个数减1,这里加上第一个DummyLowSplit(threshold是Double.MinValue),和最后一个DummyHighSplit(threshold是Double.MaxValue)构造的bin,恰好个数是numBins中的个数

4.3. 离散特征
bin的主要作用其实就是用来做连续特征离散化,离散特征是用不着的。 
对有序离散特征而言,其split直接用特征值表征,因此这里的splits和bins都是空的Array。 
对于无序离散特征而言,其split是特征值的组合,不是简单的上下界比较关系,bin是空Array,而split需要计算。

4.3.1. split
// Unordered features
// 2^(maxFeatureValue - 1) - 1 combinations
val featureArity = metadata.featureArity(i)
val split = Range(0, metadata.numSplits(i)).map { splitIndex =>
    val categories = extractMultiClassCategories(splitIndex + 1, featureArity)
    new Split(i, Double.MinValue, Categorical, categories)
}

featureArity来自参数categoricalFeaturesInfo中设置的离散特征的特征值数。 
metadata.numSplits是吧numBins中的数量/2,相当于返回了2^(M-1)-1,M是特征值数。 
调用extractMultiClassCategories函数,入参是1到2^(M-1)和特征数M。

/**
   * Nested method to extract list of eligible categories given an index. It extracts the
   * position of ones in a binary representation of the input. If binary
   * representation of an number is 01101 (13), the output list should (3.0, 2.0,
   * 0.0). The maxFeatureValue depict the number of rightmost digits that will be tested for ones.
   */
def extractMultiClassCategories(
     input: Int,
     maxFeatureValue: Int): List[Double] = {
    var categories = List[Double]()
    var j = 0
    var bitShiftedInput = input
    while (j < maxFeatureValue) {
      if (bitShiftedInput % 2 != 0) {
        // updating the list of categories.
        categories = j.toDouble :: categories
      }
      // Right shift by one
      bitShiftedInput = bitShiftedInput >> 1
      j += 1
    }
    categories
}

如注释所述,这个函数返回给定的input的二进制表示中1的index,这里实际返回的是特征的组合,之前文章介绍过的《组合数》。

5. 样本处理
将输入样本LabelPoint与上述特征进一步封装,方便后面进行分区统计。

5.1. TreePoint
构造TreePoint的过程,是一系列函数的调用链,我们逐层分析。

val treeInput = TreePoint.convertToTreeRDD(retaggedInput, bins, metadata)

RandomForest.scala中将输入转化成TreePoint的rdd,调用convertToTreeRDD函数

def convertToTreeRDD(
    input: RDD[LabeledPoint],
    bins: Array[Array[Bin]],
    metadata: DecisionTreeMetadata): RDD[TreePoint] = {
    // Construct arrays for featureArity for efficiency in the inner loop.
    val featureArity: Array[Int] = new Array[Int](metadata.numFeatures)
    var featureIndex = 0
    while (featureIndex < metadata.numFeatures) {
      featureArity(featureIndex) = metadata.featureArity.getOrElse(featureIndex, 0)
      featureIndex += 1
    }
    input.map { x =>
      TreePoint.labeledPointToTreePoint(x, bins, featureArity)
    }
  }

convertToTreeRDD函数的入参input是所有样本,bins是二维数组,第一维是特征,第二维是特征的Bin数组。函数首先计算每个特征的特征数量,放在featureArity中,如果是连续特征,设为0。对每个样本调用labeledPointToTreePoint函数,构造TreePoint。

private def labeledPointToTreePoint(
      labeledPoint: LabeledPoint,
      bins: Array[Array[Bin]],
      featureArity: Array[Int]): TreePoint = {
    val numFeatures = labeledPoint.features.size
    val arr = new Array[Int](numFeatures)
    var featureIndex = 0
    while (featureIndex < numFeatures) {
      arr(featureIndex) = findBin(featureIndex, labeledPoint, featureArity(featureIndex),
        bins)
      featureIndex += 1
    }
    new TreePoint(labeledPoint.label, arr)
  }

labeledPointToTreePoint计算每个样本的所有特征对应的特征值属于哪个bin,放在在arr数组中;如果是连续特征,存放的实际是binIndex,或者说是第几个bin;如果是离散特征,直接featureValue.toInt,这其实暗示着,对有序离散值,其编码只能是[0,featureArity - 1],闭区间,其后的部分逻辑也依赖于这个假设。这部分是在findBin函数中完成的,这里不再赘述。 
我们在这里把TreePoint的成员再罗列一下,方便查阅

class TreePoint(val label: Double, val binnedFeatures: Array[Int])

这里是把每个样本从LabelPoint转换成TreePoint,label就是样本label,binnedFeatures就是上述的arr数组。

5.2. BaggedPoint
同理构造BaggedPoint的过程,也是一系列函数的调用链,我们逐层分析。

val withReplacement = if (numTrees > 1) true else false
val baggedInput = BaggedPoint.convertToBaggedRDD(treeInput,
          strategy.subsamplingRate, numTrees,
          withReplacement, seed).persist(StorageLevel.MEMORY_AND_DISK)

这里同时对样本进行了抽样,如果树个数大于1,就有放回抽样,否则无放回抽样,调用convertToTreeRDD函数将TreePoint转化成BaggedPoint的rdd

/**
   * Convert an input dataset into its BaggedPoint representation,
   * choosing subsamplingRate counts for each instance.
   * Each subsamplingRate has the same number of instances as the original dataset,
   * and is created by subsampling without replacement.
   * @param input Input dataset.
   * @param subsamplingRate Fraction of the training data used for learning decision tree.
   * @param numSubsamples Number of subsamples of this RDD to take.
   * @param withReplacement Sampling with/without replacement.
   * @param seed Random seed.
   * @return BaggedPoint dataset representation.
   */
  def convertToBaggedRDD[Datum] (
      input: RDD[Datum],
      subsamplingRate: Double,
      numSubsamples: Int,
      withReplacement: Boolean,
      seed: Long = Utils.random.nextLong()): RDD[BaggedPoint[Datum]] = {
    if (withReplacement) {
      convertToBaggedRDDSamplingWithReplacement(input, subsamplingRate, numSubsamples, seed)
    } else {
      if (numSubsamples == 1 && subsamplingRate == 1.0) {
        convertToBaggedRDDWithoutSampling(input)
      } else {
        convertToBaggedRDDSamplingWithoutReplacement(input, subsamplingRate, numSubsamples, seed)
      }
    }
  }

根据有放回还是无放回,或者不抽样分别调用相应函数。无放回抽样

def convertToBaggedRDDSamplingWithoutReplacement[Datum] (
      input: RDD[Datum],
      subsamplingRate: Double,
      numSubsamples: Int,
      seed: Long): RDD[BaggedPoint[Datum]] = {
    //对每个partition独立抽样
    input.mapPartitionsWithIndex { (partitionIndex, instances) =>
      // Use random seed = seed + partitionIndex + 1 to make generation reproducible.
      val rng = new XORShiftRandom
      rng.setSeed(seed + partitionIndex + 1)
      instances.map { instance =>
      //对每条样本进行numSubsamples(实际是树的个数)次抽样,
      //一次将本条样本在所有树中是否会被抽取都获得,牺牲空间减少访问数据次数
        val subsampleWeights = new Array[Double](numSubsamples)
        var subsampleIndex = 0
        while (subsampleIndex < numSubsamples) {
          val x = rng.nextDouble()
          //无放回抽样,只需要决定本样本是否被抽取,被抽取就是1,没有就是0
          subsampleWeights(subsampleIndex) = {
            if (x < subsamplingRate) 1.0 else 0.0
          }
          subsampleIndex += 1
        }
        new BaggedPoint(instance, subsampleWeights)
      }
    }
  }

有放回抽样

def convertToBaggedRDDSamplingWithReplacement[Datum] (
      input: RDD[Datum],
      subsample: Double,
      numSubsamples: Int,
      seed: Long): RDD[BaggedPoint[Datum]] = {
    input.mapPartitionsWithIndex { (partitionIndex, instances) =>
      // Use random seed = seed + partitionIndex + 1 to make generation reproducible.
      val poisson = new PoissonDistribution(subsample)
      poisson.reseedRandomGenerator(seed + partitionIndex + 1)
      instances.map { instance =>
        val subsampleWeights = new Array[Double](numSubsamples)
        var subsampleIndex = 0
        while (subsampleIndex < numSubsamples) {
        //与无放回抽样对比,这里用泊松抽样返回的是样本被抽取的次数,
        //可能大于1,而无放回是0/1,也可认为是被抽取的次数
          subsampleWeights(subsampleIndex) = poisson.sample()
          subsampleIndex += 1
        }
        new BaggedPoint(instance, subsampleWeights)
      }
    }
  }

不抽样,或者说抽样率为1

def convertToBaggedRDDWithoutSampling[Datum] (
      input: RDD[Datum]): RDD[BaggedPoint[Datum]] = {
    input.map(datum => new BaggedPoint(datum, Array(1.0)))
  }

这里再啰嗦的罗列下BaggedPoint

class BaggedPoint[Datum](
    val datum: Datum, 
    val subsampleWeights: Array[Double])

datum是TreePoint,subsampleWeights是数组,维数等于numberTrees,每个值是样本在每棵树中被抽取的次数

至此,Random Forest的初始化工作已经完成

timer.stop("init")
 

6. 随机森林训练
6.1. 数据结构
6.1.1. Node
树中的每个节点是一个Node结构

class Node @Since("1.2.0") (
    @Since("1.0.0") val id: Int,
    @Since("1.0.0") var predict: Predict,
    @Since("1.2.0") var impurity: Double,
    @Since("1.0.0") var isLeaf: Boolean,
    @Since("1.0.0") var split: Option[Split],
    @Since("1.0.0") var leftNode: Option[Node],
    @Since("1.0.0") var rightNode: Option[Node],
    @Since("1.0.0") var stats: Option[InformationGainStats])

emptyNode,只初始化nodeIndex,其他都是默认值

def emptyNode(nodeIndex: Int): Node = 
    new Node(nodeIndex, new Predict(Double.MinValue),
    -1.0, false, None, None, None, None)

根据node的id,计算孩子节点的id

   * Return the index of the left child of this node.
   */
  def leftChildIndex(nodeIndex: Int): Int = nodeIndex << 1

  /**
   * Return the index of the right child of this node.
   */
  def rightChildIndex(nodeIndex: Int): Int = (nodeIndex << 1) + 1

左孩子节点就是当前id * 2,右孩子是id * 2+1。

6.1.2. Entropy
6.1.2.1. Entropy
Entropy是个Object,里面最重要的是calculate函数

/**
   * :: DeveloperApi ::
   * information calculation for multiclass classification
   * @param counts Array[Double] with counts for each label
   * @param totalCount sum of counts for all labels
   * @return information value, or 0 if totalCount = 0
   */
  @Since("1.1.0")
  @DeveloperApi
  override def calculate(counts: Array[Double], totalCount: Double): Double = {
    if (totalCount == 0) {
      return 0
    }
    val numClasses = counts.length
    var impurity = 0.0
    var classIndex = 0
    while (classIndex < numClasses) {
      val classCount = counts(classIndex)
      if (classCount != 0) {
        val freq = classCount / totalCount
        impurity -= freq * log2(freq)
      }
      classIndex += 1
    }
    impurity
  }

熵的计算公式 
H=E[−logpi]=−∑i=1n−pilogpi
H=E[−logpi]=−∑i=1n−pilogpi

因此这里的入参count是各class的出现的次数,先计算出现概率,然后取log累加。
6.1.2.2. EntropyAggregator
class EntropyAggregator(numClasses: Int)
  extends ImpurityAggregator(numClasses)

只有一个成员变量class的个数,关键是update函数

/**
   * Update stats for one (node, feature, bin) with the given label.
   * @param allStats  Flat stats array, with stats for this (node, feature, bin) contiguous.
   * @param offset    Start index of stats for this (node, feature, bin).
   */
  def update(allStats: Array[Double], offset: Int, label: Double, instanceWeight: Double): Unit = {
    if (label >= statsSize) {
      throw new IllegalArgumentException(s"EntropyAggregator given label $label" +
        s" but requires label < numClasses (= $statsSize).")
    }
    if (label < 0) {
      throw new IllegalArgumentException(s"EntropyAggregator given label $label" +
        s"but requires label is non-negative.")
    }
    allStats(offset + label.toInt) += instanceWeight
  }

offset是特征值偏移,加上label就是该class在allStats里的位置,累加出现的次数

/**
   * Get an [[ImpurityCalculator]] for a (node, feature, bin).
   * @param allStats  Flat stats array, with stats for this (node, feature, bin) contiguous.
   * @param offset    Start index of stats for this (node, feature, bin).
   */
  def getCalculator(allStats: Array[Double], offset: Int): EntropyCalculator = {
    new EntropyCalculator(allStats.view(offset, offset + statsSize).toArray)
  }

截取allStats中属于该特征的split的部分数组,长度是statSize,也就是class数

6.1.2.3. EntropyCalculator
/**
   * Calculate the impurity from the stored sufficient statistics.
   */
  def calculate(): Double = Entropy.calculate(stats, stats.sum)

结合上面的函数可以看到,计算entropy的路径是调用Entropy的getCalculator函数,里面截取allStats中属于该split的部分,然后实际调用Entropy的calculate函数计算熵。 
这里还重载了prob函数,主要是返回label的概率,例如0的统计有3个,1的统计7个,则label 0的概率就是0.3.

6.1.3. DTStatsAggregator
这里啰嗦下node分裂时需要怎样统计,这与DTStatsAggregator的设计是相关的。以使用信息熵为例,node分裂时,迭代每个特征的每个split,这个split会把样本集分成两部分,要计算entropy,需要分别统计左/右部分class的分布情况,然后计算概率,进而计算entropy,因此aggregator中statsSize等于numberclasses,同时allStats里记录了所有的统计值,实际这个统计值就是class的分布情况

class DTStatsAggregator(
    val metadata: DecisionTreeMetadata,
    featureSubset: Option[Array[Int]]) extends Serializable {

  /**
   * [[ImpurityAggregator]] instance specifying the impurity type.
   */
  val impurityAggregator: ImpurityAggregator = metadata.impurity match {
    case Gini => new GiniAggregator(metadata.numClasses)
    case Entropy => new EntropyAggregator(metadata.numClasses)
    case Variance => new VarianceAggregator()
    case _ => throw new IllegalArgumentException(s"Bad impurity parameter: ${metadata.impurity}")
  }

  /**
   * Number of elements (Double values) used for the sufficient statistics of each bin.
   */
  private val statsSize: Int = impurityAggregator.statsSize

  /**
   * Number of bins for each feature.  This is indexed by the feature index.
   */
  private val numBins: Array[Int] = {
    if (featureSubset.isDefined) {
      featureSubset.get.map(metadata.numBins(_))
    } else {
      metadata.numBins
    }
  }

  /**
   * Offset for each feature for calculating indices into the [[allStats]] array.
   */
  private val featureOffsets: Array[Int] = {
    numBins.scanLeft(0)((total, nBins) => total + statsSize * nBins)
  }

  /**
   * Total number of elements stored in this aggregator
   */
  private val allStatsSize: Int = featureOffsets.last

  /**
   * Flat array of elements.
   * Index for start of stats for a (feature, bin) is:
   *   index = featureOffsets(featureIndex) + binIndex * statsSize
   * Note: For unordered features,
   *       the left child stats have binIndex in [0, numBins(featureIndex) / 2))
   *       and the right child stats in [numBins(featureIndex) / 2), numBins(featureIndex))
   */
  private val allStats: Array[Double] = new Array[Double](allStatsSize)

每个node有一个DTStatsAggregator,构造函数接受2个参数,metadata和node使用的特征子集。其他的类成员 
- impurityAggregator:目前支持Gini,Entropy和Variance,后面我们以Entropy为例,其他类似 
- statsSize:每个bin需要的统计数,分类时等于numClasses,因为于每个class都需要单独统计;回归等于3,分别存着特征值个数,特征值sum,特征值平方和,为计算variance 
- numBins:node所用特征对应的numBins数组元素 
- featureOffsets:计算特征在allStats中的index,与每个特征的bin个数和statsSize有关,例如我们有3个特征,其bins分别为3,2,2,statsSize为2,则第一个特征需要的bin的个数是3 * 2=6,2 * 2=4,2 * 2=4,则featureOffsets为0,6,10,14,是从左到右的累计值 
- allStatsSize:需要的桶的个数 
- allStats:存储统计值的桶 

f0,f1,f2是3个特征,f0有3个特征值(其实是binIndex)0/1/2,f1有2个0/1,f2有2个0/1,每个特征值都有statsSize个状态桶,因此共14个,个数allStatsSize=14, 比如我们想在f1的v1的c1的index,就是从featureOffsets中取得f1的特征偏移量featureOffsets(1)=6,v1的binIndex相当于是1,statsSize是2,其label是1,则桶的index=6+1*2+1=9,恰好是图中f1v1的c1的桶的index

我们对其中的关键函数进行说明

/**
   * Update the stats for a given (feature, bin) for ordered features, using the given label.
   */
  def update(featureIndex: Int, binIndex: Int, label: Double, instanceWeight: Double): Unit = {
  //第一部分是特征偏移
  //binIndex相当于特征内特征值的偏移,每个特征有statsSize个桶,因此两者相加就是这个特征值对应的桶
  //例如Entropy的update函数,里面再加上label.toInt就是这个label的桶
  //从这里特征偏移的计算可以看出ordered特征其特征值最好是连续的,中间无间断,并且必须从0开始
  //当然如果有间断,这里相当于浪费部分空间
    val i = featureOffsets(featureIndex) + binIndex * statsSize
    impurityAggregator.update(allStats, i, label, instanceWeight)
  }
  /**
   * Get an [[ImpurityCalculator]] for a given (node, feature, bin).
   * @param featureOffset  For ordered features, this is a pre-computed (node, feature) offset
   *                           from [[getFeatureOffset]].
   *                           For unordered features, this is a pre-computed
   *                           (node, feature, left/right child) offset from
   *                           [[getLeftRightFeatureOffsets]].
   */
  def getImpurityCalculator(featureOffset: Int, binIndex: Int): ImpurityCalculator = {
  //偏移的计算同上,不过这里特征偏移是入参给出的,不需要再计算
    impurityAggregator.getCalculator(allStats, featureOffset + binIndex * statsSize)
  }

6.2. 训练初始化
// FIFO queue of nodes to train: (treeIndex, node)
val nodeQueue = new mutable.Queue[(Int, Node)]()

val topNodes: Array[Node] = Array.fill[Node](numTrees)(Node.emptyNode(nodeIndex = 1))
    Range(0, numTrees).foreach(treeIndex => nodeQueue.enqueue((treeIndex, topNodes(treeIndex))))

构造了numTrees个Node,赋默认值emptyNode,这些node将作为每棵树的root node,参与后面的训练。将这些node与treeIndex封装加入到队列nodeQueue中,后面会将所有待split的node都加入到这个队列中,依次split,直到所有node触发截止条件,也就是后面的while循环中队列为空了。

6.3. 选择待分裂node
这部分逻辑在selectNodesToSplit中,主要是从nodeQueue中取出本轮需要分裂的node,并计算node的参数。

/**
   * Pull nodes off of the queue, and collect a group of nodes to be split on this iteration.
   * This tracks the memory usage for aggregates and stops adding nodes when too much memory
   * will be needed; this allows an adaptive number of nodes since different nodes may require
   * different amounts of memory (if featureSubsetStrategy is not "all").
   *
   * @param nodeQueue  Queue of nodes to split.
   * @param maxMemoryUsage  Bound on size of aggregate statistics.
   * @return  (nodesForGroup, treeToNodeToIndexInfo).
   *          nodesForGroup holds the nodes to split: treeIndex --> nodes in tree.
   *
   *          treeToNodeToIndexInfo holds indices selected features for each node:
   *            treeIndex --> (global) node index --> (node index in group, feature indices).
   *          The (global) node index is the index in the tree; the node index in group is the
   *           index in [0, numNodesInGroup) of the node in this group.
   *          The feature indices are None if not subsampling features.
   */
  private[tree] def selectNodesToSplit(
      nodeQueue: mutable.Queue[(Int, Node)],
      maxMemoryUsage: Long,
      metadata: DecisionTreeMetadata,
      rng: scala.util.Random): (Map[Int, Array[Node]], Map[Int, Map[Int, NodeIndexInfo]]) = {
    // Collect some nodes to split:
    //  nodesForGroup(treeIndex) = nodes to split
    val mutableNodesForGroup = new mutable.HashMap[Int, mutable.ArrayBuffer[Node]]()
    val mutableTreeToNodeToIndexInfo =
      new mutable.HashMap[Int, mutable.HashMap[Int, NodeIndexInfo]]()
    var memUsage: Long = 0L
    var numNodesInGroup = 0
    while (nodeQueue.nonEmpty && memUsage < maxMemoryUsage) {
      val (treeIndex, node) = nodeQueue.head
      //用蓄水池抽样(之前的文章有介绍)对node使用的特征集抽样
      // Choose subset of features for node (if subsampling).
      val featureSubset: Option[Array[Int]] = if (metadata.subsamplingFeatures) {
        Some(SamplingUtils.reservoirSampleAndCount(Range(0,
          metadata.numFeatures).iterator, metadata.numFeaturesPerNode, rng.nextLong)._1)
      } else {
        None
      }
      // Check if enough memory remains to add this node to the group.
      val nodeMemUsage = RandomForest.aggregateSizeForNode(metadata, featureSubset) * 8L
      if (memUsage + nodeMemUsage <= maxMemoryUsage) {
        nodeQueue.dequeue()
        mutableNodesForGroup.getOrElseUpdate(treeIndex, new mutable.ArrayBuffer[Node]()) += node
        mutableTreeToNodeToIndexInfo
          .getOrElseUpdate(treeIndex, new mutable.HashMap[Int, NodeIndexInfo]())(node.id)
          = new NodeIndexInfo(numNodesInGroup, featureSubset)
      }
      numNodesInGroup += 1
      memUsage += nodeMemUsage
    }
    // Convert mutable maps to immutable ones.
    val nodesForGroup: Map[Int, Array[Node]] = mutableNodesForGroup.mapValues(_.toArray).toMap
    val treeToNodeToIndexInfo = mutableTreeToNodeToIndexInfo.mapValues(_.toMap).toMap
    (nodesForGroup, treeToNodeToIndexInfo)
  }

代码比较简单明确,受限于内存,将本次能够处理的node从nodeQueue中取出,放入nodesForGroup和treeToNodeToIndexInfo中。 
是否对特征集进行抽样的条件是metadata的 numFeatures是否等于numFeaturesPerNode,这两个参数是metadata的入参,在buildMetadata时,根据featureSubsetStrateg确定,参见前文。 
nodesForGroup是Map[Int, Array[Node]],其key是treeIndex,value是Node数组,其中放着该tree本次要分裂的node。 
treeToNodeToIndexInfo的类型是Map[Int, Map[Int, NodeIndexInfo]],key为treeIndex,value中Map的key是node.id,这个id来自Node初始化时的第一个参数,第一轮时node的id都是1。其value为NodeIndexInfo结构,

class NodeIndexInfo(
      val nodeIndexInGroup: Int,
      val featureSubset: Option[Array[Int]])

第一个成员是此node在本次node选择的while循环中的index,称为groupIndex,第二个成员是特征子集。
 

6.4. node分裂
逻辑主要在DecisionTree.findBestSplits函数中,是RF训练最核心的部分

DecisionTree.findBestSplits(baggedInput, metadata, topNodes, nodesForGroup,
        treeToNodeToIndexInfo, splits, bins, nodeQueue, timer, nodeIdCache = nodeIdCache)

6.4.1. 数据统计
数据统计分成两部分,先在各个partition上分别统计,再累积各partition成全局统计。

6.4.1.1. 取出node的特征子集
val nodeToFeatures = getNodeToFeatures(treeToNodeToIndexInfo)
val nodeToFeaturesBc = input.sparkContext.broadcast(nodeToFeatures)

取出各node的特征子集,如果不需要抽样则为None;否则返回Map[Int, Array[Int]],其实就是将之前treeToNodeToIndexInfo中的NodeIndexInfo转换为map结构,将其作为广播变量nodeToFeaturesBc。

6.4.1.2. 分区统计
一系列函数的调用链,我们逐层分析

val partitionAggregates : RDD[(Int, DTStatsAggregator)] = if (nodeIdCache.nonEmpty) {
      input.zip(nodeIdCache.get.nodeIdsForInstances).mapPartitions { points =>
        // Construct a nodeStatsAggregators array to hold node aggregate stats,
        // each node will have a nodeStatsAggregator
        val nodeStatsAggregators = Array.tabulate(numNodes) { nodeIndex =>
          val featuresForNode = nodeToFeaturesBc.value.flatMap { nodeToFeatures =>
            Some(nodeToFeatures(nodeIndex))
          }
          new DTStatsAggregator(metadata, featuresForNode)
        }

        // iterator all instances in current partition and update aggregate stats
        points.foreach(binSeqOpWithNodeIdCache(nodeStatsAggregators, _))

        // transform nodeStatsAggregators array to (nodeIndex, nodeAggregateStats) pairs,
        // which can be combined with other partition using `reduceByKey`
        nodeStatsAggregators.view.zipWithIndex.map(_.swap).iterator
      }
    } else {
      input.mapPartitions { points =>
        // Construct a nodeStatsAggregators array to hold node aggregate stats,
        // each node will have a nodeStatsAggregator
        val nodeStatsAggregators = Array.tabulate(numNodes) { nodeIndex =>
          val featuresForNode = nodeToFeaturesBc.value.flatMap { nodeToFeatures =>
            Some(nodeToFeatures(nodeIndex))
          }
          new DTStatsAggregator(metadata, featuresForNode)
        }

        // iterator all instances in current partition and update aggregate stats
        points.foreach(binSeqOp(nodeStatsAggregators, _))

        // transform nodeStatsAggregators array to (nodeIndex, nodeAggregateStats) pairs,
        // which can be combined with other partition using `reduceByKey`
        nodeStatsAggregators.view.zipWithIndex.map(_.swap).iterator
      }
    }

首先对每个partition构造一个DTStatsAggregator数组,长度是node的个数,注意这里实际使用的是数组,node怎样与自己的aggregator的对应?前面我们提到NodeIndexInfo的第一个成员是groupIndex,其值就是node的次序,和这里aggregator数组index其实是对应的,也就是说可以从NodeIndexInfo中取得groupIndex,然后作为数组index取得对应node的agg。DTStatsAggregator的入参是metadata和每个node的特征子集。然后将每个点统计到DTStatsAggregator中,其中调用了内部函数binSeqOp,

 /**
     * Performs a sequential aggregation over a partition.
     *
     * Each data point contributes to one node. For each feature,
     * the aggregate sufficient statistics are updated for the relevant bins.
     *
     * @param agg  Array storing aggregate calculation, with a set of sufficient statistics for
     *             each (node, feature, bin).
     * @param baggedPoint   Data point being aggregated.
     * @return  agg
     */
    def binSeqOp(
        agg: Array[DTStatsAggregator],
        baggedPoint: BaggedPoint[TreePoint]): Array[DTStatsAggregator] = {
    //对每个node
      treeToNodeToIndexInfo.foreach { case (treeIndex, nodeIndexToInfo) =>
        val nodeIndex = predictNodeIndex(topNodes(treeIndex), baggedPoint.datum.binnedFeatures,
          bins, metadata.unorderedFeatures)
        nodeBinSeqOp(treeIndex, nodeIndexToInfo.getOrElse(nodeIndex, null), agg, baggedPoint)
      }

      agg
    }

首先调用函数predictNodeIndex计算nodeIndex,如果是首轮或者叶子节点,直接返回node.id;如果不是首轮,因为传入的是每棵树的root node,就从root node开始,逐渐往下判断该point应该是属于哪个node的,因为我们已经对node进行了分裂,这里其实实现了样本的划分。举个栗子,当前node如果是root的左孩子节点,而point预测节点应该属于右孩子,则调用nodeBinSepOp时就直接返回了,不会将这个point统计进去,用不大的时间换取样本集划分的空间,还是比较巧妙的。

/**
   * Get the node index corresponding to this data point.
   * This function mimics prediction, passing an example from the root node down to a leaf
   * or unsplit node; that node's index is returned.
   *
   * @param node  Node in tree from which to classify the given data point.
   * @param binnedFeatures  Binned feature vector for data point.
   * @param bins possible bins for all features, indexed (numFeatures)(numBins)
   * @param unorderedFeatures  Set of indices of unordered features.
   * @return  Leaf index if the data point reaches a leaf.
   *          Otherwise, last node reachable in tree matching this example.
   *          Note: This is the global node index, i.e., the index used in the tree.
   *                This index is different from the index used during training a particular
   *                group of nodes on one call to [[findBestSplits()]].
   */
  private def predictNodeIndex(
      node: Node,
      binnedFeatures: Array[Int],
      bins: Array[Array[Bin]],
      unorderedFeatures: Set[Int]): Int = {
    if (node.isLeaf || node.split.isEmpty) {
      // Node is either leaf, or has not yet been split.
      node.id
    } else {
    //判断point属于当前node的左孩子还是右孩子
      val featureIndex = node.split.get.feature
      val splitLeft = node.split.get.featureType match {
        case Continuous => {
          val binIndex = binnedFeatures(featureIndex)
          val featureValueUpperBound = bins(featureIndex)(binIndex).highSplit.threshold
          // bin binIndex has range (bin.lowSplit.threshold, bin.highSplit.threshold]
          // We do not need to check lowSplit since bins are separated by splits.
          featureValueUpperBound <= node.split.get.threshold
        }
        case Categorical => {
          val featureValue = binnedFeatures(featureIndex)
          node.split.get.categories.contains(featureValue)
        }
        case _ => throw new RuntimeException(s"predictNodeIndex failed for unknown reason.")
      }
      if (node.leftNode.isEmpty || node.rightNode.isEmpty) {
      //下面还有完整的左右孩子node,递归判断
        // Return index from next layer of nodes to train
        if (splitLeft) {
          Node.leftChildIndex(node.id)
        } else {
          Node.rightChildIndex(node.id)
        }
      } else {
        if (splitLeft) {
          predictNodeIndex(node.leftNode.get, binnedFeatures, bins, unorderedFeatures)
        } else {
          predictNodeIndex(node.rightNode.get, binnedFeatures, bins, unorderedFeatures)
        }
      }
    }
  }

然后调用nodeBinSeqOp函数

/**
     * Performs a sequential aggregation over a partition for a particular tree and node.
     *
     * For each feature, the aggregate sufficient statistics are updated for the relevant
     * bins.
     *
     * @param treeIndex Index of the tree that we want to perform aggregation for.
     * @param nodeInfo The node info for the tree node.
     * @param agg Array storing aggregate calculation, with a set of sufficient statistics
     *            for each (node, feature, bin).
     * @param baggedPoint Data point being aggregated.
     */
    def nodeBinSeqOp(
        treeIndex: Int,
        nodeInfo: RandomForest.NodeIndexInfo,
        agg: Array[DTStatsAggregator],
        baggedPoint: BaggedPoint[TreePoint]): Unit = {
      if (nodeInfo != null) {
      //node的groupIndex,见前文
        val aggNodeIndex = nodeInfo.nodeIndexInGroup
        //node使用的特征子集
        val featuresForNode = nodeInfo.featureSubset
        //取样本在这棵树中出现的次数 0/1/k
        val instanceWeight = baggedPoint.subsampleWeights(treeIndex)
        if (metadata.unorderedFeatures.isEmpty) {
          orderedBinSeqOp(agg(aggNodeIndex), baggedPoint.datum, instanceWeight, featuresForNode)
        } else {
          mixedBinSeqOp(agg(aggNodeIndex), baggedPoint.datum, splits,
            metadata.unorderedFeatures, instanceWeight, featuresForNode)
        }
      }
    }

函数的入参是treeIndex,该node的NodeIndexInfo结构,所有node的累加器数组,样本。本函数是针对单个node的操作,这里可以看到取node对应的aggregator就是通过NodeIndexInfo的第一个成员nodeIndexInGroup作为agg数组的index。 
如果不包含无序特征,调用orderedBinSeqOp函数

 /**
   * Helper for binSeqOp, for regression and for classification with only ordered features.
   *
   * For each feature, the sufficient statistics of one bin are updated.
   *
   * @param agg  Array storing aggregate calculation, with a set of sufficient statistics for
   *             each (feature, bin).
   * @param treePoint  Data point being aggregated.
   * @param instanceWeight  Weight (importance) of instance in dataset.
   */
  private def orderedBinSeqOp(
      agg: DTStatsAggregator, //node的agg
      treePoint: TreePoint,
      instanceWeight: Double,
      featuresForNode: Option[Array[Int]]): Unit = {
    val label = treePoint.label

    // Iterate over features.
    if (featuresForNode.nonEmpty) {
      // Use subsampled features
      var featureIndexIdx = 0
      while (featureIndexIdx < featuresForNode.get.size) {
      //连续特征:离散化后的index
      //离散特征:featureValue.toInt
        val binIndex = treePoint.binnedFeatures(featuresForNode.get.apply(featureIndexIdx))
        agg.update(featureIndexIdx, binIndex, label, instanceWeight)
        featureIndexIdx += 1
      }
    } else {
      // Use all features
      val numFeatures = agg.metadata.numFeatures
      var featureIndex = 0
      while (featureIndex < numFeatures) {
        val binIndex = treePoint.binnedFeatures(featureIndex)
        agg.update(featureIndex, binIndex, label, instanceWeight)
        featureIndex += 1
      }
    }
  }

函数中区分了是否使用了全部特征,区别仅在于如果使用了部分特征(特征抽样),需要先在featuresForNode中取得特征的实际index。 
函数其实就是取出样本的使用特征,特征值,label和weight,更新到aggregator中,更新逻辑我们在前文已经说明过了。 
包含了无序离散特征,则使用mixedBinSeqOp,只有无序离散特征处理方法不同于orderedBinSeqOp函数

// Unordered feature
val featureValue = treePoint.binnedFeatures(featureIndex)
//找到特征值对应的allStats中的范围
//特征起始位置从featureOffsets中取得,长度是bins的个数乘以分类个数,2*(2^(M-1)-1)*statsSize,
//每一个split将样本集分成2部分,allStats中左边部分连续存放,右半部分连续存放,而不是左右一起存放。
//因此,左边的起始位置直接可以从featureOffsets中获取,右边起始位置是(2^(M-1)-1)*statsSize
val (leftNodeFeatureOffset, rightNodeFeatureOffset) =
agg.getLeftRightFeatureOffsets(featureIndexIdx)
// Update the left or right bin for each split.
val numSplits = agg.metadata.numSplits(featureIndex)
var splitIndex = 0
while (splitIndex < numSplits) {
    //split中的categories中包含左半边特征值组合,splitIndex相当于其离散化后的特征index
    if (splits(featureIndex)(splitIndex).categories.contains(featureValue)) {
    agg.featureUpdate(leftNodeFeatureOffset, splitIndex, treePoint.label,
              instanceWeight)
    } else {
        agg.featureUpdate(rightNodeFeatureOffset, splitIndex, treePoint.label,
              instanceWeight)
    }
          splitIndex += 1
}

6.4.1.3. 全局统计
partitionAggregates.reduceByKey((a, b) => a.merge(b))
1
就是将所有存在allStats中的分区统计结果逐个对应相加得到全局统计结果。

6.4.2. bestSplits
获得所有的统计后,就可以遍历所有的特征,计算impurity gain,确定最佳的split。

val nodeToBestSplits = partitionAggregates.reduceByKey((a, b) => a.merge(b))
    .map { case (nodeIndex, aggStats) =>
         val featuresForNode = nodeToFeaturesBc.value.map { nodeToFeatures =>
        nodeToFeatures(nodeIndex)
         }
         // find best split for each node
        val (split: Split, stats: InformationGainStats, predict: Predict) =
            binsToBestSplit(aggStats, splits, featuresForNode, nodes(nodeIndex))
          (nodeIndex, (split, stats, predict))
        }.collectAsMap()

对每个node其中调用了binsToBestSplit函数,下面进行详细说明。

6.4.2.1 init
函数首先获取node在树的第几层,树结构如图 
 
树的id如图所示,判断node在第几层只需要判断id的二进制表示的最高位的1在第几位即可,比如6的二进制表示是110,最高位的1是在第3位,则其在第3层。 
然后获取当前node的预测值和impurity

// calculate predict and impurity if current node is top node
    val level = Node.indexToLevel(node.id)
    var predictWithImpurity: Option[(Predict, Double)] = if (level == 0) {
      None
    } else {
      Some((node.predict, node.impurity))
    }

6.4.2.2 连续特征
对于连续特征而言,当取其某个特征值为best split后,node的样本会被分成大于该特征值和小于等于该特征值两部分,需要分别统计两部分的class分布情况;另一方面,我们查找best,因此要遍历所有特征值的情况,一种巧妙的方法是,从左边开始逐次累积统计数据,需要从某个特征值作为split时,当前累计值就是左边小于等于的情况,用最右的值减去左边就是右边的情况。 

例如上图中的情况,是某特征6个特征值分布情况,第一行是左累计,第二行是原始分布,当以v2作为split时,左边的分布就是c0:8,c1:5,右边是v6的分布减去v2,c0:19-8=11,c1:14-5=9。

if (binAggregates.metadata.isContinuous(featureIndex)) {
        // Cumulative sum (scanLeft) of bin statistics.
        // Afterwards, binAggregates for a bin is the sum of aggregates for
        // that bin + all preceding bins.
        //如上所述,累计
        val nodeFeatureOffset = binAggregates.getFeatureOffset(featureIndexIdx)
        var splitIndex = 0
        while (splitIndex < numSplits) {
            binAggregates.mergeForFeature(nodeFeatureOffset, splitIndex + 1, splitIndex)
            splitIndex += 1
        }
        // Find best split.
        val (bestFeatureSplitIndex, bestFeatureGainStats) =
            Range(0, numSplits).map { case splitIdx =>
              val leftChildStats = binAggregates.getImpurityCalculator(nodeFeatureOffset, splitIdx)
              val rightChildStats =
                binAggregates.getImpurityCalculator(nodeFeatureOffset, numSplits)
              rightChildStats.subtract(leftChildStats)
              //获得node的impurity,level==0时,需要根据当前class的分布计算
              predictWithImpurity = Some(predictWithImpurity.getOrElse(
                calculatePredictImpurity(leftChildStats, rightChildStats)))
              val gainStats = calculateGainForSplit(leftChildStats,
                rightChildStats, binAggregates.metadata, predictWithImpurity.get._2)
              (splitIdx, gainStats)
            }.maxBy(_._2.gain)
        (splits(featureIndex)(bestFeatureSplitIndex), bestFeatureGainStats)

计算split分裂node的impurity增益时,调用了calculateGainForSplit函数,其中分别计算了左右的增益,然后概率合并,并计算了左右的预测值,代码比较简单,这里不再赘述。

6.4.2.3. Unordered categorical feature
只有获取左右class的统计情况方法不一致,其他是一样的。

6.4.2.4. Ordered categorical feature
对于连续特征,特征值或者是binIndex是有序的,或者说其数值可以排序,因此如果某个特征值被当做split,分隔的就是左右两部分;对于无序离散特征,其被split分隔后特征值属于哪个bin是确定的;对于有序离散特征,其特征值代表一定次序关系,但是不具有绝对大小的含义,其处理方法可以近似按照连续特征的方法处理,但是spark这里处理了下,可能更优点。 
spark首先会确定一个centroid,然后特征会按这个排序,这个相当于连续特征的binIndex。例如centroid如果取每个特征值中class1的个数,假设有特征值0,1,2,3,class1的个数分别为4,2,1,3,其中如果按照连续特征的处理方法,假设用1作为node的分裂点,计算impurity gain的时候分成0,1和2,3两部分统计。如果按照centroid的方法,其特征值排序次序应该是2,1,3,0,以1作为分裂点,会被分成2,1和3,0两部分。

// Ordered categorical feature
val nodeFeatureOffset = binAggregates.getFeatureOffset(featureIndexIdx)
val numBins = binAggregates.metadata.numBins(featureIndex)

/* Each bin is one category (feature value).
* The bins are ordered based on centroidForCategories, and this ordering determines which
* splits are considered.  (With K categories, we consider K - 1 possible splits.)
*
* centroidForCategories is a list: (category, centroid)
*/
val centroidForCategories = Range(0, numBins).map { case featureValue =>
    val categoryStats = binAggregates.getImpurityCalculator(nodeFeatureOffset, featureValue)
    val centroid = if (categoryStats.count != 0) {
        if (binAggregates.metadata.isMulticlass) {
        // For categorical variables in multiclass classification,
        // the bins are ordered by the impurity of their corresponding labels.
            categoryStats.calculate()
        } else if (binAggregates.metadata.isClassification) {
        // For categorical variables in binary classification,
        // the bins are ordered by the count of class 1.
            categoryStats.stats(1)
        } else {
            // For categorical variables in regression,
            // the bins are ordered by the prediction.
            categoryStats.predict
        }
    } else {
        Double.MaxValue
    }
    (featureValue, centroid)
}

logDebug("Centroids for categorical variable: " + centroidForCategories.mkString(","))

// bins sorted by centroids
val categoriesSortedByCentroid = centroidForCategories.toList.sortBy(_._2)

上面的代码为不同的情况设置不同的centroid的选取方法。如果是多分类,使用impurity;如果是二分类,使用class1的个数;如果是回归,使用预测值(实际是均值)。然后将特征值按centroid重排序。 
下面的处理基本与连续特征类似,先按排序次序累计,然后计算左右的impurity,计算impurity gain。由于要返回split,之前离散特征的split返回的是空Array,这里构造了split,第四个参数中加入了实际的特征值,类比unordered的情况。

计算完完所有的特征的gain,就可以选取最大增益时的split,最后collectAsMap,key是nodeIndex,value是split, InfomationGainStats,predict的三元组。

6.4.3. node分裂
计算完节点的best split,就要根据这个split进行node的分裂,包括当前节点的一些属性完善,左右孩子节点的构造等。

// Iterate over all nodes in this group.
    nodesForGroup.foreach { case (treeIndex, nodesForTree) =>
      nodesForTree.foreach { node =>
        val nodeIndex = node.id
        val nodeInfo = treeToNodeToIndexInfo(treeIndex)(nodeIndex)
        val aggNodeIndex = nodeInfo.nodeIndexInGroup
        //从刚刚计算的best split中获取相关数据
        val (split: Split, stats: InformationGainStats, predict: Predict) =
          nodeToBestSplits(aggNodeIndex)
        logDebug("best split = " + split)

        // Extract info for this node.  Create children if not leaf.
        //截止条件
        val isLeaf = (stats.gain <= 0) || (Node.indexToLevel(nodeIndex) == metadata.maxDepth)
        assert(node.id == nodeIndex)
        node.predict = predict
        node.isLeaf = isLeaf
        node.stats = Some(stats)
        node.impurity = stats.impurity
        logDebug("Node = " + node)
        //如果不是叶子节点,需要构造左右孩子节点
        if (!isLeaf) {
          node.split = Some(split)
          //叶子节点的depth,当前level+1
          val childIsLeaf = (Node.indexToLevel(nodeIndex) + 1) == metadata.maxDepth
          //左右孩子节点是否是叶子节点
          val leftChildIsLeaf = childIsLeaf || (stats.leftImpurity == 0.0)
          val rightChildIsLeaf = childIsLeaf || (stats.rightImpurity == 0.0)
          //构造左右孩子节点
          node.leftNode = Some(Node(Node.leftChildIndex(nodeIndex),
            stats.leftPredict, stats.leftImpurity, leftChildIsLeaf))
          node.rightNode = Some(Node(Node.rightChildIndex(nodeIndex),
            stats.rightPredict, stats.rightImpurity, rightChildIsLeaf))

          if (nodeIdCache.nonEmpty) {
            val nodeIndexUpdater = NodeIndexUpdater(
              split = split,
              nodeIndex = nodeIndex)
            nodeIdUpdaters(treeIndex).put(nodeIndex, nodeIndexUpdater)
          }
        //如果不是叶子节点,加入到nodeQueue待分裂队列中
          // enqueue left child and right child if they are not leaves
          if (!leftChildIsLeaf) {
            nodeQueue.enqueue((treeIndex, node.leftNode.get))
          }
          if (!rightChildIsLeaf) {
            nodeQueue.enqueue((treeIndex, node.rightNode.get))
          }

          logDebug("leftChildIndex = " + node.leftNode.get.id +
            ", impurity = " + stats.leftImpurity)
          logDebug("rightChildIndex = " + node.rightNode.get.id +
            ", impurity = " + stats.rightImpurity)
        }
      }
    }

这里将当前节点的左右孩子节点继续加入nodeQueue中,这里面放的是需要继续分裂的节点,至此本轮的findBestSplits就完成了。

// Choose node splits, and enqueue new nodes as needed.
timer.start("findBestSplits")
DecisionTree.findBestSplits(baggedInput,
    metadata, topNodes, nodesForGroup,
    treeToNodeToIndexInfo, splits, bins, nodeQueue,
    timer, nodeIdCache = nodeIdCache)
timer.stop("findBestSplits")

6.5. 循环训练
上节我们说到最后待分裂的节点会加入到nodeQueue中,回到RandomForest.run函数中

while (nodeQueue.nonEmpty) {
      // Collect some nodes to split, and choose features for each node (if subsampling).
      // Each group of nodes may come from one or multiple trees, and at multiple levels.
      val (nodesForGroup, treeToNodeToIndexInfo) =
        RandomForest.selectNodesToSplit(nodeQueue, maxMemoryUsage, metadata, rng)
      // Sanity check (should never occur):
      assert(nodesForGroup.size > 0,
        s"RandomForest selected empty nodesForGroup.  Error for unknown reason.")

      // Choose node splits, and enqueue new nodes as needed.
      timer.start("findBestSplits")
      DecisionTree.findBestSplits(baggedInput, metadata, topNodes, nodesForGroup,
        treeToNodeToIndexInfo, splits, bins, nodeQueue, timer, nodeIdCache = nodeIdCache)
      timer.stop("findBestSplits")
    }

当有非叶子节点不断加入nodeQueue中,这里不断分裂出节点,直到所有节点触发截止条件。
 

7. 构造随机森林
在上面的训练过程可以看到,从根节点topNode中不断向下分裂一直到触发截止条件就构造了一棵树所有的node,因此构造整个森林也是非常简单

//构造
val trees = topNodes.map(topNode => new DecisionTreeModel(topNode, strategy.algo))
//返回rf模型
new RandomForestModel(strategy.algo, trees)

8. 随机森林模型
8.1. TreeEnsembleModel
随机森林RandomForestModel继承自树集合模型TreeEnsembleModel

class TreeEnsembleModel(
    protected val algo: Algo,
    protected val trees: Array[DecisionTreeModel],
    protected val treeWeights: Array[Double],
    protected val combiningStrategy: EnsembleCombiningStrategy)

algo:Regression/Classification
trees:树数组
treeWeights:每棵树的权重,在RF中每棵树的权重是相同的,在Adaboost可能是不同的
combiningStrategy:树合并时的策略,Sum/Average/Vote,分类的话应该是Vote,RF应该是Average,GBDT应该是Sum。
sumWeights:成员变量,不在参数表中,是treeWeights的sum
预测函数

/**
   * Predicts for a single data point using the weighted sum of ensemble predictions.
   *
   * @param features array representing a single data point
   * @return predicted category from the trained model
   */
  private def predictBySumming(features: Vector): Double = {
    val treePredictions = trees.map(_.predict(features))
    blas.ddot(numTrees, treePredictions, 1, treeWeights, 1)
  }

将每棵树的预测结果与各自的weight向量相乘

/**
   * Classifies a single data point based on (weighted) majority votes.
   */
  private def predictByVoting(features: Vector): Double = {
    val votes = mutable.Map.empty[Int, Double]
    trees.view.zip(treeWeights).foreach { case (tree, weight) =>
      val prediction = tree.predict(features).toInt
      votes(prediction) = votes.getOrElse(prediction, 0.0) + weight
    }
    votes.maxBy(_._2)._1
  }

将每棵树的预测class为key,将树的weight累加到Map中作为value,最后取权重和最大对应的class

8.2. RandomForestModel
RandomForestModel @Since("1.2.0") (
    @Since("1.2.0") override val algo: Algo,
    @Since("1.2.0") override val trees: Array[DecisionTreeModel])
  extends TreeEnsembleModel(algo, trees, Array.fill(trees.length)(1.0),
    combiningStrategy = if (algo == Classification) Vote else Average)

对于随机森林,其weight都是1,树合并策略如果是分类就是Vote,回归是Average。 
模型生成后,如果要应用到线上,需要将训练后的模型保存下来,自己写代码解析模型文件,进行预测,因此要了解模型的保存和加载。

8.2.1. 模型保存
分为两部分,第一部分是metadata,保存了一些配置,包括模型名,模型版本,模型的algo是classification/regression,合并策略,每棵树的权重。

implicit val format = DefaultFormats
val ensembleMetadata = Metadata(model.algo.toString,
    model.trees(0).algo.toString,
    model.combiningStrategy.toString, 
    model.treeWeights)
val metadata = compact(render(
    ("class" -> className) ~ ("version" -> thisFormatVersion) ~
    ("metadata" -> Extraction.decompose(ensembleMetadata))))
sc.parallelize(Seq(metadata), 1).saveAsTextFile(Loader.metadataPath(path))

第二部分是随机森林的每棵树的保存

// Create Parquet data.
val dataRDD = sc.parallelize(model.trees.zipWithIndex).flatMap { case (tree, treeId) =>
    tree.topNode.subtreeIterator.toSeq.map(node => NodeData(treeId, node))
}.toDF()
dataRDD.write.parquet(Loader.dataPath(path))

其中首先调用node的subtreeIterator函数,返回所有node的Iterator,然后转成DataFrame结构,再写成parquet格式的文件。我们来看subtreeIterator函数

/** Returns an iterator that traverses (DFS, left to right) the subtree of this node. */
  private[tree] def subtreeIterator: Iterator[Node] = {
    Iterator.single(this) ++ leftNode.map(_.subtreeIterator).getOrElse(Iterator.empty) ++
      rightNode.map(_.subtreeIterator).getOrElse(Iterator.empty)
  }

其实就是用前序遍历的方式返回了树中的所有node的Iterrator。 
我们再来看NodeData,看看每个node保存了什么数据

def apply(treeId: Int, n: Node): NodeData = {
    NodeData(treeId, n.id, PredictData(n.predict), n.impurity,
    n.isLeaf, n.split.map(SplitData.apply), n.leftNode.map(_.id), 
    n.rightNode.map(_.id), n.stats.map(_.gain))
}

保存了node的预测值,impurity,是否是否叶子节点,split,左右孩子节点的id,gain。其中split中包含了特征id,特征阈值,特征类型,离散特征数组(其实就是Split结构)。

8.2.2. 模型加载
metadata的加载就是解析json,主要是树的重建

val trees = TreeEnsembleModel.SaveLoadV1_0.loadTrees(sc, 
    path, metadata.treeAlgo)
new RandomForestModel(Algo.fromString(metadata.algo), trees)

其中调用了loadTrees函数

/**
 * Load trees for an ensemble, and return them in order.
 * @param path path to load the model from
 * @param treeAlgo Algorithm for individual trees (which may differ from the ensemble's
 *                 algorithm).
 */
def loadTrees(
        sc: SparkContext,
        path: String,
        treeAlgo: String): Array[DecisionTreeModel] = {
    val datapath = Loader.dataPath(path)
    val sqlContext = SQLContext.getOrCreate(sc)
    val nodes = sqlContext.read.parquet(datapath).map(NodeData.apply)
    val trees = constructTrees(nodes)
    trees.map(new DecisionTreeModel(_, Algo.fromString(treeAlgo)))
}

先是读取数据文件,读成NodeData格式,然后调用constructTrees重建树结构

    def constructTrees(nodes: RDD[NodeData]): Array[Node] = {
      val trees = nodes
        .groupBy(_.treeId)
        .mapValues(_.toArray)
        .collect()
        .map { case (treeId, data) =>
          (treeId, constructTree(data))
        }.sortBy(_._1)
      val numTrees = trees.size
      val treeIndices = trees.map(_._1).toSeq
      assert(treeIndices == (0 until numTrees),
        s"Tree indices must start from 0 and increment by 1, but we found $treeIndices.")
      trees.map(_._2)
    }

主要功能按树的id分组后,调用constructTree重建树

    /**
     * Given a list of nodes from a tree, construct the tree.
     * @param data array of all node data in a tree.
     */
    def constructTree(data: Array[NodeData]): Node = {
      val dataMap: Map[Int, NodeData] = data.map(n => n.nodeId -> n).toMap
      assert(dataMap.contains(1),
        s"DecisionTree missing root node (id = 1).")
      constructNode(1, dataMap, mutable.Map.empty)
    }

    /**
     * Builds a node from the node data map and adds new nodes to the input nodes map.
     */
    private def constructNode(
      id: Int,
      dataMap: Map[Int, NodeData],
      nodes: mutable.Map[Int, Node]): Node = {
      if (nodes.contains(id)) {
        return nodes(id)
      }
      val data = dataMap(id)
      val node =
        if (data.isLeaf) {
          Node(data.nodeId, data.predict.toPredict, data.impurity, data.isLeaf)
        } else {
          val leftNode = constructNode(data.leftNodeId.get, dataMap, nodes)
          val rightNode = constructNode(data.rightNodeId.get, dataMap, nodes)
          val stats = new InformationGainStats(data.infoGain.get, data.impurity, leftNode.impurity,
            rightNode.impurity, leftNode.predict, rightNode.predict)
          new Node(data.nodeId, data.predict.toPredict, data.impurity, data.isLeaf,
            data.split.map(_.toSplit), Some(leftNode), Some(rightNode), Some(stats))
        }
      nodes += node.id -> node
      node
    }

其实就是递归的从NodeData中获取数据,重建node

从上面的分析可以看到,spark保存模型使用了parquet格式,对于我们在别的环境中使用是非常不方便的,训练完模型后,我们可以参照spark的做法,按照前序遍历的方法以json的格式保存node,在别的环境下复建树结构就可以了。

9. 坑
特征id,样本是libsvm格式的,特征id从1开始,但是设置离散特征数categoricalFeaturesInfo需要从0开始,相当于样本特征id-1
离散特征值,一旦在categoricalFeaturesInfo中指定了特征值的个数k,spark会认为这个特征是从0开始,连续到k-1。如果其中特征不连续,特征数应该设置成最大特征值+1
对于连续特征,spark使用等频离散化方法,又对样本进行了抽样,效果其实很难保证,不知道作者是否比较过这种方法与等间隔离散化效果孰优孰劣
maxBins的设置需要考虑连续特征离散化效果,连续特征离散化值的个数是maxBins-1,同时maxBins必须大于categoricalFeaturesInfo中最大离散特征值的个数
ordered feature,之前的理解是有误的,这里的order仅仅是说这种特征是可以经过某种方式排列后变成有序,排序标准根据分类/回归而不同,在上面的文章有具体介绍。在我们的实践中,有的离散特征,例如薪资,1代表0-1000元,2代表1000-2000元,3代表2000-3000元,特征值的大小本身就表征了实际意义,这种应该直接按连续特征处理(当然也可以对比下效果决定)。
10. 结语
我们基本上是逐行分析了spark随机森林的实现,展现了其实现过程中使用的技巧,希望对大家在理解随机森林和其实现方法有所帮助。


--------------------- 
原文:https://blog.csdn.net/snaillup/article/details/72820346 
 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值