决策树是一类常见的机器学习方法,我们可以通过对样本的属性进行一系列判断,最终决策其所属的标签类别,也就是说决策树是一类分类算法。
以西瓜书第4章决策树所给数据为例,构建决策树的过程大致为,我们每次通过选出“信息熵增益(Gain Information Entropy)”最大的属性,直到最后能够对样本标签进行预测。
一、基本概念
信息熵
信息熵 是度量样本集合纯度常用的一种指标。假定当前样本集合D中第k类标签所占的比例为
p
k
(
k
=
1
,
2
,
.
.
.
.
∣
Y
∣
)
(
1.1
)
p_{k}(k=1,2,....|Y|) \qquad(1.1)
pk(k=1,2,....∣Y∣)(1.1),则D的信息熵定义为
E
n
t
(
D
)
=
−
∑
k
=
1
∣
y
∣
p
k
l
o
g
2
p
k
Ent(D)=-\sum^{|y|}_{k=1}{p_{k}log_{2}p_{k}}
Ent(D)=−k=1∑∣y∣pklog2pk
其中,
E
n
t
(
D
)
Ent(D)
Ent(D)的值越小,那么D的纯度就越高
信息增益
假定离散属性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。我们可以根据式1.1计算出
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
)
(
1.2
)
Gain(D,a)=Ent(D)-\sum^{V}_{v=1}{\dfrac{|D^{v}|}{|D|}Ent(D^{v})} \qquad(1.2)
Gain(D,a)=Ent(D)−v=1∑V∣D∣∣Dv∣Ent(Dv)(1.2)
增益率
信息增益准则对可取值数目较多的属性有所偏好,为减少这种偏好所带来的不利影响,著名的C4.5决策树算法不直接使用信息增益,而是使用“增益率(Gain Ratio)”来选择划分最优属性。增益率采用和(1.2)式相同的符号,其公式表达如下
G
a
i
n
R
a
t
i
o
(
D
,
a
)
=
G
a
i
n
(
D
,
a
)
I
V
(
a
)
(
1.3
)
GainRatio(D,a)=\dfrac{Gain(D,a)}{IV(a)}\qquad(1.3)
GainRatio(D,a)=IV(a)Gain(D,a)(1.3)
其中,
I
V
(
a
)
=
−
∑
v
=
1
V
∣
D
v
∣
∣
D
∣
l
o
g
2
∣
D
v
∣
∣
D
∣
(
1.4
)
IV(a)=-\sum^{V}_{v=1}{\dfrac{|D_{v}|}{|D|}log_{2}\dfrac{|D^{v}|}{|D|}}\qquad(1.4)
IV(a)=−v=1∑V∣D∣∣Dv∣log2∣D∣∣Dv∣(1.4)
需要注意的是,增益率准则对可取值数目较少的属性有所偏好,因此,C4.5算法并不是直接选择增益率最大的候选划分属性,而是使用了一个启发式:先从候选划分属性中找出信息增益高出平均水平的属性
连续值
上述所有内容全是建立在数据样本特征为离散值的基础之上的,当数据的信息为连续值时,其决策树的构建与离散值还有一定的不同。其具体区别为,如果针对于某一特征a,其取值可能为{a1,a2,a3},由于是离散值,我们可以很自然的由特征a
引申出3个分支。但是针对于连续值,我们只能在已有的连续值范围(min,max)间寻找几个端点,用于分支划分。对于给定样本D和连续属性a,假定a在D上出现了n个不同的连续取值,将这些取值从小到大进行排序,记为
a
1
,
a
2
,
.
.
.
,
a
3
{a_{1},a_{2},...,a_{3}}
a1,a2,...,a3。基于划分点t,可以将D分为子集
D
v
−
和
D
v
+
D_{v}^{-}和D_{v}^{+}
Dv−和Dv+,分别包含在属性a上取值小于t和大于t的样本点。那么对于属性取值
a
i
和
a
i
+
1
a^{i}和a^{i+1}
ai和ai+1来说,t在区间
[
a
i
,
a
i
+
1
)
[a^{i},a^{i+1})
[ai,ai+1)中取任意值所产生的划分结果相同。因此,对于连续属性a,我们可考察包含n-1个元素的候选划分点集合
T
a
=
a
i
+
a
i
+
1
2
∣
1
≤
i
≤
n
−
1
(
1.7
)
T_{a}={\dfrac{a^{i}+a^{i+1}}{2}|1\le i\le n-1}\qquad(1.7)
Ta=2ai+ai+1∣1≤i≤n−1(1.7)
最终,基于连续值属性的信息增益为
G
a
i
n
(
D
,
a
)
=
m
a
x
t
∈
T
a
G
a
i
n
(
D
,
a
,
t
)
Gain(D,a)=max_{t\in T_{a}}Gain(D,a,t)
Gain(D,a)=maxt∈TaGain(D,a,t)
=
m
a
x
t
∈
T
a
E
n
t
(
D
)
−
∑
λ
∈
{
−
,
+
}
∣
D
t
λ
∣
∣
D
∣
E
n
t
(
D
t
λ
)
=max_{t \in T_{a}} Ent(D)-\sum_{\lambda\in \{-,+\}}{\dfrac{|D_{t}^{\lambda}|}{|D|}}Ent(D_{t}^{\lambda})
=maxt∈TaEnt(D)−λ∈{−,+}∑∣D∣∣Dtλ∣Ent(Dtλ)
其他功能
目前囿于时间限制,只完成了基础的离散值和连续值决策树构建,缺失值处理、剪枝等功能待后续有时间和精力继续完善…
二、决策树构建算法
决策树构建算法的伪代码如下:
输入:训练集
D
=
(
x
1
,
y
1
)
,
(
x
2
,
y
2
)
,
.
.
.
,
(
x
m
,
y
m
)
D={(x_{1},y_{1}),(x_{2},y_{2}),...,(x_{m},y_{m})}
D=(x1,y1),(x2,y2),...,(xm,ym);
属性集
A
=
a
1
,
a
2
,
.
.
.
,
a
d
A={a_{1},a_{2},...,a_{d}}
A=a1,a2,...,ad
过程:
函数TreeGenerate(D,A)
1:生成节点node;
2:if D中的样本全部属于某一个标签类别C
3: then 将node标记为C类叶节点;return
4:end if
5:if
A
=
∅
A=\emptyset
A=∅ OR D中样本在A上的取值全部相同 then
6:将node标记为叶节点,其类别标记为D中样本数量最多的类
7:end if
8:从A中选择最优划分属性a*;
9:for a的每一个值
a
v
∗
a_{v}^{*}
av∗ do:
10:为node生成一个分支;令
D
v
D_{v}
Dv表示D中在a上取值为
a
v
∗
a_{v}^{*}
av∗ 的样本子集;
11:if
D
v
D_{v}
Dv为空 then
12:将分支节点标记为叶节点,其类别标记为D中样本组多的类;return
13:else:
14:以TreeGenerate(Dv,A-a*)为分支结点(即用样本子集Dv,和去掉属性a*的属性集再衍生出新的树)
15:end if
16:end for
决策树的生成过程是一个递归函数,导致递归返回的,有三种情况:
一是样本D中的样本标签,也就是y值全都一样,那么就会生成一个叶子节点,然后返回;二是样本D中的样本各属性(或者叫特征)的值完全一样,这样的话,将该节点标记为叶节点,然后将其类别标记为D中数据量最多的标记;三是当前节点所含的样本为空,那么利用其父节点的数据中数量最多的标记来标记当前节点的标签。
那么,只要不是叶节点,都会继续向下开拓出子树。
三、代码实现
节点Node结构定义,在这里我是用一个类然后实例化对象来保存,看过大佬写的文章是用字典来保存。这里可以凭借个人喜好。我选择类的原因是调用各种参数相对比较方便,而且还可以加内置函数。
定义节点类class Node:
class Node:
def __init__(self,attribute,ifleaf,subnode,label,contivalue=None):
self.attribute=attribute
self.ifleaf=ifleaf
self.subnode=subnode
self.label=label
self.contivalue=contivalue
def UpdateAttr(self,attribute):
self.attribute=attribute
def UpdataIfleaf(self,ifleaf):
self.ifleaf=ifleaf
def UpdateSubnode(self,subnode):
self.subnode=subnode
def UpdateLabel(self,label):
self.label=label
def UpdateSubnode(self,attribute,node):
self.subnode.update({attribute:node})
定义TreeGenerate函数:
def TreeGenerate(data,string,attributes,fatherNode,layer,attrValue): #attribute为输入属性
dataNumpy=data.values
node=Node(None,False,{},None)
tempValue = attrValue
if ">" in attrValue:
fatherNode.contivalue = tempValue.replace(">", "")
elif "<" in attrValue:
fatherNode.contivalue = tempValue.replace("<", "")
labels=data[string]
labels=np.unique(np.array(labels))
# theFirstele=data[0][string]
singleClassMark=True #用来标记数据集中是否都为同一类型
singleFeatureMark=True #用来标记数据集的特征取值是否都一样
if len(labels)>1: #判断数据集中的标记是否相同
singleClassMark=False
if singleClassMark==True: #如果相同则将其标记为该类叶节点
node.ifleaf=True
node.label=data[string][0]
# return node
fatherNode.UpdateSubnode(attrValue,node)
layer+=1
G.add_node(node.label,subset=layer)
dot.node(node.label,fontname="Microsoft YaHei")
if fatherNode.attribute!=None:
G.add_edge(fatherNode.attribute,node.label,name=attrValue)
dot.edge(fatherNode.attribute,node.label,label=attrValue,fontname="Microsoft YaHei")
elif fatherNode.label!=Node:
G.add_edge(fatherNode.label,node.label,name=attrValue)
dot.edge(fatherNode.label,node.label,label=attrValue,fontname="Microsoft YaHei")
return None
for i in dataNumpy: #用来判断,数据的特征取值是否都一样
if (i!=dataNumpy[0]).any(): #只要两组数据中有一个数据不一样,那么特征取值就会不同
singleFeatureMark=False
break
if attributes==None or singleFeatureMark==True:
node.ifleaf=True
#获取数据中元素数量最多的标签
labelDivide,labelKind=DataDivided(data,string)
templabellen=0
index=0
for i in range(len(labelDivide)):
if len(labelDivide[labelKind[i]])>templabellen:
templabellen=len(labelDivide[labelKind[i]])
index=i
node.label=labelKind[index]
fatherNode.UpdateSubnode(attrValue,node)
layer+=1
G.add_node(node.label,subset=layer)
dot.node(node.label,fontname="Microsoft YaHei")
if fatherNode.attribute != None:
G.add_edge(fatherNode.attribute, node.label,name=attrValue)
dot.edge(fatherNode.attribute,node.label,label=attrValue,fontname="Microsoft YaHei")
elif fatherNode.label != Node:
G.add_edge(fatherNode.label, node.label,name=attrValue)
dot.edge(fatherNode.label,node.label,label=attrValue,fontname="Microsoft YaHei")
return None
# return node
#从A中选择最优的划分属性
attrTuple=SelBestAttr(data,attributes,string,attrValue,layer)
if len(attrTuple)==2:
bestAttr,bestEntGain=attrTuple[0],attrTuple[1]
# bestAttrValue=np.unique(data[bestAttr])
bestAttrDiv,bestAttrValue=DataDivided(data,bestAttr)
node.attribute=bestAttr
node.label=None
fatherNode.UpdateSubnode(attrValue,node)
layer += 1
G.add_node(node.attribute, subset=layer)
dot.node(node.attribute,fontname="Microsoft YaHei")
if fatherNode.attribute != None:
G.add_edge(fatherNode.attribute, node.attribute,name=attrValue)
dot.edge(fatherNode.attribute,node.attribute,label=attrValue,fontname="Microsoft YaHei")
elif fatherNode.label != Node:
G.add_edge(fatherNode.label, node.attribute,name=attrValue)
dot.edge(fatherNode.label,node.attribute,label=attrValue,fontname="Microsoft YaHei")
attributes.remove(bestAttr) #将最优特征从特征表中去除
#为最优属性展开子树
for i in range(len(bestAttrDiv)):
# if isinstance(data[bestAttr][1],str):
if len(bestAttrDiv[bestAttrValue[i]])==0:
labelDivide, labelKind = DataDivided(data, string)
templabellen = 0
index = 0
for j in range(len(labelDivide)):
if len(labelDivide[labelKind[j]]) > templabellen:
templabellen = len(labelDivide[labelKind[j]])
index = j
newNode1=Node(None,True,None,labelKind[index])
node.UpdateSubnode(bestAttrValue[i],newNode1)
layer+=1
G.add_node(newNode1.label,subset=layer)
dot.node(newNode1.label,fontname="Microsoft YaHei")
G.add_edge(node.attribute,newNode1.label,name=bestAttrValue[i])
dot.edge(node.attribute,newNode1.label,label=bestAttrValue[i],fontname="Microsoft YaHei")
else:
bestAttrDiv[bestAttrValue[i]] = pd.DataFrame(bestAttrDiv[bestAttrValue[i]], columns=data.columns)
# newNode2=Node(bestAttrValue[i],False,Node,string)
TreeGenerate(bestAttrDiv[bestAttrValue[i]],string,attributes,node,layer,bestAttrValue[i])
else:
bestAttr,bestValue,divideNum=attrTuple[0],attrTuple[1],attrTuple[2]
node.attribute = bestAttr
node.label = None
fatherNode.UpdateSubnode(attrValue, node)
layer += 1
G.add_node(node.attribute, subset=layer)
dot.node(node.attribute,fontname="Microsoft YaHei")
if fatherNode.attribute != None:
G.add_edge(fatherNode.attribute, node.attribute, name=attrValue)
dot.edge(fatherNode.attribute,node.attribute,label=attrValue,fontname="Microsoft YaHei")
elif fatherNode.label != Node:
G.add_edge(fatherNode.label, node.attribute, name=attrValue)
dot.edge(fatherNode.label,node.attribute,label=attrValue,fontname="Microsoft YaHei")
for i in range(len(divideNum)):
if len(divideNum[i])==0:
labelDivide, labelKind = DataDivided(data, string)
templabellen = 0
index = 0
for j in range(len(labelDivide)):
if len(labelDivide[labelKind[j]]) > templabellen:
templabellen = len(labelDivide[labelKind[j]])
index = j
newNode1 = Node(None, True, None, labelKind[index])
node.contivalue=bestValue
edgeName="<%f"%bestValue if i==0 else ">%f"%bestValue
node.UpdateSubnode(edgeName, newNode1)
layer += 1
G.add_node(newNode1.label, subset=layer)
dot.node(newNode1.label,fontname="Microsoft YaHei")
G.add_edge(node.attribute, newNode1.label, name=edgeName)
dot.edge(node.attribute,newNode1.label,label=edgeName,fontname="Microsoft YaHei")
else:
edgeName = "<%f" % bestValue if i == 0 else ">%f" % bestValue
TreeGenerate(divideNum[i],string,attributes,node,layer,edgeName)
计算信息熵
def InforEntCreate(data,label):
labelValue=np.unique(data[label])
len=data.shape[0]
countList=[]
for i in labelValue:
tempcount=0
for j in range(len):
if data[label][j]==i:
tempcount+=1
countList.append(tempcount)
countList=np.array(countList)
countList=countList/len
countList=-np.multiply(countList,np.log2(countList))
return np.sum(countList)
对数据进行分割
def DataDivided(data,attribute): #用于针对某一特性的数据分割
singleClassData = dataInit[attribute]
singleClassData=list(singleClassData) #将singleClassData转化为列表,从而可以使用数标引用
attrKinds = np.unique(singleClassData)
singleClassData=data[attribute] #将输入数据的某一列特征值抽取
singleClassData=list(singleClassData)
dataDivided = {} # 用于将数据分割保存
for i in attrKinds:
dataDivided.update({i:[]})
data=data.values #将dataFrame格式变成Narray形式
for i in range(len(dataDivided)):
for j in range(len(singleClassData)):
if singleClassData[j] == attrKinds[i]:
dataDivided[attrKinds[i]].append(data[j])
# for i in range(len(dataDivided)):
# dataDivided[i]=pd.DataFrame(dataDivided[i],data.columns)
return dataDivided,attrKinds
计算信息增益
def InforGainEnt(data,attribute,label):
dataDivided,dataKinds=DataDivided(data,attribute)
multiInforEnt=0
for i in range(len(dataDivided)):
dataDivided[dataKinds[i]]=pd.DataFrame(dataDivided[dataKinds[i]],columns=data.columns)
length=data.shape[0]
for i in range(len(dataDivided)):
multiInforEnt+=InforEntCreate(dataDivided[dataKinds[i]],label)*dataDivided[dataKinds[i]].shape[0]/length
#将dataFrame重组,以便保证其index从0开始
data.index=np.arange(data.shape[0])
totalEnt=InforEntCreate(data,label)
inforGain=totalEnt-multiInforEnt
return inforGain
增益率计算
def GainRatio(data,attribute,label):
length=data.shape[0]
gain=InforGainEnt(data,attribute,label)
dataDivided,attrKinds=DataDivided(data,attribute)
attrIV=0
for i in range(len(dataDivided)):
valueLen=len(dataDivided[attrKinds[i]])
if valueLen!=0:
attrIV+=-valueLen/length*log2(valueLen/length)
return gain/attrIV
选择最优划分属性
def SelBestAttr(data,attributes,label,attrValue,layer):
tempAttr=0
maxAttr=0
maxIndex=""
bestValue=0
bestDivNum=[]
tempValue=0
tempDivNum=[]
for i in attributes:
if isinstance(data[i][1],str):
tempAttr=InforGainEnt(data,i,label)
# tempAttr=GainRatio(data,i,label)
else:
tempAttr,tempValue,tempDivNum=ContiAttrSelect(data,i,label)
if tempAttr>maxAttr:
maxAttr=tempAttr
maxIndex=i
bestValue=tempValue
bestDivNum=tempDivNum
print("maxIndex:",maxIndex,"attributes",attributes,"attrValue",attrValue,"layer",layer)
if isinstance(data[maxIndex][1],str): #这里多少得有问题
return maxIndex,maxAttr
else:
return maxIndex,bestValue,bestDivNum
计算连续值信息熵
def ContiAttrSelect(data,attribute,label):
length=data.shape[0]
columns=data.columns
contiAttrs=data[attribute]
contiAttrs=np.unique(np.array(contiAttrs))
contiAttrs=np.sort(contiAttrs)
for i in range(len(contiAttrs)-1):
contiAttrs[i]=(contiAttrs[i]+contiAttrs[i+1])/2
contiAttrs=list(contiAttrs)
contiAttrs.pop(-1)
tempEnt=0
tempBest=0
bestValue=0
bestDivNum=[]
for j in contiAttrs:
divideNum=[[],[]]
for i in range(length):
if data[attribute][i]<j:
divideNum[0].append(data.loc[i])
else:
divideNum[1].append(data.loc[i])
divideNum[0]=pd.DataFrame(divideNum[0],columns=columns,index=np.arange(len(divideNum[0])))
divideNum[1]=pd.DataFrame(divideNum[1],columns=columns,index=np.arange(len(divideNum[1])))
tempEnt+=InforEntCreate(divideNum[0],label)*divideNum[0].shape[0]/length
tempEnt+=InforEntCreate(divideNum[1],label)*divideNum[1].shape[0]/length
totalEnt=InforEntCreate(data,label)
if totalEnt-tempEnt>tempBest:
tempBest=totalEnt-tempEnt
bestValue=j
bestDivNum=divideNum
tempEnt=0
return tempBest,bestValue,bestDivNum
初始化以及画图
这里我用了两种框架,一个是专门用来画图结构的networkx和另一个Graphviz,两种框架上手都比较快。networkx好像是只能以图的形式表现,如果树的结构比较复杂的话看上去就会比较乱。
G=nx.DiGraph()
dot=Digraph(format="jpg")
attributes=dataInit.columns
attributes=list(attributes)
dataNumpy=dataInit.values
global root
root=Node("root",False,{},None)
G.add_node("root",subset=0)
dot.node("root")
layer=0
TreeGenerate(dataInit,attributes[-1],attributes[:-1],root,layer,"root")
pos=nx.multipartite_layout(G)
edge_labels=nx.get_edge_attributes(G,"name")
nx.draw_networkx(G, node_size=1000, pos=pos,font_family="SimSun")
nx.draw_networkx_edge_labels(G,pos,edge_labels=edge_labels,font_family="SimSun")
pyplot.show()
dot.render("DecisionTree.jpg",view=True)
随机生成样本
原理大概就是,如果某一属性a是离散值的话,那就按照离散值{a1,a2,…,an}生成(0,n)之间的整数,然后将整数标记对应转换为相应的离散值。如果a是连续值,那就获取a属性中的最大最小值,生成(min,max)的随机浮点数。
import numpy as np
from DataProcess import dataInit,attributes
import pandas as pd
size=10000
attrlen={} # 利用numpy随机数生成字典
for i in attributes:
if isinstance(dataInit[i][1],str):
attrcol=dataInit[i]
attrcol=np.array(attrcol)
length=len(np.unique(attrcol))
attrlen.update({i:np.random.randint(length,size=size)})
else:
max=np.max(np.array(dataInit[i]))
min=np.min(np.array(dataInit[i]))
attrlen.update({i:np.random.rand(size)*(max-min)+min})
dataRan=pd.DataFrame(attrlen) #利用字典生成伪随机数dataFrame
#将随机整数对应于相应的标签
for i in attributes:
if isinstance(dataInit[i][1],str):
attrcol=dataInit[i]
attrcol=np.array(attrcol)
attrValue=np.unique(attrcol)
for j in range(dataRan.shape[0]):
dataRan[i][j]=attrValue[dataRan[i][j]]
dataRan.to_csv("NewWatermelon2.csv")
测试函数
import pandas as pd
dataTest=pd.read_csv("NewWatermelon2.csv",index_col=0)
# dataTest=dataTest.iloc[:50,:]
from AllKindsDT import root
def WatermelonEstimate(data,node):
if node.label==None and node.attribute==None:
print("This node is not a estimate node.")
if node.label==None and node.attribute!=None:
if isinstance(data[node.attribute],str):#这里要分属性是离散值还是连续值
attrValue=data[node.attribute]
return WatermelonEstimate(data,node.subnode[attrValue])
else:
contiValue=float(node.contivalue)
attrValue=">%f"%contiValue if data[node.attribute]>contiValue else "<%f"%contiValue
return WatermelonEstimate(data,node.subnode[attrValue])
if node.label!=None:
currentLabel=node.label
return currentLabel
def AccuracyCal(data,node,label):
length=data.shape[0]
accuracy=0
for i in range(data.shape[0]):
label_pred=WatermelonEstimate(data.loc[i],node)
if label_pred==data[label][i]:
accuracy+=1
accRatio=accuracy/length
return accRatio
acc=AccuracyCal(dataTest,root.subnode["root"],"好瓜")
print(acc)
西瓜书P84 表4.3数据集生成决策树结构如下
基于西瓜书数据随机生成的数据量为100的数据生成决策树如下
由于连续值属性使用后不用从特征集中删除,可以重复使用,因此连续属性在表现上会产生超多的分支,数据越多分支越多。因此,需要对决策树进行剪枝。
基于西瓜书数据再生成数据量为10000的数据集,然后在上述决策树上进行测试。
决策树-100
准确率为0.4994
决策树-17
准确率为0.5031
我们可以看到准确率基本都逼近0.5.
因为生成随机数使用的是numpy的random模块。从Numpy的官网可以找到有关于numpy.random的大概说明
即随机数生成:这个对象将BitGenerator(二进制生成器)生成的一串随机二进制转化为一串遵循特定分(均匀分布,正态分布,二项分布)布具有特定间隔的数。
因此,无论是上述分布中的哪一种,基于生成决策树数据集产生的随机数据集都是关于50%的密度值对称的,也就是说最后只有50%的值分布满足于原数据集。