本文为《机器学习实战》学习笔记
决策树可以从数据集合汇总提取一系列的规则,创建规则的过程就是机器学习的过程。在构造决策树的过程中,不断选取特征划分数据集,直到具有相同类型的数据均在数据子集内。
由于不同属性的数据类型不同,其对应的测试条件也不同。即非叶子节点的每条出边代表的含义不同。
二元属性产生两个可能的输出。
标称属性具有多个属性值。可以根据属性值的数量产生多路划分,每个出边代表一个属性值;对于只产生二元划分的算法(CART),可以创建k个属性值二元划分的所有 2(k−1)−1 种划分方法。
序数属性在保证序数属性值有序性的同时,可以产生二元或多路划分。
连续属性可以通过比较测试进行二元划分,必须考虑所有可能的划分点;也可以设定范围区间多路划分,必须考虑所有可能的连续区间。
划分数据集的最大原则是把无序数据变得有序。可以使用信息论量化度量信息的内容。
如果待分类的事务可能划分在多个分类中,则符号 xi 的信息定义为:
在决策树的构建过程中,使用贪心的策略和递归的方法。每次在所有的属性中选择能够最好的划分数据集的属性,将数据集划分为不同的子集,判断子集是否为叶子节点,如果是确定叶子节点的类标号并结束递归,否则对子集递归调用该方法继续划分。
决策树过大容易受过拟合的影响,可以通过剪枝减小树的规模,提高树的泛化能力。
1)非参数方法,不需要先验假设;
2)NP完全问题,经常采用启发式方法搜索假设空间;
3)可快速建立模型,对未知样本分类快,时间复杂度为树的深度;
4)对噪声干扰鲁棒性高;
5)冗余属性不会对准确率造成不利影响。不相关属性可能使决策树过于庞大,因此特征选择技术有助于提高决策树的准确率;
6)子树可能重复多次,因为采用分治算法,对不同的数据子集可能采用相同的属性划分;
7)涉及单个属性的测试条件不一定能很好地划分数据集,可以组合多个属性划分
8)不纯性度量对决策树算法的影响很小,因为度量方法相互一致。
优点:
计算复杂度不高。输出结果易于理解,对缺失值不敏感,可以处理不相关的特征数据。
缺点:
可能会产生过拟合问题。
模型训练误差小,检验误差大时产生过拟合;模型训练误差和检验误差都大产生欠拟合。
噪声、代表性样本缺失、包含大量的候选属性和少量训练记录的多重比较都会导致过拟合。
通常通过控制模型的复杂度解决过拟合问题。理想的复杂度是产生最低泛化误差的模型的复杂度。
1)结合模型复杂度估计
训练误差对泛化误差的估计过于乐观。通常会结合模型的复杂度估计。奥卡姆剃刀原则认为:两个具有相同泛华误差的模型,较简单的模型比较复杂的模型更可取。
1)先剪枝
构建决策树时,设置限制条件,当信息增益或者估计的泛化误差的改进低于某个确定阈值时,停止扩展叶子节点。
该方法避免产生过拟合的复杂子树,但阈值选取困难,太高导致欠拟合,太低导致过拟合。
2)后剪枝
初始决策树按照最大规模生长,然后自底向上修剪决策树。
可以用叶子节点替换子树。叶子节点的类别为子树中出现次数最多的类别。也可以用子树中出现最多的分支代替子树。
后剪枝的结果更好,但剪枝后浪费了生长完全决策树的额外计算。
针对每个特征,计算以该特征划分数据集前后的熵,得到信息增益,并选择信息增益最高的特征进行划分。
定义数据集的香农熵计算方法:
#定义香农熵的计算方法
from math import log
def calcShannonEnt(dataSet):
numEntries = len(dataSet) #计算数据集中实例总数
labelCounts = {} #创建数据字典,键值为标签
#将所有可能分类加入字典
for featVec in dataSet: #遍历数据集中的每一项,featVec可用任意字符表示,局部变量而已
currentLabel = featVec[-1] #标签为最后一列
#更新不同标签对应的实例数
if currentLabel not in labelCounts.keys():
labelCounts[currentLabel] = 0
labelCounts[currentLabel] += 1
#计算信息熵
shannonEnt = 0.0
for key in labelCounts:
prob = float(labelCounts[key])/numEntries
shannonEnt -= prob * log(prob, 2)
return shannonEnt
定义划分数据集的方法,得到指定特征为特定值的数据集:
#按照给定特征划分数据集
def splitDataSet(dataSet, axis, value): #按照axis对应的特征值为value对数据进行划分
retDataSet = [] #由于python函数传引用,在函数内对列表对象的修改会影响该对象的整个生命周期,
#为了不修改原始数据集,声明一个新列表对象
for featVec in dataSet:
if featVec[axis] == value: #将符合条件的项保存并返回,去掉axis列
reducedFeatVec = featVec[:axis]
reducedFeatVec.extend(featVec[axis+1:]) #扩展得到两个列表所有元素
retDataSet.append(reducedFeatVec) #为结果数据集添加新的列表
return retDataSet
遍历数据集,循环计算香农熵和划分数据集函数,找到最好的特征划分方式;
#选择最好的数据集划分方式,返回最好划分方式对应的特征
def chooseBestFeatureToSplit(dataSet):
numFeature = len(dataSet[0]) - 1 #计算特征个数
baseEntropy = calcShannonEnt(dataSet) #计算原始数据集的信息熵
#初始化最佳信息增益和对应特征所在的列
bestInfoGain = 0.0
bestFeature = -1
#对所有特征计算划分后的信息增益,选择最好的特征
for i in range(numFeature):
featList = [example[i] for example in dataSet] #特征i的取值列表,使用列表推导实现
uniqueVals = set(featList) #特征i的取值类型
newEntropy = 0.0
for value in uniqueVals: #统计每种类型的划分结果并计算信息熵
subDataSet = splitDataSet(dataSet, i, value)
prob = len(subDataSet)/float(len(dataSet))
newEntropy += prob * calcShannonEnt(subDataSet)
infoGain = baseEntropy - newEntropy #得到当前特征的信息增益
if infoGain > bestInfoGain: #更新最佳划分
bestInfoGain = infoGain
bestFeature = i
return bestFeature
构建决策树时,使用递归的思想,对于给定数据集,选择最好的特征将数据集划分为若干子集,对于每个子集,可递归调用该方法再次划分。递归结束的条件为程序遍历完所有的属性或者每个分支下所有的实例都有相同的分类。如果所有实例具有相同的分类,即得到叶子节点。
如果程序已经遍历完所有的属性,但位于该节点的实例的类别标签不统一,则使用多数表决的方式决定该叶子节点的类别。
#多数表决
#当处理完所有的属性后,类标签仍不唯一,通过多数表决定义叶子节点
def majorityCnt(classList):
classCount = {} #数据字典存储每个类标签出现的频率
for vote in classCount:
if vote not in classCount.keys():
classCount[vote] = 0
classCount[vote] += 1
#按照标签出现的频率降序排序
sortedClassCount = sorted(classCount.iteritems(), key = operator.itemgetter(1), reverse = True)
return sortedClassCount[0][0] #返回出现次数最多的标签
构建决策树时,输入数据集和包含数据集所有特征的标签
#递归创建决策树
def createTree(dataSet, labels):
classList = [example[-1] for example in dataSet] #创建包含数据集所有类别的列表变量
if classList.count(classList[0]) == len(classList): #类别完全相同,得到叶子节点,返回类标签
return classList[0]
if len(dataSet[0]) == 1: #处理完所有属性后类标签不唯一,使用多数表决决定类标签
return majorityCnt(classList)
bestFeat = chooseBestFeatureToSplit(dataSet) #选择最好的划分属性
bestFeatLabel = labels[bestFeat]
myTree = {bestFeatLabel: {}} #使用字典保存树结构
del(labels[bestFeat]) #删除已选择的最优特征,更新特征标签列表
featValues = [example[bestFeat] for example in dataSet] #得到特征列所有值的列表
uniqueVals = set(featValues)
for value in uniqueVals: #对每种类型的值,递归构建决策树
subLabels = labels[:]
myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet, bestFeat, value), subLabels)
return myTree
使用注解绘制节点:
import matplotlib.pyplot as plt
from pylab import *
mpl.rcParams['font.sans-serif'] = ['SimHei'] #动态设置添加中文黑体
mpl.rcParams['axes.unicode_minus'] = False #更改字体导致显示不出负号,所以设置为true,保证负号的显示
#定义文本框和箭头格式
decisionNode = dict(boxstyle = "sawtooth", fc = "0.8")
leafNode = dict(boxstyle = "round4", fc = "0.3")
arrow_args = dict(arrowstyle = "<-")
#绘制带箭头的注解
def plotNode(nodeTxt, centerPt, parentPt, nodeType): #文本,center坐标,父节点坐标,节点类型
createPlot.ax1.annotate(nodeTxt, xy = parentPt, xycoords = 'axes fraction',\
xytext = centerPt, textcoords = 'axes fraction', \
va = "center", ha = "center", bbox = nodeType, arrowprops = arrow_args)
matplotlib.pyplot.annotate(*args, **kwargs)使用文本s注释节点xy。s为注释内容,字符串类型;xy为长度为2的序列,标明了点(x, y),可迭代类型;xytext长度为2的序列,标明文本放置的位置,可迭代类型,可选,默认为xy;xycoords指xy给定的坐标系统,可从给定的字符串中选择;textcoords指xytext给定的坐标系统;arrowprops,设置xy和xytext间的箭头属性。
由于构造注解树时,需要每个节点的坐标,需要根据叶子节点的数量确定x轴的长度,根据树的高度确定y轴的长度。
#需要通过叶子节点和树的深度确定x,y轴的长度
#得到叶子节点的数量
def getNumLeafs(myTree):
numLeafs = 0
firstStr = list(myTree.keys())[0] #取字典中的第一个key值
secondDict = myTree[firstStr]
for key in secondDict.keys():
if type(secondDict[key]) is 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 secondDict.keys():
if type(secondDict[key]) is 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'}}}},{'no surfacing': {0: 'no', 1: {'flippers': {0: {'head': {0: 'no', 1: 'yes'}}, 1: 'no'}}}}]
return listOfTrees[i]
在绘制树时,使用全局变量保存坐标轴的长度,高度和节点在x轴y轴的偏移量,然后递归地绘制树,包括绘制中间节点,叶子节点和父子节点间的文本,并不断更新节点在x轴和y轴的偏移量。
在父子节点之间填充文本信息时,输入为当前节点的坐标,父节点的坐标和文本内容,根据父子节点的坐标取中点求出文本的坐标,然后在对应坐标处添加文本。
#在父子节点之间填充文本信息
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)
matplotlib.pyplot.text(x, y, s, fontdict=None, withdash=False, **kwargs)将文本s添加到坐标(x, y)处。
在绘制树时,首先计算当前树的叶子节点数和深度,然后根据已绘制节点的偏移量和树的宽和高计算当前树根节点的坐标,根节点的横坐标位于所有叶子节点的中间,绘制根节点,更新其子节点的y偏移量并处理每个分支节点,如果分支节点为子树,递归调用plotTree绘制子树,否则直接绘制叶子节点,并更新x轴的偏移量。
def plotTree(myTree, parentPt, nodeTxt):
#计算树的宽和高
numLeafs = getNumLeafs(myTree)
depth = getNumLeafs(myTree)
firstStr = list(myTree.keys())[0]
#变量plotTree.xOff,plotTree.yOff追踪已绘制节点位置
#变量plotTree.totalW, plotTree.totalD存储树的总宽度和总深度
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 secondDict.keys():
if type(secondDict[key]) is 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调用plotTree完成树的绘制
def createPlot(inTree):
fig = plt.figure(1, facecolor = 'white')
#fig.clf()
axprops = dict(xticks = [], yticks = [])
createPlot.ax1 = plt.subplot(111, frameon = False, **axprops) #数字代表行列和第几块,frameon代表是否有边框
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), '')
plt.show()
matplotlib.pyplot.clf()清除当前的图
在使用决策树进行分类时,需要数据构建好的决策树,用于构建树的属性(标签)向量,和待分类样本对应属性的值。通过比较测试数据与决策树上的数值,递归执行决策树直至进入叶子节点,待分类样本所属的类别为叶子节点的类别。
def classify(inputTree, featLabels, testVec):
firstStr = list(inputTree.keys())[0]
secondDict = inputTree[firstStr]
featIndex = featLabels.index(firstStr) #确定特征值在数据集中的位置
for key in secondDict.keys():
if testVec[featIndex] == key:
if type(secondDict[key]) is dict:
classLabel = classify(secondDict[key], featLabels, testVec)
else:
classLabel = secondDict[key]
return classLabel
当前,在每次预测之前,需要先使用训练数据得到决策树,为了能够快速调用已经建好的决策树,可以将建好的决策树保存在磁盘上,需要时读取。
#使用pickle序列化对象,将决策树保存在磁盘上,需要时读取出来
#保存决策树
def storeTree(inputTree, filename):
import pickle
fw = open(filename, 'wb')
pickle.dump(inputTree, fw)
fw.close()
#读取决策树
def grabTree(filename):
import pickle
fr = open(filename, 'rb')
return pickle.load(fr)
pickle模块实现将python对象结构序列化和反序列化的二进制协议。pickle对于错误或恶意构造数据是不安全的,不要反序列化不可信或未认证的数据。
pickle.dump(obj, file, protocol=None, *, fix_imports=True)将obj序列化到打开的文件对象file,file必须具有接受单字节参数的可写方法,例如能够二进制写的磁盘文件。protocol参数可选,用整数表示对应的协议。等价于Pickler(file, protocol).dump(obj)
pickle.dumps(obj, protocol=None, *, fix_imports=True)返回obj序列化后的字节对象,不写入文件。
pickle.load(file, *, fix_imports=True, encoding=”ASCII”, errors=”strict”)从file对象中读取序列化的对象,返回指定的重组对象的层次,file必须具有返回字节的read()方法和readline()方法。等价于Unpickler(file).load()
pickle.loads(bytes_object, *, fix_imports=True, encoding=”ASCII”, errors=”strict”)读取字节对象,返回指定的重构对象。
#使用决策树预测隐性眼镜类型
#读取并处理数据
import treePloter
fr = open('C:/Users/hp/Desktop/SH/python/MLInAction/machinelearninginaction/Ch03/lenses.txt', 'r')
try:
lenses = [instance.strip().split('\t') for instance in fr.readlines()]
lensesLabels = ['age', 'prescript', 'astigmatic', 'tearRate']
#构造树
lensesTree = createTree(lenses, lensesLabels)
treePloter.createPlot(lensesTree)
finally:
fr.close()
构建的决策树如下图所示。
决策树能够很好的匹配数据,但出现过度匹配的问题。可以通过裁剪决策树,去掉不必要的叶子节点或者合并叶子节点,消除过度匹配的问题。