游戏中的机器学习——决策树

游戏中的机器学习——决策树

20年前的著名策略游戏《黑与白》中有着一种称为「神兽」的有趣智能体,它们有着自己的个性,且会根据玩家给出的反馈去学习应该做什么。《黑与白》是较早一批使用机器学习NPC的游戏,构造这样的智能体他们混合使用了多种学习方法,其中用于塑造智能体判断能力的便是 决策树。决策树是经典的机器学习方法,它的逻辑直观易懂,不需要复杂的数学公式。即便从未接触过机器学习,也不难理解,不妨一起来尝试下用 C# 来实现决策树。

image

本文提及的代码与样例用例相关内容可戳这里

表达决策的树状结构

机器学习的一大特点便是「从数据中自行总结规律」。我们直接以《黑与白》中神兽的攻击行为为例,假设这是神兽对不同目标进行了攻击行为后,玩家给出奖惩情况(正数表示奖励,负数表示惩罚,数值表示力度,范围是[0, 1.0]):

阵营关系 战斗力 部族 玩家反馈
友好 弱小 KK -1.0
敌对 弱小 KK +0.4
友好 强大 NN -1.0
敌对 强大 NN -0.2
友好 弱小 XX -1.0
敌对 中等 XX +0.2
敌对 强大 XX -0.4
敌对 中等 AA 0.0
友好 弱小 AA -1.0

在这个表格中,前三列的「阵营关系」「战斗力」「部族」都是与被攻击对象相关的 属性,这些属性后面会被用来帮助我们构建决策判断。而「玩家反馈」则是学习的 目标标签,也是该决策树的核心学习对象——它代表了神兽攻击行为的最终结果反馈,是模型需要通过属性规律去「预测」的目标。

假设我们就是神兽,该如何通过这些数据来理解玩家的意图呢,奖惩到底与什么相关呢?也许你看出来了,只要对友好阵营发动攻击,就会受到玩家的严厉惩罚。对于敌方阵营,如果其战斗力强大,对其发起攻击时也会受到一定程度的惩罚。由此,我们可以做这样一个树状图来表示:

image

树中叶节点的数值,就是我们预测的玩家反馈。这里取该种情况下的平均值,例如,对敌对强大阵营发动攻击,树中给出的预测就是 \(\frac{(-0.4) + (-0.2)}{2} = -0.3\)。我们还可以发现,表格中的「部族」这项数据并没有什么用,可以推测玩家预期的攻击并不是针对特定部族的。

我们刚刚总结出的树状图便是一个决策树,它以树形结构表述了规律,从攻击对象属性与玩家反馈的数据中总结出了玩家期望的攻击意图。面对全新的攻击对象,我们也可以将它的相关属性带入这个决策树中,来预测玩家的奖惩从而决定是否进行攻击。

例如,现在多了一个未曾见过的:

阵营关系 战斗力 部族 玩家反馈
友好 中等 BB ?

按照决策树的结构,先看「阵营关系」,它属于友好,因此如果发动攻击想必玩家会给出 -1.0 反馈,故而不能进行攻击。

但是,刚刚我们构建的树还是人为分析得出的,有什么办法能自行从数据中总结出决策树吗?这是当然的,接下来我们就看看决策树的建立方法。

建立决策树的过程

决策树是自上而下递归建立的,其中有两个关键的问题:

  1. 选择何种属性作为下个节点
  2. 何时停止划分

对于第一个问题,需要我们在当前数据中选出一个能 最大程度划分「纯度」 的属性,「纯度」可以理解成数据类别的相同程度。之前我们用「阵营关系」进行一次划分后,「友好」这一分支中清一色都是 -1.0 纯度很高。但仅单一分支纯度高还不够,我们希望划分后的所有新分支中的纯度综合起来比之前好才行。

决策树选择何种属性作为节点的方法其实并不复杂,就是遍历计算每个属性作为划分依据时的纯度,选取能带来最大纯度的属性作为当前节点的划分属性

那要如何计算纯度呢,常见的方法有借助 信息熵基尼值均方差。这里我们采用 基尼值 的方法,因为它的计算比较简单,也比较好理解:

\[Gini = 1 - \sum_{i = 1}^n{p_i}^2 \]

\(n\) 是类别数量;\(p_i\) 是第i类样本在该节点的占比,基尼值越低,纯度越高。可就拿玩家反馈来说,它是一个浮点数数值,该如何给它们划分类别呢?这就是按需发挥的时候了,在玩家奖惩中已知数值范围是 \([-1.0, 1.0]\),我们就按每0.5为一类别将其离散化,划分为4种:

数值范围 类别
[-1.0, -0.5) 1
[-0.5, 0.0) 2
[0.0, 0.5) 3
[0.5, 1.0] 4

现在,之前的9组数据就变成了这样,你可以尝试计算它的基尼值吗:

阵营关系 战斗力 部族 玩家反馈(类别表示)
友好 弱小 KK 1
敌对 弱小 KK 3
友好 强大 NN 1
敌对 强大 NN 2
友好 弱小 XX 1
敌对 中等 XX 3
敌对 强大 XX 2
敌对 中等 AA 3
友好 弱小 AA 1

应该是:

\[Gini = 1 - [(\frac{4}{9})^2 + (\frac{2}{9})^2 + (\frac{3}{9})^2 + 0] = 1 - \frac{16 + 4 + 9}{81} = \frac{52}{81} \]

再来计算下,经过「阵营关系」划分后,各自分支上的基尼值吧:

image

友好分支的样本数据都是同一种类型的,基尼值是0(最纯);敌对分支的则应为:

\[Gini = 1 - [0 + 0 + (\frac{2}{5})^2 + (\frac{3}{5})^2] = 1 - \frac{4 + 9}{25} = \frac{13}{25} \]

现在,来综合计算下基尼值,怎么综合呢?很简单,加权和——将基尼值乘上各自 样本数量的占比,再累加。友好分支有4个样本数据,占比是 \(\frac{4}{9}\);敌对分支有5个,所以是 \(\frac{5}{9}\)。因此,用「阵营关系」划分所得到的综合基尼值为

\[0*\frac{4}{9} + \frac{13}{25} * \frac{5}{9} = \frac{13}{45} \]

如果你有计算「战斗力」、「部族」划分时的基尼值的话,你会发现它们计算的结果都比「阵营关系」的要大,因此我们这次选「阵营关系」作为划分属性。

友好一侧样本的纯度已经很高,不需要再进一步划分了。敌对一侧的样本还有机会进一步提高,我们各自用其余两个属性划分试试:

image

看来已经没有悬念了,选择「战斗力」作为划分依据计算出的基尼值为0,是适合我们进一步划分的属性。

现在已经没必要继续划分了,因为所有分支上样本的纯度已经够高了。这时我们就可以生成叶节点来收尾,如前所述,由于我们用浮点数代表玩家反馈,故我们计算样本的平均值作为预测值,由此,我们得到了与前文相同的决策树:

image

至此,对于第二个问题,我们也不难回答了。何时停止进一步划分?当前分支中 样本数据的纯度够高时 就可以停止了。只不过,还有另一种情况,假设所有属性都拿来划分后,分支下的样本纯度还是不高,这时也不得不停止了,因此,已经没有可以用来划分的属性时 也停止划分。

代码实现

属性

先用一个接口表示 属性,也就是上文例子中的「阵营关系」这些,因其数据类型多样,故我们需要接口来同一表示。它需要包含什么内容呢?属性值肯定要有,我们之前做的与属性相关的最重要的一步就是「划分」,因此,最好还能知道每种属性各自有多少种类型:

/// <summary>
/// 决策树决策数据属性接口,用于将数据离散化以便决策树分子节点
/// </summary>
public interface IDTAttribute
{
    // 整数表示的属性值,方便构建数组分桶
    int AttributeValue {get; set;}
    // 获取该属性下的类别范围
    int GetAttributeRange();
}

用该接口表示「战斗力」属性,以及实例化一个具体的战斗力,就可以这样做:

public class Power : IDTAttribute
{
    public int AttributeValue { get ; set ; }
    // 0:弱, 1:中, 2:强
    public int GetAttributeRange() => 3;
}

Power p = new Power{AttributeValue = 1;} // 战斗力:中等

组合训练数据

为了方便决策树的建立,我们希望能快速获取一组训练数据。因此我们将一组属性与一个标签打包在一个类中,相当于表达了表格的一行:

属性A 属性B 属性C …… 标签
XX XX XX XX XX
using System.Collections.Generic;

/// <summary>
/// 单条样本数据,包含一组离散属性和label值,用于表示「判断」的决策树训练
/// </summary>
public class DTSampleData<TLabel>
{
    public IReadOnlyList<IDTAttribute> allAttributes;
    public TLabel labelValue;

    public DTSampleData(IReadOnlyList<IDTAttribute> attrs, TLabel value)
    {
        allAttributes = attrs;
        labelValue = value;
    }
}

标签离散化策略

决策树所预测的标签数据类型也是多样的,就拿先前的「玩家反馈」来说,它是一个浮点数,我们不能把每个数都单独作为一个标签类型。这就需要指定策略来为其分类。

using System.Collections.Generic;

/// <summary>
/// label值分桶和统计策略
/// </summary>
public interface ILabelStrategy<TLabel>
{
    /// <summary> 该标签下,桶的数量(用于Gini计算) </summary>
    int BucketCount { get; }

    /// <summary> 获取该label值所属的类别或桶下标 </summary>
    int GetBucketIndex(TLabel value);

    /// <summary> 从样本中计算叶子节点的“预测值” </summary>
    TLabel GetLeafResult(IReadOnlyList<DTSampleData<TLabel>> mems);
}

利用该接口,「玩家反馈」标签的分类策略就可以这样表达:

public class PlayerFeedback_Strategy : ILabelStrategy<float>
{
    public int BucketCount => 4;

    // 每0.5区间为一类别,将浮点数映射成类别
    public int GetBucketIndex(float value)
    {
        return Mathf.Clamp((int)((value + 1f) / 0.5), 0, 3);
    }

    // 以平均数作为叶子节点
    public float GetLeafResult(IReadOnlyList<DTSampleData<float>> mems)
    {
        float sum = 0;
        foreach (var mem in mems)
            sum += mem.labelValue;
        return sum / mems.Count;
    }
}

决策树的训练与预测

在进入正题前,我们还要设计个决策树的节点类,决策树的 非叶子节点 需要包含它所代表的具体属性以及子节点。这里,我们假设决策树训练和预测时传入的属性集顺序是一样的,也就是说训练时如果我们是:

属性A 属性B 属性C 标签
XX XX XX XX
XX XX XX XX
…… …… …… ……

预测时传入的属性集也是按相同顺序的[属性A: XX,属性B:XX,属性C:XX]的话,我们就可以直接用下标表示对应属性类别,0表示属性A、1表示属性B……所以,我们这样表达决策树节点:

/// <summary>
/// 决策树的节点
/// </summary>
public class DTNode
{
    public int splitAttrIndex; // 当前节点使用的属性类别
    public DTNode[] children; // 子节点
    public TLabel Result; // 仅当前节点为叶节点时有用,表示 叶节点预测值
    public bool IsLeaf => children == null; // 是否为叶节点

    public DTNode(int attrIndex, int childCount)
    {
        splitAttrIndex = attrIndex;
        children = new DTNode[childCount];
    }

    public DTNode(TLabel result)
    {
        splitAttrIndex = -1;
        children = null;
        Result = result;
    }
}

对于根据指定属性类别将当前样本分成不同组的操作,用朴实无华的两次遍历来处理即可。第一次是根据该属性下所拥有的具体属性数量来创建对应的列表,例如「战斗力」属性下会有弱小、中等、强大三种,就创建3个列表。第二次就是遍历所有样本并根据其具体属性放进刚刚创建的列表里,至此完成划分:

/// <summary>
/// 根据指定属性类型划分样本
/// </summary>
/// <param name="mems">需要进行划分的样本</param>
/// <param name="attrIndex">指定属性对应的下标</param>
/// <param name="range">该属性的类别总数</param>
/// <returns>划分后的各组样本</returns>
private List<DTSampleData<TLabel>>[] SplitByAttribute(IReadOnlyList<DTSampleData<TLabel>> mems, int attrIndex, int range)
{
    var groups = new List<DTSampleData<TLabel>>[range];
    for (int i = 0; i < range; i++)
    {
        groups[i] = new List<DTSampleData<TLabel>>();
    }
    foreach (var mem in mems)
    {
        groups[mem.allAttributes[attrIndex].AttributeValue].Add(mem);
    }
    return groups;
}

计算一组样本的基尼值也用两次循环,第一次是用 count int类型数组记录各类型标签的数量(为计算样本占比 p 做准备),第二次就是按公式计算基尼值了。

\[Gini = 1 - \sum_{i = 1}^n{p_i}^2 \]

/// <summary>
/// 计算一组样本数据的基尼值
/// </summary>
/// <param name="mems">样本数据</param>
/// <returns>基尼值</returns>
private float CalculateGini(IReadOnlyList<DTSampleData<TLabel>> mems)
{
    if (mems.Count == 0) 
        return 0;
    
    Array.Clear(counts, 0, labelStrategy.BucketCount);
    for (int i = 0; i < mems.Count; i++)
    {
        counts[labelStrategy.GetBucketIndex(mems[i].labelValue)]++;
    }
    float impurity = 1f;
    for (int i = 0; i < labelStrategy.BucketCount; ++i)
    {
        if (counts[i] == 0) 
            continue;
        float p = (float)counts[i] / mems.Count;
        impurity -= p * p;
    }
    return impurity;
}

决策树的递归构建如下,需要注意的是对应的usedAttrs 需要在回溯时重新设为true,因为虽然自身子树不会再用到该属性,但兄弟节点还可能需要用。例如下面这个决策树的Alternate属性就被用到了两次:

image
/// <summary>
/// 递归构建决策树(补全splitGinis赋值,无重复计算基尼值)
/// </summary>
/// <param name="mems">当前样本数据</param>
/// <param name="usedAttrs">各属性被使用的情况</param>
/// <param name="beforeGini">父节点的基尼值</param>
/// <returns>构建后的节点</returns>
private DTNode BuildTree(IReadOnlyList<DTSampleData<TLabel>> mems, bool[] usedAttrs, float beforeGini)
{
    // 1. 判断是否叶节点(无样本数据或父节点基尼值足够小时)
    if (mems.Count == 0 || beforeGini < 1e-6)
        return new DTNode(labelStrategy.GetLeafResult(mems));

    // 2. 寻找最优划分属性
    int selectedAttr = -1;
    float bestGini = float.MaxValue;
    float[] splitGinis = null; // 仅声明,不初始化,最优属性确定后再赋值
    List<DTSampleData<TLabel>>[] bestSplit = null;

    for (int attrIndex = 0; attrIndex < usedAttrs.Length; ++attrIndex)
    {
        if (usedAttrs[attrIndex]) 
            continue;   
        int range = attrRanges[attrIndex];
        var splitGroups = SplitByAttribute(mems, attrIndex, range);
        var newGinis = new float[splitGroups.Length]; // 当前属性划分后的子样本集基尼数组

        float weightedGini = 0f;
        for (int j = 0; j < range; j++)
        {
            if (splitGroups[j].Count == 0)
            {
                newGinis[j] = 0; // 空样本集基尼值置0,避免后续空引用
                continue;
            }
            newGinis[j] = CalculateGini(splitGroups[j]);
            weightedGini += newGinis[j] * splitGroups[j].Count / mems.Count;
        }
        if (weightedGini < bestGini)
        {
            bestGini = weightedGini;
            selectedAttr = attrIndex;
            bestSplit = splitGroups;
            splitGinis = newGinis; // 将最优属性的子基尼数组赋值给splitGinis
        }
    }

    if (selectedAttr == -1) //未能寻得最优划分属性
        return new DTNode(labelStrategy.GetLeafResult(mems));

    // 3. 构建节点
    var node = new DTNode(selectedAttr, attrRanges[selectedAttr]);
    usedAttrs[selectedAttr] = true;
    for (int i = 0; i < node.children.Length; i++)
    {
        node.children[i] = bestSplit[i].Count > 0
            ? BuildTree(bestSplit[i], usedAttrs, splitGinis[i]) // 直接传递子样本集的基尼值
            : new DTNode(labelStrategy.GetLeafResult(mems));
    }

    usedAttrs[selectedAttr] = false; // 回溯
    return node;
}

补上「训练」与「预测」的函数,完整的类就可以写成这样:

using System;
using System.Collections.Generic;

/// <summary>
/// 决策树主体
/// </summary>
public class DTClassifier<TLabel>
{
    private ILabelStrategy<TLabel> labelStrategy;
    private DTNode rootNode;
    
    // 属性信息
    private int[] attrRanges;
    private int[] counts;
    
    public DTClassifier(ILabelStrategy<TLabel> strategy)
    {
        labelStrategy = strategy;
        counts = new int[strategy.BucketCount];
    }

    /// <summary>
    /// 训练决策树
    /// </summary>
    public void Train(IReadOnlyList<DTSampleData<TLabel>> mems)
    {
        int attrCount = mems[0].allAttributes.Count;
        attrRanges = new int[attrCount];
        for (int i = 0; i < attrCount; i++)
        {
            attrRanges[i] = mems[0].allAttributes[i].GetAttributeRange();
        }
        bool[] usedAttrs = new bool[attrCount];
        rootNode = BuildTree(mems, usedAttrs, CalculateGini(mems));
    }

    /// <summary>
    /// 预测
    /// </summary>
    public TLabel Predict(IReadOnlyList<IDTAttribute> attributes)
    {
        // 从根节点开始遍历
        var currentNode = rootNode;
        // 循环向下遍历,直到找到叶节点
        while (!currentNode.IsLeaf)
        {
            int attributeValue = attributes[currentNode.splitAttrIndex].AttributeValue;
            // 检查子节点索引是否有效(避免越界)
            if (attributeValue < 0 || attributeValue >= currentNode.children.Length)
            {
                // 若索引无效,可返回当前节点的默认值(如所有子节点的平均值)
                return currentNode.Result;
            }
            currentNode = currentNode.children[attributeValue];
        }
        return currentNode.Result;
    }

    /// <summary>
    /// 递归构建决策树(补全splitGinis赋值,无重复计算基尼值)
    /// </summary>
    /// <param name="mems">当前样本数据</param>
    /// <param name="usedAttrs">各属性被使用的情况</param>
    /// <param name="beforeGini">父节点的基尼值</param>
    /// <returns>构建后的节点</returns>
    private DTNode BuildTree(IReadOnlyList<DTSampleData<TLabel>> mems, bool[] usedAttrs, float beforeGini)
    {
        // 1. 判断是否叶节点
        if (mems.Count == 0 || beforeGini < 1e-6)
            return new DTNode(labelStrategy.GetLeafResult(mems));

        // 2. 寻找最优划分属性
        int selectedAttr = -1;
        float bestGini = float.MaxValue;
        float[] splitGinis = null; // 仅声明,不初始化,最优属性确定后再赋值
        List<DTSampleData<TLabel>>[] bestSplit = null;

        for (int attrIndex = 0; attrIndex < usedAttrs.Length; ++attrIndex)
        {
            if (usedAttrs[attrIndex]) 
                continue;   
            int range = attrRanges[attrIndex];
            var splitGroups = SplitByAttribute(mems, attrIndex, range);
            var newGinis = new float[splitGroups.Length]; // 当前属性划分后的子样本集基尼数组

            float weightedGini = 0f;
            for (int j = 0; j < range; j++)
            {
                if (splitGroups[j].Count == 0)
                {
                    newGinis[j] = 0; // 空样本集基尼值置0,避免后续空引用
                    continue;
                }
                newGinis[j] = CalculateGini(splitGroups[j]);
                weightedGini += newGinis[j] * splitGroups[j].Count / mems.Count;
            }
            if (weightedGini < bestGini)
            {
                bestGini = weightedGini;
                selectedAttr = attrIndex;
                bestSplit = splitGroups;
                splitGinis = newGinis; // 将最优属性的子基尼数组赋值给splitGinis
            }
        }

        if (selectedAttr == -1) //未能寻得最优划分属性
            return new DTNode(labelStrategy.GetLeafResult(mems));

        // 3. 构建节点
        var node = new DTNode(selectedAttr, attrRanges[selectedAttr]);
        usedAttrs[selectedAttr] = true;
        for (int i = 0; i < node.children.Length; i++)
        {
            node.children[i] = bestSplit[i].Count > 0
                ? BuildTree(bestSplit[i], usedAttrs, splitGinis[i]) // 直接传递子样本集的基尼值
                : new DTNode(labelStrategy.GetLeafResult(mems));
        }

        usedAttrs[selectedAttr] = false; // 回溯
        return node;
    }

    /// <summary>
    /// 根据指定属性类型划分样本
    /// </summary>
    /// <param name="mems">需要进行划分的样本</param>
    /// <param name="attrIndex">指定属性对应的下标</param>
    /// <param name="range">该属性的类别总数</param>
    /// <returns>划分后的各组样本</returns>
    private List<DTSampleData<TLabel>>[] SplitByAttribute(IReadOnlyList<DTSampleData<TLabel>> mems, int attrIndex, int range)
    {
        var groups = new List<DTSampleData<TLabel>>[range];
        for (int i = 0; i < range; i++)
        {
            groups[i] = new List<DTSampleData<TLabel>>();
        }
        foreach (var mem in mems)
        {
            groups[mem.allAttributes[attrIndex].AttributeValue].Add(mem);
        }
        return groups;
    }

    /// <summary>
    /// 计算一组样本数据的基尼值
    /// </summary>
    /// <param name="mems">样本数据</param>
    /// <returns>基尼值</returns>
    private float CalculateGini(IReadOnlyList<DTSampleData<TLabel>> mems)
    {
        if (mems.Count == 0) 
            return 0;
        
        Array.Clear(counts, 0, labelStrategy.BucketCount);
        for (int i = 0; i < mems.Count; i++)
        {
            counts[labelStrategy.GetBucketIndex(mems[i].labelValue)]++;
        }
        float impurity = 1f;
        for (int i = 0; i < labelStrategy.BucketCount; ++i)
        {
            if (counts[i] == 0) 
                continue;
            float p = (float)counts[i] / mems.Count;
            impurity -= p * p;
        }
        return impurity;
    }
}

样例测试

现在来验证下代码并看看其实际使用过程,我们直接采用 《人工智能-一种现代的方法(第3版)》 的第585页的样例:

image

在这个例子中,用这些数据训练决策树,核心是要解决一个分类预测问题:

  • 目标是预测顾客在给定一系列饭店特征(输入属性)的情况下,最终是否会选择等待(WillWait)。
  • 输入属性包含了 10 个特征,比如是否有等位区(Alt)、是否有酒吧(Bar)、是否是周五/周末(Fri)、是否饥饿(Hun)、是否有座位(Pat)、价格区间(Price)、是否下雨(Rain)、是否有预订(Res)、菜系类型(Type)、预计等待时长(Est)。
  • 决策树会从这些历史数据中学习特征与 “是否等待” 之间的规律,从而对新的、未见过的饭店场景做出预测。但这里我们就只看看构建出的决策树是否与书中最终给出的一致即可:
image

首先,我们为这10个属性创建对应的、继承了IDTAttribute的类:

public class Alt : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: No, 1: Yes
	public int GetAttributeRange() => 2;
}

public class Bar : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: No, 1: Yes
	public int GetAttributeRange() => 2;
}

public class Fir : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: No, 1: Yes
	public int GetAttributeRange() => 2;
}

public class Hun : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: No, 1: Yes
	public int GetAttributeRange() => 2;
}

public class Pat : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: None, 1: Some, 2: Full
	public int GetAttributeRange() => 3;
}

public class Price : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: $, 1: $$, 2: $$$
	public int GetAttributeRange() => 3;
}

public class Rain : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: No, 1: Yes
	public int GetAttributeRange() => 2;
}

public class Res : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: No, 1: Yes
	public int GetAttributeRange() => 2;
}

public class Type : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: French, 1: Italian, 2: Thai, 3: Burger
	public int GetAttributeRange() => 4;
}

public class Est : IDTAttribute
{
	public int AttributeValue { get ; set ; }

	// 0: 0-10, 1: 10-30, 2: 30-60, 3: >60
	public int GetAttributeRange() => 4;
}

然后,为需要预测的WillWait标签也做一个类,将其布尔值映射成整数下标:

public class BoolLabelStrategy : ILabelStrategy<bool>
{
    public int BucketCount => 2;

    public int GetBucketIndex(bool value)
    {
        return value ? 1 : 0; // No为0,Yes为1
    }

    public bool GetLeafResult(IReadOnlyList<DTSampleData<bool>> mems)
    {
        int[] count = new int[2];
        foreach (var mem in mems)
        {
            if(mem.labelValue)
            {
                ++count[1];
            }
            else
            {
                ++count[0];
            }
        }
        return count[1] >= count[0]; //true和false,哪个多算哪个
    }
}

为了更直观地看到训练后的决策树结构,我们在 DTClassifier 类中添加辅助决策树可视化的代码:

public void PrintTree()
{
    if (rootNode == null)
    {
        Debug.Log("决策树未训练");
        return;
    }
    StringBuilder treeText = new StringBuilder();
    treeText.AppendLine("决策树结构:");
    BuildTreeString(rootNode, 0, false, new bool[50], treeText); // 50为最大深度限制
    Debug.Log(treeText.ToString());
}

/// <summary>
/// 递归构建树结构字符串
/// </summary>
/// <param name="node">当前节点</param>
/// <param name="depth">当前深度</param>
/// <param name="isLastChild">是否为父节点的最后一个子节点</param>
/// <param name="hasSibling">记录每层是否有兄弟节点(用于绘制竖线)</param>
/// <param name="treeText">字符串构建器</param>
private void BuildTreeString(DTNode node, int depth, bool isLastChild, bool[] hasSibling, StringBuilder treeText)
{
    // 处理前缀缩进和竖线
    if (depth > 0)
    {
        // 绘制上层的竖线或空格
        for (int i = 0; i < depth - 1; i++)
        {
            treeText.Append("   ");
        }
        // 绘制当前节点的连接线
        treeText.Append("|  ");
        treeText.Append("- ");
    }

    // 处理当前节点内容
    if (node.IsLeaf)
    {
        treeText.AppendLine($"结果: {node.Result:F2}");
    }
    else
    {
        treeText.AppendLine($"分裂属性: {node.splitAttrIndex}");

        // 递归处理子节点
        for (int i = 0; i < node.children.Length; i++)
        {
            bool currentIsLast = (i == node.children.Length - 1);
            hasSibling[depth] = !currentIsLast; // 记录当前层是否有后续节点
            BuildTreeString(node.children[i], depth + 1, currentIsLast, hasSibling, treeText);
        }
    }
}

接着将表格中的属性转成各类中对应的整数,就可以训练了:

public class DT_Test : MonoBehaviour
{
    private void Start()
    {
        var trainData = new List<DTSampleData<bool>> //将表格数据转化成规定的整数
        {
            CreateSample(true,  1, 0, 0, 1, 1, 2, 0, 1, 0, 0),
            CreateSample(false, 1, 0, 0, 1, 2, 0, 0, 0, 2, 2),
            CreateSample(true,  0, 1, 0, 0, 1, 0, 0, 0, 3, 0),
            CreateSample(true,  1, 0, 1, 1, 2, 0, 1, 0, 2, 1),

            CreateSample(false, 1, 0, 1, 0, 2, 2, 0, 1, 0, 3),
            CreateSample(true,  0, 1, 0, 1, 1, 1, 1, 1, 1, 0),
            CreateSample(false, 0, 1, 0, 0, 0, 0, 1, 0, 3, 0),
            CreateSample(true,  0, 0, 0, 1, 1, 1, 1, 1, 2, 0),

            CreateSample(false, 0, 1, 1, 0, 2, 0, 1, 0, 3, 3),
            CreateSample(false, 1, 1, 1, 1, 2, 2, 0, 1, 1, 1),
            CreateSample(false, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0),
            CreateSample(true,  1, 1, 1, 1, 2, 0, 0, 0, 3, 2),
        };

        var classifier = new DTClassifier<bool>(new BoolLabelStrategy());
        classifier.Train(trainData);

        classifier.PrintTree();
    }

    public DTSampleData<bool> CreateSample(bool label, params int[] attrValues)
    {
        IDTAttribute[] attrs = new IDTAttribute[10];
        attrs[0] = new Alt{AttributeValue = attrValues[0]};
        attrs[1] = new Bar{AttributeValue = attrValues[1]};
        attrs[2] = new Fir{AttributeValue = attrValues[2]};
        attrs[3] = new Hun{AttributeValue = attrValues[3]};
        attrs[4] = new Pat{AttributeValue = attrValues[4]};
        attrs[5] = new Price{AttributeValue = attrValues[5]};
        attrs[6] = new Rain{AttributeValue = attrValues[6]};
        attrs[7] = new Res{AttributeValue = attrValues[7]};
        attrs[8] = new Type{AttributeValue = attrValues[8]};
        attrs[9] = new Est{AttributeValue = attrValues[9]};

        return new DTSampleData<bool>(attrs, label);
    }
}

在Unity中,我们可以看到打印的结果,我们把同一竖线的从上到下当成从左到右,顺序对应其具体属性从零开始的映射下标,往右靠一格的竖线看作是往下了一层(需要些想象力):

image

如果将对应序号的属性对应回去,下标也对应回具体属性,False对应No,True对应Yes,那你就发现我们构建的树与之前提到书中得到的是一样的:

image

尾声:一些机器学习的概念

至此,我们已大致了解了决策树,我们借此再多认识下机器学习相关的概念吧。

决策树首先需要有一系列数据作为依据才能构建,这些数据就是 训练样本,建树的过程就是 训练。这些数据都是由“输入-输出”对(“若干属性-标签”对)所组成的,这是典型的 监督学习,相当于已知输入数据与结果的情况下“推测”它们之间的映射函数。

有时,决策树用尽了所有我们给定的属性后依旧有纯度不高的样例,此时只能选择这些样例中出现最多的作为叶节点了,而导致这一现象的原因很可能是没有使用到能更好区分它们的属性。

选择合适的属性是很重要的,遗漏了相关属性会使训练出的决策树推理能力下降;一股脑用上了无关属性会导致训练速度下降,而如果恰巧其中有些属性与真正有用的属性有些因果关系的话,还可能会误导决策树的判断。例如,在之前预测饭店等待的决策树中,我们加入一个无关属性 “饭店是否有免费停车”,而这个属性恰巧和 Price(价格)有相关性(比如贵的饭店通常有免费停车),决策树可能会被误导,最终构建的树的结果也有所变化,错误地认为 “有免费停车” 是顾客愿意等待的原因。

即便是如今的神经网络大模型也无法完全规避这点,但却有办法缓解这一情况,其中有个简单粗暴的方法,也许你也想到了,就是 增加训练样本数据。就像只要我们的数据有包含一些没有免费停车但又贵的饭店,“是否免费停车”对饭店等待的干扰就会自然在决策树训练中被排除掉。

训练样本的数量也关系到最终训练的成果,训练样本过少可能导致无法学到可靠的规律。在最开始给出的「神兽」攻击目标的决策树中,但凡我们少给一条玩家反馈类别为2的数据,那么根据「部族」划分的结果也会是高纯度的,决策树可能就会顺着这个属性作划分。

image

而如果训练样本给得很多,就很可能造成训练上的性能问题,如果是真实环境的机器学习问题会更大,比如引入冗余与噪声、过拟合等。通常为了检验训练结果,还会准备一些未参与训练的样本输入到训练后的模型,看看得到的结果与这些样本的是否相近。

但游戏中的机器学习与真实场景的机器学习是有较大不同的,差别来自二者的环境,游戏是一个封闭、确定、规则固定、状态有限的环境,在这种情况下我们收集的数据是无误的,不存在数据获取方式导致的误差。像《黑与白》中的决策树是动态构建的,这时它是不需要考虑训练完后是否正确这一块,因为如果它的行为与玩家预期不同,玩家自然会给出反馈,之后再去构建一次即可。即便神兽AI展现出了愚钝,也影响不大,顶多会让玩家觉得有些难驯养。

posted @ 2026-02-09 14:26  狐王驾虎  阅读(185)  评论(0)    收藏  举报