1. 程式人生 > >深入瞭解機器學習決策樹模型——C4.5演算法

深入瞭解機器學習決策樹模型——C4.5演算法

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

今天是機器學習專題的第22篇文章,我們繼續決策樹的話題。

上一篇文章當中介紹了一種最簡單構造決策樹的方法——ID3演算法,也就是每次選擇一個特徵進行拆分資料。這個特徵有多少個取值那麼就劃分出多少個分叉,整個建樹的過程非常簡單。如果錯過了上篇文章的同學可以從下方傳送門去回顧一下:

如果你還不會決策樹,那你一定要進來看看

既然我們已經有了ID3演算法可以實現決策樹,那麼為什麼還需要新的演算法?顯然一定是做出了一些優化或者是進行了一些改進,不然新演算法顯然是沒有意義的。所以在我們學習新的演算法之前,需要先搞明白,究竟做出了什麼改進,為什麼要做出這些改進。

一般來說,改進都是基於缺點和不足的,所以我們先來看看ID3演算法的一些問題。

其中最大的問題很明顯,就是它無法處理連續性的特徵。不能處理的原因也很簡單,因為ID3在每次在切分資料的時候,選擇的不是一個特徵的取值,而是一個具體的特徵。這個特徵下有多少種取值就會產生多少個分叉,如果使用連續性特徵的話,比如說我們把西瓜的直徑作為特徵的話。那麼理論上來說每個西瓜的直徑都是不同的,這樣的資料丟進ID3演算法當中就會產生和樣本數量相同的分叉,這顯然是沒有意義的。

其實還有一個問題,藏得會比較深一點,是關於資訊增益的。我們用劃分前後的資訊熵的差作為資訊增益,然後我們選擇帶來最大資訊增益的劃分。這裡就有一個問題了,這會導致模型在選擇的時候,傾向於選擇分叉比較多的特徵。極端情況下,就比如說是連續性特徵好了,每個特徵下都只有一個樣本,那麼這樣算出來得到的資訊熵就是0,這樣得到的資訊增益也就非常大。這是不合理的,因為分叉多的特徵並不一定劃分效果就好,整體來看並不一定是有利的。

針對這兩個問題,提出了改進方案,也就是說C4.5演算法。嚴格說起來它並不是獨立的演算法,只是ID3演算法的改進版本。

下面我們依次來看看C4.5演算法究竟怎麼解決這兩個問題。

資訊增益比

首先,我們來看資訊增益的問題。前面說了,如果我們單純地用資訊增益去篩選劃分的特徵,那麼很容易陷入陷阱當中,選擇了取值更多的特徵。

針對這個問題,我們可以做一點調整,我們把資訊增益改成資訊增益比。所謂的資訊增益比就是用資訊增益除以我們這個劃分本身的資訊熵,從而得到一個比值。對於分叉很多的特徵,它的自身的資訊熵也會很大。因為分叉多,必然導致純度很低。所以我們這樣可以均衡一下特徵分叉帶來的偏差,從而讓模型做出比較正確的選擇。

我們來看下公式,真的非常簡單:

這裡的D就是我們的訓練樣本集,a是我們選擇的特徵,IV(a)就是這個特徵分佈的資訊熵。

我們再來看下IV的公式:

解釋一下這裡的值,這裡的V是特徵a所有取值的集合。 自然就是每一個v對應的佔比,所以這就是一個特徵a的資訊熵公式。

處理連續值

C4.5演算法對於連續值同樣進行了優化,支援了連續值,支援的方式也非常簡單,對於特徵a的取值集合V來說,我們選擇一個閾值t進行劃分,將它劃分成小於t的和大於t的兩個部分。

也就是說C4.5演算法對於連續值的切分和離散值是不同的,對於離散值變數,我們是對每一種取值進行切分,而對於連續值我們只切成兩份。其實這個設計非常合理,因為對於大多數情況而言,每一條資料的連續值特徵往往都是不同的。而且我們也沒有辦法很好地確定對於連續值特徵究竟分成幾個部分比較合理,所以比較直觀的就是固定切分成兩份,總比無法用上好。

在極端情況下,連續值特徵的取值數量等於樣本條數,那麼我們怎麼選擇這個閾值呢?即使我們遍歷所有的切分情況,也有n-1種,這顯然是非常龐大的,尤其在樣本數量很大的情況下。

針對這個問題,也有解決的方法,就是按照特徵值排序,選擇真正意義上的切分點。什麼意思呢,我們來看一份資料:

直徑 是否甜
3
4
5 不甜
6 不甜
7
8 不甜
9 不甜

這份資料是我們隊西瓜直徑這個特徵排序之後的結果,我們可以看出來,訓練目標改變的值其實只有3個,分別是直徑5,7還有8的時候,我們只需要考慮這三種情況就好了,其他的情況可以不用考慮。

我們綜合考慮這兩點,然後把它們加在之前ID3模型的實現上就好了。

程式碼實現

光說不練假把式,我們既然搞明白了它的原理,就得自己親自動手實現以下才算是真的理解,很多地方的坑也才算是真的懂。我們基本上可以沿用之前的程式碼,不過需要在之前的基礎上做一些修改。

首先我們先來改造構造資料的部分,我們依然沿用上次的資料,學生的三門考試等級以及它是否通過達標的資料。我們認為三門成績在150分以上算是達標,大於70分的課程是2等級,40-70分之間是1等級,40分以下是0等級。在此基礎上我們增加了分數作為特徵,我們在分數上增加了一個誤差,避免模型直接得到結果。

import numpy as np
import math
def create_data():
    X1 = np.random.rand(50, 1)*100
    X2 = np.random.rand(50, 1)*100
    X3 = np.random.rand(50, 1)*100
    
    def f(x):
        return 2 if x > 70 else 1 if x > 40 else 0
    
    # 學生的分數作為特徵,為了公平,加上了一定噪音
    X4 = X1 + X2 + X3 + np.random.rand(50, 1) * 20
    
    y = X1 + X2 + X3
    Y = y > 150
    Y = Y + 0
    r = map(f, X1)
    X1 = list(r)
    
    r = map(f, X2)
    X2 = list(r)
    
    r = map(f, X3)
    X3 = list(r)
    x = np.c_[X1, X2, X3, X4, Y]
    return x, ['courseA', 'courseB', 'courseC', 'score']

由於我們需要計算資訊增益比,所以需要開發一個專門的函式用來計算資訊增益比。由於這一次的資料涉及到了連續型特徵,所以我們需要多傳遞一個閾值,來判斷是否是連續性特徵。如果是離散型特徵,那麼閾值為None,否則為具體的值。

def info_gain(dataset, idx):
    # 計算基本的資訊熵
    entropy = calculate_info_entropy(dataset)
    m = len(dataset)
    # 根據特徵拆分資料
    split_data, _ = split_dataset(dataset, idx)
    new_entropy = 0.0
    # 計算拆分之後的資訊熵
    for data in split_data:
        prob = len(data) / m
        # p * log(p)
        new_entropy += prob * calculate_info_entropy(data)
    return entropy - new_entropy


def info_gain_ratio(dataset, idx, thred=None):
    # 拆分資料,需要將閾值傳入,如果閾值不為None直接根據閾值劃分
    # 否則根據特徵值劃分
    split_data, _ = split_dataset(dataset, idx, thred)
    base_entropy = 1e-5
    m = len(dataset)
    # 計算特徵本身的資訊熵
    for data in split_data:
        prob = len(data) / m
        base_entropy -= prob * math.log(prob, 2)
    return info_gain(dataset, idx) / base_entropy, thred

split_dataset函式也需要修改,因為我們拆分的情況多了一種根據閾值拆分,通過判斷閾值是否為None來判斷進行閾值劃分還是特徵劃分。

def split_dataset(dataset, idx, thread=None):
    splitData = defaultdict(list)
    # 如果閾值為None那麼直接根據特徵劃分
    if thread is None:
        for data in dataset:
            splitData[data[idx]].append(np.delete(data, idx))
        return list(splitData.values()), list(splitData.keys())
    else:
        # 否則根據閾值劃分,分成兩類大於和小於
        for data in dataset:
            splitData[data[idx] < thread].append(np.delete(data, idx))
        return list(splitData.values()), list(splitData.keys())

前面說了我們在選擇閾值的時候其實並不一定要遍歷所有的取值,因為有些取值並不會引起label分佈的變化,對於這種取值我們就可以忽略。所以我們需要一個函式來獲取閾值所有的可能性,這個也很簡單,我們直接根據閾值排序,然後遍歷觀察label是否會變化,記錄下所有label變化位置的值即可:

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)
    # 記錄最佳增益比、特徵和閾值
    bestGain = 0.0
    feature = -1
    thred = None
    for i in range(n):
        # 判斷是否是連續性特徵,預設整數型特徵不是連續性特徵
        # 這裡只是我個人的判斷邏輯,可以自行diy
        if not dataset[0][i].is_integer():
            threds = get_thresholds(dataset, i)
            for t in threds:
                # 遍歷所有的閾值,計算每個閾值的資訊增益比
                ratio, th = info_gain_ratio(dataset, i, t)
                if ratio > bestGain:
                    bestGain, feature, thred = ratio, i, t
        else:
            # 否則就走正常特徵拆分的邏輯,計算增益比
            ratio, _ = info_gain_ratio(dataset, i)    
            if ratio > bestGain:
                bestGain = ratio
                feature, thred = i, None
    return feature, thred

到這裡,基本方法就開發完了,只剩下建樹和預測兩個方法了。這兩個方法和之前的程式碼改動都不大,基本上就是細微的變化。我們先來看建樹,建樹唯一的不同點就是在dict當中需要額外儲存一份閾值的資訊。如果是None表示離散特徵,不為None為連續性特徵,其他的邏輯基本不變。

def create_decision_tree(dataset, feature_names):
    dataset = np.array(dataset)
    # 如果都是一類,那麼直接返回類別
    counter = Counter(dataset[:, -1])
    if len(counter) == 1:
        return dataset[0, -1]
    
    # 如果只有一個特徵了,直接返回佔比最多的類別
    if len(dataset[0]) == 1:
        return counter.most_common(1)[0][0]
    
    # 記錄最佳拆分的特徵和閾值
    fidx, th = choose_feature_to_split(dataset)
    fname = feature_names[fidx]
    
    node = {fname: {'threshold': th}}
    feature_names.remove(fname)
    
    split_data, vals = split_dataset(dataset, fidx, th)
    for data, val in zip(split_data, vals):
        node[fname][val] = create_decision_tree(data, feature_names[:])
    return node

最後是預測的函式,邏輯和之前一樣,只不過加上了閾值是否為None的判斷而已,應該非常簡單:

def classify(node, feature_names, data):
    key = list(node.keys())[0]
    node = node[key]
    idx = feature_names.index(key)
    
    pred = None
    thred = node['threshold']
    # 如果閾值為None,那麼直接遍歷dict
    if thred is None:
        for key in node:
            if key != 'threshold' and data[idx] == key:
                if isinstance(node[key], dict):
                    pred = classify(node[key], feature_names, data)
                else:
                    pred = node[key]
    else:
        # 否則直接訪問
        if isinstance(node[data[idx] < thred], dict):
            pred = classify(node[data[idx] < thred], feature_names, data)
        else:
            pred = node[data[idx] < thred]
            
    # 放置pred為空,挑選一個葉子節點作為替補
    if pred is None:
        for key in node:
            if not isinstance(node[key], dict):
                pred = node[key]
                break
    return pred

總結

到這裡整個決策樹的C4.5演算法就開發完了,整體來說由於加上了資訊增益比以及連續性特徵的邏輯,所以整體的程式碼比之前要複雜一些,但是基本上的邏輯和套路都是一脈相承的,基本上沒什麼太大的變化。

決策樹說起原理來非常簡單,但是很多細節如果沒有親自做過是意識不到的。比如說連續性特徵的閾值集合應該怎麼找,比如說連續性特徵和離散型的特徵混合的情況,怎麼在程式碼當中區分,等等。只有實際動手做過,才能意識到這些問題。雖然平時也用不到決策樹這個模型,但是它是很多高階模型的基礎,吃透它對後面的學習和進階非常有幫助,如果有空,推薦大家都親自試一試。

今天的文章就到這裡,原創不易,需要你的一個關注,你的舉手之勞對我來說很重要。

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