决策树,是有监督学习的一种算法,并且是一种基本的分类和回归的方法,也就是说决策树有两种:分类树和回归树。这里我们主要讨论分类树。
1.一个例子理解决策树的原理:
- 你是否玩过二十个问题的小游戏,游戏的规则很简单,参与游戏的一方在脑海中想象某一事物,其他参与者向他提问问题,只允许提问20个问题,问题的答案也只能是对或者错。问问题的人通过推断分解,逐步缩小待猜测事物的范围。
- 决策树的原理与上述的 二十个问题 的游戏类是,都只通过用户输入的一系列数据,然后找出游戏的答案。
2. 决策树相较于K近邻的优势
- K近邻可以完成很多的分类任务,但是他最大的缺点就是无法给出数据的内在含义,决策树的主要优势救赎在于数据的形式非常容易理解。
3.特点:
- 优点:计算复杂度不高:输出结果易于理解,对于中间值的缺失不敏感,可以处理不相关特征数据。
- 缺点:可能会产生过度匹配的问题。
- 适用数据类型:数值型和标称型。
决策树的构造
在构造决策树时,我们需要解决的第一个为问题就是,当前数据集上那个特征在划分数据分类时起决定性作用,为找到决定性的特征,划分出最好的效果,我们必须评估每一个特征。完成测试之后原始数据集就被分配为几个数据子集,这些数据会分布在第一个决策节点的所有分支上,如果某个分支下的数据属于同一类型,则当前的数据集已经做好了分类。就无需再对数据集进行分类,如果分支下的数据子集不属于同一分类那么就需要重复划分数据子集的功能。划分方法类似。知道所有所有具有相同类型的数据同一数据子集中。
- 决策树的一般流程
(1)收集数据:可以使用任何方法。
(2)准备数据:书构造算法只适用于标称型数据,因此数值型数据必须离散化。
(3)分析数据:可以使用任何方法,构造树完成以后,我们检查该图形是否符合预期。
(4)训练算法:构造树的数据结构。
(5)测试算法:使用经验树计算错误率。
(6)使用算法:此步骤可以适用于任何监督学习的算法,而使用决策树可以更好的理解数据的内在含义。
1. 特征选择
特征选择就是决定用那个特征来划分特征空间,其目的在于选取对数据具有分类能力较好的特征,这样可以提高决策树的学习效率。如果利用一个特征进行分类的结果和随机分类的结果没有很大差别,则称这个特征是没有分类特征能力的经验上丢弃这些特征对决策树学习的精度影响不会很大。
那么如何选择最优的特征来划分呢?一般而言随着划分过程的不断进行,我们希望决策树的分支节点所包含的样本尽量属于同一类别,也就是说节点的纯度越来越高。
- 在现实生活中,我们衡量的往往不是纯度而是不纯度,衡量不纯度的指标有很多,比如熵、信息增益、基尼指数。我们这里主要讨论熵,也叫香农熵。这名字来源于信息论之父 克劳德·香农。
1.1. 香农熵及其计算公式
- 熵定义为信息的期望值。
- 假定当前样本集合D中一共有n类样本,第i类样本为
x
i
x_i
xi,那么
x
i
x_i
xi的信息定义为:
l ( x i ) = − l o g 2 P ( x i ) l(x_i) = - log_2P(x_i) l(xi)=−log2P(xi)
其中 p ( x i ) p(x_i) p(xi)是选择该分类的概率。
为了计算信息熵,我们需要计算所有类别所有可能值包含的信息期望值,通过下面的公式可以得到:
E n t ( D ) = − ∑ i = 1 n p ( x i ) l o g 2 p ( x i ) Ent(D) = - \sum_{i=1}^{n}p(x_i)log_2p(x_i) Ent(D)=−i=1∑np(xi)log2p(xi)
H的值越小,则D的不纯度越低。(熵越小,不纯度就越低)
香农熵的python代码如下:
'''
函数功能:计算香农熵
参数说明:datsSet:原始数据集(DataFrame格式最后一列为标签列)
返回:ent:香农熵的值
'''
def calEnt(dataSet):
n = dataSet.shape[0] #数据集总行数
iset = dataSet.iloc[:,-1].value_counts() #标签的所有类别(的数目)
p = iset/n #每一类标签所占比
ent = (-p*np.log2(p)).sum() #计算信息熵(np模块)
return ent
以书上海洋生物数据为例,我们来构建数据集,并计算香农熵。
NO. | no surfacing | flippers | fisl |
---|---|---|---|
1 | 1 | 1 | yes |
2 | 1 | 1 | yes |
3 | 1 | 0 | no |
4 | 0 | 1 | no |
5 | 0 | 1 | no |
#创建数据集
import numpy as np
import pandas as pd
def createDataSet():
row_data = {'no surfacing':[1,1,1,0,0],
'flippers':[1,1,0,1,1],
'fish':['yes','yes','no','no','no']}
dataSet = pd.DataFrame(row_data) # 将字典的数据集转化为DataFrame格式
return dataSet
dataSet = createDataSet()
dataSet
no surfacing | flippers | fish | |
---|---|---|---|
0 | 1 | 1 | yes |
1 | 1 | 1 | yes |
2 | 1 | 0 | no |
3 | 0 | 1 | no |
4 | 0 | 1 | no |
# 计算原始数据集的香农熵
calEnt(dataSet)
0.9709505944546686
1.2. 信息增益
信息增益的计算公式其实就是父节点的信息熵与其下所有子节点总信息熵之差。但是这里要注意的是此时计算子节点的总信息熵不能简单求和,而要求在求和汇总之前进行修正。
假设离散属性a有V个可能取值
a
1
,
a
2
,
.
.
.
.
.
.
,
a
V
{a^1,a^2,......,a^V}
a1,a2,......,aV,若使用a对样本数据集D进行划分,则会产生V个分支节点,其中第V个分支节点包含了D中所有在属性a熵取值
a
v
a^v
av的样本,记为
D
v
D^v
Dv。我们可以根据信息熵的计算公式计算出
D
v
D^v
Dv的信息熵,再考虑到不同的分支节点所包含的样本数不同,给分支节点赋予权重
∣
D
v
∣
/
∣
D
∣
|D^v|/|D|
∣Dv∣/∣D∣,这就是所谓的修正。
所以信息增益的计算公式为:
G
a
i
n
(
D
,
a
)
=
E
n
t
(
D
)
−
∑
v
=
1
V
∣
D
V
∣
∣
D
∣
E
n
t
(
D
v
)
Gain(D,a) = Ent(D) - \sum_{v=1}^{V}\frac{|D^V|}{|D|}Ent(D^v)
Gain(D,a)=Ent(D)−v=1∑V∣D∣∣DV∣Ent(Dv)
那我们手动计算一下海洋生物数据中第0列的信息增益:
\begin{aligned}
Gain(‘no surfacing’) &= Ent(D) - [\frac{3}{5}Ent(D_1)+\frac{2}{5}Ent(D_2)] \
&= calEnt(dataSet) - [\frac{3}{5}(-\frac{2}{3}log_2\frac{2}{3}-\frac{1}{3}log_2\frac{1}{3})+\frac{2}{5}(-\frac{2}{2}log_2\frac{2}{2})] \
&= 0.97-0.55 \
&=0.42
\end{aligned}
a = (3/5)*(-(2/3)*np.log2(2/3)-(1/3)*np.log2(1/3))
calEnt(dataSet) - a
0.4199730940219749
同样的我们可以计算出第一列的信息增益,结果为0.17
2.1数据集最佳切分函数
划分数据集的最大准则是选择最大的信息增益也就是,也就是信息下降最快的方向。
"""
函数功能:根据信息增益选择出最佳数据集切分的列
参数说明:
dataSet:原始数据集
返回:
axis:数据集最佳切分列的索引
"""
#选择最优的列进行切分
def bestSplit(dataSet):
baseEnt = calEnt(dataSet) #计算原始熵
bestGain = 0 #初始化信息增益
axis = -1 #初始化最佳切分列,标签列
for i in range(dataSet.shape[1]-1): #对特征的每一列进行循环
levels= dataSet.iloc[:,i].value_counts().index #提取出当前列的所有取值
ents = 0 #初始化子节点的信息熵
for j in levels: #对当前列的每一个取值进行循环
childSet = dataSet[dataSet.iloc[:,i]==j] #某一个子节点的dataframe
ent = calEnt(childSet) #计算某一个子节点的信息熵
ents += (childSet.shape[0]/dataSet.shape[0])*ent #计算当前列的信息熵
#print(f'第{i}列的信息熵为{ents}')
infoGain = baseEnt-ents #计算当前列的信息增益
# print(f'第{i}列的信息增益为{infoGain}')
if (infoGain > bestGain):
bestGain = infoGain #选择最大信息增益
axis = i #最大信息增益所在列的索引___________
return axis
bestSplit(dataSet)
0
通过上面的计算我们知道,第0列的信息增益为0.42,第1列的信息为0.17,0.42>0.17,所以我们应选择第0列进行切分数据集。
3.1按照给定列划分数据集
通过最佳切分函数返回最佳切分列的索引,我们就可以根据这个做因,构建一个暗战给定列切分数据集的函数
"""
函数功能:按照给定列划分数据集
参数说明:
dataSet:原始数据集
axis:指定列的索引
values:指定的属性值
返回:
redataSet:按照指定索引和属性值切分后的数据集
"""
def mySplit(dataSet,axis,value):
col = dataSet.columns[axis] # 提取出特征的属性名
redataSet = dataSet.loc[dataSet[col]==value,:].drop(col,axis=1)# 删除第0列的特征 选取第 axis列 属性值value的样本
return redataSet
验证函数,以axis=0,value=1为例
mySplit(dataSet,axis = 0, value = 1)
flippers | fish | |
---|---|---|
0 | 1 | yes |
1 | 1 | yes |
2 | 0 | no |
3.1. 递归构建决策树
以上都是构造决策树算法所需要的子功能模块,其工作原理如下:得到原始数据集,然后基于最好的属性值划分数据集,由于特征值可能多余两个,因此可能存在大于两个分支的数据集划分。第一次划分之后数据集被向下传递到树的分支的下一个结点,在这个结点上,我们可以再次划分数据。因此我们可以采用递归的原则处理数据集。
决策树生成算法递归的产生决策树,直到不能继续下去为止。这样产生的树往往对训练数据的分类很准确,但对未知的测试数据的分类却没有那么准确。即出现过拟合现象。过拟合现象产生的原因在于在学习时过多的考虑如何提高对训练数据的正确分类,从而构建了过于复杂的决策树。解决这个问题的办法是考虑决策树的复杂度对已生成的决策树进行简化,也就是常常说的剪枝处理。剪枝处理多用于回归树。
3.2. ID3算法
构建决策树的算法有很多,如ID3、C4.5和CART,基于《机器学习实战》这本书,我们选择ID3算法。
ID3算法的核心是在决策树各个节点上对应信息增益准则选择特征,递归的构建决策树。具体方法是:从根节点开始,对节点计算所有可能的特征的信息增益,选则信息增益最大的特征作为节点的特征,由该特征的不同取值建立子节点;再对子节点递归的调用以上方法,构建决策树;直到所有特征的信息增益均很小或没有特征可以选择未知。最后得到一个决策树。
递归结束的条件是:程序遍历完所有的特征列,或则每个分支下的所有实例都具有相同的分类。如果所有实例都具有相同的分类,则得到一个叶节点。任何到达叶节点的数据必然属于叶节点的分类,即叶节点里面不许是标签。
3.3 编写代码构建决策树
"""
函数功能:基于最大信息增益切分数据集,递归构建决策树
参数说明:
dataSet:原始数据集(最后一列是标签)
返回:
myTree:字典形式的树
"""
def createTree(dataSet):
featlist = list(dataSet.columns) #提取出数据集所有的列(属性名)
classlist = dataSet.iloc[:,-1].value_counts() #获取最后一列类标签
#判断最多标签数目是否等于数据集行数,或者数据集是否只有一列
if classlist[0]==dataSet.shape[0] or dataSet.shape[1] == 1:
return classlist.index[0] #如果是,返回类标签(纯度最高标签列都一样/只有标签列)///递归结束的条件
axis = bestSplit(dataSet) #确定出当前最佳切分列的索引
bestfeat = featlist[axis] #获取该索引对应的特征 根节点的名字
myTree = {bestfeat:{}} #采用字典嵌套的方式存储树信息
del featlist[axis] #删除当前特征
valuelist = set(dataSet.iloc[:,axis]) #提取最佳切分列所有属性值(zet去重复值)
for value in valuelist: #对每一个属性值递归建树
myTree[bestfeat][value] = createTree(mySplit(dataSet,axis,value))#dataSet是切分后的结果
return myTree
myTree = createTree(dataSet)
myTree
{'no surfacing': {0: 'no', 1: {'flippers': {0: 'no', 1: 'yes'}}}}
树的存储
构造决策树是很耗费时间的任务,即使处理很小的数据集,也要花费几秒的时间,如果数据集很大就会耗费很多的计算时间。因此为了节省时间,建好树后立马将其保存,后续使用直接调用即可。
我们这里使用的是numpy里的savea()函数,他乐意将字典形式的数据保存为.npy文件,调用的时候直接向hi用load()函数即可。
# 树的存储
np.save('mytree.npy',myTree)
# 树的读取
read_myTree = np.load('myTree.npy').item()
read_myTree
{'no surfacing': {0: 'no', 1: {'flippers': {0: 'no', 1: 'yes'}}}}
使用决策树执行分类
"""
函数说明:对一个测试实例进行分类
参数说明:
inputTree:已经生成的决策树
lables:存储选择的最有特征标签
testVec:测试集数据列表,特征顺序对应原始数据集
返回:
classLable:分类结果
"""
def classify(inputTree,labels, testVec):
firstStr = next(iter(inputTree)) #获取决策树第一个节点 // iter生成迭代器对象;next返回迭代器下一个项目
secondDict = inputTree[firstStr] #下一个字典 根节点下的字典
featIndex = labels.index(firstStr) #第一个节点所在列的索引(测试集与训练集索引一致)
for key in secondDict.keys():
if testVec[featIndex] == key:
if type(secondDict[key]) == dict : # 判断是否为字典类型
classLabel = classify(secondDict[key], labels, testVec)
else:
classLabel = secondDict[key]
return classLabel
对测试集所有数据进行分类,并计算准确率
"""
函数功能:对测试集进行预测,并返回预测后的结果
参数说明:
train:训练集
teat:测试机
返回:
test:预测好分类的测试机
"""
def acc_classify(train,test):
inputTree = createTree(train) #根据测试集生成一棵树 //前面的生成树的算法 //load
labels = list(train.columns) #数据集所有的列名称 //
result = []
for i in range(test.shape[0]): #对测试集中每一条数据进行循环
testVec = test.iloc[i,:-1] #测试集中的一个实例
classLabel = classify(inputTree,labels,testVec) #预测该实例的分类
result.append(classLabel) #将分类结果追加到result列表中
test['predict']=result #将预测结果追加到测试集最后一列
acc = (test.iloc[:,-1]==test.iloc[:,-2]).mean() #计算准确率
print(f'模型预测准确率为{acc}')
return test
测试函数
train = dataSet # 训练集
test = dataSet.iloc[:3,:] # 测试集
acc_classify(train,test)
模型预测准确率为1.0
f:\python3\lib\site-packages\ipykernel_launcher.py:17: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
no surfacing | flippers | fish | predict | |
---|---|---|---|---|
0 | 1 | 1 | yes | yes |
1 | 1 | 1 | yes | yes |
2 | 1 | 0 | no | no |
#导入相应的包
from sklearn import tree
from sklearn.tree import DecisionTreeClassifier
import graphviz
#特征
Xtrain = dataSet.iloc[:,:-1]
#标签
Ytrain = dataSet.iloc[:,-1]
labels = Ytrain.unique().tolist()
Ytrain = Ytrain.apply(lambda x: labels.index(x)) #将本文转换为数字
#绘制树模型
clf = DecisionTreeClassifier()
clf = clf.fit(Xtrain, Ytrain)
tree.export_graphviz(clf)
dot_data = tree.export_graphviz(clf, out_file=None)
graphviz.Source(dot_data)
#给图形增加标签和颜色
dot_data = tree.export_graphviz(clf, out_file=None,
feature_names=['no surfacing', 'flippers'],
class_names=['fish', 'not fish'],
filled=True, rounded=True,
special_characters=True)
graphviz.Source(dot_data)
#利用render方法生成图形
graph = graphviz.Source(dot_data)
graph.render("fish")
'fish.pdf'