网易首页 > 网易号 > 正文 申请入驻

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

0
分享至


【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!

这是侑虎科技第1902篇文章,感谢作者狐王驾虎供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:

https://home.cnblogs.com/u/OwlCat

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


一、前言

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

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


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

二、什么是多层感知机

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

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


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


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

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


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

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

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

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


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

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

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

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

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

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


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

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

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


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

视频1:


https://www.bilibili.com/video/BV1bx411M7Zx/?spm_id_from=333.999.0.0&vd_source=c9a1131d04faacd4a397411965ea21f4

视频2:


https://www.bilibili.com/video/BV1o64y1i7yw/?spm_id_from=333.788&vd_source=c9a1131d04faacd4a397411965ea21f4

三、代码实现

1. 相关数学

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

a. 初始化权重函数

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

  • 随机初始化(std = 0.01):是比较普通的方法,深度学习新手接触的第一个初始化方式。

  • Xavier初始化:适用于激活函数为Sigmoid和Tanh的场合。

  • He初始化:适用于激活函数为ReLU及其衍生函数,如Leaky ReLU的场合。


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

                                                           using System;


namespaceJufGame.AI.ANN
{
publicstaticclassInitWFunc
{
publicenum 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. 激活函数

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


  • 直接输出(Identify):不做处理直接输出,用于输出层。

  • Sigmoid:早期的主流,现在一般用于输出层需要将输出值限制在0~1的场合,或者是只有两个输出的二分问题。

  • Tanh:相当于Sigmoid的改造,将输出限制在了-1~1。

  • ReLU:当今的主流激活函数,长得十分友好,甚至不用加减运算。一般选它准没错。

  • Leaky ReLU:ReLU的改造,使得对负数输入也有响应,但并没有说它一定好于ReLU。如果你用ReLU训练出现问题,可以换这个试试。

  • Softmax:把一系列输出转为总和为1的小数,并且维持彼此的大小关系,相当于把输出结果转为了概率。适用于多分类问题,但一定要搭配交叉熵损失函数使用

                                                           using System;

namespaceJufGame.AI.ANN
{
publicstaticclassActivationFunc
{
private delegate float FuncCalc(float x);
privatestatic FuncCalc curAcFunc;
publicenum 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()
{
return1;
}
#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)
{
return1.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)
{
return1.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. 更新权重函数

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


  • 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,一个训练集有这么多样本,要以哪个样本训练时产生的权重变化为准呢?


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


https://www.bilibili.com/video/BV16x411V7Qg/?spm_id_from=333.999.0.0&vd_source=c9a1131d04faacd4a397411965ea21f4

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

                                                           using System;

namespaceJufGame.AI.ANN
{
publicstaticclassUpdateWFunc
{
privateconstfloat MinDelta = 1e-7f;
privateconstfloat beta1 = 0.9f;
privateconstfloat beta2 = 0.999f;
private delegate void UpdateLayer(Neuron curNeuron, float learningRate, int curEpochs, int samplesCount);
publicenum 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. 损失函数

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

  • 均方差函数:简单实用,形式如下:


  • 交叉熵函数:主要用在多分类问题上,配合Softmax使用:


                                                           using System;

namespaceJufGame.AI.ANN
{
publicstaticclassLossFunc
{
privateconstfloat MinDelta = 1e-7f;
publicenum 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. 感知机(神经元)


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

  • 为什么不记录激活函数的计算结果out?

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

  • 为什么没有激活函数f(x)?

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

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


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


namespaceJufGame.AI.ANN
{
[Serializable]
publicclassLayer
{
public Neuron[] Neurons => neurons;//存储神经元
publicfloat[] Output => output;//存储各神经元激活函数输出
[SerializeField] private Neuron[] neurons;
[SerializeField] privatefloat[] output;
public Layer(int neuronCount)
{
output = newfloat[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;

namespaceJufGame.AI.ANN
{
[Serializable]
publicclassNeuralNet
{
publicfloat TargetError = 0.0001f;//预期误差,当损失函数的结果小于它时,就停止训练
publicfloat LearningRate = 0.01f;//学习率
publicint 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。Unity本身也已经可以导入ONNX模型,如果要在游戏里想实现图像识别这类复杂功能的话,导入模型似乎更方便,所以实现更多神经网络的必要性就值得考虑了。当然,这些文末会再进行讨论。

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

                                                           using UnityEngine;

namespaceJufGame.AI.ANN
{
publicabstractclassTraining
{
public NeuralNet TrainingNet;//需要训练的神经网络
publicfloat[][] InputSet => inputSet;//训练输入集

/*没有「训练输出集」是因为并非所有类型的神经网络都需要「训练输出」
所以它不是基类必需的,当然,这些就是题外话了*/

protectedfloat[][] inputSet;
[SerializeField] protectedint 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):从损失函数开始,用链式求导法则,反向(输出层➡隐藏层➡输入层)计算每个神经元的损失(下图中的δ、代码中的Params["Error"])。通过神经元的损失,可以计算出神经元的每个参数(权重、偏置)的损失贡献(权重更新函数代码中的WeightParams["Delta"])并一直累加,直到训练集被读取完。这时,我们就说完成了一次训练迭代


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

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

                                                           using UnityEngine;


namespaceJufGame.AI.ANN
{
[System.Serializable]
publicclassBPNN : Training
{
publicfloat[][] OutputSet => outputSet;
[SerializeField] privatefloat meanError = float.MaxValue;
[SerializeField] private LossFunc.Type errorFunc;
[SerializeField] private UpdateWFunc.Type updateWFunc;
privatefloat[][] 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]或设置成了私有类):


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

                                                           public classTrainANN : MonoBehaviour
{
publicint inputCount;
public BPNN bp;
public InitWFunc.Type initW;

privatefloat[][] inSet = //异或运算的输入
{
newfloat[]{1, 0},
newfloat[]{1, 1},
newfloat[]{0, 0},
newfloat[]{0, 1},
};
privatefloat[][] outSet = //异或运算的输出
{
newfloat[]{1},
newfloat[]{0},
newfloat[]{0},
newfloat[]{1},
};

private void Awake()
{
...

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
阿尔及利亚苏-57E开始亮相,中国歼-35AE型战斗机还有机会吗?

阿尔及利亚苏-57E开始亮相,中国歼-35AE型战斗机还有机会吗?

军武次位面
2026-04-15 10:44:27
日本全面叫停种植牙?种牙潜藏的风险与后遗症,一次为你讲明白

日本全面叫停种植牙?种牙潜藏的风险与后遗症,一次为你讲明白

垚垚分享健康
2026-04-11 08:51:57
“中年返贫三件套”,正在吞掉一代人的存款

“中年返贫三件套”,正在吞掉一代人的存款

阅读第一
2026-04-15 08:34:45
国有召,召必回!许昕、马龙助力备战伦敦世乒赛,这下国乒有救了

国有召,召必回!许昕、马龙助力备战伦敦世乒赛,这下国乒有救了

以茶带书
2026-04-16 14:50:39
英军上将警告:若台海开战,英军将同时打击中俄,重点对付中!

英军上将警告:若台海开战,英军将同时打击中俄,重点对付中!

别吵吵
2026-04-15 09:49:16
广东3消息!杜锋病愈回归带队,焦泊乔正式复出,深圳新小外到位

广东3消息!杜锋病愈回归带队,焦泊乔正式复出,深圳新小外到位

多特体育说
2026-04-16 23:06:15
特朗普不演了,警告中国有大麻烦,话音刚落,美国人推动罢免总统

特朗普不演了,警告中国有大麻烦,话音刚落,美国人推动罢免总统

晓岇就是我
2026-04-16 19:31:19
王传福彻夜难眠:比亚迪卖车460万辆却沦为电池厂打工仔

王传福彻夜难眠:比亚迪卖车460万辆却沦为电池厂打工仔

老特有话说
2026-04-16 15:07:19
交管12123重磅升级!2026年5大实用新功能,车主务必及时更新

交管12123重磅升级!2026年5大实用新功能,车主务必及时更新

趣味萌宠的日常
2026-04-16 19:36:39
百亿巨资打造的重庆东站变“空城”? 5对绿皮车连夜迁入,这步棋能盘活新枢纽吗?

百亿巨资打造的重庆东站变“空城”? 5对绿皮车连夜迁入,这步棋能盘活新枢纽吗?

王晓爱体彩
2026-04-16 14:02:18
女人为还赌债,被迫在两人围观下发生关系,她的结局最终是怎样

女人为还赌债,被迫在两人围观下发生关系,她的结局最终是怎样

长安一孤客
2026-03-25 16:22:18
集团党委书记、董事长龚小波带队赴重庆考察调研

集团党委书记、董事长龚小波带队赴重庆考察调研

新浪财经
2026-04-16 18:52:02
别再尬演情妇了!一脸疲态、五大三粗,这是迷倒男人该有的皮囊?

别再尬演情妇了!一脸疲态、五大三粗,这是迷倒男人该有的皮囊?

白面书誏
2026-04-14 14:09:53
特朗普怕是没料到,先等来的不是中国交出稀土,央行公布黄金储备

特朗普怕是没料到,先等来的不是中国交出稀土,央行公布黄金储备

触摸史迹
2026-04-16 14:49:27
“父亲的心已经死了!”10后女孩职高报到,父亲用沉默诠释了心死

“父亲的心已经死了!”10后女孩职高报到,父亲用沉默诠释了心死

妍妍教育日记
2026-04-14 10:30:08
“带宠物入园可免门票”,辽宁一国家4A级景区推出免门票活动,游客脑洞大开,带着鸡、鸭、鹅、羊、孔雀来了→

“带宠物入园可免门票”,辽宁一国家4A级景区推出免门票活动,游客脑洞大开,带着鸡、鸭、鹅、羊、孔雀来了→

极目新闻
2026-04-15 20:24:44
女神也老了,颜值再也不如从前了,不过还是好漂亮啊!

女神也老了,颜值再也不如从前了,不过还是好漂亮啊!

小椰的奶奶
2026-04-16 10:31:12
三天闪电访华!苏林急得直跺脚,东南亚集体掉头靠向中国

三天闪电访华!苏林急得直跺脚,东南亚集体掉头靠向中国

瓦伦西亚月亮
2026-04-16 18:51:44
把F1轮毂做成意面,谁在买单?

把F1轮毂做成意面,谁在买单?

篮坛第一线
2026-04-16 09:15:08
恭喜俄罗斯和乌克兰!打了1500多天,终于打成全世界都喜欢的样子

恭喜俄罗斯和乌克兰!打了1500多天,终于打成全世界都喜欢的样子

嫹笔牂牂
2026-04-15 10:03:39
2026-04-16 23:31:00
侑虎科技UWA incentive-icons
侑虎科技UWA
游戏/VR性能优化平台
1567文章数 987关注度
往期回顾 全部

科技要闻

赵明:智驾之战,看谁在大模型上更高效

头条要闻

美国启动"经济狂怒"行动 对伊朗施加最大化的经济压力

头条要闻

美国启动"经济狂怒"行动 对伊朗施加最大化的经济压力

体育要闻

皇马拜仁踢出名局,但最抢镜的还是他

娱乐要闻

丝芭传媒创始人王子杰去世,享年63岁

财经要闻

海尔与医美女王互撕 换血抗衰生意迷雾

汽车要闻

空间大五个乘客都满意?体验岚图泰山X8

态度原创

家居
旅游
游戏
教育
亲子

家居要闻

智能舒适 简约风尚

旅游要闻

宜动宜静!上海乐高乐园悟空小侠冒险项目正式开放,周边住宿业态升级

AL横扫WE!WE已经五连败了,什么时候可以恭喜WE?

教育要闻

孩子一遇到数学难题就想放弃?成华嘉祥名师这样建议

亲子要闻

有两娃的家庭每天都有断不完的官司

无障碍浏览 进入关怀版