1. 程式人生 > >機器學習——動手從決策樹實現隨機森林

機器學習——動手從決策樹實現隨機森林

本文始發於個人公眾號:**TechFlow**,原創不易,求個關注

今天是機器學習專題的第26篇文章,我們一起聊聊另外一個整合學習模型,它就是大名鼎鼎的隨機森林。

隨機森林在業內名氣和使用範圍都很廣,曾經在許多演算法比賽當中拔得頭籌。另外,它也是一個通過組合多個弱分類器構建強分類器的經典模型,因此它在業內廣受歡迎。

本文基於決策樹相關的文章,沒有閱讀過的同學可以從最上方的專輯檢視過往決策樹相關的文章。

演算法原理

上一篇文章介紹AdaBoost的時候把整合學習的幾種思路大概介紹了一遍,整合學習主要是三種方法,Bagging、Boosting和Stacking。AdaBoost屬於Boosting,而隨機森林則屬於Bagging。

Bagging最大的特點就是"民主",其中的思路很容易理解,針對同一份訓練資料我們會訓練出多個弱分類器來。當面臨一個新的樣本的時候,由這些分類器聚集在一起進行民主投票,每一個分類器都是平等的,它們的權重一樣。最後,根據所有這些弱分類器的結果整合出最後的結果來。比如說我們一共訓練了50個弱分類器,在面對一個樣本的時候,其中的35個給出類別1,15個給出類別0,那麼整個模型的結果就是類別1。

另外一點是,Bagging在訓練每一個分類器的時候所使用的資料並不是固定的,而是通過有放回抽樣選擇出來的。所以樣本在訓練集中出現的分佈也是各不相同的,有些樣本可能在同一個分類器中出現了多次,同樣也有一些樣本沒有出現過。單單有樣本隨機性還是不夠的,因為訓練樣本的分佈大體上是差不多的,雖然我們隨機取樣,也不會使得每一份訓練樣本會有很大的差距。所以為了保證每個分類器的側重點不同,擁有更強的隨機性,我們還可以從特徵入手,限制每個分類器只能隨機使用部分特徵。這樣得到的分類器每一個的特徵都各不相同,側重點也就不同,可以儘可能地增強隨機的效果,讓模型專注於資料。

隨機森林這個模型的名字當中隱藏了兩個關鍵點,分別是隨機和森林。隨機我們已經解釋過了,一方面是每一個分類器樣本的隨機,另外一個是分類器可以使用的特徵的隨機。而森林也很好理解,因為我們使用的分類器是決策樹,所以多棵決策“樹”組成的模型,自然就是森林了。

抓住這兩個特徵,隨機森林很好理解,也很好實現,畢竟決策樹模型我們之前已經實現過好幾次了。

程式碼實現

我們選擇決策樹當中最經典的CART演算法來實現決策樹,資料我們依然沿用上次AdaBoost模型當中乳腺癌預測的資料。

首先,我們還是一樣,先讀入資料:

import numpy as np
import pandas as pd
from sklearn.datasets import load_breast_cancer

breast = load_breast_cancer()
X, y = breast.data, breast.target
# reshape,將一維向量轉成二維
y = y.reshape((-1, 1))

之後我們利用sklearn庫中的train_test_split工具將資料拆分成訓練資料和測試資料。

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=23)

隨機森林的核心結構依然是決策樹,我們可以直接沿用之前CART決策樹的程式碼,只需要稍稍做一些小小的改動即可。首先是計算GIni指數和根據所選的特徵拆分資料的函式,這兩個函式不需要做任何改動,我們從之前的文章當中複製過來。

from collections import Counter

def gini_index(dataset):
    dataset = np.array(dataset)
    n = dataset.shape[0]
    if n == 0:
        return 0
    
    counter = Counter(dataset[:, -1])
    ret = 1.0
    for k, v in counter.items():
        ret -= (v / n) ** 2
    return ret

def split_gini(dataset, idx, threshold):
    left, right = [], []
    n = dataset.shape[0]
    # 根據閾值拆分,拆分之後計算新的Gini指數
    for data in dataset:
        if data[idx] <= threshold:
            left.append(data)
        else:
            right.append(data)
    left, right = np.array(left), np.array(right)
    # 拆分成兩半之後,乘上所佔的比例
    return left.shape[0] / n * gini_index(left) + right.shape[0] / n * gini_index(right)

然後是拆分資料集,我們根據特徵和閾值將資料集拆分成兩個部分。和上面的split_gini函式類似,只是split_gini函式計算的是拆分之後的Gini指數,而我們現在開發的是將資料集拆分的功能。

from collections import defaultdict

def split_dataset(dataset, idx, thread):
    splitData = defaultdict(list)
    # 否則根據閾值劃分,分成兩類大於和小於
    for data in dataset:
        splitData[data[idx] <= thread].append(np.delete(data, idx))
    return list(splitData.values()), list(splitData.keys())

再然後是獲取所有閾值以及選擇最佳的特徵和拆分閾值的函式,這兩個函式的邏輯和之前也完全一樣,我們也複製過來。

def get_thresholds(X, idx):

    # numpy多維索引用法
    new_data = X[:, [idx, -1]].tolist()
    # 根據特徵值排序
    new_data = sorted(new_data, key=lambda x: x[0], reverse=True)
    base = new_data[0][1]
    threads = []

    for i in range(1, len(new_data)):
        f, l = new_data[i]
        # 如果label變化則記錄
        if l != base:
            base = l
            threads.append(f)

    return threads


def choose_feature_to_split(dataset):
    n = len(dataset[0])-1
    m = len(dataset)
    # 記錄最佳Gini,特徵和閾值
    bestGini = float('inf')
    feature = -1
    thred = None
    for i in range(n):
        threds = get_thresholds(dataset, i)

        for t in threds:
            # 遍歷所有的閾值,計算每個閾值的Gini
            gini = split_gini(dataset, i, t)
            if gini < bestGini:
                bestGini, feature, thred = gini, i, t
    return feature, thred

再然後就是建樹的部分了,這裡我們做了一點小小的調整。因為我們只使用了一部分訓練資料以及一部分特徵訓練的決策樹,所以一般不會出現過擬合的情況,所以我去掉了防止過擬合的預剪枝邏輯。另外一個變動是加上了backup的設定,因為特徵和資料的稀疏,可能會出現某個節點只有一個分支的情況。為了防止這種情況,我在每個節點都儲存了一個backup,它代表的當前節點的資料中出現次數最多的那個類別。

def create_decision_tree(dataset):
    dataset = np.array(dataset)
    # 如果都是一類,那麼直接返回類別
    counter = Counter(dataset[:, -1])
    if len(counter) == 1:
        return dataset[0, -1]
    
    # 如果已經用完了所有特徵
    if dataset.shape[1] == 1:
        return counter.most_common(1)[0][0]
    
    # 記錄最佳拆分的特徵和閾值
    fidx, th = choose_feature_to_split(dataset)
    
    node = {'threshold': th, 'feature': fidx, 'backup': counter.most_common(1)[0][0]}
    
    split_data, vals = split_dataset(dataset, fidx, th)
    for data, val in zip(split_data, vals):
        node[val <= th] = create_decision_tree(data)
    return node

再然後是兩個工具函式,第一個是單棵子樹對於單個樣本的預測函式,第二個是單個子樹對於批量樣本的預測函式。整體的邏輯和之前大同小異,我們直接看程式碼即可:

def subtree_classify(node, data):
    key = node['feature']
    pred = None
    thred = node['threshold']
    
    # 如果當前的分支沒有出現過,那麼返回backup
    if (data[key] < thred) not in node:
        return node['backup']

    if isinstance(node[data[key] < thred], dict):
        pred = subtree_classify(node[data[key] < thred], data)
    else:
        pred = node[data[key] < thred]
            
    # 防止pred為空,挑選一個葉子節點作為替補
    if pred is None:
        for key in node:
            if not isinstance(node[key], dict):
                pred = node[key]
                break
    return pred


def subtree_predict(node, X):
    y_pred = []
    # 遍歷資料,批量預測
    for x in X:
        y = subtree_classify(node, x)
        y_pred.append(y)
    return np.array(y_pred)

到這裡其實都是決策樹的部分,決策樹實現了之後,構建森林的部分非常簡單。只做了一件事,就是隨機樣本和特徵,然後用隨機出的樣本和特徵建立新的決策樹並進行記錄。

首先我們來看隨機樣本和特徵的函式:

def get_random_sample(X, y):
    rnd_idx = np.random.choice(range(X.shape[0]), 350, replace=True)
    ft_idx = np.random.choice(range(X.shape[1]), 15, replace=False)
    x_ret = X[rnd_idx][:, ft_idx]
    # 將類別拼接到X當中
    x_ret = np.hstack((x_ret, y[rnd_idx]))
    # 對特徵的下標進行排序
    ft_idx.sort()
    return x_ret, np.array(ft_idx)

在這個函式當中,我們設定了每次訓練新的決策樹的時候,使用的樣本數量是350條,特徵數量是15個。通過這種方式保證構建的決策樹的隨機性。

隨機樣本搞定了之後,構建森林的程式碼就很簡單了,通過一重迴圈建立樹並記錄即可:

trees_num = 20
subtrees = []

for i in range(trees_num):
    X_rnd, features = get_random_sample(X_train, y_train)
    node = create_decision_tree(X_rnd)
    # 記錄下創建出來的決策樹與用到的特徵
    subtrees.append((node, features))

我們在建立樹的時候隨機使用了一些特徵,我們在後續預測的時候需要用同樣的特徵去預測。所以我們需要記錄下每一棵樹用到了哪些特徵值,並且決策樹當中記錄的只是特徵的下標,所以我們在記錄之前需要先對這些特徵進行排序。

這些函式都實現之後就是predict方法了,根據Bagging的定義,對於每一個樣本,我們都需要獲取每一棵決策樹的分類。然後選擇其中數量最多的那個作為最終的預測結果,這段邏輯也不復雜,相信大家觀看程式碼都能想明白。

def predict(X, trees):
    y_pred = []
    for x in X:
        ret = []
        for tree, features in trees:
            ret.append(subtree_classify(tree, x[features]))
        ret = Counter(ret)
        y_pred.append(ret.most_common(1)[0][0])
    return np.array(y_pred)

最後,我們在測試集上驗證一下模型的效果:

準確率65.7%,看起來並不高,和我們預想中有些差距,我們來看下森林當中單棵樹的效果:

會發現有些樹的準確率只有30%多,都沒有達到50%的理論值。這當中的原因很多,一方面是我們選擇特徵的隨機性,導致了我們可能選出來一些效果比較差的特徵。另外一方面和我們訓練的樣本數量也有關係,在這個例子當中,我們可以使用的樣本數量太少了,所以隨機性很大,偏差自然也不小。

另外我們可以看下我們呼叫sklearn當中的隨機森林的效果,我們同樣設定森林中決策樹的數量是40,並且選擇Gini指數作為劃分樣本的依據。

我們可以看到它的準確率甚至比我們自己開發的還要低,這也是很正常的,畢竟模型不是普適的,會存在一些場景下不太適用或者是效果不好的情況,這是非常正常的。

總結

隨機森林模型最大的特點是隨機,其實其中的原理和AdaBoost非常接近,我們通過隨機樣本和特徵來保證模型的隨機性,以及保證這樣訓練得到的模型是一個弱分類器。弱分類器除了效果弱之外,還有很大的一個特點就是不能過擬合,也就是說它們都是欠擬合的模型,自然不可能過擬合。我們通過整合大量弱分類器獲得強大的分類效果。

和AdaBoost比起來,隨機森林的隨機性更強,並且對於引數的依賴更高,森林中決策樹的數量,每一棵決策樹需要使用的特徵數量,以及剪枝的策略等等。這些因素都會影響到最終模型的效果,但它也有AdaBoost沒有的優勢,因為隨機性,使得我們可能會發掘一些常規方法無法發現的特徵組合或者是樣本當中獨特的資料分佈。這也是為什麼經過合適的調參之後,隨機森林往往能夠獲得非常好的效果。

關於隨機森林的介紹到這裡就結束了,如果喜歡本文,可以的話,請點個關注,給我一點鼓勵,也方便獲取更多文章。

本文使用 mdnice 排版

![](https://user-gold-cdn.xitu.io/2020/6/29/172fe0b822674a25?w=258&h=258&f=png&