云华00 2016-12-15
大多数机器学习算法的计算复杂度都是随着数据量或者维度呈线性增长,这是大规模机器学习的一大挑战。上一篇文章介绍了随机决策树算法的基本方法,并从理论层面粗略的探讨了为什么随机决策树具有学习能力。本篇文章我们将着重介绍随机决策树的算法实现,算法的复杂度和实验结果中展示的精度和效率。
随机决策树的基本方法上篇文章已经介绍了,这一方法并不复杂,没有什么高深的东西。但在实现过程中,还是有许多注意的问题。我们这里仅讨论一棵树的构造算法,多棵树仅是需要多次执行这一个算法。
在随机决策树(参考资源 [1])中的原始算法采用的是递归方式建立空树,以设置的最大树深为结束条件。然后以递归方式基于训练数据统计每个节点上的类别的频率,再去除掉没有数据或者不满足条件(训练样本数不足)的叶子节点。在空树的构建过程中,非数值型的特征在一条路径上只能出现一次,为所有可能的取值建立分支。而数值型的则可出现多次,每次都是建立两个子分支,但是分裂点则只能在这个节点的可能取值范围内随机选。在统计阶段,把所有训练数据自树的根开始向下逐步分配到叶子节点,中间统计每个节点的类别分布频率。在统计过程中,对于缺失值要做特殊处理。即,首先要统计一个节点的各个子分支的出现频率(非缺失值的频率),然后将缺失值的样本按照这个频率随机选择一个分支分配。在预测的时候,如果遇到缺失值也是根据子分支的频率随机分配。
这个算法的实现并不算复杂,麻烦一点的地方在于缺失值的处理。但是在数据量较大,维度较多和树深较深的时候,会有一些缺点。首先是建立空树就需要考虑所有的可能性。在最简单的情况下,即所有维度都是 2 值时,则需要建立的是满二叉树。如果树深为 d, 则一棵树的节点数为,这在树深很大的时候树的结构会急剧膨胀从而使得内存开销无法承受。而一般的情况下,每个特征的可能取值是大于等于 2 的,则树的结构膨胀速度会比二叉树的情况更遭。其次,树的构建过程和统计过程,都是采用的递归实现,这在树结构很大,树很深的时候容易导致栈溢出。特别是在用 Java 实现时,由于栈空间相对于堆空间是非常小的,这个问题很容易出现。再有一个缺点就是缺失值的处理问题,不仅程序上带来一些麻烦,提高了计算开销,更重要的是这种处理方式并不足够科学,特别是在缺失比例很高的情况下。
Dice[2] 项目是用 Java 语言开发的,支持二分类,多分类,多标签分类和回归。随机决策树的原始实现存在很多问题,为了克服这些问题我在 Dice 项目的实现中进行了一些优化:
首先是先建空树,然后剪枝的方式改成在树的生长过程中根据数据的情况决定是否分支。对于非数值型的特征来说,在某个节点检查在这个节点上的数据有多少种取值,然后根据取值个数分支。举个例子,学历这个特征可以有本科,研究生,博士生三个可能取值,但是在某一个节点上的数据里只有本科生和研究生两种取值,则只会分两支。对于数值型的特征,则会随机选择两个不同的值进行平均作为分裂值,如果所有的特征都相同则不会选择这个特征。
其次,对于缺失值的处理是将其当作一个新的特征值独立分支。这样就不需要统计每个节点上的特征取值的分布,简化了算法实现。
最后,将递归算法改为非递归算法,树是逐层生长的。在生长过程中,有数据结构记录每个样本落在了哪个节点上,避免样本重复从树根走起。非递归实现不依赖于栈。
Dice 实现除了克服以上三个主要问题,算法实现上还做了很多的细节优化。比如,为了尽可能的节省内存,数据的存储尽可能使用 primitive 类型的数组,相比于 Weka 将每个样本封装成一个对象,节省了大量的空间。Dice 的实现克服了原始算法的一些缺点,使得算法的效率和处理的数据规模有较大提高。下一节在算法的计算复杂度分析中将基于 Dice 的实现。
随机决策树算法的计算复杂度来自于两部分。一是构建树结构的计算复杂度,二是统计每个叶子节点上的样本类别分布的计算复杂度。构建树结构的计算复杂度与训练样本数和特征数有关。但是在最坏的情况下,即树的生长达到了最大的深度限制时,其复杂度仅与树的最大深度限制和训练数据数量有关。这是因为在最坏的情况下,树的最大深度达到最大限制,而树还是满树,即所有训练样本都走到了最深的深度上。设树的最大深度为 d,训练样本树为 n,则在此种情况下,构造一棵树仅需要分配样本 dn 次。则构造树的最大复杂度为O(dn)。而一般情况下,并不是每棵树都会生成最大深度的满树,实际开销是还会要小不少。至于叶子节点统计时的开销是极小的,这里不做考虑。其他的决策树算法分析里也不考虑这部分开销。基于以上分析,可以认为在训练数据总数为 n,最大树深为 d,树棵树为 m 时,随机决策树算法的训练开销为O(mdn)。一般的情况下,m<<n,d<<n。可以看到,算法的训练复杂度在 m 和 d 给定的情况下仅与训练样本数 n 线性相关。
而传统的 TDIDT 决策树(Top-Down Induction of Decision Trees,包括 ID3 和 C4.5 算法)其训练复杂度为, 其中, 而是第个特征的取值个数。显然,该类算法的算法复杂度与特征个数是超线性关系。在一般情况下,所以随机决策树算法的计算复杂度是远小于这类算法的。
前面简单介绍了随机决策树算法的实现和训练复杂度,这一节将简要介绍下其精度和训练效率,并与其他经典算法进行比较。
一个简单例子
该例子引自范伟博士的网站:有一二维的人造正负样本分布,如图 1 所示。其中红色为正样本,绿色为负样本。显然这个问题不是线性可分的,
下面图 2—图 5 分别给出随机决策树算法、决策树,线性 SVM 和 RBF 核的 SVM 算法的训练结果。
显然,随机决策树对原分布的估计是较好的,与 RBF SVM 的效果在同一水平上。而决策树算法则带来了明显的锯齿型边界,而线性 SVM 则无法将右上方的正样本正确划分。从训练时间看,SVM 开销非常大(可能未使用 SMO 方法),树的算法都很快。但是随机决策树算法的训练开销比决策树要高 5 倍,与我们前面的分析相反。这有两个原因,一是对于低维度问题,随机决策树算法的效率与决策树相比并没有明显优势。因为决策树算法的时间复杂度与特征数量的关系是超线性的,在特征较少时,其复杂度并不会太高,而随着特征的增长,计算复杂度会快速增长。二是这个实验是使用的随机决策树的原始实现,其效率并不是太高。
Dice 测试结果
我们从 Libsvm 选取了 9 个二分类数据集,这些数据集的统计信息列在表 1 中。
Name | Feature Type | Feature Number | Train Data Number | Test Data Number | Train Data Size | Test Data Size |
a1a | Binary | 123 | 1,605 | 30,956 | 0.11M | 2.11M |
a9a | Binary | 123 | 32,561 | 16,281 | 2.22M | 3.33M |
mushrooms | Binary | 112 | 7,124 | 1,000 | 0.753M | 0.105M |
w1a | Binary | 300 | 2,477 | 47,272 | 0.166M | 3.15M |
w8a | Binary | 300 | 49,749 | 14,951 | 3.31M | 1.00M |
splice | Categorical | 60 | 1,000 | 2,175 | 0.7M | 1.48M |
cod-rna | Numeric | 8 | 59,535 | 271,617 | 4.45M | 20.3M |
covtype | Numeric | 54 | 481,082 | 100,000 | 56.2M | 11.6M |
gisette | Numeric | 5,000 | 6,000 | 1,000 | 50.4M | 8.52M |
对于以上数据集,我们使用 30 棵树的随机决策树(其叶子节点最少保留 4 个训练样本,最大深度为特征数)进行实验。并使用 Weka 测试 J48(C4.5 决策树),SMO(SMO 算法实现的 SVM) 和 Logistic Regression 算法。Weka 测试的算法的参数使用默认参数。所有的实验结果列在表 2,其中训练时间的单位为秒。 表 1. Weka 测试结果
Data | RDT | J48 | SMO | LR | |||||
AUC | Tr.Time | AUC | Tr.Time | AUC | Tr.Time | AUC | Tr.Time | ||
a1a | <strong>0.879</strong> | <strong>0.569</strong> | 0.712 | 1.861 | 0.760 | 1.574 | 0.751 | 1.010 | |
a9a | <strong>0.890</strong> | <strong>24.171</strong> | 0.755 | 647.013 | 0.761 | 1637.011 | 0.763 | 35.901 | |
mushrooms | 1.000 | 3.608 | 1.000 | 13.651 | 1.000 | <strong>1.665</strong> | 1.000 | 3.993 | |
w1a | <strong>0.953</strong> | <strong>0.934</strong> | 0.613 | 23.022 | 0.748 | 0.759 | 0.732 | 6.561 | |
w8a | <strong>0.997</strong> | <strong>33.759</strong> | - | - | 0.797 | 487.39 | 0.822 | 371.836 | |
splice | 0.909 | <strong>0.387</strong> | <strong>0.935 </strong> | 0.770 | 0.843 | 1.742 | 0.853 | 0.819 | |
cod-rna | <strong>0.969</strong> | 7.799 | 0.944 | 155.763 | 0.944 | 62.705 | 0.937 | <strong>4.271</strong> | |
covtype | <strong>0.768</strong> | <strong>24.392</strong> | - | - | - | - | 0.705 | 299.667 | |
gisette | 0.934 | 82.513 | - | - | - | - | - |
从上表中我们可以看到,随机决策树的精度总体上超过其他 3 个经典算法。虽然其他三个算法使用的是默认参数,但是随机决策树使用的也是默认参数,并未对不同数据集进行调参,因此比较也是基本公平的。而速度上可以看到随机决策树算法也是具有很大的优势。注意到表中有一些空缺项,那是在 1 个小时内跑不出结果从而终止的实验。可以看到 RDT 可以在所有数据集上跑出结果,而其他的算法则在稍大的数据集上有可能跑不出结果(Weka 的实现不够高效可能也是原因之一)。
Dice 的实现是比较高效的,我曾经用在过腾讯广点通的 CTR 估计上训练上千万样本的问题,仅需要 10 分钟就能完成训练。但是毕竟这是个单机实现,处理问题的规模总有上限。为了处理更大规模的问题,我们需要考虑随机决策树算法的并行化问题(仅考虑在 Hadoop 和 Spark 上的并行)。但是,随机决策树算法的并行化却有比较大的困难。Dice 对原始实现的优化中,很重要的一条就是将先建空树改成逐层建树,从而减少了内存的消耗。但是这会要求要多次使用训练数据,即多次迭代训练数据,在单机模式下不是大问题。但是在 Hadoop 上并行实现,则会要多次从 HDFS 上读取训练数据,而 Hadoop 程序最大的开销就在 IO 上,这一实现会带来效率的严重降低。而原始实现的先建空树,反而可以只需要再读一次数据进行剪枝,统计即可,只不过树深受到极大限制。当然在 Spark 平台上可以把数据都存放在内存中,但是由于内存资源比较稀缺,并不是所有的问题,都可以把数据放在内存中,这一问题仅仅是缓解了而不是彻底解决了。同时,树结构的复杂性,使得树模型在不同节点间的同步和合并也有很多麻烦。目前,随机决策树在并行化上的困难,也让该算法在解决大数据问题时遇到瓶颈。
随机决策树算法在精度和效率上都有不错的表现,但是并行化却很困难。可以看到我们在决策树算法中用随机方法替代了贪婪方法来构建树结构,从而获得了效率上的很大的提高。但是树结构本身的复杂性,使得其很难进行并行。为了克服这个问题,我们是否可以考虑用其他方法(例如随机决策哈希算法)来取代树结构对数据空间的划分,从而克服并行化困难呢?但由于篇幅有限,该问题暂不属于本文的讨论范围,有机会笔者将在后续文章中讨论。