本文讨论sklearn源码中的树模型,其中包括 Decision Trees 与 Ensemble methods 两篇,源码文件夹在 sklearn/tree 与 sklearn/ensemble 下。
本文涉及到的模型参数解释请先自行翻阅官方文档,如非必要本文将不再列出。本文将简要介绍全部模型源码(工具源码不介绍),建议在看本文时自己打开源码对照查看,本文不再附大片源码来灌水。涉及到Cython语法细节本文将不详细讲解,因为我也看不懂,用着自己仅有的C++和Python那点知识勉强的在Cython的海洋中狗刨。
基于Cython底层架构
首先树模型的底层代码均由Cython编写,核心文件是为 _tree.pyx, _splitter.pyx,_criterion.pyx 。_utils.pyx 是源代码所使用的的工具文件,就不细说了。
先大致看一下架构:
-
_tree.pyx 是树模型的主架构,负责树的level生成,由Cython类Tree封装。生成树的结点有两种方法:DepthFirstTreeBuilder类与BestFirstTreeBuilder类。它们均继承自TreeBuilder类,其实就是为了继承它的_check_input方法,检查输入类型,并转化为占用连续内存储存形式(目的是加速计算与索引)如np.asfortranarray和np.ascontiguousarray格式。
-
_splitter.pyx 是负责叶节点分裂的架构,有四种分裂方式,两种Sparse处理,两种Dense处理。Dense处理中分为BestSplitter和RandomSplitter两种分裂方式,其实很容易理解,一种是正常的挑最好的特征和特征点分裂,一种是像随机分裂树那样瞎分裂。他们均继承自BaseDenseSplitter,继承它的初始化方法,而BaseDense继承自Splitter类,这是基类,提供了对接分裂准则方法的方法等。
-
_criterion.pyx 是负责叶节点分裂准则的架构,有N多种准则计算方法,有分类ClassificationCriterion和连续RegressionCriterion两种计算准则。这两种类均继承自Criterion基类,继承他的对接Splitter接口。就拿默认的分类准则基尼指数举例子。Gini类继承自ClassificationCriterion,Gini类仅提供了自己的计算准则方法(算基尼指数)。
_tree.pyx
主要有两种构造树的方式,DepthFirstTreeBuilder和BestFirstTreeBuilder,当不限制叶子结点数的时候就会使用DepthFirstTreeBuilder,使用栈的方式递归先在左子树分裂;限制叶子结点数的时候就会BestFirstTreeBuilder,使用优先级队列的方式将得分最大叶子结点进行分裂;堆和栈都定义在util.pyx中,比较常规的定义,就不说了。
DepthFirstTreeBuilder&BestFirstTreeBuilder类
-
_check_input :检查输入类型并转化为占用连续内存储存形式;
-
build:建立树;
-
_add_split_node(BestFirstTreeBuilder):添加树节点
Tree类
-
_resize、_resize_c:设置内存缓冲区;
-
_add_node:添加树节点;
-
apply:返回叶子索引;
-
_apply_dense、_apply_sparse_csr:返回dense和sparse树叶子节点索引;
-
_get_value_ndarray:返回所有树节点各类别权重和;
-
_get_node_ndarray:返回节点;
-
predict:返回当前节点各类别权重和权重和;
-
decision_path:返回决策路径;
-
_decision_path_dense、_decision_path_sparse_csr:返回dense和sparse树决策路径;
-
compute_feature_importances:计算特征重要度,(当前节点的样本权重数*损失数-左右节点的样本权重数*损失数)/样本权重数,然后再归一化即可得,计算方法如下所示;
cpdef compute_feature_importances(self, normalize=True):
...
with nogil:
while node != end_node:
if node.left_child != _TREE_LEAF:
# ... and node.right_child != _TREE_LEAF:
left = &nodes[node.left_child]
right = &nodes[node.right_child]
importance_data[node.feature] += (
node.weighted_n_node_samples * node.impurity -
left.weighted_n_node_samples * left.impurity -
right.weighted_n_node_samples * right.impurity)
node += 1
importances /= nodes[0].weighted_n_node_samples
if normalize:
normalizer = np.sum(importances)
if normalizer > 0.0:
# Avoid dividing by zero (e.g., when root is pure)
importances /= normalizer
return importances
_splitter.pyx
有两类四种分裂方式,BestSplitter是最优分裂,跟正常的分裂方式一样,RandomSplitter是随机分裂。
BestSplitter&RandomSplitter类
-
init :初始化,有效样本(样本权重>0)samples,所有样本权重和 weighted_n_samples,预排序特征指针X_idx_sorted_ptr,预排序特征内存间隔 X_idx_sorted_stride,有效样本标志位 sample_mask;
-
node_reset: 初始化ClassificationCriterion类的样本区间,得到区间样本权重和 weighted_n_node_samples;
-
node_impurity:计算当前节点得分数;
-
node_split:分裂节点,使用了Fisher-Yates随机方法选取特征列,记录常量特征剔除出运算;
-
node_value:保存得分,将类别权重和 sum_total 复制到树的 value 内存,当做树得分;
对于稀疏类BestSparseSplitter和RandomSparseSplitter,他们相比于Dense类来说仅仅多了一个排序过程,由于稀疏类的特性,对他们进行如Dense类的预排序性能是非常差的:Python内置sort函数是基于快速排序,对于稀疏问题快排时间复杂度直接飙升到O(n方),使用插入排序效率最高,但还是O(n)的复杂度,所以sklearn源码中选择了只sort排序非0特征,时间复杂度只有O(m),其中m<<n。
-
extract_nnz:提取非零特征;
-
extract_nnz_index_to_samples:通过样本索引值 提取非零特征;
-
extract_nnz_binary_search:通过二分法查找 提取非零特征;
-
sparse_swap:交换样本位置,使正特征值样本在零特征值样本之后;
-
_partition:分割样本;
-
binary_search:二分法查找当前样本行索引以确定对应特征是否为非零;
<