感觉好久都没有写过程序了,一直上课没有时间。最近有点空,然后就写了下西瓜书中的决策树的实现。由于本人才疏学浅,采用的实现方式和数据结构可能不合理,没有考虑代码的复杂度和时间复杂度等等,仅写下自己的实现想法(大神们就打扰了)。该程序是基于C++语言来实现的,算法就是西瓜书上面的实现算法,采用最简单的ID3算法,用信息增益来选择最优划分,进而进行决策树的实现(没有对决策树进行剪枝操作,以后有时间再改进)。
1、基本概念
决策树的详细算法我就不介绍了,具体参看西瓜书。在这里,我想写一下算法的大体。首先说明一下属性、属性值、属性集、类别、训练集的概念。
1、属性:属性就是用来描述物体特征的量。比如:描述一个西瓜可以用色泽、根蒂、敲声、纹理、脐部、触感等等属性来说明一个西瓜是说明样子的。
2、属性值:属性值是属性的取值。比如:一个西瓜的色泽可以用青绿、乌黑、浅白来描述,这些就是西瓜的色泽属性的属性值。
3、属性集:属性集是物体所有属性的集合。顾名思义,就是上述列出的属性打包在一起组成的集合。
4、类别:物体的种类,决策树的目的就是根据属性来对物体进行分类。比如:西瓜有好瓜和坏瓜。
5、训练集:训练集就是样本集,所有的学习都需要通过数据来形成,决策树也是一样的。需要通过样本来形成一棵决策树。训练集必须包括物体各个属性的属性值和类别。
然后说明下算法的三种情况:
设训练集为D,属性集为A,需要生成树的节点。
1、若训练集D中的全部样本的类别都是一样的,比如都是好瓜或者都是坏瓜。这时候将节点的值标记为样本的类别。
2、若属性集A中的属性为空,即树的分支已经到底了,样本的所有属性已经用完,这时候需要将节点标记为叶节点。或者有训练集D中的所有样本,在属性集A上的取值是一样的。比如属性集A={色泽、根蒂、敲声},而训练集D={{青绿、蜷缩、浊响、清晰、凹陷、硬滑、好瓜}、{青绿、蜷缩、浊响、稍糊、稍凹、软粘、好瓜}、{青绿、蜷缩、浊响、模糊、凹陷、软粘、坏瓜}},这时可以看出,训练集D在属性集A上的取值都为:青绿、蜷缩、浊响。这两种情况需要将节点标记为叶节点,叶节点的属性值为训练集中类别最多的类。如上述例子,就需要将叶节点标记为好瓜。
3、在第三步之前需要先从属性集A中选择一个最优划分属性a,如何选择最优属性a,下面再说明。
假设选择最优属性a的值的集合为{b1、b2、b3}。例如,假设选择的属性是a=色泽,而其对应的属性集为b={青绿、乌黑、浅白}。
这时候,我们需要对每个属性值从训练集D中划分出属性a的取值为b1(b2、b3)的子集c。例如,我们的训练集D={{青绿、蜷缩、浊响、清晰、凹陷、硬滑、好瓜}、{青绿、蜷缩、浊响、稍糊、稍凹、软粘、好瓜}、{青绿、蜷缩、浊响、模糊、凹陷、软粘、坏瓜}},然后假设我们根据属性“纹理”=“凹陷”进行划分,则子集c={{青绿、蜷缩、浊响、清晰、凹陷、硬滑、好瓜}、{青绿、蜷缩、浊响、模糊、凹陷、软粘、坏瓜}}。
然后产生一个节点,(1)若子集c为空,则将该节点标记为叶节点,标记的类别为训练集D中类别最多的类。(2)若c不为空,则将属性a从属性集A中剔除,将剩下的属性集合子集c,从第一步开始继续划分(即递归)。
下面说明最优属性a是如何划分的。首先是信息熵的概念,信息熵的定义如下:
其中,n为训练集D的类别数(如子集中的类别为好瓜、坏瓜,则n=2)。pk为第k个类别的样本在训练集中的比例。并规定若,则
。
有了信息熵就可以写出信息增益了,信息增益定义为:
其中,V为属性a的可能取值个数,Dv为属性a对应的属性值划分出来的训练子集(比如上面提到的c子集),D为训练集。
该算法划分最优属性a是根据信息增益来划分的,即信息增益越大,说明以a作为下一个属性来生成决策树最佳。
举个例子以便理解。
若训练集D={{青绿 蜷缩 浊响 清晰 凹陷 硬滑 好瓜} {乌黑 蜷缩 沉闷 清晰 凹陷 硬滑 好瓜 } {青绿 蜷缩 沉闷 稍糊 稍凹 硬滑 坏瓜}},则n=2,信息熵为
以计算"色泽"信息增益为例(D中色泽属性值有:青绿*2,乌黑*1):
2、C++算法实现
首先我把西瓜书上的训练集列出来,对理解程序有帮助。
(1)输入函数
输入函数我没有在类内定义,而是直接在主函数中定义,因为我感觉这样比较好,输入与类分开。我们可以看到,数据是非常多的,有属性以及各个样本的各属性对应的属性值。我这里是主要采用map这个结构来对这张表进行存储。废话不多说,直接上程序:
//全局变量
//定义属性数组,存放可能的属性,包括类别
vector<string> data_Attributes;//对于本数据集来说就是:色泽 根蒂 敲声 纹理 脐部 触感 类别
//定义各属性对应的属性值
map<string, vector<string>> data_AttValues;//比如:色泽={青绿 乌黑 浅白}
//定义剩余属性,不包括类别(这个主要用于后面算法的递归)
vector<string> remain_Attributes;//色泽 根蒂 敲声 纹理 脐部 触感
//定义数据表,属性-属性值(全部数据的属性值放在同一个数组)
map<string, vector<string>>data_Table;//整张表
//输入数据生成数据集
void data_Input()
{
//输入属性(色泽 根蒂 敲声 纹理 脐部 触感 好瓜)
string input_Line,temp_Attributes;
cout << "请输入属性:" << endl;
//获取一行数据,然后绑定到数据流istringstream
getline(cin, input_Line);
istringstream input_Attributes(input_Line);
//将数据流内容(空格不输出)输入数据属性数组中
while (input_Attributes >> temp_Attributes)
{
data_Attributes.push_back(temp_Attributes);
}
//剔除类别这个属性
remain_Attributes = data_Attributes;
remain_Attributes.pop_back();
//定义样本数量
int N = 0;
cout << "请输入样本数量:" << endl;
cin >> N;
cin.ignore();//清空cin缓冲区中的留下的换行符
//输入数据(属性值)
cout << "请输入样本:" << endl;
//一共N个训练样本
for (int j = 0; j < N; j++)
{
string temp_AttValues;
//获取一行属性值输入
getline(cin, input_Line);
istringstream input_AttValues(input_Line);
//将各属性值输入到数据表data_table中
for (int i = 0; i < data_Attributes.size(); i++)
{
input_AttValues >> temp_AttValues;
data_Table[data_Attributes[i]].push_back(temp_AttValues);
}
}
//生成各属性对应的属性值集的映射data_AttValues
for (int i = 0; i < data_Attributes.size(); i++)
{
//通过set结构来统计所有样本中各属性对应的属性值的所有可能的取值
//如:“色泽”的可能取值为:青绿 乌黑 浅白
set<string> attValues;
for (int j = 0; j < N; j++)
{
//注意:data_Attributes[i]代表某个属性
//而data_Table[data_Attributes[i]]是一个数组
string temp = data_Table[data_Attributes[i]][j];
//若有重复属性值,set是不会插入的
attValues.insert(temp);
}
for (set<string>::iterator it = attValues.begin(); it != attValues.end(); it++)
{
//将所有可能的属性值存入data_AttValues[data_Attributes[i]]
data_AttValues[data_Attributes[i]].push_back(*it);
}
}
}
(2)决策树节点类的设计
决策树类需要包含的东西挺多的,成员变量主要有:
样本数据集的属性个数:attribute_Num
本节点的属性:node_Attribute
本节点属性对应的所有可取的属性值:node_AttValues
数据集的属性:data_Attribute
从根节点到本节点未被用于最优划分属性的属性集:remain_Attributes
本节点属性对应的属性值与子节点的地址的映射集,即本节点属性取属性值后下一个节点的地址的集合(有多个属性值,所以有多个不同的地址):childNode(为空说明该节点是叶节点)
该节点对应的样本的数据表:MyDateTable
各属性对应的属性值(外部传进来的,对后面操作有作用):data_AttValues
成员函数主要有:
计算信息熵函数:calc_Entropy()
计算信息增益并寻找最优划分属性a:findBestAttribute()
生成本节点的子节点:generate_ChildNode()
设置节点的属性:set_NodeAttribute()
训练成完整的决策树后,可根据所给样本的属性,预测出该样本的类别:findClass()
class Tree_Node
{
public:
//构造函数,参数依次为:数据集表(西瓜数据表)、西瓜所有的属性包括类别、每个属性可能的取值构成的表、剩余的未被划分的属性
Tree_Node(map<string, vector<string>> temp_Table, vector<string> temp_Attribute,map<string, vector<string>> data_AttValues, vector<string> temp_remain);
//生成子节点
void generate_ChildNode();
//计算信息增益 寻找最优划分属性
string findBestAttribute();
//计算信息熵
double calc_Entropy(map<string, vector<string>> temp_Table);
//设置节点的属性
void set_NodeAttribute(string atttribute);
//根据所给属性,对数据进行分类
string findClass(vector<string> attributes);
virtual ~Tree_Node();
private:
//属性个数,不包括类别
int attribute_Num;
//本节点的属性
string node_Attribute;
//数据集属性
vector<string> data_Attribute;
//本节点的所有属性值
vector<string> node_AttValues;
//剩余属性集
vector<string>remain_Attributes;
//子节点,本节点属性对应的属性值与子节点地址进行一一映射
//为空说明该节点为叶节点
map<string, Tree_Node *> childNode;
//样本集合表
map<string, vector<string>> MyDateTable;
//定义各属性对应的属性值
map<string, vector<string>> data_AttValues;
};
(3)类的实现
首先是类的构造函数,构造函数主要是对类的成员变量进行初始化:
Tree_Node::Tree_Node(map<string, vector<string>> temp_Table,vector<string> temp_Attribute, map<string, vector<string>> data_AttValues, vector<string> temp_remain)
{
//全部属性,包括类别
data_Attribute = temp_Attribute;
//属性个数,不包括类别
attribute_Num = (int)temp_Attribute.size() - 1;
//各属性对应的属性值
this->data_AttValues = data_AttValues;
//属性表
MyDateTable = temp_Table;
//剩余属性集
remain_Attributes = temp_remain;
}
然后是计算信息熵的成员函数的实现:
//计算信息熵
double Tree_Node::calc_Entropy(map<string, vector<string>> temp_Table)
{
map<string, vector<string>> table = temp_Table;
//数据集中样本的数量
int sample_Num = (int)temp_Table[data_Attribute[0]].size();
//计算数据集中的类别数量
map<string, int> class_Map;
for (int i = 0; i < sample_Num; i++)
{
//data_Attribute[attribute_Num]对应的就是数据集的类别
string class_String = table[data_Attribute[attribute_Num]][i];
class_Map[class_String]++;
}
map<string, int>::iterator it = class_Map.begin();
//存放类别及其对应的数量
//vector<string> m_Class;
vector<int> n_Class;
for (; it != class_Map.end(); it++)
{
//m_Class.push_back(it->first);
n_Class.push_back(it->second);
}
//计算信息熵
double Ent = 0;
for (int k = 0; k < class_Map.size(); k++)
{
//比例
double p = (double) n_Class[k] / sample_Num;
if (p == 0)
{
//规定了p=0时,plogp=0
continue;
}
//c++中只有log和ln,因此需要应用换底公式
Ent -= p * (log(p) / log(2));//信息熵
}
return Ent;
}
接下来实现信息增益的计算以及寻找出最优的划分属性:
//寻找最优划分
string Tree_Node::findBestAttribute()
{
//样本个数
int N = (int)MyDateTable[data_Attribute[0]].size();
//定义用于存放最优属性
string best_Attribute;
//信息增益
double gain = 0;
//对每个剩余属性
for (int i = 0; i < remain_Attributes.size(); i++)
{
//定义信息增益,选取增益最大的属性来划分即为最优划分
double temp_Gain = calc_Entropy(MyDateTable);//根据公式先将本节点的信息熵初始化给增益
//对该属性的数据集进行分类(获取各属性值的数据子集)
string temp_Att = remain_Attributes[i];//假设选取的属性
vector<string> remain_AttValues;//属性可能的取值
for (int j = 0; j < data_AttValues[temp_Att].size(); j++)
{
remain_AttValues.push_back(data_AttValues[temp_Att][j]);
}
//对每个属性值求信息熵
for (int k = 0; k < remain_AttValues.size(); k++)
{
//属性值
string temp_AttValues = remain_AttValues[k];
int sample_Num = 0;//该属性值对应样本数量
//定义map用来存放该属性值下的数据子集
map<string, vector<string>>sub_DataTable;
for (int l = 0; l < MyDateTable[temp_Att].size(); l++)
{
if (temp_AttValues == MyDateTable[temp_Att][l])
{
sample_Num++;
//将符合条件的训练集存入sub_DataTable
for (int m = 0; m < data_Attribute.size(); m++)
{
sub_DataTable[data_Attribute[m]].push_back(MyDateTable[data_Attribute[m]][l]);
}
}
}
//累加每个属值的信息熵
temp_Gain -= (double)sample_Num / N * calc_Entropy(sub_DataTable);
}
//比较寻找最优划分属性
if (temp_Gain > gain)
{
gain = temp_Gain;
best_Attribute = temp_Att;
}
}
return best_Attribute;
}
然后是实现如何生成子节点的成员函数,在这之前先实现一个设置节点的属性的函数,该函数主要用于设置子节点的节点属性:
void Tree_Node::set_NodeAttribute(string attribute)
{
//设置节点的属性
this->node_Attribute = attribute;
}
生成子节点,生成方式就是按照西瓜书上面的算法一步一步实现即可,代码如下:
void Tree_Node::generate_ChildNode()
{
//样本个数
int N = (int)MyDateTable[data_Attribute[0]].size();
//将数据集中类别种类和数量放入map里面,只需判断最后一列即可
map<string,int> category;
for (int i = 0; i < N; i++)
{
vector<string> temp_Class;
temp_Class = MyDateTable[data_Attribute[attribute_Num]];
category[temp_Class[i]]++;
}
//第一种情况
//只有一个类别,标记为叶节点
if (1 == category.size())
{
map<string, int>::iterator it = category.begin();
node_Attribute = it->first;
return;
}
//第二种情况
//先判断所有属性是否取相同值
bool isAllSame = false;
for (int i = 0; i < remain_Attributes.size(); i++)
{
isAllSame = true;
vector<string> temp;
temp = MyDateTable[remain_Attributes[i]];
for (int j = 1; j < temp.size(); j++)
{
//只要有一个不同,即可退出
if (temp[0] != temp[j])
{
isAllSame = false;
break;
}
}
if (isAllSame == false)
{
break;
}
}
//若属性集为空或者样本中的全部属性取值相同
if (remain_Attributes.empty()||isAllSame)
{
//找出数量最多的类别及其出现的个数,并将该节点标记为该类
map<string, int>::iterator it = category.begin();
node_Attribute = it->first;
int max = it->second;
it++;
for (; it != category.end(); it++)
{
int num = it->second;
if (num > max)
{
node_Attribute = it->first;
max = num;
}
}
return;
}
//第三种情况
//从remian_attributes中划分最优属性
string best_Attribute = findBestAttribute();
//将本节点设置为最优属性
node_Attribute = best_Attribute;
//对最优属性的每个属性值
for (int i = 0; i < data_AttValues[best_Attribute].size(); i++)
{
string best_AttValues = data_AttValues[best_Attribute][i];
//计算属性对应的数据集D
//定义map用来存放该属性值下的数据子集
map<string, vector<string>> sub_DataTable;
for (int j = 0; j < MyDateTable[best_Attribute].size(); j++)
{
//寻找最优属性在数据集中属性值相同的数据样本
if (best_AttValues == MyDateTable[best_Attribute][j])
{
//找到对应的数据集,存入子集中sub_DataTable(该样本的全部属性都要存入)
for (int k = 0; k < data_Attribute.size(); k++)
{
sub_DataTable[data_Attribute[k]].push_back(MyDateTable[data_Attribute[k]][j]);
}
}
}
//若子集为空,将分支节点(子节点)标记为叶节点,类别为MyDateTable样本最多的类
if (sub_DataTable.empty())
{
//生成子节点
Tree_Node * p = new Tree_Node(sub_DataTable, data_Attribute, data_AttValues, remain_Attributes);
//找出样本最多的类,作为子节点的属性
map<string, int>::iterator it = category.begin();
string childNode_Attribute = it->first;
int max_Num = it->second;
it++;
for (; it != category.end(); it++)
{
if (it->second > max_Num)
{
max_Num = it->second;
childNode_Attribute = it->first;
}
}
//设置子叶节点属性
p->set_NodeAttribute(childNode_Attribute);
//将子节点存入childNode,预测样本的时候会用到
childNode[best_AttValues] = p;
}
else//若不为空,则从剩余属性值剔除该属性,调用generate_ChildNode继续往下细分
{
vector<string> child_RemainAtt;
child_RemainAtt = remain_Attributes;
//找出child_RemainAtt中的与该最佳属性相等的属性
vector<string>::iterator it = child_RemainAtt.begin();
for (; it != child_RemainAtt.end(); it++)
{
if (*it == best_Attribute)
{
break;
}
}
//删除
child_RemainAtt.erase(it);
//生成子节点
Tree_Node * pt = new Tree_Node(sub_DataTable, data_Attribute, data_AttValues, child_RemainAtt);
//将子节点存入childNode
childNode[best_AttValues] = pt;
//子节点再调用generate_ChildNode函数
pt->generate_ChildNode();
}
}
}
在最后,我们必须有个预测函数,即如果输入一个样本,需要给出该样本的是什么类别的。比如:给出西瓜数据:青绿 蜷缩 浊响 清晰 凹陷 硬滑,则调用该函数应该输出:“好瓜”。该函数实现如下:
//输入为待预测样本的所有属性集合
string Tree_Node::findClass(vector<string> attributes)
{
//若存在子节点
if (childNode.size() != 0)
{
//找出输入的样例中与本节点属性对应的属性值,以便寻找下个节点,直到找到叶节点
string attribute_Value;
for (int i = 0; i < data_AttValues[node_Attribute].size(); i++)
{
for (int j = 0; j < attributes.size(); j++)
{
//data_AttValues[node_Attribute]为属性node_Attribute对应的所有可能的取值集合
if (attributes[j] == data_AttValues[node_Attribute][i])
{
//找到了样例对应的属性值
attribute_Value = attributes[j];
break;
}
}
//找到后就没必要继续循环了
if (!attribute_Value.empty())
{
break;
}
}
//找出该属性值对应的子节点的地址,以便进行访问
Tree_Node *p = childNode[attribute_Value];
return p->findClass(attributes);//递归寻找,直到找到叶节点为止
}
else//不存在子节点说明已经找到分类,类别为本节点的node_Attribute
{
return node_Attribute;
}
}
3、预测结果
先放上我的主函数,主函数主要是调用函数,然后格式化输出。
int main()
{
//输入
data_Input();
Tree_Node myTree(data_Table, data_Attributes, data_AttValues, remain_Attributes);
//进行训练
myTree.generate_ChildNode();
//输入预测样例,进行预测
vector<string> predict_Sample;
string input_Line, temp;
cout << "请输入属性进行预测:" << endl;
getline(cin, input_Line);
istringstream input_Sample(input_Line);
while (input_Sample >> temp)
{
//将输入预测样例的属性都存入predict_Sample,以便传参
predict_Sample.push_back(temp);
}
cout << endl;
//预测
cout << "分类结果为:" << myTree.findClass(predict_Sample) << endl;
system("pause");
return 0;
}
运行结果:
可以看出,预测结果是正确的。但真正的预测应该不能取样本中已有的样例,我这样做是为了验证程序的正确性。
好了,文章到此就结束了。关于程序的相关优化和剪枝操作以后有时间再来完善。下面附上源程序代码供下载:https://download.csdn.net/download/m0_37543178/10793382。(ps:没有积分的可以直接找我给你源代码:))