游戏AI行为决策——MLP(多层感知机/人工神经网络)

发布时间 2024-01-11 23:32:05作者: 狐王驾虎

游戏AI行为决策(特别篇)——MLP(附代码与项目)

你一定听说过神经网络的大名,你有想过将它用于游戏AI的行为决策上吗?其实在(2010年发布的)《最高指挥官2》中就有应用了,今天请允许我班门弄斧一番,与大家一同用C#实现最经典的神经网络——多层感知机(Multilayer Perceptron,简称MLP)。

前言

神经网络或者深度学习,总给人一种「量子力学」的感觉,总感觉它神秘无比,又无所不能。我未学习神经网络之前,总以为它是某种能够修改自身代码的代码,否则怎么能做到从「不会」变成「会」的呢?但在亲自学习后才会明白,它并没有做到这种地步,但依旧十分神奇。多层感知机是最基础的神经网络,很多其它类别的神经网络都是在这之上的变形。可以说,学会它是迈入深度学习的第一步。

多层感知机虽说经典,但并不过时。提到神经网络,大多数人脑海里想到的大概也就是类似这样的图片:

image

这就是一张典型的多层感知机结构图,看着好像很复杂>︿<,但实现它所需要用到的数学原理和编程知识都不难,早年间,研究神经网络的学者们还用C语言实现呢!

什么是多层感知机

现在进入正题,我们先来简单讲讲MLP的原理(如果你对此十分熟悉,只是对代码实现感兴趣,那可以跳过这部分)。

既然叫「多层感知机」,那有单个的感知机吗?那是自然,单个感知机的结构十分简单:

image

它其实就是个算式(为方便理解,我将其分成两部分):

\[ \begin{cases} sum = (x_0*w_0 + x_1*w_1 + x_2*w_2) + b \\ out = f(sum) \end{cases}\]

将传入感知机的多个输入x,与对应的权重w相乘(输入的数量于权重的数量是一样的,且数量是任意的,本例中用了3个),再加上偏置b就可以得出一个计算值sum。再将这个计算值传入一个函数f(x)就可得到感知机的最终输出out。

相信你肯定能理解,只是可能对f(x)函数有些好奇,它具体内容是什么呢?这个函数也被称为 「激活函数」 ,为什么叫这个名字?这就得提感知机的另一个名字——人工神经元。其实感知机正是受神经元结构启发而被提出来的:

image

神经元会通过树突接受输入信号并汇总,而神经元对于各个输入刺激的响应强度并不相同,所以我们给各个输入设置了相应权重来模拟这个现象。之后,将处理的信息通过轴突传给末梢(终端)。但实际上,只有汇总的信号强度大于一定程度时神经元才会向末梢传递信号,而模拟这个现象的就是「激活函数」。

emmm……那偏置b呢?它是模拟什么的<(  ̄^ ̄)?其实它是从数学角度考虑的、方便调整输入加权和的变量值而已。

既然感知机被称为「人工神经元」,那多层感知机岂不就是 「人工神经网络」?一点没错,我们现在所说的「神经网络」,基本都是指人工神经网络,而不是真正的、生物的神经网络。而「神经网络」起初就是指多层感知机,只不过现在种类多了,定义也变宽泛了。

结合我们对单个感知机的认识,再看看多层感知机:

image

欸?这里的单个感知机(后面用「神经元」来代称了)怎么输出了多个值啊(看标蓝色的那部分)?这种结构图可能会误导某些人,我做个解释,这里的每一条线并不是输出,可以看到它是有箭头的,每条线表示它所指向的那个神经元的一个权重。加上箭头是为了表示数据传递的方向。

不难看出,它就是将多个感知机以层为单位进行了组合,每层都有任意数量 (每层的数量可以不同) 的感知机,并将一层感知机的输出作为下一层的输入,依次套娃下去。像图中,下一层神经元的权重数量 = 上一层的神经元数量,称为全连接,是神经网络中常见的连接方式,本文也只考虑这种连接方式。

这里的「输入层」其实就是输入的数据(是的,这一层不是神经元),类似之前的 \(x_0、x_1、x_2\);「输出层」就是用于输出的神经元所组成的层,有了多个感知机,我们也可以得到多个输出;夹在「输入层」与「输出层」之间的就叫「隐藏层」,因为在实际使用神经网络时,就只是输入一组数值作为「输入层」,再看看「输出层」得到的结果,并不关心中间的运算。

我们常说的 「深度学习」 里的「深度」指的就是神经网络中 「隐藏层」的层数(只不过现在这个词有点被炒作了),当一个神经网络的隐藏层 超过3层 时,它就是「深度神经网络」。

通过改变 神经网络的结构 或者调节 神经网络的权重和偏置,我们可以用神经网络近似任何的函数、甚至是一些摸不着头脑的规律。

比如影响小明今天玩不玩网游的因素有:今日作业量、心情、本月剩余流量、今天是星期几,但我们并不知道这些因素与小明玩不玩的具体数学关系,只能大概地推断:今天小明作业多,不会玩游戏;又或者今天是星期六,虽然作业还有很多,但他还是会玩游戏……可一旦知道具体数学关系,我们就可以通过计算准确预测小明是否会玩游戏,就像我们知道了牛顿力学公式,就可以根据物体的质量和被射出的力来计算它的运动轨迹一样。

image

所以我们所关心的、实际所使用的都是这种已经设置好正确权重和偏置的神经网络,像在与GPT聊天时怕「污染数据库」这类事就不用操心啦~

要如何为神经网络的各个神经元的各个权重设置正确的值,使它能够输出我们预期的结果呢?手动调肯定不现实,所以我们会运用一些数学知识让程序自行调整权重,这个过程就是 「训练/学习」

我们会给出一些输入以及该输入所对应的正确输出,比如我们可以记录小明上个学期玩网游时的各因素值以及不玩时的各因素值,这些作为 「训练集」。然后设计一个 「损失函数」 评判当前神经网络的输出与正确输出之间的差距。而程序就是不断地调节各个权重,使差距越来越小,这种调节的根据是 「导数」,但在这里我就不展开了。总之,如果训练得当,神经网络的损失就会越来越小,直到停在一个值附近,这就是 「收敛」

image

篇幅所限,我刻意没有讲相关的数学原理,如果你对此感兴趣,又或者对MLP的运作仍有困惑,可以看看这个视频或者这个视频。如果准备好了,下面就进入代码实现环节吧。

代码实现

1. 相关数学

关于数学部分,我只进行简要说明,不讲它们的数学原理,也不过多注释。如果你只是想将神经网络应用到游戏中,那这部分完全可以不必深究原理,弄清它们应用的场合即可。

a. 初始化权重函数

神经网络权重的初始化十分重要,它会影响你的神经网络最后能否训练成功。这里实现了3种典型的初始化方法:

  • 随机初始化(std = 0.01):是比较普通的方法,深度学习新手接触的第一个初始化方式
  • Xavier初始化:适用于激活函数为 Sigmoid和Tanh 的场合
  • He初始化:适用于激活函数为 ReLU及其衍生函数 如Leaky ReLU的场合
image

这里我还用了枚举,方便在编辑时切换初始化的方法(后续几类数学函数也会用这种方法):

using System;

namespace JufGame.AI.ANN
{
    public static class InitWFunc
    { 
        public enum Type
		{
			Random, Xavier, He, None
		}
		public static void InitWeights(Type initWFunc, Neuron neuron)
		{
			switch(initWFunc)
			{
				case Type.Xavier:
					XavierInitWeights(neuron.Weights);
					break;
				case Type.He:
					HeInitWeights(neuron.Weights);
					break;
				case Type.Random:
					RandomInitWeights(neuron.Weights);
					break;
				default:
					break;
			}
		}
        private static void RandomInitWeights(float[] weightsList)
        {
            var rand = new Random();
            for (int i = 0; i < weightsList.Length; ++i)
            {
                //使用较小的标准差,适合普通的随机初始化
                weightsList[i] = (float)(rand.NextGaussian() * 0.01); 
            }
        }
        private static void XavierInitWeights(float[] weightsList)
        {
            var rand = new Random();
            var scale = 1f / MathF.Sqrt(weightsList.Length);
            for (int i = 0; i < weightsList.Length; ++i)
            {
                weightsList[i] = (float)(rand.NextDouble() * 2 * scale - scale);
            }
        }
        private static void HeInitWeights(float[] weightsList)
        {
            var rand = new Random();
            var stdDev = MathF.Sqrt(2f / weightsList.Length); //计算标准差
            for (int i = 0; i < weightsList.Length; ++i)
            {   
                //生成服从正态分布的随机数,并乘以标准差
                weightsList[i] = (float)(rand.NextGaussian() * stdDev); 
            }
        }
        // 用于生成服从标准正态分布的随机数的辅助方法
        private static double NextGaussian(this Random rand)
        {
            double u1 = 1.0 - rand.NextDouble(); // 生成 [0, 1) 之间的随机数
            double u2 = 1.0 - rand.NextDouble();
            // 使用 Box-Muller 变换生成正态分布的随机数
            return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2); 
        }
    }
}

b. 激活函数

一般神经网络中所有隐藏层都使用同一种激活函数,输出层根据问题需求可能会使用和隐藏层不一样的激活函数。激活函数都有非线性且可导的特点,我也实现了一些典型的激活函数:

image
  • 直接输出(Identify):不做处理直接输出,用于输出层
  • Sigmoid:早期的主流,现在一般用于输出层需要将输出值限制在0~1的场合,或者是只有两个输出的二分问题
  • Tanh:相当于Sigmoid的改造,将输出限制在了-1~1
  • ReLU:当今的主流激活函数,长得十分友好,甚至不用加减运算。一般选它准没错
  • Leaky ReLU:ReLU的改造,使得对负数输入也有响应,但并没有说它一定好于ReLU。如果你用ReLU训练出现问题,可以换这个试试
  • Softmax:把一系列输出转为总和为1的小数,并且维持彼此的大小关系,相当于把输出结果转为了概率。适用于多分类问题,但一定要搭配交叉熵损失函数使用
using System;

namespace JufGame.AI.ANN
{
    public static class ActivationFunc
    {
        private delegate float FuncCalc(float x);
        private static FuncCalc curAcFunc;
        public enum Type
        {
            Identify, Softmax, Tanh, Sigmoid, ReLU, LeakyReLU
        }  
        //按层使用激活函数计算
        public static void Calc(Type funcType, Layer layer)
        {
            if(funcType == Type.Softmax)
            {
                Softmax_Calc(layer);
            }
            else
            {
                curAcFunc = funcType switch
                {
                    Type.Sigmoid => Sigmoid_Calc,
                    Type.Tanh => Tanh_Calc,
                    Type.ReLU => ReLU_Calc,
                    Type.LeakyReLU => LeakyReLU_Calc,
                    _ => Identify_Calc,
                };
                for(int i = 0; i < layer.Neurons.Length; ++i)
                {
                    layer.Output[i] = curAcFunc(layer.Neurons[i].Sum);
                }
            }
        }
        //根据传入下标index选取层中神经元,并进行求导
        public static float Diff(Type funcType, Layer layer, int index)
        {
            return funcType switch
            {
                Type.Softmax => Softmax_Diff(layer, index),
                Type.Sigmoid => Sigmoid_Diff(layer, index),
                Type.Tanh => Tanh_Diff(layer, index),
                Type.ReLU => ReLU_Diff(layer, index),
                Type.LeakyReLU => LeakyReLU_Diff(layer, index),
                _ => Identify_Diff(),
            };   
        }
        
        #region 直接输出
        private static float Identify_Calc(float x)
        {
            return x;
        }
        private static float Identify_Diff()
        {
            return 1;
        }
        #endregion

        #region Softmax
        private static void Softmax_Calc(Layer layer)
        {
            var neurons = layer.Neurons;
            var expSum = 0.0f;
            for(int i = 0; i < neurons.Length; ++i)
            {
                layer.Output[i] = MathF.Exp(neurons[i].Sum);
                expSum += layer.Output[i];
            }
            for(int i = 0; i < neurons.Length; ++i)
            {
                layer.Output[i] /= expSum;
            }
        }
        private static float Softmax_Diff(Layer outLayer, int index)
        {
            return outLayer.Output[index] * (1 - outLayer.Output[index]);
        }
        #endregion

        #region Sigmoid
        private static float Sigmoid_Calc(float x)
        {
            return 1.0f / (1.0f + MathF.Exp(-x));
        }
        private static float Sigmoid_Diff(Layer outLayer, int index)
        {
            return outLayer.Output[index] * (1 - outLayer.Output[index]);
        }
        #endregion

        #region Tanh
        private static float Tanh_Calc(float x)
        {
            var expVal = MathF.Exp(-x);
            return (1.0f - expVal) / (1.0f + expVal);
        }
        private static float Tanh_Diff(Layer outLayer, int index)
        {
            return 1.0f - MathF.Pow(outLayer.Output[index], 2.0f);
        }
        #endregion

        #region ReLU
        public static float ReLU_Calc(float x)
        {
            return x > 0 ? x : 0;
        }
        public static float ReLU_Diff(Layer outLayer, int index)
        {
            return outLayer.Neurons[index].Sum > 0 ? 1 : 0;
        }
        #endregion

        #region LeakyReLU
        private static float LeakyReLU_Calc(float x)
        {
            return x > 0 ? x : 0.01f * x;
        }
        private static float LeakyReLU_Diff(Layer outLayer, int index)
        {
            return outLayer.Neurons[index].Sum > 0 ? 1 : 0.01f;
        }
        #endregion
    }
}

c. 更新权重函数

权重的更新涉及一些「超参数」,比如学习率、最大迭代次数等。这些参数是程序不会进行更新的,只能人工提前设置好。在神经网络的学习中,学习率的值很重要,过小会导致训练费时;过大则会导致学习发散而不能正确进行 (于是就有了调参侠 。但好在后面人们想出来更好的权重更新函数,它们对「超参数」的依赖会减小很多。我们所实现的有:

image
  • SGD:随机梯度下降,最简单的一种更新方法,但有时并不是这么高效,容易陷入局部最优解
  • Movement:基于物理上的动量概念,它会在更新权重的过程中考虑先前的更新步骤,需要为每个权重设置额外参数(用m表示)来记录「动量」
  • AdaGrad:运用了学习率衰减的技巧,为每个权重适当地调整学习率,相当于给每个权重都设置了独立的学习率,也需要额外参数(用v表示)记录
  • Adam:将Movment与AdaGrad结合了起来,通过组合二者的优点,有望实现参数空间的高效搜索

当然,上述4个方法各有优劣,可以优先考虑SGD和Adam。

顺带一提,权重的更新都是建立在 「梯度」 之上的,「梯度」可以理解为对神经网络整体权重的变化趋势。你想,有这么多权重要更新,有时训练一个样本A后,会要求权重w0 += 0.01、w1 -= 0.05以减小误差,但训练下一个样本B时,又要求w0 -= 0.02、w1 += 0.04,一个训练集有这么多样本,要以哪个样本训练时产生的权重变化为准呢?

image

答案是累加每个样本带来的误差并取平均值(如果觉得还不清楚,可以看看这个视频

还有一点,偏置b也是随着权重更新的,它可以视为一个输入始终为1,权重为b的权重。在后续的实现中,我将偏置放在储存权重的列表的最后一位。(但后来才知道不提倡这种写法〒▽〒)

using System;

namespace JufGame.AI.ANN
{
    public static class UpdateWFunc
    {
        private const float MinDelta = 1e-7f;
        private const float beta1 = 0.9f;
        private const float beta2 = 0.999f;
        private delegate void UpdateLayer(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount);
        public enum Type
        {
            SGD, Momentum, AdaGrad, Adam
        }
        public static void UpdateNetWeights(Type type, NeuralNet net, int samplesCount)
        {
            UpdateLayer updateLayerFunc = type switch
            {
                Type.Momentum => Momentum_UpdateW,
                Type.AdaGrad => AdaGrad_UpdateW,
                Type.Adam => Adam_UpdateW,
                _ => SGD_UpdateW,
            };
            var curLayer = net.OutLayer;
            for(int j = 0; j < curLayer.Neurons.Length; ++j)
            {
                updateLayerFunc(curLayer.Neurons[j], net.LearningRate, net.CurEpochs ,samplesCount);
            }
            for(int i = 0; i < net.HdnLayers.Length; ++i)
            {
                curLayer = net.HdnLayers[i];
                for(int j = 0; j < curLayer.Neurons.Length; ++j)
                {
                    updateLayerFunc(curLayer.Neurons[j], net.LearningRate, net.CurEpochs, samplesCount);
                }	
            }
        }

        #region 各参数损失贡献计算
        //计算各参数对损失的贡献程度(也是各参数的变化的值)
        public static void CalcDelta(NeuralNet net, float[] input)
        {
            var lastInput = input;
            for(int i = 0; i < net.HdnLayers.Length; ++i)
            {
                var curLayer = net.HdnLayers[i];
                CalcLayerDelta(curLayer, lastInput);
                lastInput = curLayer.Output;
            }
            CalcLayerDelta(net.OutLayer, lastInput);
        }
        private static void CalcLayerDelta(Layer curLayer, float[] lastInput)
        {
            for(int j = 0, k; j < curLayer.Neurons.Length; ++j)
            {
                var curNeuron = curLayer.Neurons[j];
                for(k = 0; k < lastInput.Length; ++k)
                {
                    //通过反向传播时神经元的损失,计算每个权重的贡献贡献并累加
                    curNeuron.WeightParams["Delta"][k] += curNeuron.Params["Error"] * lastInput[k];
                }
                //同理计算偏置的损失贡献
                curNeuron.WeightParams["Delta"][k] += curNeuron.Params["Error"];
            }
        }
        #endregion

        #region SGD
        private static void SGD_UpdateW(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount)
        {
            for(int k = 0; k < curNeuron.Weights.Length; ++k)
            {
                var gradient = curNeuron.WeightParams["Delta"][k] / samplesCount;
                curNeuron.Weights[k] -= learningRate * gradient;
                curNeuron.WeightParams["Delta"][k] = 0;
            }	
        }
        #endregion

        #region Momentum
        private static void Momentum_UpdateW(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount)
        {
            for(int k = 0; k < curNeuron.Weights.Length; ++k)
            {
                var gradient = curNeuron.WeightParams["Delta"][k] / samplesCount;
                curNeuron.WeightParams["m"][k] = beta1 * curNeuron.WeightParams["m"][k] - learningRate * gradient;
                curNeuron.Weights[k] += curNeuron.WeightParams["m"][k];
                curNeuron.WeightParams["Delta"][k] = 0;
            }
        }
        #endregion

        #region AdaGrad
        private static void AdaGrad_UpdateW(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount)
        {
            for(int k = 0; k < curNeuron.Weights.Length; ++k)
            {
                var gradient = curNeuron.WeightParams["Delta"][k] / samplesCount;
                curNeuron.WeightParams["v"][k] += gradient * gradient;
                curNeuron.Weights[k] -= learningRate * gradient / MathF.Sqrt(curNeuron.WeightParams["v"][k] + MinDelta);
                curNeuron.WeightParams["Delta"][k] = 0;
            }
        }
        #endregion

        #region  Adam
        private static void Adam_UpdateW(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount)
        {
            for(int k = 0; k < curNeuron.Weights.Length; ++k)
            {
                var gradient = curNeuron.WeightParams["Delta"][k] / samplesCount;
                curNeuron.WeightParams["m"][k] = beta1 * curNeuron.WeightParams["m"][k] + (1 - beta1) * gradient;
                curNeuron.WeightParams["v"][k] = beta2 * curNeuron.WeightParams["v"][k] + (1 - beta2) * gradient * gradient;
                var mHat = curNeuron.WeightParams["m"][k] / (1 - MathF.Pow(beta1, curEpochs));
                var vHat = curNeuron.WeightParams["v"][k] / (1 - MathF.Pow(beta2, curEpochs));
                curNeuron.Weights[k] -= learningRate * mHat / (MathF.Sqrt(vHat) + MinDelta);
                curNeuron.WeightParams["Delta"][k] = 0;
            }
        }
        #endregion
    }
}

d. 损失函数

损失函数用来衡量输出与正确值之间的差距,这里实现的是最常用的两个损失函数:

  • 均方差函数:简单实用,形式如下:
image
  • 交叉熵函数:主要用在多分类问题上,配合Softmax使用
image
using System;

namespace JufGame.AI.ANN
{
    public static class LossFunc
    {
        private const float MinDelta = 1e-7f;
        public enum Type
        {
            MeanSqurad, CrossEntropy,
        }
        public static float Calc(Type type, float[] targetOut, Layer outLayer)
        {
            return type switch
            {
                Type.MeanSqurad => MeanSquradErr_Calc(targetOut, outLayer),
                _ => CrossEntropy_Calc(targetOut, outLayer),
            };
        }
        public static void Diff(Type type, float[] targetOut, Layer outLayer)
        {
            switch(type)
            {
                case Type.MeanSqurad:
                    MeanSquradErr_Diff(targetOut, outLayer);
                    break;
                case Type.CrossEntropy:
                    CrossEntropy_Diff(targetOut, outLayer);
                    break;
            };
        }

        private static float MeanSquradErr_Calc(float[] targetOut, Layer outLayer)
        {
            var errSum = 0.0f;
            for(int i = 0; i < targetOut.Length; ++i)
            {
                errSum += MathF.Pow(outLayer.Output[i] - targetOut[i], 2);
            }
            return errSum / (2 * targetOut.Length);
        }
        private static void MeanSquradErr_Diff(float[] targetOut, Layer outLayer)
        {
            for(int i = 0; i < targetOut.Length; ++i)
            {
                var curNeuron = outLayer.Neurons[i];
                curNeuron.Params["Error"] = outLayer.Output[i] - targetOut[i];
            }
        }
                
        private static float CrossEntropy_Calc(float[] targetOut, Layer outLayer)
        {
            var errSum = 0.0f;
            for(int i = 0; i < targetOut.Length; ++i)
            {
                //加上一个极小值再取log,放置出现log(0)报错
                errSum -= targetOut[i] * MathF.Log(outLayer.Output[i] + MinDelta);
            }
            return errSum;
        }
        private static void CrossEntropy_Diff(float[] targetOut, Layer outLayer)
        {
            for(int i = 0; i < targetOut.Length; ++i)
            {
                var curNeuron = outLayer.Neurons[i];
                //用Output[i]的前提:神经网络的输出经过了softmax处理
                curNeuron.Params["Error"] = outLayer.Output[i] - targetOut[i];
            }
        }
    }
}

2. 感知机(神经元)

image

简单地对神经元结构进行实现,只是神经元在训练时,需要为自身或者自身的权重记录一些额外信息,所以多了WeightParams和Params备以记录。

  • 为什么不记录激活函数的计算结果out?
    因为在训练过程中常常要以层为单位统一处理激活函数的计算结果,故而将out都记录在层中了。(实际上python中许多深度学习框架库都是 以层为最小单位 构建神经网络的,这有利于进行矩阵运算,但在我们的实现中,一来没用到矩阵运算,二来是希望能让大家更直接地看到神经网络训练、计算的细节,所以我们以单个神经元作为最小的单位)

  • 为什么没有激活函数\(f(x)\)
    因为我们之前说过,神经网络的隐藏层都是使用同一种激活函数,顶多输出层用的不太一样。也就是说我们只需要记录两个函数的类型,所以让后续实现的神经网络类记下就行了,没必要每个神经元都记录,浪费空间。

using System;
using System.Collections.Generic;
using UnityEngine;

namespace JufGame.AI.ANN
{
    [Serializable] // 方便在编辑器页面查看
    public class Neuron
    {
        //神经元权重列表,末位放置偏置b
        public float[] Weights => weights;
        //加权和
        public float Sum => sum;
        //为各个权重分配的额外参数
        public Dictionary<string, float[]> WeightParams{ get; private set; }
        //为神经元本身分配的额外参数
        public Dictionary<string, float> Params{ get; private set; }
        [SerializeField]private float[] weights;
        private float sum;
        public Neuron(int weightCount)
        {
            weights = new float[weightCount + 1];//末尾放偏置
        }
        /// <summary>
        /// 初始化训练所需参数列表,仅在训练时调用
        /// </summary>
        public void InitCache()
        {
            Params = new Dictionary<string, float>
            {
                ["Error"] = 0,//该值用来记录,每次更新时的累计损失
            };
            WeightParams = new Dictionary<string, float[]>
            {
                //记录权重待变化值
                ["Delta"] = new float[weights.Length],
                //Momentum和Adam中,用于记录权重变化的「动量」
                ["m"] = new float[weights.Length],
                //AdaGrad和Adam中,用于记录权重独立学习率
                ["v"] = new float[weights.Length],           
            };
        }
        //计算Sum
        public float CalcSum(float[] input)
        {
            int i;
            sum = 0;
            for(i = 0; i < input.Length; ++i)
            {
                sum += weights[i] * input[i];//加权和
            }
            sum += weights[i];//加上权重
            return Sum;
        }
    }
}

3. 层

没有太多必要说的,就是嵌套调用了包含的各神经元的函数,比如层的计算就是各个神经元的计算,其它同理。

using System;
using UnityEngine;

namespace JufGame.AI.ANN
{
    [Serializable]
    public class Layer
    {
        public Neuron[] Neurons => neurons;//存储神经元
        public float[] Output => output;//存储各神经元激活函数输出
        [SerializeField] private Neuron[] neurons;
        [SerializeField] private float[] output;
        public Layer(int neuronCount)
        {
            output = new float[neuronCount];
            neurons = new Neuron[neuronCount];
        }
        //对层中的每个神经元的权重进行初始化
        public void InitWeights(int weightCount, InitWFunc.Type initType)
        {
            for(int i = 0; i < neurons.Length; ++i)
            {
                neurons[i] = new Neuron(weightCount);
                InitWFunc.InitWeights(initType, neurons[i]);
            }
        }
        //初始化层中每个神经元的额外参数
        public void InitCache()
        {
            for(int i = 0; i < neurons.Length; ++i)
            {
                neurons[i].InitCache();
            }
        }
        //计算该层,实际上就是计算所有神经元的加权和,并求出激活函数的输出
        public float[] CalcLayer(float[] inputData, ActivationFunc.Type acFuc)
        {
            for(int i = 0; i < neurons.Length; ++i)
            {
                neurons[i].CalcSum(inputData);
            }
            ActivationFunc.Calc(acFuc, this);
            return output;
        }
    }
}

4. 多层感知机(神经网络)

神经网络也一样,是对层的各个功能的再度包装,只是多了些超参数成员变量。

  • 为什么没有输入层?
    因为输入层其实就只是输入的数值,没必要单独设置一个层(python中许多深度学习框架也是这样的)。
  • 怎么读取输出?
    直接读取输出层的输出列表即可。
using System;
using UnityEngine;

namespace JufGame.AI.ANN
{
    [Serializable]
    public class NeuralNet
    {
        public float TargetError = 0.0001f;//预期误差,当损失函数的结果小于它时,就停止训练
        public float LearningRate = 0.01f;//学习率
        public int CurEpochs;//记录当前迭代的次数
        public ActivationFunc.Type hdnAcFunc;//隐藏层激活函数类型
        public ActivationFunc.Type outAcFunc;//输出层激活函数类型
        public Layer[] HdnLayers => hdnLayers;//隐藏层
        public Layer OutLayer => outLayer;//输出层
        [SerializeField] private Layer[] hdnLayers;
        [SerializeField] private Layer outLayer;

        public NeuralNet(int hdnLayerCount, int[] neuronsOfLayers, int outCount, 
            ActivationFunc.Type hdnAcFnc, ActivationFunc.Type outAcFnc,
            float targetError = 0.0001f, float learningRate = 0.01f)
        {
            outLayer = new Layer(outCount);
            hdnLayers = new Layer[hdnLayerCount];
            for(int i = 0, j = 0; i < hdnLayerCount; ++i)
            {
                hdnLayers[i] = new Layer(neuronsOfLayers[j]);
            }
            hdnAcFunc = hdnAcFnc;
            outAcFunc = outAcFnc;
            TargetError = targetError;
            LearningRate = learningRate;
        }
        //初始化各神经元权重
        public void InitWeights(int inputDataCount, InitWFunc.Type initType)
        {
            int neuronNum = inputDataCount;
            for(int i = 0; i < HdnLayers.Length; ++i)
            {
                hdnLayers[i].InitWeights(neuronNum, initType);
                neuronNum = HdnLayers[i].Neurons.Length;
            }
            outLayer.InitWeights(neuronNum, initType);
        }
        //初始化各神经元额外参数列表
        public void InitCache()
        {
            for(int i = 0; i < HdnLayers.Length; ++i)
            {
                hdnLayers[i].InitCache();
            }
            outLayer.InitCache();
        }
        //计算神经网络
        public float[] CalcNet(float[] inputData)
        {
            var curInput = inputData;
            for(int j = 0; j < hdnLayers.Length; ++j)
            {
                curInput = hdnLayers[j].CalcLayer(curInput, hdnAcFunc);
            }
            return outLayer.CalcLayer(curInput, outAcFunc);
        }
    }
}

至此,神经网络就搭建完成了!并没有想象的那么复杂对吧!(我似乎每篇文章都这么说过

5. 训练器

先实现一个训练器的基类……等等,明明就一种神经网络,为什么还要有基类,直接写不好吗?

其实最初是打算实现多种神经网络的,但后来考试临近,不得不转移重心,最终只实现了最简单的MLP。(再后来就直接转入python了 而Unity本身也已经可以导入onnx模型,如果要在游戏里想实现图像识别这类复杂功能的话,导入模型似乎更方便,所以实现更多神经网络的必要性就值得考虑了。当然,这些文末会再进行讨论。

先来看看这个基类有哪些东西吧:

using UnityEngine;

namespace JufGame.AI.ANN
{
    public abstract class Training
    {
        public NeuralNet TrainingNet;//需要训练的神经网络
        public float[][] InputSet => inputSet;//训练输入集
        
        /*没有「训练输出集」是因为并非所有类型的神经网络都需要「训练输出」
        所以它不是基类必需的,当然,这些就是题外话了*/
        
        protected float[][] inputSet;
        [SerializeField] protected int maxEpochs;//最大迭代次数
        
        public Training(NeuralNet initedNet, int maxEpochs)
        {
            this.maxEpochs = maxEpochs;
            TrainingNet = initedNet;
        }

        public void SetInput(float[][] inputSet)//设置训练输入集
        {
            this.inputSet = inputSet;
        }
        public abstract bool IsTrainEnd();//是否训练完成
        public abstract void Train(); //不断训练神经网络
        public abstract void Train_OneTime();//训练(迭代)一次神经网络

        //打印神经网络输出的结果,调试用的
        public static void DebugNetRes(NeuralNet net, float[][] testInput)
        {
            for(int i = 0; i < testInput.GetLength(0); ++i)
            {
                var res = net.CalcNet(testInput[i]);
                for(int j = 0; j < res.Length; ++j)
                {
                    Debug.Log("检验结果 " + i + " = " + res[j]);
                }
            }
        }
    }
}

最后,就是真正用来训练的类了,我们将采用最常见梯度下降法进行训练。其中涉及前向传播和反向传播,我稍作解释:

  • 前向传播(Forward Propagation):传入训练输入样本计算出当前神经网络模型的输出,并进一步计算损失(损失函数的计算结果其实在反向传播中并没有用,只是给开发者看的,用来判断当前训练情况)

  • 反向传播(Backward Propagation):从损失函数开始,用链式求导法则,反向(输出层 \(\rightarrow\) 隐藏层 \(\rightarrow\) 输入层)计算每个神经元的损失(下图中的 \(\delta\)、代码中的Params["Error"])。通过神经元的损失,可以计算出神经元的每个参数(权重、偏置)的损失贡献(权重更新函数代码中的WeightParams["Delta"])并一直累加,直到训练集被读取完。这时,我们就说完成了一次训练迭代

image

完成一次迭代后(不是训练完一个样本后),将累加的损失除以训练样本数取得均值,再用权重更新函数对各参数进行更新。

这一迭代过程反复进行,直到损失函数计算的误差达到可容许范围(也就是小于预期损失)或达到最大训练次数,详情可看该文章(但注意!该文章为了方便讲解,训练完一个样本就开始更新权重了),实现如下:

using UnityEngine;

namespace JufGame.AI.ANN
{
    [System.Serializable]
    public class BPNN : Training
    {
        public float[][] OutputSet => outputSet;
        [SerializeField] private float meanError = float.MaxValue;
        [SerializeField] private LossFunc.Type errorFunc;
        [SerializeField] private UpdateWFunc.Type updateWFunc;
        private float[][] outputSet;
        public BPNN(NeuralNet initedNet, LossFunc.Type errorFunc, UpdateWFunc.Type updateWFunc, int maxEpochs): base(initedNet,maxEpochs)
        {
            this.errorFunc = errorFunc;
            this.updateWFunc = updateWFunc;
        }
        public void SetOutput(float[][] outputSet)
        {
            this.outputSet = outputSet;
        }
        public override bool IsTrainEnd()//判断是否训练完成
        {
            return meanError < TrainingNet.TargetError 
                || maxEpochs < TrainingNet.CurEpochs;
        }
        public override void Train()
        {
            meanError = float.MaxValue;
            while(!IsTrainEnd())
            {
                Train_OneTime();
            }
        }
        public override void Train_OneTime()
        {
            int samplesCount = inputSet.GetLength(0);//记下样本数量
            ++TrainingNet.CurEpochs;//更新迭代次数
            meanError = 0;
            for(int i = 0; i < samplesCount; ++i)
            {
                ForWard(i);
                Backpropagation();
                UpdateWFunc.CalcDelta(TrainingNet, inputSet[i]);
            }
            UpdateWFunc.UpdateNetWeights( updateWFunc, TrainingNet, samplesCount);
            meanError /= samplesCount;//取样本误差均值作为本次迭代的误差
            #if UNITY_EDITOR
            Debug.Log($"误差:{meanError}");//调试时用的
            #endif
        }
        private void ForWard(int trainIndex)
        {
            var outLayer = TrainingNet.OutLayer;
            TrainingNet.CalcNet(inputSet[trainIndex]);
            meanError = LossFunc.Calc(errorFunc, outputSet[trainIndex], outLayer);
            /*这里图省事,将反向传播的第一步一并计算了*/
            LossFunc.Diff(errorFunc, outputSet[trainIndex], outLayer);//损失函数求导
            for(int i = 0; i < outLayer.Neurons.Length; ++i)//输出层激活函数求导
            {
                outLayer.Neurons[i].Params["Error"] *= ActivationFunc.Diff(TrainingNet.outAcFunc, outLayer, i);
            }
        }
        private void Backpropagation()
        {
            var lastLayer = TrainingNet.OutLayer;
            for(int i = TrainingNet.HdnLayers.Length - 1; i > -1; --i)
            {
                var curLayer = TrainingNet.HdnLayers[i];
                for(int j = 0; j < curLayer.Neurons.Length; ++j)
                {
                    var curNeuron = curLayer.Neurons[j];
                    //每次计算损失时要清零,避免上次迭代结果产生的干扰
                    curNeuron.Params["Error"] = 0;
                    for(int k = 0; k < lastLayer.Neurons.Length; ++k)
                    {
                        var lastNeuron = lastLayer.Neurons[k];
                        curNeuron.Params["Error"] += lastNeuron.Params["Error"] * lastNeuron.Weights[j];
                    }
                    curNeuron.Params["Error"] *= ActivationFunc.Diff(TrainingNet.hdnAcFunc, curLayer, j);
                }
                lastLayer = curLayer;
            }
        }
    }
}

使用教程

一切都准备就绪了,那要怎么运转这个神经网络呢?我们创建一个继承了MonoBehavior的脚本,并声明下面三个公开的字段:

using UnityEngine;
using JufGame.AI.ANN;

public class TrainANN : MonoBehaviour
{
    public int inputCount;
    public BPNN bp;
    public InitWFunc.Type initW;
}

将它挂载在场景的任一物体上,不出意外的话,你可以在编辑器看到神经网络类的许多关键变量都可以显示出来(如果你的没有,就要注意是否遗漏[System.Serializable]或设置成了私有类):

image

我们再完善下脚本,设置好训练输入和输出(以「异或」运算为例),使得神经网络能在Unity运行时逐帧训练,

public class TrainANN : MonoBehaviour
{
    public int inputCount;
    public BPNN bp;
    public InitWFunc.Type initW;
    
    private float[][] inSet = //异或运算的输入
    {
        new float[]{1, 0},
        new float[]{1, 1},
        new float[]{0, 0},
        new float[]{0, 1},
    };
    private float[][] outSet = //异或运算的输出
    {
        new float[]{1},
        new float[]{0},
        new float[]{0},
        new float[]{1},
    };

    private void Awake()
    {
        bp.SetInput(inSet);//为训练器设置训练输入集
        bp.SetOutput(outSet);//为训练器设置训练输出集
    }
    private void Start()
    {
        bp.TrainingNet.InitWeights(inputCount, initW);//初始化权重
        bp.TrainingNet.InitCache();//初始化额外参数存储
    }

    private void Update()
    {
        if(bp.IsTrainEnd())//如果训练结束,就打印训练完成的神经网络 对训练输入集输出
        {
            Training.DebugNetRes(bp.TrainingNet, inSet);
            return;
        }
        bp.Train_OneTime();//没有训练结束,就每帧训练一次
    }
}

回到编辑页面,由于关键变量都可以直接修改,所以我们无需调用构造函数进行初始化。我们设置好它的预期误差、最大迭代数等,可以参照下面这个设置:

image

然后我们开始运行,不出意外的话,训练很快就结束了(大概只花了100多次迭代),你也可以看到Console面板打印了神经网络的输出结果:

image

可以看到,结果是很接近真实值的(把预期误差设小点还可以更接近),这也说明我们的训练是奏效的!

  • 可能在尝试其它参数时(尤其是激活函数),会发现有时误差会「停」在某个值下不去了,是怎么回事?
    这是陷入了局部最优解的情况,又或者是神经网络几乎停止训练,在使用较少数据并且激活函数为ReLU的场合可能会出现。可以尝试其它激活函数,或增加训练样本。

可一旦结束Unity运行后,编辑页面这些被训练好的参数就又变回去了,这该怎么办?很简单,一旦训练完后,就暂停运行(是暂停,不是结束),右键神经网络的名字,将它复制下来。

image

创建一个新的脚本并将它挂在场景中,用以测试被复制的神经网络能否正常工作:

using System.Collections;
using System.Collections.Generic;
using JufGame.AI.ANN;
using UnityEngine;

public class UseMLP : MonoBehaviour
{
    public NeuralNet net;
    private float[][] inSet = //异或运算的输入
    {
        new float[]{1, 0},
        new float[]{1, 1},
        new float[]{0, 0},
        new float[]{0, 1},
    };
    private void Awake()
    {
        Training.DebugNetRes(net, inSet);
    }
}

将之前训练好的神经网络,粘贴给它,然后运行Unity:

image

不出意外的话,可以看到,这个神经网络的输出是十分正确的(我就不贴图了)!

项目链接

最后一步,神经网络要如何用在AI的行为决策中呢?

  • 确定好AI决策相关的数据、并将其作为神经网络的输入。
    比如生命值、攻击力、与敌人之间的距离等,并将它们 进行归一化处理 ,比如一个角色生命值上限为100,现在生命值为50,那在将生命值作为输入时不是传入5,而是 \(\frac{50}{100} = 0.5\) 总之,要将 输入的数据限制在0~1 。这有利于神经网络训练,也能更正确地表达数据的信息,还是以刚才这个角色为例,假设他的魔法值上限为50,当前魔法值为40,能因为当前生命值50 > 当前魔法值40而认为当前角色更需要补充魔法值吗?这显然是不合理的。

  • 根据输入样本拟定对应的输出结果作为训练输出。
    选取并想好了数据的归一化处理后,我们需要拟定一部分输入和输出。设计训练输入还是比较简单的,但输出怎么量化呢?因为决策要做的可是一个行为呀!我们可以这么做,将输出设置为执行某个行为的概率

    image

    这样一来,我们只要遍历训练完的神经网络的所有输出,选出值最大的那个神经元对应的行为来执行就可以了。

我所提供的项目也是这样做的,在项目中我用神经网络控制了小机器人的三个行为:攻击、防御、奔跑 (但其实不会动,也没区分进攻与撤退。影响因素有自身生命值、敌方生命值、敌我间距。预期的结果就是当我方血少又离得很近(手动拖拽二者距离)时,机器人会防御;当我方与对方旗鼓相当或更优势时,机器人会攻击,其余情况会奔跑。总之,也是很简单的行为。

image

尾声

最后,我们再谈谈之前遗留的问题:是否有必要使用C#构建神经网络?我个人觉得,除了用来做这种决策行动,其它情况大可不必费力构建。主要原因在于:Unity可以导入由Python深度学习框架训练出来的神经网络模型 (虽然我还没试过,省时省力、效果还好。

不过,作为行为决策这种与游戏逻辑紧密联系的部分,用游戏引擎的脚本语言编写是合适的。这方便观察现象时时调整,也能更好地与其它游戏脚本结合,而且行为决策所需的神经网络复杂度并不大,因此带来的性能影响并不大。

以《最高指挥官2》中使用的MLP为例:它的输入层有34个神经元,隐藏层有98个神经元,输出层有15个神经元,但它的计算不超过0.03毫秒。这显然是个可以接受的结果。(该数据来源于2015版《游戏人工智能》——第30章)

但是,神经网络比起传统的决策算法,最大的弊端是不方便调试以及最终效果的不可控。毕竟它的训练是个纯数学的过程,很难像以往那样在程序中打断点跟踪;最终效果的不可控也导致可能要训练多次才能得出较满意的模型。所以在《最高指挥官2》中神经网络也不是全权控制AI决策,而是搭配有限状态机使用。还有一点就是需要准备大量的训练数据,其实这并非是一定的,如果你能用好Excel导入数据或者是像《最高指挥官2》那样 使用适应度函数 来代替具体的训练样本(篇幅所限,不展开了),感兴趣的同学可以去了解下。

无庸置疑的是,这种AI决策方法的确会使角色更加生动,而且需要额外编写的代码也并不多,只是要多花些时间训练。最后的最后,我也一直在想该如何更好地与大家交流有趣的内容,如果你对这篇文章还有感到困惑、不满的地方,都欢迎评论区指出哈♪( ^ ∇ ^ *)!