C4.5决策树生成算法完整版(Python)
转载请注明出处:©️ Sylvan Ding
ID3算法实验
决策树从一组无次序、无规则的事例中推理出决策树表示的分类规则,采用自顶向下的递归方式,在决策树的内部节点进行属性值的比较并根据不同的属性值判断从该结点向下的分支,在决策树叶结点得到结论。
实验目的
- 理解ID3算法原理
- 理解C4.5算法原理
- 编程实现C4.5算法
决策树生成算法
决策树的生成过程是使用满足划分准则的特征不断地将数据集划分为纯度更高、不确定性更小的子集的过程。
Generate_decision_tree
输入: 训练样本samples,由离散值属性表示;候选属性的集合attribute_list。
输出: 一颗决策树
创建结点N
IF samples都在同一个类C THEN返回N作为叶结点,以类C标记;
IF attribute_list为空 THEN返回N作为叶结点,标记为samples中最普通的类;
选择attribute_list中具有最高信息增益(或增益率)的属性test_attribute;
标记结点N为test_attribute;
FOR each test_attribute中的已知值ai //划分samples由结点N长出一个条件为test_attribute=ai的分支;
设si是samples中test_attribute=ai的样本集合 //一个划分
IF si为空 THEN加上一个树叶,标记为samples中最普通的类;
ELSE 加上一个由Generate_decision_tree(si, attribute_list - test_attribute)返回的结点;
ID3算法原理
- 决策树中每一个非叶结点对应着一个非类别属性,树枝代表这个属性的值。一个叶结点代表从树根到叶结点之间的路径对应的记录所属的类别属性值。
- 采用信息增益来选择最佳划分属性。
信息增益计算
ID3总是选择具有最高信息增益(最大熵)的属性作为当前结点的测试属性(test_attribute)。
设S是s个数据样本的集合,假定类标号属性有m个不同值,定义m个不同类Ci(i=1,2,…,m)C_i(i=1,2,\dots,m)Ci(i=1,2,…,m)。设sis_isi是类CiC_iCi中的样本数。对一个给定的样本分类所需的期望信息为:
I(s1,s2,…,sm)=−∑i=1mpilog(pi)I(s_1,s2,\dots,s_m) = -\sum_{i=1}^{m}p_i\log(p_i)I(s1,s2,…,sm)=−i=1∑mpilog(pi)
其中,pip_ipi是任意样本属于CiC_iCi的概率,pi=sisp_i=\frac{s_i}{s}pi=ssi。
设属性A具有v个不同值,则可用属性A将S划分为v个子集,设sijs_{ij}sij是子集SjS_jSj中类CiC_iCi的样本数,则根据A划分子集的熵为:
E(A)=−∑j=1v∑i=1msijsI(s1j,s2j,…,smj)E(A) = -\sum_{j=1}^{v}\frac{\sum_{i=1}^{m}s_{ij}}{s}I(s_{1j}, s_{2j}, \dots, s_{mj})E(A)=−j=1∑vs∑i=1msijI(s1j,s2j,…,smj)
由期望信息和熵值可以得到对应的信息增益值:
Gain(A)=I(s1j,s2j,…,smj)−E(A)Gain(A)=I(s_{1j}, s_{2j}, \dots, s_{mj})-E(A)Gain(A)=I(s1j,s2j,…,smj)−E(A)
ID3算法分析
优点
ID3算法避免了搜索不包含目标函数的不完整假设空间的主要风险,因为有限个离散值函数可以表示某个决策树。
缺点
无法回溯爬山搜索中常见的风险,如收敛到局部最优,而不是全局最优。ID3算法只能处理离散值的属性。
当特征的取值较多时,根据此特征划分更容易得到纯度高的子集,因此划分之后的熵更低,由于划分前的熵是一定的,所以信息增益更大,ID3偏袒较多值的属性。
C4.5算法
- 用信息增益率来代替信息增益
- 合并具有连续属性的值
- 处理缺少属性值的训练样本
信息增益率
GainRatio(A)=Gain(A)SplitI(A)GainRatio(A)=\frac{Gain(A)}{SplitI(A)}GainRatio(A)=SplitI(A)Gain(A)
其中,
SplitI(A)=−∑j=1vpjlog(pj)SplitI(A)=-\sum_{j=1}^{v}p_j\log(p_j)SplitI(A)=−j=1∑vpjlog(pj)
连续属性的离散化
- 根据属性的值,对数据集排序;
- 用不同的阈值将数据集动态划分;
- 取两个实际值中点作为一个阈值;
- 取两个划分,所有样本都在这两个划分中;
- 得到所有可能的阈值和增益率;
对连续属性A进行排序,按阈值将A划分为两部分,一部分落入vjv_jvj对范围内,而另一部分则大于vjv_{j}vj,选择增益率最大的划分所对应的阈值为划分阈值进行属性离散化。注意,当前划分属性为连续属性,则该属性还可以作为其后代的划分属性。新增labelProperties表示属性为连续还是离散。
缺失值处理
选取最优划分属性
有缺失值属性的信息增益为该属性(设该属性为A)下的无缺失值样本占比×\times×无缺失值样本子集的信息增益。
Gain(A)=p×Gain(A~)Gain(A) = p\times Gain(\widetilde{A})Gain(A)=p×Gain(A)
其中,p为A属性下无缺失值样本的占比,即p=∑x∈D~wx∑x∈Dwxp=\frac{\sum_{x\in \widetilde{D}}w_x}{\sum_{x\in D}w_x}p=∑x∈Dwx∑x∈Dwx,Gain(A~)Gain(\widetilde{A})Gain(A)为A属性下无缺失值样本的信息增益,wxw_xwx是样本x的权重,D是所有样本,D~\widetilde{D}D是无缺失样本。
缺失值样本的划分
增加样本权重概念。样本权重的初始值为1,对于无缺失值样本,将其划分到子结点时,权重保持不变。而对于有缺失值样本,在划分时按无缺失值样本在每个分支中所占比重(即对于分支中无缺失值样本数/该属性下无缺失值样本总数)划分到分支中。此时,ID3中所得公式需改写:
Gain(A~)=I(S)−E(A)Gain(\widetilde{A})=I(S)-E(A)Gain(A)=I(S)−E(A)
I(S)=−∑i=1mpi~log(pi~)I(S)= -\sum_{i=1}^{m}\widetilde{p_i}\log(\widetilde{p_i})I(S)=−i=1∑mpilog(pi)
E(A)=−∑j=1vrj~I(Sj)E(A)=-\sum_{j=1}^v \widetilde{r_j}I(S^j)E(A)=−j=1∑vrjI(Sj)
Sj=(s1j,s2j,…,smj)S^j=(s_{1j},s_{2j},\dots,s_{mj})Sj=(s1j,s2j,…,smj)
S=(s1,s2,…,sm)S=(s_{1},s_{2},\dots,s_{m})S=(s1,s2,…,sm)
pi~=∑x∈Di~wx∑x∈D~wx\widetilde{p_i}=\frac{\sum_{x\in \widetilde{D_i}}w_x}{\sum_{x\in \widetilde{D}}w_x}pi=∑x∈Dwx∑x∈Diwx
rj~=∑x∈Dj~wx∑x∈D~wx\widetilde{r_j}=\frac{\sum_{x\in \widetilde{D^j}}w_x}{\sum_{x\in \widetilde{D}}w_x}rj=∑x∈Dwx∑x∈Djwx
pi~\widetilde{p_i}pi是无缺失值样本中第i类所占比例,样本个数按权重计算;rj~\widetilde{r_j}rj是无缺失值样本中属性A上取值为aja_jaj的样本所占比例,样本个数按权重计算。i是样本索引,j是属性索引。
*缺失测试样本的分类
C4.5算法的决策树构造完成后,需要对含缺失值的测试样本进行分类。在为测试样本某属性下的未知值选择分支时,考虑该缺失属性在该分支下的每个叶子结点中属于不同分类的概率,最大概率对于的分类即为所属分类。概率的计算使用该分支下每个叶子结点中不同分类的权值的加权平均。
本实验仅涉及决策树生成算法,故不考虑测试集的分类和决策树剪枝算法。
*C4.5算法的缺陷和修正
当离散属性和连续属性并存时,C4.5算法倾向于选择连续特征作为最佳树分裂点。因此要对最佳分裂点的信息增益进行修正:
Gain(A)=Gain(A)−log(N−1)∣D∣Gain(A) = Gain(A)-\frac{\log{(N-1)}}{\left| D\right|}Gain(A)=Gain(A)−∣D∣log(N−1)
其中,N为连续特征可能的分裂点个数,D是样本数目。
此外,C4.5算法的信息增益率偏向取值较少的特征。因此,并不直接选择信息增益率最大的特征,而是在候选特征中找出信息增益高于平均水平的特征,然后在这些特征中再选择信息增益率最高的特征作为最佳划分特征。
本实验编写的决策树生成算法,不考虑上述两种问题的修正。
代码
import copy
import operator
from math import log
from numpy import infNAN = 'Nan' # 缺失值定义def calcShannonEnt(dataSet: list, labelIndex: int):"""计算对应属性索引下样本的香农熵:param dataSet: 样本:param labelIndex: 属性索引:return: shannonEnt 香农熵"""numEntries = 0 # 样本数(按权重计算)labelCounts = {}# 遍历样本,计算每类的权重for featVec in dataSet:# 样本的属性不为空if featVec[labelIndex] != NAN:weight = featVec[-2]numEntries += weightcurrentLabel = featVec[-1] # 当前样本的类别# 如果样本类别不在labelCountsif currentLabel not in labelCounts.keys():# 添加该类别,令该类别权重为0labelCounts[currentLabel] = .0# 添加该类别的权重labelCounts[currentLabel] += weightshannonEnt = .0for key in labelCounts: # 计算信息熵prob = labelCounts[key] / numEntriesshannonEnt -= prob * log(prob, 2)return shannonEntdef splitDataSet(dataSet: list, axis: int, value, AttrType='N'):"""划分数据集:param dataSet: 数据集:param axis: 按第几个特征划分:param value: 划分特征的值:param AttrType: N-离散属性; L-小于等于value值; R-大于value值:return: 对应axis为value(连续情况下则为大于或小于value)的数据集dataSet的子集"""subDataSet = []# N-离散属性if AttrType == 'N':for featVec in dataSet:if featVec[axis] == value:reducedFeatVec = featVec[:axis]reducedFeatVec.extend(featVec[axis + 1:])subDataSet.append(reducedFeatVec)# L-小于等于value值elif AttrType == 'L':for featVec in dataSet:# 样本axis对应属性非空if featVec[axis] != NAN:if featVec[axis] <= value:# 无需减少该特征subDataSet.append(featVec)# R-大于value值elif AttrType == 'R':for featVec in dataSet:if featVec[axis] != NAN:if featVec[axis] > value:# 无需减少该特征subDataSet.append(featVec)else:exit(0)return subDataSetdef calcTotalWeight(dataSet: list, labelIndex: int, isContainNull: bool):"""计算样本对某个特征值的总样本数(按权重计算):param dataSet: 数据集:param labelIndex: 属性索引:param isContainNull: 是否包含空值:return: 样本的总权重"""totalWeight = .0# 遍历样本for featVec in dataSet:# 样本权重weight = featVec[-2]# 不包含空值并且该属性非空if isContainNull is False and featVec[labelIndex] != NAN:# 非空样本树,按权重计算totalWeight += weight# 包含空值if isContainNull is True:# 总样本数totalWeight += weightreturn totalWeightdef splitDataSetWithNull(dataSet: list, axis: int, value, AttrType='N'):"""划分含有缺失值的数据集:param dataSet: 数据集:param axis: 按第几个特征划分:param value: 划分特征的值:param AttrType: N-离散属性; L-小于等于value值; R-大于value值:return: 按value划分的数据集dataSet的子集"""# 属性值未缺失样本子集subDataSet = []# 属性值缺失样本子集nullDataSet = []# 计算非空样本总权重totalWeightV = calcTotalWeight(dataSet, axis, False)# N-离散属性if AttrType == 'N':for featVec in dataSet:if featVec[axis] == value:reducedFeatVec = featVec[:axis]reducedFeatVec.extend(featVec[axis + 1:])subDataSet.append(reducedFeatVec)# 样本该属性值缺失elif featVec[axis] == NAN:reducedNullVec = featVec[:axis]reducedNullVec.extend(featVec[axis + 1:])nullDataSet.append(reducedNullVec)# L-小于等于value值elif AttrType == 'L':for featVec in dataSet:# 样本该属性值未缺失if featVec[axis] != NAN:if value is None or featVec[axis] < value:subDataSet.append(featVec)# 样本该属性值缺失elif featVec[axis] == NAN:nullDataSet.append(featVec)# R-大于value值elif AttrType == 'R':for featVec in dataSet:# 样本该属性值未缺失if featVec[axis] != NAN:if featVec[axis] > value:subDataSet.append(featVec)# 样本该属性值缺失elif featVec[axis] == NAN:nullDataSet.append(featVec)# 计算此分支中非空样本的总权重totalWeightSub = calcTotalWeight(subDataSet, -1, True)# 缺失值样本按权值比例划分到分支中for nullVec in nullDataSet:nullVec[-2] = nullVec[-2] * totalWeightSub / totalWeightVsubDataSet.append(nullVec)return subDataSetdef calcGainRatio(dataSet: list, labelIndex: int, labelType: bool):"""计算信息增益率,返回信息增益率和连续属性的划分点:param dataSet: 数据集:param labelIndex: 属性索引:param labelType: 属性类型,0为离散,1为连续:return: 信息增益率和连续属性的划分点"""# 计算根节点的信息熵baseE = calcShannonEnt(dataSet, labelIndex)# 对应labelIndex的特征值向量featVec = [row[labelIndex] for row in dataSet]# featVec值的种类uniqueVals = set(featVec)newE = .0 # 新信息熵bestPivotValue = None # 最佳划分属性IV = .0 # 该变量取自西瓜书# 总样本权重totalWeight = calcTotalWeight(dataSet, labelIndex, True)# 非空样本权重totalWeightV = calcTotalWeight(dataSet, labelIndex, False)# 对离散的特征if labelType == 0:# 按属性值划分数据集,计算各子集的信息熵for value in uniqueVals:# 划分数据集subDataSet = splitDataSet(dataSet, labelIndex, value)# 计算子集总权重totalWeightSub = calcTotalWeight(subDataSet, labelIndex, True)# 过滤空属性if value != NAN:prob = totalWeightSub / totalWeightVnewE += prob * calcShannonEnt(subDataSet, labelIndex)prob1 = totalWeightSub / totalWeightIV -= prob1 * log(prob1, 2)# 对连续的特征else:uniqueValsList = list(uniqueVals)# 过滤空属性if NAN in uniqueValsList:uniqueValsList.remove(NAN)# 计算空值样本的总权重,用于计算IVdataSetNull = splitDataSet(dataSet, labelIndex, NAN)totalWeightN = calcTotalWeight(dataSetNull, labelIndex, True)probNull = totalWeightN / totalWeightif probNull > 0:IV += -1 * probNull * log(probNull, 2)# 属性值排序sortedUniqueVals = sorted(uniqueValsList)minEntropy = inf # 定义最小熵# 如果UniqueVals只有一个值,则说明只有左子集,没有右子集if len(sortedUniqueVals) == 1:totalWeightL = calcTotalWeight(dataSet, labelIndex, True)probL = totalWeightL / totalWeightVminEntropy = probL * calcShannonEnt(dataSet, labelIndex)IV = -1 * probL * log(probL, 2)# 如果UniqueVals只有多个值,则计算划分点else:for j in range(len(sortedUniqueVals) - 1):pivotValue = (sortedUniqueVals[j] + sortedUniqueVals[j + 1]) / 2# 对每个划分点,划分得左右两子集dataSetL = splitDataSet(dataSet, labelIndex, pivotValue, 'L')dataSetR = splitDataSet(dataSet, labelIndex, pivotValue, 'R')# 对每个划分点,计算左右两侧总权重totalWeightL = calcTotalWeight(dataSetL, labelIndex, True)totalWeightR = calcTotalWeight(dataSetR, labelIndex, True)probL = totalWeightL / totalWeightVprobR = totalWeightR / totalWeightVEnt = probL * calcShannonEnt(dataSetL, labelIndex) + probR * calcShannonEnt(dataSetR, labelIndex)# 取最小的信息熵if Ent < minEntropy:minEntropy = EntbestPivotValue = pivotValueprobL1 = totalWeightL / totalWeightprobR1 = totalWeightR / totalWeightIV += -1 * (probL1 * log(probL1, 2) + probR1 * log(probR1, 2))newE = minEntropygain = totalWeightV / totalWeight * (baseE - newE)# 避免IV为0(属性只有一个值的情况下)if IV == 0.0:IV = 0.0000000001gainRatio = gain / IVreturn gainRatio, bestPivotValuedef chooseBestFeatureToSplit(dataSet: list, labelProps: list):"""选择最佳数据集划分方式:param dataSet: 数据集:param labelProps: 属性类型,0离散,1连续:return: 最佳划分属性的索引和连续属性的最佳划分值"""numFeatures = len(labelProps) # 属性数bestGainRatio = -inf # 最大信息增益bestFeature = -1 # 最优划分属性索引bestPivotValue = None # 连续属性的最佳划分值for featureI in range(numFeatures): # 对每个特征循环gainRatio, bestPivotValuei = calcGainRatio(dataSet, featureI, labelProps[featureI])# 取信息益率最大的特征if gainRatio > bestGainRatio:bestGainRatio = gainRatiobestFeature = featureIbestPivotValue = bestPivotValueireturn bestFeature, bestPivotValuedef majorityCnt(classList: list, weightList: list):"""返回出现次数最多的类别(按权重计):param classList: 类别:param weightList: 权重:return: 出现次数最多的类别"""classCount = {}# 计算classCountfor cls, wei in zip(classList, weightList):if cls not in classCount.keys():classCount[cls] = .0classCount[cls] += wei# 排序sortedClassCount = sorted(classCount.items(), key=operator.itemgetter(1), reverse=True)# 仅剩一个类别if len(sortedClassCount) == 1:return sortedClassCount[0][0], sortedClassCount[0][1]# 剩余多个类别,返回出现次数最多的类别return sortedClassCount[0][0], sortedClassCount[0][1]def isSame(dataSet: list):"""比较样本特征是否相同:param dataSet: 数据集:return: 相同True,否则False"""for j in range(len(dataSet[0])-2):for i in range(1, len(dataSet)):if not dataSet[i][j] == dataSet[0][j]:return Falsereturn Truedef createTree(dataSet: list, labels: list, labelProps: list):"""创建决策树(Decision Tree):param dataSet: 数据集:param labels: 属性集:param labelProps: 属性类型,0离散,1连续:return: 决策树"""classList = [sample[-1] for sample in dataSet] # 类别向量weightList = [sample[-2] for sample in dataSet] # 权重向量# 如果只剩一个类别,返回并退出if classList.count(classList[0]) == len(classList):totalWeight = calcTotalWeight(dataSet, 0, True)return classList[0], totalWeight# 如果所有特征都遍历完了,返回出现次数最多的类别,并退出if len(dataSet[0]) == 1:return majorityCnt(classList, weightList)# 如果剩余样本特征相同,返回出现次数最多的类别,并退出if isSame(copy.copy(dataSet)):return majorityCnt(classList, weightList)# 计算最优分类特征的索引,若为连续属性,则还返回连续属性的最优划分点bestFeat, bestPivotValue = chooseBestFeatureToSplit(dataSet, labelProps)# 对离散的特征if labelProps[bestFeat] == 0:bestFeatLabel = labels[bestFeat]myTree = {bestFeatLabel: {}}labelsNew = copy.copy(labels)labelPropertyNew = copy.copy(labelProps)# 已经选择的离散特征不再参与分类del (labelsNew[bestFeat])del (labelPropertyNew[bestFeat])featValues = [sample[bestFeat] for sample in dataSet]# 最佳花划分属性包含的所有值uniqueValue = set(featValues)# 删去缺失值uniqueValue.discard(NAN)# 遍历每个属性值,递归构建树for value in uniqueValue:subLabels = labelsNew[:]subLabelProperty = labelPropertyNew[:]myTree[bestFeatLabel][value] = createTree(splitDataSetWithNull(dataSet, bestFeat, value),subLabels, subLabelProperty)# 对连续特征,不删除该特征,分别构建左子树和右子树else:bestFeatLabel = labels[bestFeat] + '<' + str(bestPivotValue)myTree = {bestFeatLabel: {}}subLabels = labels[:]subLabelProperty = labelProps[:]# 构建左子树valueLeft = 'Y'myTree[bestFeatLabel][valueLeft] = createTree(splitDataSetWithNull(dataSet, bestFeat, bestPivotValue, 'L'),subLabels, subLabelProperty)# 构建右子树valueRight = 'N'myTree[bestFeatLabel][valueRight] = createTree(splitDataSetWithNull(dataSet, bestFeat, bestPivotValue, 'R'),subLabels, subLabelProperty)return myTreeif __name__ == '__main__':# 读取数据文件fr = open(r'data.csv')data = [row.strip().split(',') for row in fr.readlines()]labels = data[0][0:-1] # labels:属性dataset = data[1:] # dataset:数据集(初始样本)labelProperties = [0, 1, 0] # labelProperties:属性标识,0为离散,1为连续# 样本权重初始化for row in dataset:row.insert(-1, 1.0)# 按labelProperties连续化离散属性for row in dataset:for i, lp in enumerate(labelProperties):# 若标识为连续属性,则转化为float型if lp:row[i] = float(row[i])# C4.5算法生成决策树trees = createTree(copy.copy(dataset), copy.copy(labels), copy.copy(labelProperties))print(trees)
Python3.6
结果验证
在data.csv数据集上运行上述代码,得到结果如下:
{'天气': {'多云': ('玩', 3.230769230769231), '晴': {'湿度<77.5': {'Y': ('玩', 2.0), 'N': {'有雨?': {'有': ('不玩', 1.0), '无': ('不玩', 2.0)}}}}, '雨': {'有雨?': {'有': {'湿度<85.0': {'Y': ('不玩', 2.0), 'N': ('玩', 0.38461538461538464)}}, '无': ('玩', 3.0)}}}} // (结果, 权重)
附录(data.csv)
天气 | 湿度 | 有雨? | 去玩? |
---|---|---|---|
晴 | 70 | 有 | 玩 |
晴 | 90 | 有 | 不玩 |
晴 | 85 | 无 | 不玩 |
晴 | 95 | 无 | 不玩 |
晴 | 70 | 无 | 玩 |
Nan | 90 | 有 | 玩 |
多云 | 78 | 无 | 玩 |
多云 | 65 | 有 | 玩 |
多云 | 75 | 无 | 玩 |
雨 | 80 | 有 | 不玩 |
雨 | 70 | 有 | 不玩 |
雨 | 80 | 无 | 玩 |
雨 | 80 | 无 | 玩 |
雨 | 96 | 无 | 玩 |
参考
- 数据挖掘原理与算法(第3版)
- 《机器学习》周志华
- 决策树–信息增益,信息增益比,Geni指数的理解
- 机器学习笔记(5)——C4.5决策树中的连续值处理和Python实现
- 机器学习笔记(7)——C4.5决策树中的缺失值处理