【Python機器學習實戰】決策樹和整合學習(二)——決策樹的實現

Uniqe發表於2021-08-25

摘要:上一節對決策樹的基本原理進行了梳理,本節主要根據其原理做一個邏輯的實現,然後呼叫sklearn的包實現決策樹分類。

 


  這裡主要是對分類樹的決策進行實現,演算法採用ID3,即以資訊增益作為劃分標準進行。

  首先計算資料集的資訊熵,程式碼如下:

 1 import math
 2 import numpy as np
 3 
 4 
 5 def calcShannonEnt(data):
 6     num = len(data)
 7     # 儲存每個類別的數目
 8     labelCounts = {}
 9     # 每一個樣本
10     for featVec in data:
11         currentLabel = featVec[-1]
12         if currentLabel not in labelCounts.keys():
13             labelCounts[currentLabel] = 0
14         labelCounts[currentLabel] += 1
15     # 計算資訊增益
16     shannonEnt = 0
17     for key in labelCounts.keys():
18         prob = float(labelCounts[key] / num)
19         shannonEnt -= prob * math.log(prob)
20     return shannonEnt

  然後是依據某個特徵的特徵值將資料劃分開的函式:

def splitData(dataSet, axis, value):
    """
    axis為某一特徵維度
    value為劃分該維度的值
    """
    retDataSet = []
    for featVec in dataSet:
        if featVec[axis] == value:
            # 捨棄掉這一維度上對應的值,剩餘部分作為新的資料集
            reducedFeatVec = featVec[:axis]
            reducedFeatVec.extend(featVec[axis+1:])
            retDataSet.append(reducedFeatVec)
    return retDataSet

  這個函式是依據選取的某個維度的某個值,分割後的資料,比如:

>>data
Out[1]: [[1, 1, 'yes'], [1, 1, 'yes'], [1, 0, 'no'], [0, 1, 'no'], [0, 1, 'no']]
>>splitData(data, 0, 1)
Out[2]: [[1, 'yes'], [1, 'yes'], [0, 'no']]
>>splitData(data, 0, 0)
Out[3]: [[1, 'no'], [1, 'no']]

  接下來就是從資料中選取資訊增益最大的特徵了,輸入是資料集,返回資訊增益最大的特徵的index,程式碼如下:

# 選擇最好的特徵進行資料劃分
# 輸入dataSet為二維List
def chooseBestFeatuerToSplit(dataSet):
    # 計算樣本所包含的特徵數目
    numFeatures = len(dataSet[0]) - 1
    # 資訊熵H(Y)
    baseEntropy = calcShannonEnt(dataSet)
    # 初始化
    bestInfoGain = 0; bestFeature = -1
    # 遍歷每個特徵,計算資訊增益
    for i in range(numFeatures):
        # 取出對應特徵值,即一列資料
        featList = [example[i] for example in dataSet]
        uniqueVals = np.unique(featList)
        newEntropy = 0
        for value in uniqueVals:
            subDataSet = splitData(dataSet, i, value)
            prob = len(subDataSet)/float(dataSet)
            newEntropy += prob * calcShannonEnt(subDataSet)
        # 計算資訊增益G(Y, X) = H(Y) - sum(H(Y|x))
        infoGain = baseEntropy - newEntropy
        if infoGain > bestInfoGain:
            bestInfoGain = infoGain
            bestFeature = i
    return bestFeature

  接下來就是遞迴上面的程式碼,構建決策樹了,上節提到,遞迴結束的條件一般是遍歷完所有特徵屬性,或者到某個分支下僅有一個類別了,則得到一個葉子節點。但有時即使我們已經處理了所有的屬性,但是在葉子節點時依舊沒能將資料完全分開,在這種情況下,通常採用多數表決的方法決定該葉子節點所屬類別。

def majorityCnt(classList):
    classCount = {}
    for vote in classList:
        if vote not in classCount.keys():
            classCount[vote] = 0
        classCount[vote] += 1
    # 按統計個數進行倒序排序
    sortedClassCount = sorted(classCount.items(), key=lambda item: item[1], reverse=True)
    return sortedClassCount[0][0]

  然後就可以建立一顆決策樹了,程式碼如下:

def creatTree(dataSet, labels):
    """
    labels為特徵的標籤列表
    """
    classList = [example[-1] for example in dataSet]
    # 如果data中的都為同一種類別,則停止,且返回該類別
    if classList.count(classList[0]) == len(classList):
        return classList[0]
    # 如果資料集中僅剩類別這一列了,即特徵使用完,仍沒有分開,則投票
    if len(dataSet[0]) == 1:
        return majorityCnt(classList)
    bestFeat = chooseBestFeatuerToSplit(dataSet)
    bestFeatLabel = labels[bestFeat]
    # 初始化樹,用於儲存樹的結構,是很多字典的巢狀結構
    myTree = {bestFeatLabel: {}}
    # 已經用過的特徵刪去
    del (labels[bestFeatLabel])
    # 取出最優特徵這一列的值
    featVals = [example[bestFeat] for example in dataSet]
    # 特徵的取值個數
    uniqueVals = np.unique(featVals)
    # 開始遞迴分裂
    for value in uniqueVals:
        subLabels = labels[:]
        myTree[bestFeatLabel][value] = creatTree(splitData(dataSet, bestFeat, value), subLabels)
    return myTree

  這樣一顆決策樹就構建完成了,使用上面那個小量的資料測試一下:

>>data
Out[1]: [[1, 1, 'yes'], [1, 1, 'yes'], [1, 0, 'no'], [0, 1, 'no'], [0, 1, 'no']]
>>labels
Out[2]: ['no surfacing', 'flippers']
>>creatTree(data, labels)
Out[3]: {'no surfacing': {0: 'no', 1: {'flippers': {0: 'no', 1: 'yes'}}}}

   接下來要根據所建立的決策樹,對新的樣本進行分類,同樣用到遞迴的方法:

def classify(inputTree, featLabels, testVec):
    # 自上而下搜尋預測樣本所屬類別
    firstStr = inputTree.key()[0]
    secondDict = inputTree[firstStr]
    featIndex = featLabels.index(firstStr)
    for key in secondDict.keys():
        # 按照特徵的位置確定搜尋方向
        if testVec[featIndex] == key:
            if type(secondDict[key]).__name__ == 'dict':
                # 若下一級結構還是dict,遞迴搜尋
                classLabel = classify(secondDict, featLabels, testVec)
            else:
                classLabel = secondDict[key]
    return classLabel

  同時,還有對決策樹的視覺化,下面直接給出畫圖的程式碼

decisionNode = dict(boxstyle='sawtooth', fc="0.8")
leafNode = dict(boxstyle='round4', fc="0.8")
arrow_args = dict(arrowstyle='<-')


def plotNode(nodeTxt, centerPt, parentPt, nodeType):
    createPlot.ax1.annotate(nodeTxt, xy=parentPt, xycoords='axes fraction', xytext=centerPt, textcoords='axes fraction',
                            va="center", ha="center", bbox=nodeType, arrowprops=arrow_args)


def createPlot(inTree):
    fig = plt.figure(1, facecolor='white')
    fig.clf()
    axprops = dict(xticks=[], yticks=[])
    createPlot.ax1 = plt.subplot(111, frameon=False)
    plotTree.totalW = float(getNumLeafs(inTree))
    plotTree.totalD = float(getTreeDepth(inTree))
    plotTree.xOff = -0.5/plotTree.totalW
    plotTree.yOff = 1.0
    plotTree(inTree, (0.5, 1.0), '')
    # plotNode('a decision node', (0.5, 0.1), (0.1, 0.5), decisionNode)
    # plotNode('a leaf node', (0.8, 0.1), (0.3, 0.8), leafNode)
    plt.show()

def getNumLeafs(myTree):
    numLeafs = 0
    firstStr = list(myTree.keys())[0]
    secondDict = myTree[firstStr]
    for key in list(secondDict.keys()):
        if type(secondDict[key]).__name__ == 'dict':
            numLeafs += getNumLeafs(secondDict[key])
        else:
            numLeafs += 1
    return numLeafs


def getTreeDepth(myTree):
    maxDepth = 0
    firstStr = list(myTree.keys())[0]
    secondDict = myTree[firstStr]
    for key in list(secondDict.keys()):
        if type(secondDict[key]).__name__ == 'dict':
            thisDepth = 1 + getTreeDepth(secondDict[key])
        else:
            thisDepth = 1
        if thisDepth > maxDepth:
            maxDepth = thisDepth
    return maxDepth


def retrieveTree(i):
    listOfTrees = [{'no surfacing': {0: 'no', 1: {'flippers': {0: 'no', 1: 'yes'}}}},
                   ]
    return listOfTrees[i]


def plotMidText(cntrPt, parentPt, txtString):
    xMid = (parentPt[0] - cntrPt[0])/2.0 + cntrPt[0]
    yMid = (parentPt[1] - cntrPt[1])/2.0 + cntrPt[1]
    createPlot.ax1.text(xMid, yMid, txtString)


def plotTree(myTree, parentPt, nodeTxt):
    numLeafs = getNumLeafs(myTree)
    depth = getTreeDepth(myTree)
    firstStr = list(myTree.keys())[0]
    cntrPt = (plotTree.xOff + (1.0 + float(numLeafs))/2.0/plotTree.totalW, plotTree.yOff)
    plotMidText(cntrPt, parentPt, nodeTxt)
    plotNode(firstStr, cntrPt, parentPt, decisionNode)
    secondDict = myTree[firstStr]
    plotTree.yOff = plotTree.yOff - 1.0/plotTree.totalD
    for key in list(secondDict.keys()):
        if type(secondDict[key]).__name__ == 'dict':
            plotTree(secondDict[key], cntrPt, str(key))
        else:
            plotTree.xOff = plotTree.xOff + 1.0/plotTree.totalW
            plotNode(secondDict[key], (plotTree.xOff, plotTree.yOff), cntrPt, leafNode)
            plotMidText((plotTree.xOff, plotTree.yOff), cntrPt, str(key))
    plotTree.yOff = plotTree.yOff + 1.0/plotTree.totalD

  執行createPlot函式,即可得到決策樹的視覺化,同樣運用上面那個簡單的資料集:

  上面就是決策樹的一個簡單實現過程,下面我們運用“隱形眼鏡資料集”對上面的模型進行測試,部分資料如下:

   前四列是樣本特徵,最後一列為樣本類別,運用上邊的資料集,對模型進行測試:

fr = open('lenses.txt')
lenses = [inst.strip().split('\t') for inst in fr.readlines()]
lenses_labels = ['age', 'prescript', 'astigmatic', 'tearRate']
lenses_Tree = creatTree(lenses, lenses_labels)
createPlot(lenses_Tree)

  接下來就是對樹的剪枝操作,這裡主要方法是通過剪枝生成所有可能的樹,然後利用測試集,選擇最好的樹(錯誤率最低)的樹出來,首先建立用預測資料正確率和投票節點的函式:

def testing(myTree, data_test, labels):
    error = 0.0
    for i in range(len(data_test)):
        classLabel = classify(myTree, labels, data_test[i])
        if classLabel != data_test[i][-1]:
            error += 1
    return float(error)


# 測試投票節點正確率
def testingMajor(major, data_test):
    error = 0.0
    for i in range(len(data_test)):
        if major[0] != data_test[i][-1]:
            error += 1
    # print 'major %d' %error
    return float(error)

  然後同樣採用遞迴的方法,產生所有可能的決策樹,然後捨棄掉錯誤率較高的樹:

def postPruningTree(inTree, dataSet, test_data, labels):
    """
    :param inTree: 原始樹
    :param dataSet:資料集
    :param test_data:測試資料,用於交叉驗證
    :param labels:標籤集
    """
    firstStr = list(inTree.keys())[0]
    secondDict = inTree[firstStr]
    classList = [example[-1] for example in dataSet]
    labelIndex = labels.index(firstStr)
    temp_labels = copy.deepcopy(labels)
    del (labels[labelIndex])
    for key in list(secondDict.keys()):
        if type(secondDict[key]).__name__ == 'dict':
            if type(dataSet[0][labelIndex]).__name__ == 'str':
                subDataSet = splitData(dataSet, labelIndex, key)
                subDataTest = splitData(test_data, labelIndex, key)
                if len(subDataTest) > 0:
                    inTree[firstStr][key] = postPruningTree(secondDict[key], subDataSet, subDataTest, copy.deepcopy(labels))
    if testing(inTree, test_data, temp_labels) < testingMajor(majorityCnt(classList), temp_labels):
        return inTree
    return majorityCnt(classList)

  至此,一個簡單的ID3決策樹已經實現完成了,僅作為演算法的理解過程,這裡沒有考慮連續型資料的處理問題,以及演算法中很多不合理的地方。下面就使用python自帶的sklearn進行決策樹的建立,同時使用另一個比較著名的資料集——“紅酒資料集”(資料集連結放在末尾)對建模過程進行了解。

 首先匯入決策樹所需的包:

# 匯入決策樹包
from sklearn.tree import DecisionTreeClassifier
# 畫圖工具包
import matplotlib.pyplot as plt
import seaborn as sns
sns.set(color_codes=True)
# 匯入資料處理的包
from sklearn.model_selection import train_test_split
# 模型評估
from sklearn import metrics
from sklearn.metrics import accuracy_score, f1_score, recall_score, precision_score
import missingno as msno_plot

然後是讀取資料,並用describe函式對資料做一個初步的檢視:

# 讀取紅酒資料
wine_df =pd.read_csv('F:\自學2020\PythonML_Code\Charpter 3\winequality-red.csv', sep=';')
# 檢視資料, 資料有11個特徵,類別為quality
wine_df.describe().transpose().round(2)

從統計樣本count一列來看資料無缺失值,為更直觀顯示,畫出缺失值直方圖,如下:

plt.title('Non-missing values by columns')
msno_plot.bar(wine_df)

 接下來就是異常值的檢查,通過每一列資料的箱型圖來檢視是否存在偏離較遠的異常值:

# 通過箱型圖檢視每一列的箱型圖
plt.figure()
pos = 1
for i in wine_df.columns:
    plt.subplot(3, 4, pos)
    sns.boxplot(wine_df[i])
    pos += 1

接下來處理異常值,在案例中採用樣本的四分之一分位數、四分之三中位陣列成的IQR四分位數間的範圍來對偏離較遠的點進行修正,程式碼如下:

# 處理缺失值
columns_name = list(wine_df.columns)
for name in columns_name:
    q1, q2, q3 = wine_df[name].quantile([0.25, 0.5, 0.75])
    IQR = q3 - q1
    lower_cap = q1 - 1.5*IQR
    upper_cap = q3 + 1.5*IQR
    wine_df[name] = wine_df[name].apply(lambda x: upper_cap if x > upper_cap else (lower_cap if (x<lower_cap) else x))

 然後看下資料兩兩之間的相關性,sns.pairplot()是展現涼涼之間的線性、非線性和相關等關係。http://seaborn.pydata.org/generated/seaborn.pairplot.html

sns.pairplot(wine_df)

進一步檢視兩個變數相關性和關係,注意:在決策樹中不需要刪除高度相關的特徵,因為節點只使用一個獨立變數被劃分為子節點,因此,即使兩個變數高度相關,產生最高資訊增益的變數也會用於分析。檢視變數之間的相關性程式碼如下:

plt.figure(figsize=(10, 8))
sns.heatmap(wine_df.corr(), annot=True, linewidths=.5, center=0, cbar=False, cmap='YlGnBu')

 同時,分類問題對於類別的分佈情況比較敏感,因此需要檢視quality中各個類別的分佈情況:

plt.figure(figsize=(10, 8))
sns.countplot(wine_df['quality'])

  注意到這裡類別中存在3.5連續型數值,要對其進行特殊處理,這裡直接刪去這一部分樣本即可,因為樣本量較少,可以看到類別分佈相對不是很平衡,因此需要將類別平衡,通過將類別“quality”屬性的值組合產生(或者其他的方法):

wine_df = wine_df[wine_df['quality'] != 3.5]
wine_df = wine_df[wine_df['quality'] != 7.5]
wine_df['quality'] = wine_df['quality'].replace(8, 7)
wine_df['quality'] = wine_df['quality'].replace(3, 5)
wine_df['quality'] = wine_df['quality'].replace(4, 5)
wine_df['quality'].value_counts(normalize=True)

  接下來就是將資料分為訓練集和測試兩部分,測試集是為了檢查模型的正確性和準確性,看是否欠擬合或者過擬合:

X_train, X_test, Y_train, Y_test = train_test_split(wine_df.drop(['quality'], axis=1), wine_df['quality'], test_size=0.3, random_state=22)
print(X_train.shape, X_test.shape)
Output:(1119, 11) (480, 11)

  然後就是確定一個模型,模型及引數詳解如下,具體引數解釋可參考:https://www.cnblogs.com/hgz-dm/p/10886368.html

model = DecisionTreeClassifier(criterion='gini', random_state=100, max_depth=3, min_samples_leaf=5)
"""
criterion:度量函式,包括gini、entropy等
class_weight:樣本權重,預設為None,也可通過字典形式制定樣本權重,如:假設樣本中存在4個類別,可以按照 [{0: 1, 1: 1}, {0: 1, 1: 5}, 
             {0: 1, 1: 1}, {0: 1, 1: 1}] 這樣的輸入形式設定4個類的權重分別為1、5、1、1,而不是 [{1:1}, {2:5}, {3:1}, {4:1}]的形式。
             該引數還可以設定為‘balance’,此時系統會按照輸入的樣本資料自動的計算每個類的權重,計算公式為:n_samples/( n_classes*np.bincount(y)),
             其中n_samples表示輸入樣本總數,n_classes表示輸入樣本中類別總數,np.bincount(y) 表示計算屬於每個類的樣本個數,可以看到,
             屬於某個類的樣本個數越多時,該類的權重越小。若使用者單獨指定了每個樣本的權重,且也設定了class_weight引數,則系統會將該樣本單獨指定
             的權重乘以class_weight指定的其類的權重作為該樣本最終的權重。
max_depth: 設定樹的最大深度,即剪枝,預設為None,通常會限制最大深度防止過擬合一般為5~20,具體視樣本分佈來定
splitter: 節點劃分策略,預設為best,還可以設定為random,表示最優隨機劃分,一般用於資料量較大時,較小運算量
min_sample_leaf: 指定的葉子結點最小樣本數,預設為1,只有劃分後其左右分支上的樣本個數不小於該引數指定的值時,才考慮將該結點劃分也就是說,
                 當葉子結點上的樣本數小於該引數指定的值時,則該葉子節點及其兄弟節點將被剪枝。在樣本資料量較大時,可以考慮增大該值,提前結束樹的生長。
random_state: 當splitter設定為random時,可以通過該引數設計隨機種子數
min_sample_split: 對一個內部節點劃分時,要求該結點上的最小樣本數,預設為2
max_features: 劃分節點時,所允許搜尋的最大的屬性個數,預設為None,auto表示最多搜尋sqrt(n)個屬性,log2表示最多搜尋log2(n)個屬性,也可以設定整數;
min_impurity_decrease :打算劃分一個內部結點時,只有當劃分後不純度(可以用criterion引數指定的度量來描述)減少值不小於該引數指定的值,才會對該
                       結點進行劃分,預設值為0。可以通過設定該引數來提前結束樹的生長。
min_impurity_split : 打算劃分一個內部結點時,只有當該結點上的不純度不小於該引數指定的值時,才會對該結點進行劃分,預設值為1e-7。該引數值0.25
                     版本之後將取消,由min_impurity_decrease代替。
"""

接下來就是利用訓練資料對模型進行訓練:

model.fit(X_train, Y_train)

然後就是檢視模型,並對畫出訓練出來的決策樹(這裡卡了很久,網上有很多解決辦法,在一臺電腦成功顯示,但另一臺總有問題):

 

  然後檢視模型在訓練集和測試集上的準確率:

test_labels = model.predict(X_test)
train_labels = model.predict(X_train)
print('測試集上的準確率為%s'%accuracy_score(Y_test, test_labels))
print('訓練集上的準確率為%s'%accuracy_score(Y_train, train_labels))
測試集上的準確率為0.6101694915254238
訓練集上的準確率為0.6014558689717925

  檢視每個特徵對於樣本分類的重要性程度:

feat_importance = model.tree_.compute_feature_importances(normalize=False)
feat_imp_dict = dict(zip(feature_cols, model.feature_importances_))
feat_imp = pd.DataFrame.from_dict(feat_imp_dict, orient='index')
feat_imp.rename(columns={0: 'FeatureImportance'}, inplace=True)
feat_imp.sort_values(by=['FeatureImportance'], ascending=False).head()
Output:
                                 FeatureImportance
alcohol                        0.507726
sulphates                      0.280996
total sulfur dioxide           0.190009
volatile acidity               0.021269
fixed acidity                  0.000000

  前面建模時有提到一些引數如max_depth、min_samples_leaf等引數決定樹的提前終止來防止過擬合,而在實際應用中想要找出最佳的一組引數並不容易(但也不是不可能,可以通過GridSearchCV的方法對模型進行模型),另一種在上一節中提到的後剪枝演算法,即確定不同的α值,找出最優的決策樹,下面看一下α值的變化與資料資料不純度的變化關係:

path = model.cost_complexity_pruning_path(X_train, Y_train)
ccp_alphas, impurities = path.ccp_alphas, path.impurities

fig, ax = plt.figure(figsize=(16, 8))
ax.plot(ccp_alphas[:-1], impurities[:-1], marker='o', drawstyle='steps-post')
ax.set_xlabel('effective alpha')
ax.set_ylabel('total impurity of leaves')

# 根據不同的alpha生成不同的樹並儲存
clfs = []
for ccp_alpha in ccp_alphas:
    clf = DecisionTreeClassifier(random_state=0, ccp_alpha=ccp_alpha)
    clf.fit(X_train, Y_train)
    clfs.append(clf)
# 刪去最後一個元素,因為最後只有一個節點
clfs = clfs[:-1]
ccp_alphas = ccp_alphas[:-1]

# 檢視樹的總節個點數和樹的深度隨alpha的變化
node_counts = [clf.tree_.node_count for clf in clfs]
depth = [clf.tree_.max_depth for clf in clfs]
fig, ax = plt.subplot(2, 1)
ax[0].plot(ccp_alphas, node_counts, marker='o', drawstyle='steps-post')
ax[0].set_xlabel('alpha')
ax[0].set_ylabel('number of nodes')
ax[0].set_title("Number of nodes vs alpha")
ax[1].plot(ccp_alphas, depth, marker='o', drawstyle='steps-post')
ax[1].set_xlabel('alpha')
ax[1].set_ylabel('depth of Tree')
ax[1].set_title("Depth vs alpha")
fig.tight_layout()

# 檢視不同樹的訓練誤差和測試誤差變化關係
train_scores = [clf.score(X_train, Y_train) for clf in clfs]
test_scores = [clf.score(X_test, Y_test) for clf in clfs]

fig, ax = plt.subplots()
ax.plot(ccp_alphas, train_scores, marker='o', label='train', drawstyle='steps-post')
ax.plot(ccp_alphas, test_scores, marker='o', label='test', drawstyle='steps-post')
ax.set_xlabel('alpha')
ax.set_ylabel('accuracy')
ax.legend()
plt.show()

  根據上述比較,可以選出最優的α和對應的模型:

i = np.arange(len(ccp_alphas))
ccp = pd.DataFrame({'Depth': pd.Series(depth, index=i), 'Node': pd.Series(node_counts, index=i),
                    'ccp': pd.Series(ccp_alphas, index=i), 'train_scores': pd.Series(train_scores, index=i),
                    'test_scores': pd.Series(test_scores, index=i)})
ccp.tail()
best = ccp[ccp['test_scores'] == ccp['test_scores'].max()]

 

 

 參考文獻:

  官方文件:http://seaborn.pydata.org/generated/seaborn.pairplot.html

  部落格:https://www.cnblogs.com/panchuangai/p/13445819.html

  部落格:https://www.cnblogs.com/hgz-dm/p/10886368.html

  官方文件:https://scikit-learn.org/stable/auto_examples/tree/plot_cost_complexity_pruning.html

 


至此,一個簡單的決策樹案例就算完成了,在實現的過程中也踩了很多坑,有的由於軟體和package版本的問題,沒有調通,後面再進一步去調,上面建模過程也是其他機器學習的一個較為通用建模流程,不過在資料的處理過程中根據需求有所不同,在此有了一個初步的瞭解。接下來就是由決策樹延伸至整合學習的相關內容了。

相關文章