本文始發於個人公眾號:TechFlow,原創不易,求個關注
今天是機器學習專題的第21篇文章,我們一起來看一個新的模型——決策樹。
決策樹的定義
決策樹是我本人非常喜歡的機器學習模型,非常直觀容易理解,並且和數據結構的結合很緊密。我們學習的門檻也很低,相比於那些動輒一堆公式的模型來說,實在是簡單得多。
其實我們生活當中經常在用決策樹,只是我們自己沒有發現。決策樹的本質就是一堆if-else的組合,舉個經典的例子,比如我們去小攤子上買西瓜。水果攤的小販都是怎么做的?拿起西瓜翻滾一圈,看一眼,然后伸手一拍,就知道西瓜甜不甜。我們把這些動作相關的因素去除,把核心本質提取出來,基本上是這么三條:
-
西瓜表面的顏色,顏色鮮艷的往往比較甜 -
西瓜拍打的聲音,聲音清脆的往往比較甜 -
西瓜是否有瓜藤,有藤的往往比較甜
這三條顯然不是平等的,因為拍打的聲音是最重要的,可能其次表面顏色,最后是瓜藤。所以我們挑選的時候,肯定也是先聽聲音,然后看瓜藤,最后看顏色。我們把其中的邏輯抽象出來然后整理一下,變成一棵樹結構,於是這就成了決策樹。

這個決策樹本質上做的還是分類的工作,將西瓜分成了甜的和不甜的。也就是說決策樹是一個樹形的分類器,這個也是決策樹的基本定義。另外從圖中我們還有一個啟示,在這個問題當中,決策樹的特征都是離散值,而不是連續值。也就是說決策樹可以接受像是類別、標識這樣非數值型的特征,而邏輯回歸這些模型則不太行。
如果你對這些細節還理解不深刻也沒有關系,我們可以先放一放,至少我們明白了決策樹的大概結構以及工作原理。
對於每一條數據來說,它分類的過程其實就是在決策樹上遍歷的過程。每到一個中間節點都會面臨一次判斷,根據判斷的結果選擇下一個子樹。而樹上的葉子節點代表一種分類,當數據到了葉子節點,這個葉子節點的值就代表它的分類結果。
決策樹的訓練
明白了決策樹的結構和工作原理之后,下面就是訓練的過程了。
在理清楚原理之前,我們先來看下數據。我們根據上面決策樹的結構,很容易發現,訓練數據應該是這樣的表格:
分類 | 聲音是否清脆 | 是否有瓜藤 | 是否有光澤 |
---|---|---|---|
甜 | 是 | 是 | 是 |
甜 | 是 | 是 | 否 |
不甜 | 否 | 否 | 是 |
不甜 | 否 | 否 | 否 |
那么最后我們想要實現什么效果呢?當然是得到的准確率越高越好,而根據決策樹的原理,樹上的每一個葉子節點代表一個分類。那么我們顯然希望最后到達葉子節點的數據盡可能純粹,舉個例子,如果一個葉子節點代表甜,那么我們肯定希望根據樹結構被划歸到這里的數據盡可能都是甜的,不甜的比例盡可能低。
那么我們怎么實現這一點呢?這就需要我們在越頂層提取規則的時候,越選擇一些區分度大的特征作為切分的依據。所謂區分度大的特征,也就是能夠將數據很好分開的特征。這是明顯的貪心做法,使用這樣的方法,我們只可以保證在盡可能高層取得盡可能好的分類結果,但是並不能保證這樣得到的模型是最優的。生成最優的決策樹本質上也是一個NP問題,我們當前的做法可以保證在盡量短的時間內獲得一個足夠優秀的解,但是沒辦法保證是最優解。
回到問題本身,我們想要用區分度大的特征來進行數據划分。要做到這一點的前提就是首先定義區分度這個概念,將它量化,這樣我們才好進行選擇。否則總不能憑感覺去衡量區分度,好在這個區分度還是很好解決的,我們只需要再一次引入信息熵的概念就可以了。
信息熵與信息增益
信息熵這個詞很令人費解,它英文原文是information entropy,其實一樣難以理解。因為entropy本身是物理學和熱力學當中的概念,用來衡量物體分散的不均勻程度。也就是說熵越大,說明物體分散得程度越大,可以簡單理解成越散亂。比如我們把房間里一盒整理好的乒乓球打翻,那么里面的乒乓球顯然會散亂到房間的各個地方,這個散亂的過程可以理解成熵增大的過程。
信息熵也是一樣的含義,用來衡量一份信息的散亂程度。熵越大,說明信息越雜亂無章,否則說明信息越有調理。信息熵出自大名鼎鼎的信息學巨著《信息論》,它的作者就是赫赫有名的香農。但是這個詞並不是香農原創,據說是計算機之父馮諾依曼取的,他去這個名字的含義也很簡單,因為大家都不明白這個詞究竟是什么意思。
之前我們曾經在介紹交叉熵的時候詳細解釋過這個概念,我們來簡單回顧一下。對於一個事件X來說,假設它發生的概率是P(X),那么這個事件本身的信息量就是:

比如說世界杯中國隊奪冠的概率是1/128,那么我們需要用8個比特才能表示,說明它信息量很大。假如巴西隊奪冠的概率是1/4,那么只要2個比特就足夠了,說明它的信息量就很小。同樣一件事情,根據發生的概率不同,它的信息量也是不同的。
那么信息熵的含義其實就是信息量的期望,也就是用信息量乘上它的概率:

同樣,假設我們有一份數據集合,其中一共有K類樣本,每一類樣本所占的比例是,那么我們把這個比例看成是概率的話,就可以寫出這整個集合的信息熵:

理解了信息熵的概念之后,再來看信息增益就很簡單了。信息增益說白了就是我們划分前后信息熵的變化量,假設我們選擇了某一個特征進行切分,將數據集D切分成了D1和D2。那么就叫做信息增益,也就是切分之后信息熵與之前的變化量。
我們根據熵的定義可以知道,如果數據變得純粹了,那么信息熵應該會減少。減少得越多,說明切分的效果越好。所以我們就找到了衡量切分效果的方法,就是信息增益。我們根據信息增益的定義,可以很簡單地理出整個決策樹建立的過程。就是我們每次在選擇切分特征的時候,都會遍歷所有的特征,特征的每一個取值對應一棵子樹,我們通過計算信息增益找到切分之后增益最大的特征。上層的結構創建好了之后, 通過遞歸的形式往下繼續建樹,直到切分之后的數據集變得純粹,或者是所有特征都使用結束了為止。
這個算法稱為ID3算法,它也是決策樹最基礎的構建算法。這里有一個小細節, 根據ID3算法的定義,每一次切分選擇的是特征,而不是特征的取值。並且被選中作為切分特征的特征的每一個取值都會建立一棵子樹,也就是說每一個特征在決策樹當中都只會最多出現一次。因為使用一次之后,這個特征的所有取值就都被使用完了。
舉個例子,比如拍打聲音是否清脆這個特征,我們在一開始就選擇了它。根據它的兩個取值,是和否都建立了一棵子樹。那么如果我們在子樹當中再根據這個特征拆分顯然沒有意義,因為子樹中的所有數據的這個特征都是一樣的。另外,ID3算法用到的所有特征必須是離散值,因為連續值無法完全切分。如果西瓜的重量是一個特征,那么理論上來說所有有理數都可能是西瓜的質量,我們顯然不可能窮盡所有的取值。
這一點非常重要,不僅關系到我們實現的決策樹是否正確,也直接關系到我們之后理解其他的建樹算法。
代碼實現
理解了算法原理和流程之后,就到了我們緊張刺激的編碼環節。老實講決策樹的算法實現並不難,比之前的FP-growth還要簡單,大家不要有壓力。
首先,我們來創造實驗數據:
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 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, Y] return x, ['courseA', 'courseB', 'courseC']
這份數據模擬的是學生考試,一共考三門,一共要考到150分以上才算是通過。由於ID3算法只能接受離散值的特征,所以我們要先將連續值轉成離散值,我們根據每一門的考試分數,生成三個檔次。大於70分的是2檔,40到70分的是1檔,小於40分的是0檔。
為了方便編碼,我們把預測值Y放在特征的最后,並且返回這三個特征的名稱,方便以后用來建樹。
我們運行一下數據查看一下結果:

下面,我們實現計算集合信息熵的函數。這個函數也很簡單,我們只需要計算出每個類別的占比,然后套用一下信息熵的公式即可。
from collections import Counter
def calculate_info_entropy(dataset): n = len(dataset) # 我們用Counter統計一下Y的數量 labels = Counter(dataset[:, -1]) entropy = 0.0 # 套用信息熵公式 for k, v in labels.items(): prob = v / n entropy -= prob * math.log(prob, 2) return entropy
有了信息熵的計算函數之后,我們接下來實現拆分函數,也就是根據特征的取值將數據集進行拆分的函數。
def split_dataset(dataset, idx):
# idx是要拆分的特征下標 splitData = defaultdict(list) for data in dataset: # 這里刪除了idx這個特征的取值,因為用不到了 splitData[data[idx]].append(np.delete(data, idx)) return list(splitData.values()), list(splitData.keys())
本質上就是根據特征取值歸類的過程,我們可以隨便調用測試一下:

和我們預期一樣,根據特征的取值將數據分成了若干份。接下來我們就要實現核心的特征的選擇函數了,也就是要選擇信息增益最大的特征對數據進行切分。
def choose_feature_to_split(dataset):
n = len(dataset[0])-1 m = len(dataset) # 切分之前的信息熵 entropy = calculate_info_entropy(dataset) bestGain = 0.0 feature = -1 for i in range(n): # 根據特征i切分 split_data, _ = split_dataset(dataset, i) new_entropy = 0.0 # 計算切分后的信息熵 for data in split_data: prob = len(data) / m new_entropy += prob * calculate_info_entropy(data) # 獲取信息增益 gain = entropy - new_entropy if gain > bestGain: bestGain = gain feature = i return feature
到這里,我們所有工具方法都已經開發完了,下面就到了我們緊張刺激的建樹部分了。建樹其實並沒有什么大不了的,無非是通過遞歸來重復調用上面的方法來創造每一個分支節點而已。如果你熟悉樹形數據結構,會發現它和其他樹形數據結構的構建過程並沒有什么兩樣。
我們來看下代碼,整個過程也只有十幾行而已。
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 = choose_feature_to_split(dataset) fname = feature_names[fidx] node = {fname: {}} feature_names.remove(fname) # 遞歸調用,對每一個切分出來的取值遞歸建樹 split_data, vals = split_dataset(dataset, fidx) for data, val in zip(split_data, vals): node[fname][val] = create_decision_tree(data, feature_names[:]) return node
我們運行一下這段代碼,會得到一份dict,這個dict當中的層次結構其實就是決策樹的結構:

我們這樣看可能不太清楚,但是我們把這個dict展開就得到了下圖的這棵樹結構:

我們觀察一下上圖當中紅圈的部分,這個節點只有兩個分叉,而其他的節點都有三個分叉。這並不是代碼有bug,而是說明數據當中缺失了這種情況,所以少了一個分叉。這其實非常正常,當我們訓練數據的樣本量不夠的時候,很有可能無法覆蓋所有的情況,就會出現這種沒有分叉的情況。
到這里雖然決策樹是實現完了,但是還沒有結束,還有一個關鍵的部分我們沒有做,就是預測。我們訓練完了,總得把模型用起來,顯然需要一個預測的函數。這個預測的函數也簡單,它介紹一條數據以及我們訓練完的樹結構,返回分類的結果。其實也是一個遞歸調用的過程:
def classify(node, feature_names, data):
# 獲取當前節點判斷的特征 key = list(node.keys())[0] node = node[key] idx = feature_names.index(key) # 根據特征進行遞歸 pred = None for key in node: # 找到了對應的分叉 if data[idx] == key: # 如果再往下依然還有子樹,那么則遞歸,否則返回結果 if isinstance(node[key], dict): pred = classify(node[key], feature_names, data) else: pred = node[key] # 如果沒有對應的分叉,則找到一個分叉返回 if pred is None: for key in node: if not isinstance(node[key], dict): pred = node[key] break return pred
我們來創造一些簡單的數據測試一下:


基本上和我們的預期一致,說明我們決策樹就實現完了。
總結
我們的決策樹雖然構建完了,但是仍然有很多不完美的地方。比如說,目前我們的模型只能接受離散值的特征,如果是連續值則無法進行拆分。而且我們每個特征只能用一次,有時候我們希望能夠多次使用同一個特征。在這種情況下ID3就無法實現了。所以我們還需要引入其他的優化。
在后序的文章當中我們將會討論這些相關的優化,以及決策樹這個模型本身的一些特性。如果對此感興趣,一定不要錯過。
喜歡的話,順手點個關注吧。