《集体智慧编程》读书笔记6
最近重读《集体智慧编程》,这本当年出版的介绍推荐系统的书,在当时看来很引领潮流,放眼现在已经成了各互联网公司必备的技术。 这次边阅读边尝试将书中的一些Python语言例子用C#来实现,利于自己理解,代码贴在文中方便各位园友学习。
由于本文可能涉及到的与原书版权问题,请第三方不要以任何形式转载,谢谢合作。
第六部分 决策树建模
这一部分我们继续介绍一种分类器算法 - 决策树学习。决策树产生的模型的最大特点就是可以很容易的看出推导分类的过程,甚至可以将其模型表示为if else
语句。
预测注册用户
这个场景中我们要实现的功能是,通过用户的一系列行为预测用户成为付费用户,还是继续使用免费账号的可能性。 通过了解什么样的用户更会最终成为付费用户,网站可以更好的做推广或改善相关的功能。所以这就需要知道用户特征及行为和最终付费之间的关系,而决策树方法特有的易理解推导模型的特点恰恰非常适合这种场景。 假设我们收集到了如下信息,前4列是用户的特征及行为,最后一列是用户最终选择了什么服务,这也正式需要预测的内容。
来源网站 | 位置 | 是否阅读过FAQ | 浏览网页数 | 选择服务类型 |
---|---|---|---|---|
slashdot | USA | yes | 18 | None |
France | yes | 23 | Premium | |
digg | USA | yes | 24 | Basic |
kiwitobes | France | yes | 23 | Basic |
UK | no | 21 | Premium | |
(direct) | New Zealand | no | 12 | None |
(direct) | UK | no | 21 | Basic |
USA | no | 24 | Premium | |
slashdot | France | yes | 19 | None |
digg | USA | no | 18 | None |
UK | no | 18 | None | |
kiwitobes | UK | no | 19 | None |
digg | New Zealand | yes | 12 | Basic |
slashdot | UK | no | 21 | None |
UK | yes | 18 | Basic | |
kiwitobes | France | yes | 19 | Basic |
我们在项目中添加一个TreePredict类并将这些数据添加到类中。
public class TreePredict
{
public static List<object[]> MyData = new List<object[]>()
{
new object[]{"slashdot","USA","yes",18,"None"},
new object[]{"google","France","yes",23,"Premium"},
new object[]{"digg","USA","yes",24,"Basic"},
new object[]{"kiwitobes","France","yes",23,"Basic"},
new object[]{"google","UK","no",21,"Premium"},
new object[]{"(direct)","New Zealand","no",12,"None"},
new object[]{"(direct)","UK","no",21,"Basic"},
new object[]{"google","USA","no",24,"Premium"},
new object[]{"slashdot","France","yes",19,"None"},
new object[]{"digg","USA","no",18,"None"},
new object[]{"google","UK","no",18,"None"},
new object[]{"kiwitobes","UK","no",19,"None"},
new object[]{"digg","New Zealand","yes",12,"Basic"},
new object[]{"slashdot","UK","no",21,"None"},
new object[]{"google","UK","yes",18,"Basic"},
new object[]{"kiwitobes","France","yes",19,"Basic"}
};
}
实现决策树算法
新建一个名为DecisionNode
的类表示决策树上的一个节点
public class DecisionNode
{
public DecisionNode()
{
}
public DecisionNode(int col, object value, DecisionNode tb, DecisionNode fb)
{
Col = col;
Value = value;
Tb = tb;
Fb = fb;
}
public DecisionNode(Dictionary<string, int> results)
{
Results = results;
}
public int Col { get; set; }
public object Value { get; set; }
public Dictionary<string, int> Results { get; set; }
public DecisionNode Tb { get; set; }
public DecisionNode Fb { get; set; }
}
Col
表示这个节点判断条件对应的上面表格的列的索引Value
表示为了使判断条件为true,需要的值是多少Tb
当此节点验证结果为true时对应的子节点Fb
当此节点验证结果为false时对应的子节点Results
只有叶节点这个属性不为空,表示这个分支的结果
构造决策树的函数返回一个根节点,沿着按条件沿着根节点的Tb
或Fb
往下,最终可以得到结果。
训练决策树
这里训练决策树的算法名为CART(Classification and Regression Trees,即分类回归树)。 算法首先创建一个根节点,然后评估表中所有观测变量,从中选出最合适的变量对数据进行拆分。 函数DivideSet就是用于对数据进行拆分,其接受三个参数,第一个是数据列表,第二个是表示需要拆分的参考列在列表中位置的数字,最后一个是参考值。函数执行完成返回两个列表第一个是匹配参考值的所有记录,另一个是不匹配参考值的所有记录。 我们在类TreePredict
中实现这个函数:
// 在某一列上对数据集合进行拆分,能处理数值型数据或名词性数据(字符串)
public Tuple<List<object[]>, List<object[]>> DivideSet(List<object[]> rows, int column, object value)
{
// 定义一个lambda用于判断记录应该归为第一组还是第二组(即匹配参考值还是不匹配)
Func<object[], bool> splitFunc = null;
if (value is int)
splitFunc = r => Convert.ToInt32(r[column]) >= Convert.ToInt32(value);
else if (value is float)
splitFunc = r => Convert.ToSingle(r[column]) >= Convert.ToSingle(value);
else
splitFunc = r => r[column].ToString() == value.ToString();
// 将数据集拆分成两个集合并返回
var set1 = rows.Where(r => splitFunc(r)).ToList();
var set2 = rows.Where(r => !splitFunc(r)).ToList();
return Tuple.Create(set1, set2);
}
函数中定义了名为splitFunc
的lambda用于按照不同的类型对列值和参考值进行对比处理。
接着我们测试一下上面的函数,按照"是否阅读过FAQ"来对结果进行拆分:
var treePredict = new TreePredict();
var splitSet = treePredict.DivideSet(TreePredict.MyData, 2, "yes");
Action<object[]> printRow = r => { Console.WriteLine($"{r[0]},{r[1]},{r[2]},{r[3]},{r[4]}"); };
Console.WriteLine("set1:");
splitSet.Item1.ForEach(r => printRow(r));
Console.WriteLine("set2:");
splitSet.Item2.ForEach(r => printRow(r));
拆分结果如下:
是否阅读过FAQ - True | 是否阅读过FAQ - False |
---|---|
None | Premium |
Premium | None |
Basic | Basic |
Basic | Premium |
None | None |
Basic | None |
Basic | None |
可以看到拆分的两个集合中不同的结果混在一起。这说明按照“是否阅读过FAQ”这个列来分类不合理。 所以需要找一种方法来确定使用哪个列来对数据表进行划分。
选择拆分方案
好的拆分方案得到的集合中,结果列的混杂程度应尽可能的小。 首先我们添加一个函数UniqueCounts
到TreePredict
对结果列进行计数:
// 对结果列(最后一列)进行计数
public Dictionary<string, int> UniqueCounts(List<object[]> rows)
{
var results = new Dictionary<string, int>();
foreach (var row in rows)
{
// 计数结果在最后一列
var r = row.Last().ToString();
if (!results.ContainsKey(r))
results.Add(r, 0);
results[r] += 1;
}
return results;
}
这个函数的作用就是找出所有不同的结果,并对结果进行计数。这个结果将用于计算数据集合的混杂程度。 下面介绍两种度量混杂程度的算法:基尼不纯度(Giniimpurity)和熵(entropy)。
基尼不纯度
基尼不纯度是指将来自集合中某个值随机应用于集合中某一数据项的预期误差率。 假如集合中的每个数据都是同一分类,那么推测总是正确的,所以预期误差率总是为0。而如果有4种类别的数据且数量相等,则只有25%的概率推测正确,所以误差率为75%。 在TreePredict
中添加基尼不纯度的计算方法GiniImpurity
:
// 随机放置的数据项出现于错误分类中的概率
public float GiniImpurity(List<object[]> rows)
{
var total = rows.Count;
var counts = UniqueCounts(rows);
var imp = 0f;
foreach (var k1 in counts.Keys)
{
var p1 = counts[k1] / (float)total;
foreach (var k2 in counts.Keys)
{
if (k1 == k2)
continue;
var p2 = counts[k2] / (float)total;
imp += p1 * p2;
}
}
return imp;
}
这个函数累加了某一行数据被随机分配到错误结果的概率,得到总概率。 这个概率越高说明数据拆分越不理想,而0说明每一行数据都被分到正确的集合中。
熵
熵在信息理论中用于表示集合的无序程度,和这里要求的混杂程度很类似。 将计算熵的Entropy
方法加入到TreePredict
中:
// 熵是遍历所有可能结果之后所得到的p(x)log(p(x))之和
public float Entropy(List<object[]> rows)
{
Func<float, float> log2 = x => (float)(Math.Log(x) / Math.Log(2));
var results = UniqueCounts(rows);
// 开始计算熵值
var ent = 0f;
foreach (var r in results.Keys)
{
var p = results[r] / (float)rows.Count;
ent -= p * log2(p);
}
return ent;
}
如果所有结果都相同,上面方法计算的熵为0,而如果数据集越是混乱,相应熵就越高。我们的目标就是拆分数据集并降低熵。
我们通过下面的代码测试基尼不纯度和熵的计算:
var treePredict = new TreePredict();
var gini = treePredict.GiniImpurity(TreePredict.MyData);
Console.WriteLine(gini);
var entr = treePredict.Entropy(TreePredict.MyData);
Console.WriteLine(entr);
var setTuple = treePredict.DivideSet(TreePredict.MyData, 2, "yes");
gini = treePredict.GiniImpurity(setTuple.Item1);
Console.WriteLine(gini);
entr = treePredict.Entropy(setTuple.Item1);
Console.WriteLine(entr);
在现实中熵的使用更为普遍,后文将以熵作为度量混杂程度的标准。
递归方式构造决策树
有了判断集合混杂度的方法,我们可以通过计算群组拆分后熵的信息增益来判断拆分的好坏。 信息增益是指整个群组的熵与拆分后两个新群组的熵的加权平均值之间的差。差值即信息增益越大说明拆分效果越好。我们在每个列上都进行拆分尝试并计算信息增益,最终找出信息增益最大的列。 对于新得到的子集合,如果子集合可以继续拆分(如结果有不同值存在才有必要继续拆分),将在其上继续这个拆分过程直到信息增益为0。 我们在TreePredict
添加一个递归函数BuildTree
来实现这个递归构建树的过程。
public DecisionNode BuildTree(List<object[]> rows, Func<List<object[]>, float> scoref = null)
{
if (scoref == null)
scoref = Entropy;
var rowsCount = rows.Count;
if (rowsCount == 0) return new DecisionNode();
var currentScore = scoref(rows);
//定义一些变量记录最佳拆分条见
var bestGain = 0f;
Tuple<int, object> bestCriteria = null;
Tuple<List<object[]>, List<object[]>> bestSets = null;
var columnCount = rows[0].Length - 1;
for (int i = 0; i < columnCount; i++)
{
// 在当前列中生成一个由不同值构成的序列
var columnValues = new List<object>();
if (rows[0][i] is int)
columnValues = rows.Select(r => r[i]).Cast<int>().Distinct().Cast<object>().ToList();
else if (rows[0][i] is float)
columnValues = rows.Select(r => r[i]).Cast<float>().Distinct().Cast<object>().ToList();
else
columnValues = rows.Select(r => r[i].ToString()).Distinct().Cast<object>().ToList();
// 根据这一列中的每个值,尝试对数据集进行拆分
foreach (var value in columnValues)
{
var setTuple = DivideSet(rows, i, value);
var set1 = setTuple.Item1;
var set2 = setTuple.Item2;
//信息增益
var p = set1.Count / (float)rowsCount;
var gain = currentScore - p * scoref(set1) - (1 - p) * scoref(set2);
if (gain > bestGain && set1.Count > 0 && set2.Count > 0)
{
bestGain = gain;
bestCriteria = Tuple.Create(i, value);
bestSets = setTuple;
}
}
}
// 创建子分支
if (bestGain > 0)
{
var trueBranch = BuildTree(bestSets.Item1);
var falseBranch = BuildTree(bestSets.Item2);
return new DecisionNode(
col: bestCriteria.Item1,
value: bestCriteria.Item2,
tb: trueBranch,
fb: falseBranch
);
}
else
{
return new DecisionNode(UniqueCounts(rows));
}
}
代码中,我们在每一列上,按照列中每一个不同的值进行拆分尝试,并找到一个使信息增益最大的拆分方式。递归这个过程直到树构建完成。 我们可以通过下面的代码测试决策树的构造:
var treePredict = new TreePredict();
treePredict.BuildTree(TreePredict.MyData);
很显然,现在看不到任何可视化的结果,下一节将编写代码以文本方式打印决策树
展示决策树
仍然是在TreePredict
中建立新方法,PrintTree
方法将以文本方式展示树,由于是遍历树,这个函数自然也是一个递归函数。
public void PrintTree(DecisionNode tree, string indent = "")
{
//是叶节点吗?
if (tree.Results != null)
Console.WriteLine(JsonConvert.SerializeObject(tree.Results));
else
{
//打印判断条件
Console.WriteLine($"{tree.Col}:{tree.Value}? ");
//打印分支
Console.Write($"{indent}T->");
PrintTree(tree.Tb, indent + " ");
Console.Write($"{indent}F->");
PrintTree(tree.Fb, indent + " ");
}
}
通过下面的代码来打印一下决策树:
var treePredict = new TreePredict();
var tree= treePredict.BuildTree(TreePredict.MyData);
treePredict.PrintTree(tree);
如打印结果所示,能直观看到分类过程是决策树算法的最大优势。
使用决策树分类
一旦有了决策树,给出输入后(用户特征及行为)沿着树的节点一路向下,逐个回答问题并进入下一层节点,最终就可以得到答案,即输入所属的分类。而沿着一个分类向上,也可以回溯到是什么样的输入最终得到输出这样一个推理过程。 在TreePredict
中添加Classify
来实现分类:
public Dictionary<string,int> Classify(object[] observation, DecisionNode tree)
{
if (tree.Results != null)
return tree.Results;
var v = observation[tree.Col];
DecisionNode branch;
if (v is int || v is float)
{
var val = v is int ? Convert.ToInt32(v) : Convert.ToSingle(v);
var treeVal = tree.Value is int ? Convert.ToInt32(tree.Value) : Convert.ToSingle(tree.Value);
branch = val >= treeVal ? tree.Tb : tree.Fb;
}
else
{
branch = v.ToString() == tree.Value.ToString() ? tree.Tb : tree.Fb;
}
return Classify(observation, branch);
}
使用这个函数来尝试分类一条记录:
var treePredict = new TreePredict();
var tree= treePredict.BuildTree(TreePredict.MyData);
var result = treePredict.Classify(new object[] {"(direct)","USA","yes",5}, tree);
Console.WriteLine(JsonConvert.SerializeObject(result));
到这里,我们有了一系列的方法实现决策树的构造,显示和预测。只要是类似示例数据这样的表格话数据都可以处理。
决策树剪枝
之前的训练方法可能出现的问题是决策树过度训练导致过度拟合,这种情况下决策树的分支会过于针对训练数据,从而使决策树的分类结果过于具有特殊性。 一种可能的解决办法,只要熵的减少量到小于某个值时就停止继续创建分支,而不是到熵无法减小才停止创建。这种方法的缺陷是某次创建分支熵减小量很小,而下一次创建分支却会使熵大幅减小。 另一种策略是先按之前的方法创建好整棵树,然后再尝试消除多余的节点,这个方法就称为剪枝。 具体来说剪枝过程是合并两个节点,并判断熵的增加量是否小于指定的阈值,如果小于指定阈值则进行合并,否则不予处理。 在TreePredict
中实现剪枝函数Prune
:
public void Prune(DecisionNode tree, float mingain)
{
//如果分支不是叶节点,则进行剪枝操作
if (tree.Tb.Results == null)
Prune(tree.Tb, mingain);
if (tree.Fb.Results == null)
Prune(tree.Fb, mingain);
//如果两个子分支都是叶节点,则判断是否需要合并
if (tree.Tb.Results != null && tree.Fb.Results != null)
{
//构造合并后的数据集
IEnumerable<object[]> tb = new List<object[]>();
IEnumerable<object[]> fb = new List<object[]>();
tb = tree.Tb.Results.Aggregate(tb, (current, tbKvPair)
=> current.Union(ArrayList.Repeat(new object[] {tbKvPair.Key}, tbKvPair.Value).Cast<object[]>()));
fb = tree.Fb.Results.Aggregate(fb, (current, tbKvPair)
=> current.Union(ArrayList.Repeat(new object[] { tbKvPair.Key }, tbKvPair.Value).Cast<object[]>()));
//检查熵增加情况
var mergeNode = tb.Union(fb).ToList();
var delta = Entropy(mergeNode) - (Entropy(tb.ToList()) + Entropy(fb.ToList())/2);
Debug.WriteLine(delta);
if (delta < mingain)
{
//合并分支
tree.Tb = null;
tree.Fb = null;
tree.Results = UniqueCounts(mergeNode);
}
}
}
由于这是一个递归的过程,合并后的节点仍然可能成为再次被合并的分支节点。 我们使用不同的最小增益值来尝试剪枝:
var treePredict = new TreePredict();
var tree= treePredict.BuildTree(TreePredict.MyData);
treePredict.Prune(tree,0.1f);
treePredict.PrintTree(tree);
Console.WriteLine("--------------------------");
treePredict.Prune(tree, 1.01f);
treePredict.PrintTree(tree);
处理缺失数据
可以容忍数据缺失是决策树另一个优点。待预测数据某一列的值可能会缺失,而我们要改进决策树分类方法来接受这种缺失。 方法很简单,对于空缺的数据,我们将其分支的走向概率按照两个分支所占的比例来划分。实现方法见TreePredict
中的MdClassiy
方法:
public Dictionary<string, float> MdClassify(object[] observation, DecisionNode tree)
{
if (tree.Results != null)
return tree.Results.ToDictionary(r=>r.Key,r=>(float)r.Value);
var v = observation[tree.Col];
if (v == null)
{
var tr = MdClassify(observation, tree.Tb);
var fr = MdClassify(observation, tree.Fb);
var tcount = tr.Values.Count;
var fcount = fr.Values.Count;
var tw = tcount / (float)(tcount + fcount);
var fw = fcount / (float)(tcount + fcount);
var result = tr.ToDictionary(trKvp => trKvp.Key, trKvp => trKvp.Value*tw);
foreach (var frKvp in fr)
{
if (!result.ContainsKey(frKvp.Key))
result.Add(frKvp.Key, 0);
result[frKvp.Key] += frKvp.Value * fw;
}
return result;
}
else
{
DecisionNode branch;
if (v is int || v is float)
{
var val = v is int ? Convert.ToInt32(v) : Convert.ToSingle(v);
var treeVal = tree.Value is int ? Convert.ToInt32(tree.Value) : Convert.ToSingle(tree.Value);
branch = val >= treeVal ? tree.Tb : tree.Fb;
}
else
{
branch = v.ToString() == tree.Value.ToString() ? tree.Tb : tree.Fb;
}
return MdClassify(observation, branch);
}
}
如果发现缺失的列,则其左右分值的概率将重新计算并乘以各自的权重。
最后来看看使用有列缺失的数据进行分类测试的结果:
var treePredict = new TreePredict();
var tree = treePredict.BuildTree(TreePredict.MyData);
var result = treePredict.MdClassify(new object[] { "google", null, "yes", null }, tree);
Console.WriteLine(JsonConvert.SerializeObject(result));
result = treePredict.MdClassify(new object[] { "google", "France", null, null }, tree);
Console.WriteLine(JsonConvert.SerializeObject(result));
数值型结果
之前的例子中,结果是以类似“枚举值”的形式存在(最终的结果是一个个独立的分类)。如果结果是数值,则我们不能继续使用熵或基尼不纯度来评价结果。对于这些离散的数字可以使用方差代替熵作为评价划分的方法。 同样,我们将计算方差的方法Variance
放入TreePredict
类中:
public float Variance(List<object[]> rows)
{
if (rows.Count == 0) return 0;
var data = rows.Select(r => Convert.ToSingle(r.Last())).ToList();
var mean = data.Average();
var variance = data.Select(d => (float) Math.Pow(d - mean, 2)).Average();
return variance;
}
总结
决策树最大的优势在于可以容易的解释训练模型的形成过程,而这个过程对分析一个待预测问题的结果形成原因很有帮助。通过这个可以知道如何改进过程从而得到想要的结果。另外决策树对输入数据的包容性较大,可以接受数值型数据,也可是分类数据(类似枚举值),而且允许待预测数据有个别列缺失。而且,在树的叶节点中还给出了每种结果所占的比例,给用户作为参考。 而决策树几个缺点有,对于结果集区别界限不明显的情况效果不好,另外决策树也不擅长处理输入条件有相互关联的情况,最后决策树不能增量训练对于某些场景也是很大的限制。