决策树的基本概念
决策树是一类常用的机器学习方法,决策树实现决策的过程和我们平时做决定的过程很相似。想想如果自己马上要放假,要不要出去浪就是个大问题,首先考虑老板交代的接近deadline的项目有没有完成,如果完成了就可以放心大胆的浪了,否则就乖乖磕研吧;任务完成了,但是转念一想,最近剁手太多没钱,算了还是宅着省钱吧;突然发现发工资了,有钱浪了,赶紧看看天气预报,如果假期天气不错果断室外放飞自我,如果不适合出行那就找一些室内活动……如下图所示,是不是有种if else
的感觉。
决策树的工作过程可以用类似于上图这样的流程图来表示。其中长方形代表判断模块(decision block),代表决策的判断条件;椭圆形表示终止模块(terminating block),表示决策的结论;此外从判断模块引出的箭头表示分支(branch),它指向另一个判断模块或是终止模块。如果将上面的流程图看成一棵树,每个判断模块就是一个节点,终止模块就是叶子节点,这样从根节点开始进行判断一直到叶子节点就形成了一条决策路径。显然,决策树是一种可解释的模型。
决策树的构造
决策树的决策过程比较容易理解,那么怎么构造一棵有效的决策树呢?正如程序中的判断条件的嵌套关系不能乱,构造决策树的时候也是遵循一定的规则的。想想一下我们平时做决策的时候,总是先考虑最重要的因素。构建决策树的思想也类似,我们通过划分数据集得到信息增益最高的特征并优先考虑它。下面首先介绍一下香农熵和信息增益。
1. 香农熵和信息增益
香农熵(Shannon Entropy)又叫做信息熵,是由香农提出的来自于信息论中的一个概念,用来衡量信息的无序程度(纯度)。简单来说,越是无序的数据,其包含的信息越多,其纯度越低,反之,越是有序的数据其纯度越高。假设当前样本集合为 D D D,其中第 k k k 类样本所占的比例为 p k , ( k = 1 , 2 , . . . , ∣ y ∣ ) p_k, (k=1,2,...,|y|) pk,(k=1,2,...,∣y∣),|y|表示样本类别数。则数据集 D D D 的香农熵定义为 E n t ( D ) = − ∑ k = 1 ∣ y ∣ p k l o g 2 p k (1) Ent(D)=-\sum_{k=1}^{|y|}p_{k}log_2p_k \tag{1} Ent(D)=−k=1∑∣y∣pklog2pk(1) E n t ( D ) Ent(D) Ent(D) 的值越小,则数据集 D D D 的纯度越高。接下来以一个短小精悍的数据集为例计算其香农熵:
#!/usr/bin/python3
# -*- coding: utf-8 -*-
'''
@Date : 2019/9/29
@Author : Rezero
'''
from math import log
def calShannonEnt(dataSet):
numEntries = len(dataSet) # 样本数量
labelCounts = {}
for featVec in dataSet:
currentLabel = featVec[-1] # 样本向量的最后一个元素是对应的标签
# 对每一类样本进行计数,存储为字典
if currentLabel not in labelCounts.keys():
labelCounts[currentLabel] = 0
labelCounts[currentLabel] += 1
shannonEnt = 0 # 计算信息熵
for key in labelCounts.keys():
prob = labelCounts[key] / numEntries # 类别为key的样本的比例(概率)
shannonEnt -= prob * log(prob, 2)
return shannonEnt
dataSet = [[1, 1, 'yes'],
[1, 1, 'yes'],
[1, 0, 'no'],
[0, 1, 'no'],
[0, 1, 'no']]
entropy = calShannonEnt(dataSet)
print(entropy)
上面的数据集前两列为特征,最后一列字符串为类别标签。运行上面这段程序可以得到以下输出结果
接下来我们网数据集中加入另一个样本,再计算数据集的香农熵:
dataSet.append([0, 0, 'maybe'])
运行结果如下,加入新样本后由于增加了新的类别导致数据集的熵增大,意味着其纯度降低。
说完了香农熵,接下来说说信息增益。对于样本的某个特征属性
a
a
a, 假定它是离散的,并且在整个数据集上
a
a
a 可能的取值为
a
1
,
a
2
,
.
.
.
,
a
V
{a^1,a^2,...,a^V}
a1,a2,...,aV,那么如果我们根据属性
a
a
a的取值对数据集进行划分就可以得到
V
V
V 个不同的子集,每个子集包含了所有在属性
a
a
a 上取值为
a
v
a^v
av 的样本。这样,利用特征属性
a
a
a 对数据集
D
D
D 划分所产生的信息增益可以由下式计算
G
a
i
n
(
D
,
a
)
=
E
n
t
(
D
)
−
∑
v
=
1
V
∣
D
v
∣
∣
D
∣
E
n
t
(
D
v
)
(2)
Gain(D, a)=Ent(D)-\sum_{v=1}^{V}\frac{\left|D^v\right|}{|D|}Ent(D^v) \tag{2}
Gain(D,a)=Ent(D)−v=1∑V∣D∣∣Dv∣Ent(Dv)(2)
其中
∣
D
v
∣
∣
D
∣
\frac{\left|D^v\right|}{\left|D\right|}
∣D∣∣Dv∣ 表示属性
a
a
a 取值为
a
v
a^v
av 的样本所占的比例。令上式取得最大值的属性就是从当前数据集划分出节点的属性,代码如下:
# 得到最优划分特征的索引
def chooseBestFeatureToSplit(dataSet):
numFeatures = len(dataSet[0]) - 1 # 特征数
baseEntropy = calShannonEnt(dataSet) # 整个数据集的原始香农熵
bestInfoGain = 0 # 最大的信息增益
bestFeature = -1 # 最优特征的索引
for i in range(numFeatures):
featureList = [sample[i] for sample in dataSet] # 每个样本的第i个特征组成的列表
uniqueVals = set(featureList) # 去重复,得到第i个特征的所有可能值
# 根据当前特征的不同值划分数据集并计算信息熵
newEntropy = 0
for value in uniqueVals:
subDataSet = splitDataSet(dataSet, i, value)
prob = len(subDataSet) / len(dataSet)
newEntropy += prob * calShannonEnt(subDataSet)
infoGain = baseEntropy - newEntropy # 当前划分的信息增益
# 得到最大信息增益对应的划分特征的索引
if infoGain > bestInfoGain:
bestFeature = i
bestInfoGain = infoGain
return bestFeature
上面的函数输入参数为待划分数据集,函数通过计算按不同特征属性划分数据集得到的信息增益,最后返回使得信息增益最大的特征的索引。对应的特征就是决策树的一个判断节点。上面程序中splitDataSet
函数是根据当前特征的取值从数据集中划分出子集,具体代码如下:
def splitDataSet(dataSet, axis, values):
'''
:param dataSet: 原始数据集
:param axis: 当前特征的索引
:param values: 当前的特征取值
:return:
'''
retDataset = []
# 遍历数据集,找出所有当前维特征值和传入参数的特征值相同的样本
for featVec in dataSet:
if featVec[axis] == values:
reduceFeatVec = featVec[:axis]
reduceFeatVec.extend(featVec[axis+1:]) # 去掉当前维特征构成新样本
retDataset.append(reduceFeatVec) # 符合条件的样本构成子集
return retDataset
2. 决策树的构建
决策树的构建是一个递归的过程。上面介绍了信息增益,我们每次计算使当前数据集
划分信息增益最大的特征属性,就得到一个判断节点,由于特征取值是离散的,那么根据不同的特征值就可以得到不同的数据子集,再分别对各个数据子集进行信息增益计算和划分,一旦某个数据子集中的所有样本属于同一类,那么对应的类别就成为表示终止模块的叶节点;此外,如果已经遍历完所有的特征属性但是仍存在包含不止一类样本的子集,那么选择其中数目最多的类别数作为叶节点。
以西瓜数据集2.0为例,首先对整个数据集按照西瓜的不同特征进行划分计算信息增益,计算过程略。最终得到取得最大信息增益的划分属性为“纹理”,那么纹理就作为一个判断模块(同时也是决策树的根节点),同时,根据“纹理”的不同取值将数据集可以分成三个子集,进而通过同样的方式划分三个数据子集,最终构完整的决策树。第一次计算信息增益得到最优划分属性后结果如下图所示,子节点中的数字代表样本编号。
![](https://i-blog.csdnimg.cn/blog_migrate/29c35a1438a278d2433a0eb7d3dca990.jpeg)
构造决策树的代码如下:
def createTree(dataSet, features):
'''
递归的方式创建决策树
:param dataSet: 数据集
:param features: 特征标签/特征名称的列表
:return: 决策树(用字典存储)
'''
classList = [example[-1] for example in dataSet] # 类别列表
# 递归停止条件一:所有的类别标签相同,此时所有样本被划分到同一类
if classList.count(classList[0]) == len(classList):
return classList[0]
# 递归停止条件2:已经遍历完了所有特征,仍没有得到有效划分。len(dataSet[0])=1原因是遍历完所有特征后每一个样本仅剩类别标签
if len(dataSet[0]) == 1:
return majorityCnt(classList) # 此时返回出现次数最多的类别
bestFeat = chooseBestFeatureToSplit(dataSet)
bestFeatLabel = features[bestFeat] # 最好分类特征对应的特征标签
myTree = {bestFeatLabel:{}}
del(features[bestFeat]) # 当前特征从特征列表里删除
featValues = [example[bestFeat] for example in dataSet]
uniqueValues = set(featValues) # 当前特征的所有特征值
for value in uniqueValues:
subFeatures = features[:]
# 递归地构建子树
myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet, bestFeat, value), subFeatures)
return myTree
其中majorityCnt
函数用于当遍历完所有特征后仍有子集中包含多类样本的情况。此时找出子集中类别计数最多的类别。
利用决策树实现西瓜数据集分类
西瓜数据集2.0就不过多介绍了,下面的就是基于决策树的西瓜数据集2.0分类程序的代码:
#!/usr/bin/python3
# -*- coding: utf-8 -*-
'''
@Date : 2019/9/29
@Author : Rezero
'''
import operator
from math import log
import pickle
import treePlotter
def loadData(filepath):
dataSet = []
with open(filepath, encoding='utf-8') as fr:
for line in fr.readlines():
sample = line.strip().split('\t')
dataSet.append(sample)
features = dataSet[0][:-1] # 第一行为特征标签
del(dataSet[0])
return dataSet, features
# 计算数据集的香农熵
def calShannonEnt(dataSet):
numEntries = len(dataSet) # 样本数量
labelCounts = {}
for featVec in dataSet:
currentLabel = featVec[-1] # 样本向量的最后一个元素是对应的标签
# 对每一类样本进行计数,存储为字典
if currentLabel not in labelCounts.keys():
labelCounts[currentLabel] = 0
labelCounts[currentLabel] += 1
shannonEnt = 0 # 计算信息熵
for key in labelCounts:
prob = labelCounts[key] / numEntries
shannonEnt -= prob * log(prob, 2)
return shannonEnt
def splitDataSet(dataSet, axis, values):
'''
:param dataSet: 原始数据集
:param axis: 当前特征的索引
:param values: 当前的特征值
:return:
'''
retDataset = []
# 遍历数据集,找出所有当前维特征值和传入参数的特征值相同的样本
for featVec in dataSet:
if featVec[axis] == values:
reduceFeatVec = featVec[:axis]
reduceFeatVec.extend(featVec[axis+1:]) # 去掉当前维特征构成新样本
retDataset.append(reduceFeatVec) # 符合条件的样本构成子集
return retDataset
# 得到最优划分特征的索引
def chooseBestFeatureToSplit(dataSet):
numFeatures = len(dataSet[0]) - 1 # 特征数
baseEntropy = calShannonEnt(dataSet) # 整个数据集的原始香农熵
bestInfoGain = 0 # 最大的信息增益
bestFeature = -1 # 最优特征的索引
for i in range(numFeatures):
featureList = [sample[i] for sample in dataSet] # 每个样本的第i个特征组成的列表
uniqueVals = set(featureList) # 去重复,得到第i个特征的所有可能值
# 根据当前特征的不同值划分数据集并计算信息熵
newEntropy = 0
for value in uniqueVals:
subDataSet = splitDataSet(dataSet, i, value)
prob = len(subDataSet) / len(dataSet)
newEntropy += prob * calShannonEnt(subDataSet)
infoGain = baseEntropy - newEntropy # 当前划分的信息增益
# 得到最大信息增益对应的划分特征的索引
if infoGain > bestInfoGain:
bestFeature = i
bestInfoGain = infoGain
return bestFeature
def majorityCnt(classList):
classCount = {}
for vote in classList:
if vote not in classCount: # 用字典存储每一类的计数
classCount[vote] = 0
classCount[vote] += 1
# 将每一类的计数按升序排序并返回计数最多的类别标签
sortedClassCount = sorted(classCount.items(), key=operator.itemgetter(1), reverse=True)
return sortedClassCount[0][0]
def createTree(dataSet, features):
'''
递归的方式创建决策树
:param dataSet: 数据集
:param features: 特征标签/特征名称的列表
:return: 决策树(用字典存储)
'''
classList = [example[-1] for example in dataSet] # 类别列表
# 递归停止条件一:所有的类别标签相同,此时所有样本被划分到同一类
if classList.count(classList[0]) == len(classList):
return classList[0]
# 递归停止条件2:已经遍历完了所有特征,仍没有得到有效划分。len(dataSet[0])=1原因是遍历完所有特征后每一个样本仅剩类别标签
if len(dataSet[0]) == 1:
return majorityCnt(classList) # 此时返回出现次数最多的类别
bestFeat = chooseBestFeatureToSplit(dataSet)
bestFeatLabel = features[bestFeat] # 最好分类特征对应的特征标签
myTree = {bestFeatLabel:{}}
del(features[bestFeat]) # 当前特征从特征列表里删除
featValues = [example[bestFeat] for example in dataSet]
uniqueValues = set(featValues) # 当前特征的所有特征值
for value in uniqueValues:
subFeatures = features[:]
# 递归地构建子树
myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet, bestFeat, value), subFeatures)
return myTree
def storeTree(inputTree, fileName):
with open(fileName,'wb') as fp:
pickle.dump(inputTree, fp)
def grabTree(fileName):
with open(fileName, 'rb') as f:
tree = pickle.loads(f.read())
return tree
def classify(inputTree, features, testVec):
'''
根据树的结构对输入样本进行分类
:param inputTree: 决策树的结构
:param features: 特征标签列表
:param testVec: 输入的测试样本特征向量
:return: 分类结果
'''
firstStr = list(inputTree.keys())[0] # 决策树的第一个特征属性
secondDict = inputTree[firstStr]
featIndex = features.index(firstStr)
classLabel = None
for key in secondDict.keys(): # 遍历子树
if testVec[featIndex] == key:
if type(secondDict[key]).__name__ == 'dict': # 说明当前结点还有子树
classLabel = classify(secondDict[key], features, testVec)
else: # 遍历到叶子节点,则叶子结点就是分类结果返回
classLabel = secondDict[key]
return classLabel
if __name__ == "__main__":
dataSet, features = loadData('data/watermelon2_0.txt')
myTree = createTree(dataSet, features.copy())
treePlotter.createPlot(myTree)
print(myTree)
storeTree(myTree, 'myTree.txt')
loadTree = grabTree('myTree.txt')
print(loadTree)
vector = ['青绿', '蜷缩', '沉闷', '稍糊', '稍凹', '硬滑']
result = classify(myTree, features, vector)
print(result)
几个主要的函数在前面已经说明,另外就是以下几个点单独说明一下:
1. 决策树的存储和读取
构造决策树是很耗时的,特别是对于大型的数据集。对于同一个分类任务,如果我们已经从训练集构建出了决策树,就可以将其保存下次处理同样的分类任务时只需要加载模型而不是重新训练。上面程序中的storeTree
和grabTree
分别就是保存和读取决策树的函数。
2. 绘制决策树的结构
决策树是可解释的模型,可以将其结构画出来看看构建的决策树到底长啥样。上面程序中的treePlotter.createPlot(myTree)
就是绘制训练出来的决策树的结构。代码抄自《机器学习实战》,自己也看的半清不楚的。对于西瓜数据集2.0最终构造出的决策树如下:
对比上图和西瓜书里的决策树基本一样,只是少了一个色泽为“浅白”的分支,原因是数据集中不存在纹理为清晰、根蒂为稍蜷且色泽为浅白的瓜,导致在生成树的时候少了一个叶。
sklearn决策树预测隐形眼镜类型
自己动手实践完接下来再试试现成的工具包吧——sklearn中的tree模块就是决策树模块。下面以隐形眼镜数据集lenses为例来上手试试。关于sklearn.tree.DecisionTreeClassifier的具体介绍可以看官方文档或者这篇文章,介绍的很详细。
有现成的工具包似乎很简单就是加载数据、传入参数、进行预测三步走嘛。然而,我们读取lenses数据集看看:
特征是字符串类型的数据,直接传入DecisionTreeClassifier.fit函数发现报错了,因此,需要将特征转化为数值类型,sklearn也准备好工具了——sklearn.preprocessing.LabelEncoder,它是个简单的特征转换工具,将可以标签值统一转换成range(标签值个数-1)范围内的整数。由于lenses数据集的特征都是标签型数据,构造决策树分类器的过程也不涉及归一化等操作,因此用LabelEncodr编码就可以了。转换后的数据集特征如下所示:
现在就可以将特征和标签传入fit函数进行训练了。完整代码如下
'''
@Date : 2019/10/1
@Author : Rezero
'''
from pandas import read_csv
from sklearn.preprocessing import LabelEncoder
from sklearn import tree
def loadData(fileName):
df = read_csv(fileName, sep='\t')
dataSet = df.values[:, :-1]
labels = df.values[:, -1]
le = LabelEncoder()
for i in range(dataSet.shape[1]):
dataSet[:, i] = le.fit_transform(dataSet[:, i])
print(dataSet)
return dataSet, labels
if __name__ == "__main__":
dataSet, labels = loadData('data/lenses.txt')
decisionTree = tree.DecisionTreeClassifier(max_depth=4)
decisionTree = decisionTree.fit(dataSet, labels)
clas = decisionTree.predict([[0, 0, 1, 1]])
print(clas)
程序运行结果就不展示了,重点是原理和工具的使用。
总结
1. 决策树的类型
文中构造用于西瓜数据集分类的决策树称为ID3算法,除此之外还有CART和C4.5,实际应用中CART和C4.5是最流行的两种算法,sklearn.tree.DecisionTreeClassifier默认使用的就是CART算法构建决策树。他们的树妖区别在于划分数据集的标准,CART使用基尼不纯度而C4.5使用增益率来作为划分数据集的主要依据。
2. 决策树分类器的优缺点
优点:决策树的计算复杂度不高,输出的结果也具有解释性,对于中间值的确实不敏感,可以处理不相关特征的数据,能够应用到离散型和数值型的数据(上面例子只展示了用到离散型数据,因为ID3算法无法直接处理连续特征的数据)。
缺点:直接从数据构建决策树容易出现过拟合的结果,因此在实际构建决策树的时候有一个重要的过程——剪枝,顾名思义就是裁剪掉一些结点从而消除过度匹配的问题。后面如果总结CART树会详细介绍。
参考资料
周志华老师《机器学习》
《机器学习实战》第三章:决策树