C4.5算法是用于生成决策树的一种经典算法,是ID3算法的一种延伸和优化。C4.5算法对ID3算法进行了改进 ,改进点主要有:
- 用信息增益率来选择划分特征,克服了用信息增益选择的不足,但信息增益率对可取值数目较少的属性有所偏好;
- 能够处理离散型和连续型的属性类型,即将连续型的属性进行离散化处理;
- 能够处理具有缺失属性值的训练数据;
- 在构造树的过程中进行剪枝;
特征选择
特征选择也即选择最优划分属性,从当前数据的特征中选择一个特征作为当前节点的划分标准。 随着划分过程不断进行,希望决策树的分支节点所包含的样本尽可能属于同一类别,即节点的“纯度”越来越高。
具体信息增益相关公式见:ID3算法
信息增益率
信息增益准则对可取值数目较多的属性有所偏好,为减少这种偏好可能带来的不利影响,C4.5算法采用信息增益率来选择最优划分属性。增益率公式
G
a
i
n
R
a
t
i
o
(
D
∣
A
)
=
i
n
f
o
G
a
i
n
(
D
∣
A
)
I
V
(
A
)
I
V
(
A
)
=
−
∑
k
=
1
K
∣
D
k
∣
∣
D
∣
∗
l
o
g
2
∣
D
k
∣
∣
D
∣
GainRatio(D|A)=\frac{infoGain(D|A)}{IV(A)}\\ IV(A)=-\sum_{k=1}^{K}{\frac{|D_k|}{|D|}*log_2{\frac{|D_k|}{|D|}}}
GainRatio(D∣A)=IV(A)infoGain(D∣A)IV(A)=−k=1∑K∣D∣∣Dk∣∗log2∣D∣∣Dk∣
其中
A
=
[
a
1
,
a
2
,
.
.
.
,
a
k
]
A=[a_1,a_2,...,a_k]
A=[a1,a2,...,ak],K个值。若使用A来对样本集D进行划分,则会产生K个分支节点,其中第k个节点包含D中所有属性A上取值为
a
k
a_k
ak的样本,记为
D
k
D_k
Dk。通常,属性A的可能取值数越多(即K越大),则IV(A)的值通常会越大。
信息增益率准则对可取值数目较少的属性有所偏好。所以,C4.5算法不是直接选择信息增益率最大的候选划分属性,而是先从候选划分属性中找出信息增益高于平均水平的属性,再从中选择信息增益率最高的。
对连续特征的处理
当属性类型为离散型,无须对数据进行离散化处理;
当属性类型为连续型,则需要对数据进行离散化处理。具体思路如下:
具体思路:
- m个样本的连续特征A有m个值,从小到大排列 a 1 , a 2 , . . . , a m a_1,a_2,...,a_m a1,a2,...,am,取相邻两样本值的平均数做划分点,一共有m-1个,其中第i个划分点Ti表示为: T i = ( a i + a i + 1 ) / 2 T_i=(a_i+a_{i+1})/2 Ti=(ai+ai+1)/2。
- 分别计算以这m-1个点作为二元切分点时的信息增益率。选择信息增益率最大的点为该连续特征的最佳切分点。比如取到的信息增益率最大的点为 a t a_t at,则小于 a t a_t at的值为类别1,大于 a t a_t at的值为类别2,这样就做到了连续特征的离散化。
对缺失值的处理
ID3算法不能处理缺失值,而C4.5算法可以处理缺失值(常用概率权重方法),主要有三种情况,具体如下:
- 在有缺失值的特征上如何计算信息增益率?
根据缺失比例,折算信息增益(无缺失值样本所占的比例乘以无缺失值样本子集的信息增益)和信息增益率
-
选定了划分属性,若样本在该属性上的值是缺失的,那么如何对这个样本进行划分?
将样本以不同概率同时划分到不同节点中,概率是根据其他非缺失属性的比例来得到的
-
对新的样本进行分类时,如果测试样本特性有缺失值如何判断其类别?
走所有分支,计算每个类别的概率,取概率最大的类别赋值给该样本
剪枝
为什么要剪枝?因为过拟合的树在泛化能力的表现非常差。
剪枝又分为前剪枝和后剪枝,前剪枝是指在构造树的过程中就知道哪些节点可以剪掉 。 后剪枝是指构造出完整的决策树之后再来考查哪些子树可以剪掉。
前剪枝
在节点划分前确定是否继续增长,及早停止增长的主要方法有:
- 节点内数据样本数小于切分最小样本数阈值;
- 所有节点特征都已分裂;
- 节点划分前准确率比划分后准确率高。
前剪枝不仅可以降低过拟合的风险而且还可以减少训练时间,但另一方面它是基于“贪心”策略,会带来欠拟合风险。
后剪枝
在已经生成的决策树上进行剪枝,从而得到简化版的剪枝决策树。
C4.5算法采用悲观剪枝方法。根据剪枝前后的误判率来判定是否进行子树的修剪, 如果剪枝后与剪枝前相比其误判率是保持或者下降,则这棵子树就可以被替换为一个叶子节点。 因此,不需要单独的剪枝数据集。C4.5 通过训练数据集上的错误分类数量来估算未知样本上的错误率。
把一颗子树(具有多个叶子节点)的剪枝后用一个叶子节点来替代的话,在训练集上的误判率肯定是上升的,但是在新数据上不一定。于是我们需要把子树的误判计算加上一个经验性的惩罚因子。对于一颗叶子节点,它覆盖了N个样本,其中有E个错误,那么该叶子节点的错误率为(E+0.5)/N。这个0.5就是惩罚因子,那么一颗子树,它有L个叶子节点,那么该子树的误判率估计为:
e
=
∑
E
i
+
0.5
∗
L
∑
N
i
e = \frac{\sum{E_i}+0.5*L}{\sum{N_i}}
e=∑Ni∑Ei+0.5∗L
其中,
E
i
E_i
Ei表示子树的每一个叶子节点的误判样本数量,L为子树的叶子节点个数,
N
i
N_i
Ni为每一个叶子节点的样本数量。
这样的话,我们可以看到一颗子树虽然具有多个子节点,但由于加上了惩罚因子,所以子树的误判率计算未必占到便宜。剪枝后内部节点变成了叶子节点,其误判个数J也需要加上一个惩罚因子,变成J+0.5。
那
么
子
树
是
否
可
以
被
剪
枝
就
取
决
于
剪
枝
后
的
错
误
J
+
0.5
是
否
在
(
∑
E
i
+
0.5
∗
L
)
的
标
准
误
差
内
那么子树是否可以被剪枝就取决于剪枝后的错误J+0.5是否在(\sum{E_i}+0.5*L)的标准误差内
那么子树是否可以被剪枝就取决于剪枝后的错误J+0.5是否在(∑Ei+0.5∗L)的标准误差内
对于样本的误判率e,可以根据经验把它估计成各种各样的分布模型,比如是二项式分布,比如是正态分布。
那么一棵树错误分类一个样本值为1,正确分类一个样本值为0,该树错误分类的概率(误判率)为e,e通过下式来计算
e
=
∑
E
i
+
0.5
∗
L
∑
N
i
e = \frac{\sum{E_i}+0.5*L}{\sum{N_i}}
e=∑Ni∑Ei+0.5∗L
那么树的误判次数就是伯努利分布,我们可以估计出该树的误判次数的均值和标准差:
E
(
子
树
误
判
次
数
)
=
N
∗
e
s
t
d
(
子
树
误
判
次
数
)
=
N
∗
e
∗
(
1
−
e
)
E(子树误判次数)=N∗e\\std(子树误判次数)=\sqrt{N∗e∗(1−e)}
E(子树误判次数)=N∗estd(子树误判次数)=N∗e∗(1−e)
把子树替换成叶子节点后,该叶子的误判次数也是一个伯努利分布,因为子树合并为一个叶子节点了,所以,L=1,将其代入上面计算误判率的公式中,可以得到叶子节点的误判率为
e
=
E
+
0.5
N
e= \frac{E+0.5}{N}
e=NE+0.5
因此叶子节点的误判次数均值为
E
(
叶
子
节
点
的
误
判
次
数
)
=
N
∗
e
E(叶子节点的误判次数)=N∗e
E(叶子节点的误判次数)=N∗e
这里采用一种保守的分裂方案,即有足够大的置信度保证分裂后准确率比不分裂时的准确率高时才分裂,否则就不分裂–也就是应该剪枝。
如果要分裂(即不剪枝)至少要保证分裂后的误判数E(子树误判次数)要小于不分裂的误判数E(叶子节点的误判次数),而且为了保证足够高的置信度,加了一个标准差可以有95%的置信度,所以,要分裂(即不剪枝)需满足如下不等式
E
(
子
树
误
判
次
数
)
+
s
t
d
(
子
树
误
判
次
数
)
<
E
(
叶
子
节
点
的
误
判
次
数
)
E(子树误判次数)+std(子树误判次数)<E(叶子节点的误判次数)
E(子树误判次数)+std(子树误判次数)<E(叶子节点的误判次数)
反之就是不分裂,即剪枝的条件:
E
(
子
树
误
判
次
数
)
+
s
t
d
(
子
树
误
判
次
数
)
>
=
E
(
叶
子
节
点
的
误
判
次
数
)
E(子树误判次数)+std(子树误判次数)>=E(叶子节点的误判次数)
E(子树误判次数)+std(子树误判次数)>=E(叶子节点的误判次数)
例子
对T4这棵子树进行后剪枝
子树T4的误判率:
子
树
误
判
率
e
=
∑
i
=
1
3
E
i
+
0.5
∗
L
∑
i
=
1
3
N
i
=
(
2
+
3
)
+
0.5
∗
3
16
=
0.40625
\begin{aligned}子树误判率e&=\frac{\sum_{i=1}^{3}{E_i}+0.5*L}{\sum_{i=1}^{3}{N_i}}\\&=\frac{(2+3)+0.5*3}{16}\\&=0.40625 \end{aligned}
子树误判率e=∑i=13Ni∑i=13Ei+0.5∗L=16(2+3)+0.5∗3=0.40625
子树T4误判次数的均值和标准差分别为:
E
(
子
树
误
判
次
数
)
=
N
∗
e
=
16
∗
0.40625
=
6.5
s
t
d
(
子
树
误
判
次
数
)
=
N
∗
e
∗
(
1
−
e
)
=
16
∗
0.40625
∗
(
1
−
0.40625
)
=
1.96
\begin{aligned}E(子树误判次数)&=N∗e\\&=16*0.40625\\&=6.5\end{aligned}\\ \begin{aligned}std(子树误判次数) &= \sqrt{N*e*(1-e)}\\&=\sqrt{16*0.40625*(1-0.40625)}\\&=1.96\end{aligned}
E(子树误判次数)=N∗e=16∗0.40625=6.5std(子树误判次数)=N∗e∗(1−e)=16∗0.40625∗(1−0.40625)=1.96
若将子树T4替换为一个叶节点后,其误判率为:
叶
子
节
点
误
判
率
=
7
+
0.5
16
=
0.46875
叶子节点误判率 = \frac{7+0.5}{16}=0.46875
叶子节点误判率=167+0.5=0.46875
则叶子节点误判次数均值为:
E
(
叶
子
节
点
误
判
次
数
)
=
N
∗
叶
子
节
点
错
误
率
=
16
∗
0.46875
=
7.5
\begin{aligned}E(叶子节点误判次数)&=N*叶子节点错误率\\&=16*0.46875\\&=7.5\end{aligned}
E(叶子节点误判次数)=N∗叶子节点错误率=16∗0.46875=7.5
由于
6.5
+
1.96
>
7.5
6.5 +1.96 >7.5
6.5+1.96>7.5
满足剪枝条件。所以,应该把T4的所有子节点全部剪掉,T4变成一个叶子节点。
python实现
数据集
数据集的属性有3个,分别是有房情况,婚姻状况和年收入,其中有房情况和婚姻状况是离散的取值,而年收入是连续的取值。拖欠贷款者属于分类的结果。
代码
from math import log
import operator
import numpy as np
def createDataSet():
"""构建数据集"""
dataSet = [['是', '单身', 125, '否'],
['否', '已婚', 100, '否'],
['否', '单身', 70, '否'],
['是', '已婚', 120, '否'],
['否', '离异', 95, '是'],
['否', '已婚', 60, '否'],
['是', '离异', 220, '否'],
['否', '单身', 85, '是'],
['否', '已婚', 75, '否'],
['否', '单身', 90, '是']]
labels = ['是否有房', '婚姻状况', '年收入(k)'] # 三个特征
return dataSet, labels
def calcShannonEnt(dataSet):
"""
计算给定数据集的香农熵
:param dataSet:给定的数据集
:return:返回香农熵
"""
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.0
for label in labelCounts.keys():
prob = float(labelCounts[label])/numEntries
shannonEnt -= prob*log(prob,2)
return shannonEnt
def majorityCnt(classList):
"""获取出现次数最好的分类名称"""
classCount = {}
classList= np.mat(classList).flatten().A.tolist()[0] # 数据为[['否'], ['是'], ['是']], 转换后为['否', '是', '是']
for vote in classList:
if vote not in classCount.keys():
classCount[vote] = 0
classCount[vote] += 1
sortedClassCount = sorted(classCount.items(),key=operator.itemgetter(1),reverse=True)
return sortedClassCount[0][0]
def splitDataSet(dataSet,axis,value):
"""对离散型特征划分数据集"""
retDataSet = [] # 创建新的list对象,作为返回的数据
for featVec in dataSet:
if featVec[axis] == value:
reducedFeatVec = featVec[:axis]
reducedFeatVec.extend(featVec[axis+1:]) # 抽取
retDataSet.append(reducedFeatVec)
return retDataSet
def splitContinuousDataSet(dataSet, axis, value, direction):
"""对连续型特征划分数据集"""
subDataSet = []
for featVec in dataSet:
if direction == 0:
if featVec[axis] > value: # 按照大于(>)该值进行划分
reduceData = featVec[:axis]
reduceData.extend(featVec[axis + 1:])
subDataSet.append(reduceData)
if direction == 1:
if featVec[axis] <= value: # 按照小于等于(<=)该值进行划分
reduceData = featVec[:axis]
reduceData.extend(featVec[axis + 1:])
subDataSet.append(reduceData)
return subDataSet
def chooseBestFeatureToSplit(dataSet, labels):
"""选择最好的数据集划分方式"""
baseEntropy = calcShannonEnt(dataSet)
baseGainRatio = 0.0
bestFeature = -1
numFeatures = len(dataSet[0]) - 1
# 建立一个字典,用来存储每一个连续型特征所对应最佳切分点的具体值
bestSplitDic = {}
# print('dataSet[0]:' + str(dataSet[0]))
for i in range(numFeatures):
# 获取第i个特征的特征值
featVals = [example[i] for example in dataSet]
# 如果该特征时连续型数据
if type(featVals[0]).__name__ == 'float' or type(
featVals[0]).__name__ == 'int':
# 将该特征的所有值按从小到大顺序排序
sortedFeatVals = sorted(featVals)
# 取相邻两样本值的平均数做划分点,共有 len(featVals)-1 个
splitList = []
for j in range(len(featVals) - 1):
splitList.append(
(sortedFeatVals[j] + sortedFeatVals[j + 1]) / 2.0)
# 遍历每一个切分点
for j in range(len(splitList)):
# 计算该划分方式的条件信息熵newEntropy
newEntropy = 0.0
value = splitList[j]
# 将数据集划分为两个子集
greaterSubDataSet = splitContinuousDataSet(dataSet, i, value, 0)
smallSubDataSet = splitContinuousDataSet(dataSet, i, value, 1)
prob0 = len(greaterSubDataSet) / float(len(dataSet))
newEntropy += prob0 * calcShannonEnt(greaterSubDataSet)
prob1 = len(smallSubDataSet) / float(len(dataSet))
newEntropy += prob1 * calcShannonEnt(smallSubDataSet)
# 计算该划分方式的分裂信息
splitInfo = 0.0
splitInfo -= prob0 * log(prob0, 2)
splitInfo -= prob1 * log(prob1, 2)
# 计算信息增益率 = 信息增益 / 该划分方式的分裂信息
gainRatio = float(baseEntropy - newEntropy) / splitInfo
if gainRatio > baseGainRatio:
baseGainRatio = gainRatio
bestSplit = j
bestFeature = i
bestSplitDic[labels[i]] = splitList[bestSplit] # 最佳切分点
else: # 如果该特征时连续型数据
uniqueVals = set(featVals)
splitInfo = 0.0
# 计算每种划分方式的条件信息熵newEntropy
newEntropy = 0.0
for value in uniqueVals:
subDataSet = splitDataSet(dataSet, i, value)
prob = len(subDataSet)/float(len(dataSet))
splitInfo -= prob * log(prob, 2) # 计算分裂信息
newEntropy += prob * calcShannonEnt(subDataSet) # 计算条件信息熵
# 若该特征的特征值都相同,说明信息增益和分裂信息都为0,则跳过该特征
if splitInfo == 0.0:
continue
# 计算信息增益率 = 信息增益 / 该划分方式的分裂信息
gainRatio = float(baseEntropy - newEntropy) / splitInfo
if gainRatio > baseGainRatio:
bestFeature = i
baseGainRatio = gainRatio
# 如果最佳切分特征是连续型,则最佳切分点为具体的切分值
if type(dataSet[0][bestFeature]).__name__ == 'float' or type(
dataSet[0][bestFeature]).__name__ == 'int':
bestFeatValue = bestSplitDic[labels[bestFeature]]
# 如果最佳切分特征时离散型,则最佳切分点为 切分特征名称,【其实对于离散型特征这个值没有用】
if type(dataSet[0][bestFeature]).__name__ == 'str':
bestFeatValue = labels[bestFeature]
# print('bestFeature:' + str(labels[bestFeature]) + ', bestFeatValue:' + str(bestFeatValue))
return bestFeature, bestFeatValue
def createTree(dataSet, labels):
"""创建C4.5树"""
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)
bestFeature, bestFeatValue = chooseBestFeatureToSplit(dataSet, labels)
if bestFeature == -1: # 如果无法选出最优分类特征,返回出现次数最多的类别
return majorityCnt(classList)
bestFeatLabel = labels[bestFeature]
myTree = {bestFeatLabel: {}}
subLabels = labels[:bestFeature]
subLabels.extend(labels[bestFeature + 1:])
# 针对最佳切分特征是离散型
if type(dataSet[0][bestFeature]).__name__ == 'str':
featVals = [example[bestFeature] for example in dataSet]
uniqueVals = set(featVals)
for value in uniqueVals:
reduceDataSet = splitDataSet(dataSet, bestFeature, value)
# print('reduceDataSet:' + str(reduceDataSet))
myTree[bestFeatLabel][value] = createTree(reduceDataSet, subLabels)
# print(myTree[bestFeatLabel][value])
# 针对最佳切分特征是连续型
if type(dataSet[0][bestFeature]).__name__ == 'int' or type(
dataSet[0][bestFeature]).__name__ == 'float':
# 将数据集划分为两个子集,针对每个子集分别建树
value = bestFeatValue
greaterSubDataSet = splitContinuousDataSet(dataSet, bestFeature, value, 0)
smallSubDataSet = splitContinuousDataSet(dataSet, bestFeature, value, 1)
# print('greaterDataset:' + str(greaterSubDataSet))
# print('smallerDataSet:' + str(smallSubDataSet))
# 针对连续型特征,在生成决策的模块,修改划分点的标签,如“> x.xxx”,"<= x.xxx"
myTree[bestFeatLabel]['>' + str(value)] = createTree(greaterSubDataSet,subLabels)
myTree[bestFeatLabel]['<=' + str(value)] = createTree(smallSubDataSet,subLabels)
return myTree
if __name__ == '__main__':
dataSet, labels = createDataSet()
mytree = createTree(dataSet, labels)
print("最终构建的C4.5分类树为:\n",mytree)
运行结果如下
最终构建的C4.5分类树为:
{'年收入(k)': {'>97.5': '否', '<=97.5': {'婚姻状况': {'离异': '是', '单身': '是', '已婚': '否'}}}}
将构建的C4.5分类树绘制出来,用ID3算法里介绍的绘制树形图
总结
C4.5算法优缺点
优点: 产生的分类规则易于理解,准确率较高
缺点:
- C4.5算法只能用于分类;
- C4.5是多叉树,用二叉树效率会提高;
- 在构造树的过程中,需要对数据集进行多次的顺序扫描和排序(尤其是对连续特征),因而导致算法的低效;
- 在选择分裂属性时没有考虑到条件属性间的相关性,只计算数据集中每一个条件属性与决策属性之间的期望信息,有可能影响到属性选择的正确性;
- C4.5只适合于能够驻留于内存的数据集,当训练集大得无法在内存容纳时程序无法运行;
相关链接
书籍:《机器学习实战》、周志华的西瓜书《机器学习》