机器学习|Lightgbm 直方图优化算法深入理解

一、概述 在之前的介绍Xgboost的众多博文中,已经介绍过,在树分裂计算分裂特征的增益时,xgboost 采用了预排序的方法来处理节点分裂,这样计算的分裂点比较精确。但是,也造成了很大的时间开销。为了解决这个问题,Lightgbm 选择了基于 histogram 的决策树算法。相比于 pre-sorted算法,histogram 在内存消耗和计算代价上都有不少优势。
histogram算法简单来说,就是先对特征值进行装箱处理,形成一个一个的bins。对于连续特征来说,装箱处理就是特征工程中的离散化:如[0,0.3)—>0,[0.3,0.7)—->1等。在Lightgbm中默认的#bins为256(1个字节的能表示的长度,可以设置)。对于分类特征来说,则是每一种取值放入一个bin,且当取值的个数大于max bin数时,会忽略那些很少出现的category值。
在节点分裂的时候,这时候就不需要按照预排序算法那样,对于每个特征都计算#data遍了,而是只需要计算#bins遍,这样就大大加快了训练速度。
二、算法流程 下面是训练过程中利用直方图寻找最佳分割点的算法。
机器学习|Lightgbm 直方图优化算法深入理解
文章图片
从算法中可以看到:直方图优化算法需要在训练前预先把特征值转化为bin value,也就是对每个特征的取值做个分段函数,将所有样本在该特征上的取值划分到某一段(bin)中。最终把特征取值从连续值转化成了离散值。需要注意得是:feature value对应的bin value在整个训练过程中是不会改变的。
最外面的 for 循环表示的意思是对当前模型下所有的叶子节点处理,需要遍历所有的特征,来找到增益最大的特征及其划分值,以此来分裂该叶子节点。
在某个叶子上,第二个 for 循环就开始遍历所有的特征了。对于每个特征,首先为其创建一个直方图。这个直方图存储了两类信息,分别是每个bin中样本的梯度之和( H [ f . b i n s [ i ] ] . g H[ f.bins[i] ].g H[f.bins[i]].g),还有就是每个bin中样本数量( H [ f . b i n s [ i ] ] . n H[ f.bins[i] ].n H[f.bins[i]].n)
第三个 for 循环遍历所有样本,累积上述的两类统计值到样本所属的bin中。即直方图的每个 bin 中包含了一定的样本,在此计算每个 bin 中的样本的梯度之和并对 bin 中的样本记数。
最后一个for循环,遍历所有bin,分别以当前bin作为分割点,累加其左边的bin至当前bin的梯度和( S L S_L SL?)以及样本数量( n L n_L nL?),并与父节点上的总梯度和( S p S_p Sp?)以及总样本数量( n p n_p np?)相减,得到右边所有bin的梯度和( S R S_R SR?)以及样本数量( n R n_R nR?),带入公式,计算出增益,在遍历过程中取最大的增益,以此时的特征和bin的特征值作为分裂节点的特征和分裂特征取值。
三、源码分析 从上面的分析中,我们可以看到,有几个关键问题需要解决:
①如何将特征映射到bin呢?即如何分桶?对于连续特征和类别特征分别怎么样处理?
②如何构建直方图?直方图算法累加的g是什么?难道没有二阶导数h吗?
3.1 特征分桶 特征分桶的源码在bin.cpp文件和bin.h文件中。
由于LGBM可以处理类别特征,因此对连续特征和类别特征的处理方式是不一样的。
3.1.1 连续特征
在bin.cpp中,我们可以看到GreedyFindBin函数和FindBinWithZeroAsOneBin函数,这两个函数得到了数值型特征取值(负数,0,正数)的各个bin的切分点,即bin_upper_bound。
源码如下,我加了许多注释便于理解:FindBinWithZeroAsOneBin函数调用了GreedyFindBin函数

/************************************************************************************ ****************************得到数值型特征取值的各个bin的切分点************************* *************************************************************************************/ std::vector GreedyFindBin(const double* distinct_values, const int* counts, int num_distinct_values, int max_bin, size_t total_cnt, int min_data_in_bin) { // counts为特征取值计数的数组; //distinct_values为特征的不同的取值的数组;num_distinct_values为特征有多少个不同的取值。// bin_upper_bound就是记录桶分界的数组 std::vector bin_upper_bound; CHECK(max_bin > 0); // 特征取值数比max_bin数量少,直接取distinct_values的中点放置 if (num_distinct_values <= max_bin) { bin_upper_bound.clear(); int cur_cnt_inbin = 0; for (int i = 0; i < num_distinct_values - 1; ++i) { cur_cnt_inbin += counts[i]; // min_data_in_bin默认为3。 // 若一个特征的取值比min_data_in_bin小,则累积下一个取值,直到比min_data_in_bin大,进入循环。 if (cur_cnt_inbin >= min_data_in_bin) { // 取当前值和下一个值的均值作为该桶的分界点bin_upper_bound auto val = Common::GetDoubleUpperBound((distinct_values[i] + distinct_values[i + 1]) / 2.0); // CheckDoubleEqualOrdered返回真的条件:val::infinity()); } // 特征取值数比max_bin来得大,说明几个特征值要共用一个bin else { if (min_data_in_bin > 0) { max_bin = std::min(max_bin, static_cast(total_cnt / min_data_in_bin)); max_bin = std::max(max_bin, 1); } // mean size for one bin double mean_bin_size = static_cast(total_cnt) / max_bin; int rest_bin_cnt = max_bin; int rest_sample_cnt = static_cast(total_cnt); // 定义is_big_count_value数组:初始设定特征每一个不同的值的数量都小(false)。 std::vector is_big_count_value(num_distinct_values, false); // 如果一个特征值的数目比mean_bin_size大,那么这些特征需要单独一个bin for (int i = 0; i < num_distinct_values; ++i) { // 如果一个特征值的数目比mean_bin_size大,则设定这个特征值对应的is_big_count_value为真。。 if (counts[i] >= mean_bin_size) { is_big_count_value[i] = true; --rest_bin_cnt; rest_sample_cnt -= counts[i]; } }// 剩下的特征取值的样本数平均每个剩下的bin:mean size for one bin mean_bin_size = static_cast(rest_sample_cnt) / rest_bin_cnt; std::vector upper_bounds(max_bin, std::numeric_limits::infinity()); std::vector lower_bounds(max_bin, std::numeric_limits::infinity()); int bin_cnt = 0; lower_bounds[bin_cnt] = distinct_values[0]; int cur_cnt_inbin = 0; // 重新遍历所有的特征值(包括数目大和数目小的) for (int i = 0; i < num_distinct_values - 1; ++i) { // 如果当前的特征值数目是小的 if (!is_big_count_value[i]) { rest_sample_cnt -= counts[i]; } cur_cnt_inbin += counts[i]; // 若cur_cnt_inbin太少,则累积下一个取值,直到满足条件,进入循环。 // need a new bin 当前的特征如果是需要单独成一个bin,或者当前几个特征计数超过了mean_bin_size,或者下一个是需要独立成桶的 if (is_big_count_value[i] || cur_cnt_inbin >= mean_bin_size || (is_big_count_value[i + 1] && cur_cnt_inbin >= std::max(1.0, mean_bin_size * 0.5f))) { upper_bounds[bin_cnt] = distinct_values[i]; // 第i个bin的最大就是 distinct_values[i]了 ++bin_cnt; lower_bounds[bin_cnt] = distinct_values[i + 1]; //下一个bin的最小就是distinct_values[i + 1],注意先++bin了 if (bin_cnt >= max_bin - 1) { break; } cur_cnt_inbin = 0; if (!is_big_count_value[i]) { --rest_bin_cnt; mean_bin_size = rest_sample_cnt / static_cast(rest_bin_cnt); } } } ++bin_cnt; // update bin upper bound 与特征取值数比max_bin数量少的操作类似,取当前值和下一个值的均值作为该桶的分界点 bin_upper_bound.clear(); for (int i = 0; i < bin_cnt - 1; ++i) { auto val = Common::GetDoubleUpperBound((upper_bounds[i] + lower_bounds[i + 1]) / 2.0); if (bin_upper_bound.empty() || !Common::CheckDoubleEqualOrdered(bin_upper_bound.back(), val)) { bin_upper_bound.push_back(val); } } // last bin upper bound bin_upper_bound.push_back(std::numeric_limits::infinity()); } // bin_upper_bound即数值型特征取值的各个bin的切分点 return bin_upper_bound; }/************************************************************************************ ********************得数值型特征取值(负数,0,正数)的各个bin的切分点******************* *************************************************************************************/ std::vector FindBinWithZeroAsOneBin(const double* distinct_values, const int* counts, int num_distinct_values, int max_bin, size_t total_sample_cnt, int min_data_in_bin) { std::vector bin_upper_bound; // left_cnt_data记录小于0的值 int left_cnt_data = https://www.it610.com/article/0; int cnt_zero = 0; // right_cnt_data记录大于0的值 int right_cnt_data = 0; for (int i = 0; i < num_distinct_values; ++i) { //double kZeroThreshold = 1e-35f if (distinct_values[i] <= -kZeroThreshold) { left_cnt_data += counts[i]; } else if (distinct_values[i]> kZeroThreshold) { right_cnt_data += counts[i]; } else { cnt_zero += counts[i]; } }//如果特征值里存在0和正数,则left_cnt不为-1,则left_cnt是最后一个负数的位置 int left_cnt = -1; for (int i = 0; i < num_distinct_values; ++i) { if (distinct_values[i] > -kZeroThreshold) { left_cnt = i; break; } }// 如果特征值全是负值,left_cnt = num_distinct_values if (left_cnt < 0) { left_cnt = num_distinct_values; }if (left_cnt > 0) { // 负数除以(正数+负数)的比例,即负数的桶数。-1的1就是0的桶。 int left_max_bin = static_cast(static_cast(left_cnt_data) / (total_sample_cnt - cnt_zero) * (max_bin - 1)); left_max_bin = std::max(1, left_max_bin); bin_upper_bound = GreedyFindBin(distinct_values, counts, left_cnt, left_max_bin, left_cnt_data, min_data_in_bin); // 负数桶的分界点最后一个自然是-kZeroThreshold bin_upper_bound.back() = -kZeroThreshold; }//如果特征值存在正数,则right_start不为-1,则right_start是第一个正数开始的位置 int right_start = -1; for (int i = left_cnt; i < num_distinct_values; ++i) { if (distinct_values[i] > kZeroThreshold) { right_start = i; break; } } // 如果特征值里存在正数 if (right_start >= 0) { int right_max_bin = max_bin - 1 - static_cast(bin_upper_bound.size()); CHECK(right_max_bin > 0); auto right_bounds = GreedyFindBin(distinct_values + right_start, counts + right_start, num_distinct_values - right_start, right_max_bin, right_cnt_data, min_data_in_bin); // 正数桶的分界点第一个自然是kZeroThreshold,拼接到了-kZeroThreshold后面。 bin_upper_bound.push_back(kZeroThreshold); // 插入正数桶的分界点,形成最终的分界点数组。 bin_upper_bound.insert(bin_upper_bound.end(), right_bounds.begin(), right_bounds.end()); } else { bin_upper_bound.push_back(std::numeric_limits::infinity()); }// bin_upper_bound即数值型特征取值(负数,0,正数)的各个bin的切分点 return bin_upper_bound; }

上述的代码找到了数值型特征取值的各个bin的切分点,即bin_upper_bound,之后只需要根据这个对特征的取值查找其相应的bin中即可(用二分搜索),二分搜索的代码在bin.h里的BinMapper::ValueToBin(double value) 函数。
3.1.2 类别特征
阅读上述的代码,我们可以看到在上述代码寻找Bind的切分点时我们需要一些关键信息:如 counts为特征取值计数的数组;distinct_values为特征的不同的取值的数组;num_distinct_values为特征有多少个不同的取值。
这些信息不仅连续特征需要,类别特征也需要。
因此在调用FindBinWithZeroAsOneBin函数之前,我们必须先计算出这些关键信息,在bin.cpp中有函数BinMapper::FindBin
在这个函数太长了,我就不全部贴出来了。在这个函数的前半段有:
//从小到大排序values数组,从第0个到num_sample_values个。 std::stable_sort(values, values + num_sample_values); // push zero in the front // 如果最小的特征值大于0且存在0,则把0放到distinct_values的第一位。或者全部是NaN数据。 if (num_sample_values == 0 || (values[0] > 0.0f && zero_cnt > 0)) { distinct_values.push_back(0.0f); counts.push_back(zero_cnt); }if (num_sample_values > 0) { distinct_values.push_back(values[0]); counts.push_back(1); }for (int i = 1; i < num_sample_values; ++i) { // 如果values[i - 1]小于values[i] if (!Common::CheckDoubleEqualOrdered(values[i - 1], values[i])) { if (values[i - 1] < 0.0f && values[i] > 0.0f) { distinct_values.push_back(0.0f); counts.push_back(zero_cnt); } distinct_values.push_back(values[i]); counts.push_back(1); } else { // use the large value // 如果values[i - 1]不小于values[i],即只可能values[i - 1]等于values[i],说明distinct_values已经有了这个值了,只需要把它的counts加1. distinct_values.back() = values[i]; ++counts.back(); } }

可以看到已经统计了特征的distinct_values和对应的counts了。
接下来,该函数计算了类别特征的bin。
// sort by counts 特征取值按出现的次数排序(大到小) Common::SortForPair(counts_int, distinct_values_int, 0, true); // avoid first bin is zero if (distinct_values_int[0] == 0) { if (counts_int.size() == 1) { counts_int.push_back(0); distinct_values_int.push_back(distinct_values_int[0] + 1); } // 交换counts_int[0]和counts_int[1]的值 std::swap(counts_int[0], counts_int[1]); std::swap(distinct_values_int[0], distinct_values_int[1]); } // will ignore the categorical of small counts int cut_cnt = static_cast((total_sample_cnt - na_cnt) * 0.99f); size_t cur_cat = 0; // categorical_2_bin_ (unordered_map类型) 将特征取值到哪个bin和一一对应起来 categorical_2_bin_.clear(); // bin_2_categorical_(vector类型)记录bin对应的特征取值 bin_2_categorical_.clear(); int used_cnt = 0; max_bin = std::min(static_cast(distinct_values_int.size()), max_bin); cnt_in_bin.clear(); // 类别特征值已经按数量从大到小排列,累积特征值的数目,放弃后1%的类别特征值,即忽略一些出现次数很少的特征取值 while (cur_cat < distinct_values_int.size() && (used_cnt < cut_cnt || num_bin_ < max_bin)) { if (counts_int[cur_cat] < min_data_in_bin && cur_cat > 1) { break; } //为bin_2_categorical_和categorical_2_bin_赋值 bin_2_categorical_.push_back(distinct_values_int[cur_cat]); categorical_2_bin_[distinct_values_int[cur_cat]] = static_cast(num_bin_); used_cnt += counts_int[cur_cat]; cnt_in_bin.push_back(counts_int[cur_cat]); ++num_bin_; ++cur_cat; }

关键点有:
首先对特征取值按出现的次数排序(大到小),
忽略一些出现次数很少的特征取值,
然后用bin_2_categorical_(vector类型)记录bin对应的特征取值,以及用categorical_2_bin_(unordered_map类型) 将特征取值到哪个bin和一一对应起来。这样,以后就能很方便的进行bin到特征取值和特征取值到bin的转化。
3.1.3 总结
我们介绍了3个函数,分别是FindBin,FindBinWithZeroAsOneBin,GreedyFindBin。这3个函数的调用顺序为:
FindBin→FindBinWithZeroAsOneBin→GreedyFindBin。
另外,在dataset_loader.cpp文件中CostructFromSampleData函数调用了FindBin函数,依次处理每一个特征,即可以求出每一个特征如何转换成bin,无论其是连续特征还是类别特征。
3.2 构建直方图 给定一个特征的值,我们现在已经可以转化为对应的bin了。现在我们就可以构建直方图了。
ConstructHistogram函数即为关键代码如下(这个函数出现在很多文件中):
void ConstructHistogram(const data_size_t* data_indices, data_size_t num_data, const score_t* ordered_gradients, const score_t* ordered_hessians, HistogramBinEntry* out) const override { // num_data与0011进行与,即num_data不会大于3 const data_size_t rest = num_data & 0x3; data_size_t i = 0; for (; i < num_data - rest; i += 4) { const VAL_T bin0 = data_[data_indices[i]]; const VAL_T bin1 = data_[data_indices[i + 1]]; const VAL_T bin2 = data_[data_indices[i + 2]]; const VAL_T bin3 = data_[data_indices[i + 3]]; out[bin0].sum_gradients += ordered_gradients[i]; out[bin1].sum_gradients += ordered_gradients[i + 1]; out[bin2].sum_gradients += ordered_gradients[i + 2]; out[bin3].sum_gradients += ordered_gradients[i + 3]; out[bin0].sum_hessians += ordered_hessians[i]; out[bin1].sum_hessians += ordered_hessians[i + 1]; out[bin2].sum_hessians += ordered_hessians[i + 2]; out[bin3].sum_hessians += ordered_hessians[i + 3]; ++out[bin0].cnt; ++out[bin1].cnt; ++out[bin2].cnt; ++out[bin3].cnt; } for (; i < num_data; ++i) { const VAL_T bin = data_[data_indices[i]]; out[bin].sum_gradients += ordered_gradients[i]; out[bin].sum_hessians += ordered_hessians[i]; ++out[bin].cnt; } }

可以看到,累加了一阶和二阶梯度和还有个数。(当然还有其它的版本,当is_constant_hessian为true的时候是不用二阶梯度的)
注:这个函数我有点疑问,为什么要与0x3进行与操作呢?这个的目的是什么?难道是为了快吗?另外,由于水平原因,在调用CostructFromSampleData函数的前后关系上,我已经蒙圈了,暂时先放弃了。希望有好心的读者能给讲讲。
四、histogram算法与 pre-sorted算法对比 3.1 优势
  • Pre-sorted 算法需要的内存约是训练数据的两倍(2 * #data * #features* 4Bytes),它需要用32位浮点(4Bytes)来保存 feature value,并且对每一列特征,都需要一个额外的排好序的索引,这也需要32位(4Bytes)的存储空间。因此是(2 * #data * #features* 4Bytes)。而对于 histogram 算法,则只需要(#data * #features * 1Bytes)的内存消耗,仅为 pre-sorted算法的1/8。因为 histogram 算法仅需要存储 feature bin value (离散化后的数值),不需要原始的 feature value,也不用排序,而 bin value 用 1Bytes(256 bins) 的大小一般也就足够了。
  • 计算上的优势则是大幅减少了计算分割点增益的次数。对于每一个特征,pre-sorted 需要对每一个不同特征值都计算一次分割增益,代价是O(#feature*#distinct_values_of_the_feature);而 histogram 只需要计算#bins次,代价是(#feature*#bins)。
  • 还有一个很重要的点是cache-miss。事实上,cache-miss对速度的影响是特别大的。预排序中有2个操作频繁的地方会造成cache miss,一是对梯度的访问,在计算gain的时候需要利用梯度,不同特征访问梯度的顺序都是不一样的,且是随机的,因此这部分会造成严重的cache-miss。二是对于索引表的访问,预排序使用了一个行号到叶子节点号的索引表(row_idx_to_tree_node_idx ),来防止数据切分时对所有的数据进行切分,即只对该叶子节点上的样本切分。在与level-wise进行结合的时候, 每一个叶子节点都要切分数据,这也是随机的访问。这样会带来严重的系统性能下降。而直方图算法则是天然的cache friendly。在直方图算法的第3个for循环的时候,就已经统计好了每个bin的梯度,因此,在计算gain的时候,只需要对bin进行访问,造成的cache-miss问题会小很多。
  • 最后,在数据并行的时候,用 histgoram 可以大幅降低通信代价。用 pre-sorted 算法的话,通信代价是非常大的(几乎是没办法用的)。所以 xgoobst 在并行的时候也使用 histogram 进行通信。
    (数据并行的优化是Lightgbm的令一个亮点,这里不是特别理解,需要再深入研究)
3.2 劣势 histogram 算法不能找到很精确的分割点,训练误差没有 pre-sorted 好。但从实验结果来看, histogram 算法在测试集的误差和 pre-sorted 算法差异并不是很大,甚至有时候效果更好。实际上可能决策树对于分割点的精确程度并不太敏感,而且较“粗”的分割点也自带正则化的效果,再加上boosting算法本身就是弱分类器的集成。
五、直方图做差加速 在histogram算法上一个trick是histogram 做差加速。一个容易观察到的现象:一个叶子的直方图可以由它的父亲节点的直方图与它兄弟的直方图做差得到。利用这个方法,Lightgbm 可以在构造一个叶子(含有较少数据)的直方图后,可以用非常微小的代价得到它兄弟叶子(含有较多数据)的直方图。
因为构建兄弟叶子的直方图是做差得到的,时间复杂度仅为O(#bins),几乎可以忽略,因此,比起不做差得到的兄弟节点的直方图,在速度上可以提升一倍。
举例来说明什么是histogram 做差加速。
假设我们共有10个样本,2个特征。
特征 f 1 f_1 f1?为类别特征,共有2个不同的属性值,分成了桶 b 11 b_{11} b11?、 b 12 b_{12} b12?;桶 b 11 b_{11} b11?的样本数是4个,桶 b 12 b_{12} b12?的样本数是6个。
特征 f 2 f_2 f2?为连续特征,离散化后分成了桶 b 21 b_{21} b21?、 b 22 b_{22} b22?和 b 23 b_{23} b23?;桶 b 21 b_{21} b21?的样本数是2个,桶 b 22 b_{22} b22?的样本数是4个,桶 b 23 b_{23} b23?的样本数是4个。
我们依次计算每个bin作为分割点的增益,假设在桶 b 11 b_{11} b11?作为分割点时增益最大,那么以桶 b 11 b_{11} b11?分割,这时候:
a. 左子节点有4个样本。特征 f 1 f_1 f1?的桶 b 11 b_{11} b11?的样本数为4个,桶 b 12 b_{12} b12?样本为0个。假设特征 f 2 f_2 f2?仍有3个桶 b 21 b_{21} b21?、 b 22 b_{22} b22?和 b 23 b_{23} b23?,且桶 b 21 b_{21} b21?的样本数是1个,桶 b 22 b_{22} b22?的样本数是2个,桶 b 23 b_{23} b23?的样本数是1个。这时候左子节点2个特征的直方图已经构建成功。
b. 左子节点有4个样本,右子节点自然有6个样本。这时候右子节点的2个特征的直方图就可以根据父节点和左子节点的2个特征的直方图做差得到:
特征 f 1 f_1 f1?只有桶 b 12 b_{12} b12?,且样本数为6个(6-0=0)。桶 b 11 b_{11} b11?样本数为0个(4-4=0)。
特征 f 2 f_2 f2?仍有3个桶 b 21 b_{21} b21?、 b 22 b_{22} b22?和 b 23 b_{23} b23?,且桶 b 21 b_{21} b21?的样本数是1个(2-1=1),桶 b 22 b_{22} b22?的样本数是2个(4-2=2),桶 b 23 b_{23} b23?的样本数是3个(4-1=3)。
这时候右子节点2个特征的直方图也已经构建成功。
下图表示了整个过程:
机器学习|Lightgbm 直方图优化算法深入理解
文章图片

深入分析就可以知道,左子节点计算直方图的复杂度是基于样本个数的,而左子节点计算直方图的复杂度却是基于桶的个数的。因此,大大节省了构建直方图的时间。
五、参考文献 【1】如何看待微软新开源的LightGBM?
【2】LightGBM 直方图优化算法
【机器学习|Lightgbm 直方图优化算法深入理解】【3】『我爱机器学习』集成学习(四)LightGBM

    推荐阅读