在python中从头开始的随机森林和决策树使用教程

144 阅读10分钟

简介

随机森林是集合机器学习方法的主要例子。简单地说,集合方法就是将预测性较差的基础模型集合起来,产生一个更好的预测模型。随机森林,正如人们直观地猜测的那样,集合了各种决策树,通过减少决策树臭名昭著的过度拟合倾向,产生了一个更通用的模型。决策树和随机森林都可以用于回归和分类问题。在这篇文章中,我们创建了一个随机森林回归器,尽管在下面的代码中只需稍作改动就可以创建一个分类器。

前提条件

我建议你对以下内容有一些工作上的了解,以便更好地掌握本帖:

  1. 决策树。我们的随机森林也将从头开始创建,但我不会解释它的理论和工作机制,因为简明扼要有利于 推理,因此把所有东西都放在一个帖子里只会让人觉得不合理。
  2. 集合学习。随机森林只是这个庞大的机器学习技术集的一个子集,如果你有这方面的基本知识,你会更好地理解这个帖子(为了这个帖子的目的,就不多说了)
  3. numpypandas库。一个好的木匠必须熟知他的工具。这两个库就是我们的工具。

理论

在开始写代码之前,我们需要了解一些基本理论:

  1. 特征装袋自举 聚合或装袋是一种从原始集合中随机选择一些样本进行替换的方法。在特征袋法中,原始特征集被随机抽样并传递到不同的树上(没有替换,因为有多余的特征就没有意义)。这样做是为了减少树之间的关联性。一个具有无可比拟的重要性的特征会导致每棵决策树在第一次和可能的后续拆分中选择它,这将使所有的树表现得相似,最终更加相关,这是不可取的。我们在这里的目的是要制造高度不相关的决策树。

为什么要使决策树高度不相关?

我们需要高度不相关的决策树,因为 "一群完全随机的错误的平均误差为零",因此,通过降低相关性,并使每棵树尽可能随机地分裂(在特征选择的意义上是随机的,我们的目标仍然是在随机选择的列中找到最佳的分裂),我们得到更好的预测,没有错误。

2.聚合:使随机森林优于决策树的核心概念是聚合不相关的树。这个想法是创建几个蹩脚的模型树(低深度),并将它们平均化,以创建一个更好的随机森林。一些随机误差的平均值为零,因此我们可以期望从我们的森林中得到普遍的预测结果。在回归的情况下,我们可以平均每棵树的预测结果(平均值),而在分类问题的情况下,我们可以简单地取每棵树投票的大多数类别(模式)。

Python代码

为了从头开始编码我们的随机森林,我们将遵循自上而下的方法。我们将从一个单一的黑盒子开始,然后进一步将其分解为几个黑盒子,并降低抽象程度,增加细节,直到我们最终达到一个没有任何东西被抽象的地步。

随机森林类

我们正在创建一个随机森林回归器,尽管同样的代码可以稍作修改以创建一个分类器。首先,我们需要知道我们的黑匣子把什么作为输入来产生输出(预测),所以我们需要知道定义我们随机森林的参数:

  1. x:训练集的自变量。为了保持事情的最小化和简单化,我不会创建一个单独的拟合方法,因此基类构造函数将接受训练集。
  2. y:监督学习所需的相应因变量(随机森林是一种监督学习技术)。
  3. n_trees:我们合奏的不相关的树的数量,以创建随机森林。
  4. n_features:要取样并传递给每棵树的特征数量,这是特征分袋发生的地方。它可以是sqrt、log2或整数。如果是sqrt,每棵树上采样的特征数是总特征数的平方根,如果是log2,则是总特征数的对数底2。
  5. sample_size:随机选择并传递给每棵树的行数。这通常等于总行数,但在某些情况下可以减少,以提高性能并减少树的相关性(树的袋装化是一种完全独立的机器学习技术)。
  6. 深度: 每个决策树的深度。更高的深度意味着更多的分叉,这增加了每棵树的过度拟合趋势,但由于我们是将几棵不相关的树聚集在一起,个别树的过度拟合几乎不会影响整个森林。
  7. min_leaf:一个节点中导致进一步分裂所需的最小行数。min_leaf越低,树的深度就越高。

让我们开始定义我们的随机森林类:

  1. __init__:构造函数,在我们的参数的帮助下简单地定义随机森林,并创建所需数量的树。
  2. create_tree:通过调用DecisionTree类的构造函数来创建一个新的决策树,目前该类被认为是一个黑盒子。我们将在后面写出它的代码。每棵树都接收一个随机的特征子集(特征包)和一个随机的行集(包树,虽然这是可选的,但我写出来是为了显示它的可能性)。
  3. 预测:我们的随机森林的预测只是所有决策树预测的平均值。

这就是如果我们能神奇地创造出树,就能想到随机森林是多么容易。现在我们降低抽象程度,写代码来创建一棵决策树。

如果你知道如何从头开始写一棵决策树,你可以在这里停止阅读,但如果你不知道,那就进一步阅读。

决策树类

它将有以下参数:

  1. indxs:这个参数的存在是为了跟踪原始集合的哪些索引进入右边,哪些进入左边的树。因此,每棵树都有这个参数 "indxs",存储它所包含的行的索引。预测是通过对这些行的平均数进行的。
  2. min_leaf:一个叶子节点所需的最小行样本,以便能够引起分裂。每个叶子节点都会有小于min_leaf的行样本,因为它们不能再分裂(忽略深度约束)。
  3. 深度。每个树内可能的最大深度或最大分裂数。

我们使用属性装饰器来使我们的代码更加简洁:

  1. __init__: 决策树构造函数。它有几个有趣的片段可以研究。

    a. if idxs is None: idxs=np.range(len(y)) 在我们没有指定这个特定树的计算中的行的索引的情况下,简单地取所有的行。

    b. self.val = np.mean(y[idxs]) 每个决策树预测的值是它所持有的所有行的平均值。变量self.val持有树的每个节点的预测值。对于根节点来说,该值将是所有观测值的平均值,因为它持有所有的行,因为我们还没有进行分割。我在这里使用了 "节点 "一词,因为从本质上讲,决策树只是一个节点,它的右边有一个决策树,左边有一个。

    c.self.score = float('inf') 一个节点的分数是根据它对原始数据集的分割程度来计算的。我们将在后面定义这个 "好",现在我们只假设我们有一个方法来衡量这样的数量。另外,对于我们的节点来说,分数被设置为无穷大,因为我们还没有进行任何分割,因此我们不存在的分割是无限坏的,这表明任何分割都会比没有分割好。

    d. self.find_varsplit() 我们进行了第一次分割!

2.find_varsplit: 我们使用蛮力方法来寻找最佳分割。这个函数依次循环所有的列,并在所有列中找到最佳分割。这个函数仍然是不完整的,因为它只做了一个分割,以后我们会扩展这个函数,为每个分割做左右决策树,直到我们到达叶子节点。

3.var_idx是这个列的索引,我们将在find_better_split函数中计算这个索引以及我们所分割的列的值。

4.split_col。 一个属性装饰器,用于返回索引为var_idx的列和索引为indxs变量的元素。基本上,将一列与选定的行分开。

5.find_better_split: 这个函数在某一列中找到可能的最佳分割,它很复杂,所以我们在上面的代码中把它看作一个黑盒子。让我们稍后定义它。

4.is_leaf。一个叶子节点是一个从未进行过分割的节点,因此它的得分是无限的(如上所述),因此这个函数被用来识别叶子节点。此外,如果我们已经越过了最大深度,即self.depth <= 0,它就是一个叶子节点,因为我们不能再深入了。

如何找到最佳分割?

决策树的训练方式是根据某些条件将数据递归地分割成两半。如果一个测试集有10列,每列有10个数据点(值),总共有10x10=100个分割点,我们的任务是找到这些分割点中最适合我们数据的。

我们根据它将我们的数据分成两半的程度来比较分割的情况。我们进行分割,使两半数据中的每一半都有最 "相似 "的数据类型。增加这种相似性的一个方法是通过减少两半的方差或标准差。因此,我们要使两半的标准差的加权平均数(得分)最小。我们使用贪婪的方法来寻找分割,将数据分成两半,对于列中的每一个值,计算两半的标准差的加权平均数(得分),最终找到最小值。(贪婪的方法)

为了加快速度,我们可以对该列进行复制和排序,通过对第n+1个索引的数值进行拆分,利用对第n个索引进行拆分后产生的两半数值的和与平方之和来计算加权平均数。

medium.com/media/d1f82…

下面的代码片段很有意思,需要做一些解释:

  1. 函数std_agg使用数值的和与平方之和计算标准差
  2. curr_score = lhs_std*lhs_cnt + rhs_std*rhs_cnt 每次迭代的分割分数只是两半的标准差的加权平均值,每半的行数为其权重。较低的分数有利于降低方差,较低的方差有利于对类似的数据进行分组,这将导致更好的预测结果。
  3. if curr_score<self.score:
    self.var_idx,self.score,self.split = var_idx,curr_score,xi 每当当前分数较好时(小于当前保存在self.score中的分数),我们将更新当前分数并将此列存储在变量self.var_idx中(记住这是帮助选择我们两个属性装饰器的列的变量)并将分割所依据的值,保存在变量self.split中 。

现在我们知道了如何为一个选定的列找到最佳分割,我们需要递归地为每个进一步的决策树定义分割。对于每一棵树,我们找到最佳的列和它的值来进行分割,然后我们递归地做两个决策树,直到我们到达叶子节点。为了做到这一点,我们对不完整的函数find_varsplit做了如下扩展。

medium.com/media/64aa1…

  1. for i in range(self.c): self.find_better_split(i) 这个循环遍历每一列,试图找到该列中的最佳分割。当这个循环结束时,我们已经用尽了所有列中所有可能的分割,并找到了可能的最佳分割。要分割的列存储在self.var_idx变量中,我们分割的那一列的值存储在self.split变量中。现在我们递归地形成两半,即左右决策树,直到我们到达叶子节点。
  2. if self.is_leaf: 返回 如果我们已经到达叶子节点,我们不再需要寻找更好的分割,而是简单地结束这个树。
  3. self.lhs持有左边的决策树。它收到的行被保存在lhs中,这些行是所选列中所有小于或等于split.value中存储的分裂值的索引。变量lf_indxs持有左决策树收到的特征(列)的样本(特征袋化)。
  4. rhs存储了传递到右决策树上的行的索引。rhs的计算方式与lhs类似,变量rf_indxs存储了右决策树收到的特征(列)样本(特征分袋)。

总结

以下是完整的代码:

medium.com/media/9b578…

这篇文章的目的是让读者和我自己更熟悉随机森林的一般工作,以便将来更好地应用和调试。随机森林有更多的参数和相关的复杂性,我不可能在一个帖子中涵盖。为了研究一个更强大、更健康的代码,我建议你阅读sklearn模块的随机森林代码,它是开放源代码的。

我想对Jeremy howardRachel thomasfast.ai机器学习课程表示感谢,这使我能够写这篇文章。事实上,我在这里的所有代码都是在对fast.ai课程的原始资料进行小幅修改后创建的