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

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

作者主页:

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

梯度下降法训练神经网络通常需要我们给定训练的输入-输出数据,而用遗传算法会便捷很多,它不需要我们给定好数据,只需要随机化多个权重进行N次“繁衍进化”,就可以得出效果不错的网络。

这种训练方式的好处就是不需要训练用的预期输出数据,适合那类可以简单通过环境交互判断训练好坏的神经网络AI。当然,坏处就是训练的时间可能需要很长,尤其是神经网络比较庞大时。

完整项目gitee链接:

https://gitee.com/OwlCat/some-projects-in-tutorials/tree/master/ANNGA

一、用Compute Shader实现神经网络

神经网络的计算一般都用矩阵优化,像Python语言者学习实现神经网络时,通常会借助NumPy的Torch进行计算,加速运算过程。

个人曾经尝试过以单个神经元为最小单位实现的神经网络,但其实这种做法并不好。后来尝试过使用C#的MathNet库中的矩阵,但发现它并没有在硬件层面对矩阵运算进行加速。虽说对于小规模网络,即便不加速计算也不会太影响性能,但总觉得得考虑的更长远些。

想到神经网络的预测过程中,其实我们只关心输入层与输出层,而隐藏层的那些计算结果其实根本不在乎。这似乎很适合用Compute Shader来完成!

隐藏层计算的结果完全可以只留在ComputeBuffer,只有输入层需要将数据写入以及输出层将结果读取,CPU与GPU间数据的传递并不会很多;而且Compute Shader强大的并行计算能力也可以加速我们的运算过程。

但由于本文主要还是想讲遗传算法,就不喧宾夺主了。

二、遗传算法

在中学生物课本有提到达尔文的自然选择学说四个主要观点:过度繁殖、生存竞争、遗传和变异、适者生存。遗传算法就是借鉴了其中的思想,它的整个流程极其相似:

1. 初始化种群

在本例中,我们想要获取神经网络中各层合适的权重与偏置的值,来使神经网络的输出符合预期,所以我们将整个神经网络的所有权重与偏置视为一个个体。

using System; using System.Collections; using System.Collections.Generic; using UnityEngine; namespace JufGame {     [CreateAssetMenu(menuName = ("JufGame/AI/ANN/WeightBias"), fileName = ("WeightAndBias_"))]     publicclassWeightBiasMemory : ScriptableObject     {         [Serializable]         public struct LayerWeightAndBias         {             publicint inputCount;             publicint outputCount;             publicfloat[] weights;             publicfloat[] bias;         }         [Tooltip("各全连层的权重和偏置")]         public LayerWeightAndBias[] WeiBiasArray;         [Tooltip("全连接层的Compute Shader")]         public ComputeShader affine;         [Tooltip("激活函数的Compute Shader")]         public ComputeShader activateFunc;         [Tooltip("损失函数的Compute Shader)]         public ComputeShader lossFunc;         [Tooltip("当前损失函数在反向传播时是否要载入上次输出,用于sigmoid等函数")]         public bool isLoadLastOutput;         [Header("随机初始化权重")]         [Tooltip("是否要随机初始化")]         public bool isRandomWeightAndBias = false;         [Tooltip("当前权重是否是训练成功后的")]         public bool isFinishedWeightAndBias = false;         [Tooltip("随机初始化的最大值和最小值")]         public float minRandValue = -1, maxRandValue = 1;         [Tooltip("是否随机化权重")]         public bool isRandomBias = false;         private void OnValidate()         {             if(isRandomWeightAndBias && !isFinishedWeightAndBias)             {                 RandomWeightAndBias(ref WeiBiasArray, minRandValue, maxRandValue, isRandomBias);                 isRandomWeightAndBias = false;             }         }         ///         /// 随机初始化权重和偏置         ///         /// WeiBiasArray">被随机化的数层权重和偏置         /// minRandValue">最小随机值         /// maxRandValue">最大随机值         /// isRandomBias">偏置是否也要随机化,如果false则置0         public static void RandomWeightAndBias(ref LayerWeightAndBias[] WeiBiasArray, float minRandValue,              float maxRandValue, bool isRandomBias = false)         {             var rand = new System.Random();             foreach (var wb in WeiBiasArray)             {                 float range = maxRandValue - minRandValue;                 // 初始化权重                 for (int i = 0; i < wb.weights.Length; ++i)                 {                     wb.weights[i] = (float)(rand.NextDouble() * range + minRandValue); // 使用指定范围生成随机数                 }                 // 初始化偏置                 for (int i = 0; i < wb.bias.Length; ++i)                 {                     wb.bias[i] = isRandomBias ?  (float)(rand.NextDouble() * range + minRandValue) : 0;                 }             }         }         ///         /// 深拷贝所有层的权重与偏置         ///         /// source">拷贝源         /// target">目标处         public static void DeepCopyAllLayerWB(ref LayerWeightAndBias[] source, ref LayerWeightAndBias[] target)         {             for(int i = 0, j; i < source.Length; ++i)             {                 var wb = target[i];                 for (j = 0; j < wb.weights.Length; ++j)                 {                     wb.weights[j] = source[i].weights[j];                 }                 for (j = 0; j < wb.bias.Length; ++j)                 {                     wb.bias[j] = source[i].bias[j];                 }             }         }         ///         /// 交换所有层的权重与偏置         ///         public static void DeepSwap(ref LayerWeightAndBias[] a, ref LayerWeightAndBias[] b)         {             float tp;             for(int i = 0, j; i < a.Length; ++i)             {                 var wb = b[i];                 for (j = 0; j < wb.weights.Length; ++j)                 {                     tp = wb.weights[j];                     wb.weights[j] = a[i].weights[j];                     a[i].weights[j] = tp;                 }                 for (j = 0; j < wb.bias.Length; ++j)                 {                     tp = wb.bias[j];                     wb.bias[j] = a[i].bias[j];                     a[i].bias[j] = tp;                 }             }         }     } }

using System.Collections; using System.Collections.Generic; using UnityEngine; namespace JufGame {     //遗传算法中的个体,具体逻辑需继承该类扩展     publicclassGAUnit : MonoBehaviour     {         public WeightBiasMemory memory;         publicfloat FitNess;         public bool isOver;         public virtual voidReStart()         {             isOver = false;             FitNess = 0;         }     } }

然后初始化指定数量的该类个体作为初始种群,担任原始父本,并让个体权重与偏置随机化。这样一来,每个个体就都是不同的了,至于它们中谁具有更好的潜质,就需要通过竞争得知了。

2. 竞争

我们让游戏中的使用神经网络决策的AI个体,分别应用种群中各个体作为神经网络的权重与偏置,并直接应用神经网络进行决策。由于这些权重与偏置都是随机的,执行的效果几乎都不堪入目。

private voidFixedUpdate() {     if(isEndTrain) //如果选择结束训练,则保留当前最好的个体     {         SaveBest();     }     elseif(TrainUnit.isOver) //如果当前训练单位的训练结束     {         parents[curIndex].fitness = TrainUnit.FitNess;         TrainUnit.ReStart();         //轮流将当前父本中个体权重与偏置赋给训练单位进行决策         if(++curIndex < AllPopulation)         {             WeightBiasMemory.DeepCopyAllLayerWB(ref parents[curIndex].WB, ref TrainUnit.memory.WeiBiasArray);         }         //……     } }

但我们需要“矮子里拔高个”,设计一个评估函数计算每个个体的适应度。比如评估一个小车,我们就可以通过它行驶的距离、速度等进行加权和得到一个适应度。总之,要确保评估函数的计算结果能合理表达出决策结果的好坏。

3. 繁殖与变异

现在,我们要随机从原始父本中选出两个不同的个体,进行繁殖得到两个新的个体。

这个繁殖的过程很简单,与染色体互换的过程极其相似。对于新权重和偏置,随机从两个作为父本的个体选择一个,选取其对应部分的值。每个位置都这么做一遍,就得到了两个新个体(子代)。

但值得注意的是,如果是自然界,其实更优秀的个体会拥有更大的繁殖机会。所以,我们可以使用一种叫轮盘赌的随机选择方式,代替之前的纯随机选择。这样,就可以让适应度更高的个体有更大机会变成父本,但也保留弱小个体被选中的可能。

以上图蓝色段被选中的机会为例,原本它应当为0.4,也就是生成一个0~1的随机数,如果随机数的值小于0.4,那么蓝色就被选中。

而转化为轮盘赌后,蓝色段的部分为0.227~0.59,也就是只有随机值落在这个范围内时,它才会被选中。如果是其它值,就留给其它段了。

可以明显看出,这样的选择更照顾整体,原本大的值会有更大概率被选中,但小的也有机会。代码实现也非常简单:

//计算轮盘赌概率分布 privatevoidCalcRouletteWheel() {     floattotalFitness=0f;     for (inti=0; i < parents.Length; i++)     {         totalFitness += parents[i].fitness;     }     floatcumulativeSum=0f;     for (inti=0; i < cumulativeProbabilities.Length; i++)     {         cumulativeSum += (parents[i].fitness / totalFitness);         cumulativeProbabilities[i] = cumulativeSum;     } } //轮盘赌随机下标 privateintGetRouletteRandom() {     floatrand= Random.value;     // 选择个体     for (inti=0; i < cumulativeProbabilities.Length; i++)     {         if (rand < cumulativeProbabilities[i])         {             return i;         }     }     // 如果没有找到,返回最后一个个体(通常不会发生)     return cumulativeProbabilities.Length - 1; }

现在还有一个问题,仅仅只是交叉互换,那么最终得到的最优个体也只会囿于初始种群。如果初始种群中无论怎么交叉互换都无法得到优良个体又该怎么办?这时就得靠变异了。

变异的手段并不固定,只要能做到突破就可以。我的做法就是在原本数值的基础上随机增减一个小数值。但变异通常不能太频繁发生,我们要为它规定一个较小的概率,否则大规模的变异反而会破坏优良父本的传承。

变异的发生可以与繁殖放在一起:

private voidGetChild() {     int p1, p2;     for(inti=0; i < parents.Length; i += 2)     {         p2 = p1 = GetRouletteRandom();         varcurWB= parents[i].WB;         while(p1 == p2 && parents.Length > 1)         {             p2 = GetRouletteRandom();         }         for(intj=0; j < curWB.Length; ++j)         {             varcurW= curWB[j].weights;             for (intk=0; k < curW.Length; ++k)             {                 if(Random.value < 0.5)                 {                     children[i].WB[j].weights[k] = parents[p2].WB[j].weights[k];                     if (i + 1 < children.Length)                     {                         children[i + 1].WB[j].weights[k] = parents[p1].WB[j].weights[k];                     }                 }                 else                 {                     children[i].WB[j].weights[k] = parents[p1].WB[j].weights[k];                     if (i + 1 < children.Length)                     {                         children[i + 1].WB[j].weights[k] = parents[p2].WB[j].weights[k];                     }                 }                 if (Random.value < mutationRate) //随机变异,mutationRate为变异率                 {                     //mutationScale为变异的幅度,即变异带来的数值增减幅度                     children[i].WB[j].weights[k] += Random.Range(-mutationScale, mutationScale);                 }                 if (i + 1 < children.Length && Random.value < mutationRate)                 {                     children[i + 1].WB[j].weights[k] += Random.Range(-mutationScale, mutationScale);                 }             }             varcurB= curWB[j].bias;             for (intk=0; k < curB.Length; ++k)             {                 if(Random.value < 0.5)                 {                     children[i].WB[j].bias[k] = parents[p2].WB[j].bias[k];                     if (i + 1 < children.Length)                     {                         children[i + 1].WB[j].bias[k] = parents[p1].WB[j].bias[k];                     }                 }                 else                 {                     children[i].WB[j].bias[k] = parents[p1].WB[j].bias[k];                     if (i + 1 < children.Length)                     {                         children[i + 1].WB[j].bias[k] = parents[p2].WB[j].bias[k];                     }                 }                 if (Random.value < mutationRate) //随机变异,mutationRate为变异率                 {                     //mutationScale为变异的幅度,即变异带来的数值增减幅度                     children[i].WB[j].bias[k] += Random.Range(-mutationScale, mutationScale);                 }                 if (i + 1 < children.Length && Random.value < mutationRate)                 {                     children[i + 1].WB[j].bias[k] += Random.Range(-mutationScale, mutationScale);                 }             }         }     } }

4. 优胜劣汰

在繁殖得到新的一批子代后,我们将这些子代也进行一次竞争,这样所有的父代、子代就都有各自的适应度了。我们将它们一起根据适应度进行排序,显然,如果父代的数量是N,那么总共就有2N个个体。在排序后我们选择前N个个体作为本轮的优胜者,也是下轮的新父本。

//在父代和子代组成的整体中选出适应度高的新父代 privatevoidGetBest() {     for(inti=0; i < totalPopulation.Length; ++i)     {         if (i < AllPopulation)             totalPopulation[i] = parents[i];         else             totalPopulation[i] = children[i - AllPopulation];     }     Array.Sort(totalPopulation, (a, b) => b.fitness.CompareTo(a.fitness)); }

也就是说,有更高适应度的个体能存活下来,其它的就被淘汰。而这些存活下来的个体会不断重复这个过程。在数次迭代后,我们就一定可以得到理想中的个体(比如适应度超高的那种)。这时,我们就可以结束算法了。

三、实例:赛道小球

用一个比较简单的实例,串一遍整个过程。我们将训练一个用来跑赛道的小球。

1. 创建神经网络

在我的实现中,已将网络结构以ScriptObject形式存储,我们先新建一个,在Project下右键Create/ANN/WeightAngBias:

然后设置具体结构,这次要完成的工作比较简单,就是训练一个可以绕圈跑的小球,所以网络结构比较简单。两个隐藏层足矣(对应Wei Bias Array的两个元素),这个神经网络接受三个输入,输出两个数据。

至于中间其它参数的设计要符合神经网络的结构,具体来说就是:每一层的Weights数量要等于InputCount * OutputCount;除了第一层外,其它层的InputCount要等于上一层的OutputCount。(如果你对神经网络有所了解,那就能理解这些。)

Affine固定使用同名的Compute Shader,至于Activate Func和Loss Func其实可以不管,因为遗传算法训练用不着。

2. 创建遗传个体

场景中已有一个球形物体,挂载了继承GAUnit的Car脚本。

神经网络的3个输入数据就来自小球的三条射线检测:

private voidCheckEnv() {     totalSensor = 0;     for(inti=0; i < direactions.Length; ++i)     {         vardir= transform.TransformDirection(direactions[i]);         if(Physics.Raycast(transform.position, dir, out RaycastHit hit,              rayLength[i], hitMask, QueryTriggerInteraction.Ignore))         {             inputVal[i] = hit.distance / rayLength[i];         }         else         {             inputVal[i] = 1;         }         totalSensor += inputVal[i];     } }

神经网络的两个输出分别用来控制,移动速度以及角位移:

private void RunMLP() {     myMLP.Predict(inputVal);     moveVel = transform.TransformDirection(new Vector3( 0, 0, myMLP.outputData[0] * 10));     moveVel = Vector3.MoveTowards(rb.velocity, moveVel, 0.02f);     rb.velocity = moveVel;     transform.eulerAngles += new Vector3(0, myMLP.outputData[1] * 90 * Time.fixedDeltaTime, 0); }

我们还需要设计一个衡量适应度的函数。而因为我们打算训练一个能在赛道正中央前进的小球,所以这里主要考虑「位移距离、速度、检测距离」以及「是否有碰到墙」。一旦isOver为true后,GA会让小球回到起始点,进行新的训练。

private voidCalculateFitness() {     totalMoveDis += Vector3.Distance(transform.position, lastPos);     avgSpeed = totalMoveDis / runningTime;     //适应度与位移距离、速度、检测距离有关     FitNess = (totalMoveDis*distanceMultipler) + (avgSpeed*avgSpeedMultiplier) + ( totalSensor / inputVal.Length *sensorMultiplier);     if (runningTime > 20 && FitNess < 40) //存活足够时间且适应度不低时,结束本轮     {         isOver = true;     }     if(FitNess >= 1000) //适应度很高时,直接算成功,结束     {         isOver = true;     } } privatevoidOnCollisionEnter(Collision other) {     if(!isOver && hitMask.ContainLayer(other.gameObject.layer))     {         isOver = true; //碰到墙上,直接结束         rb.velocity = Vector3.zero;     } }

这样,个体的设置就搞定了,它将作为训练时的运行个体。

3. 遗传算法训练器

在场景中任意激活的物体上,挂载GA脚本,并将Car拖拽在指定位置:

这个脚本中All Population是初始化种群的数量,这里填50。但注意,这并不会让场景中出现50个小球,而是每轮小球得重复50次来逐一尝试种群中的个体。Mutation Rate是变异率,这里填0.3;Mutation Scale是变异幅度默认为1即可。

至于绿色框内的,Is End Train用来结束遗传算法的训练,并将最好的结果保存到先前的ScriptObject中。其余只是用来观察小球当前训练情况而已。

一切就绪后,点击运行即可训练。训练时我们可以调整Project Settings/Time/Time Scale加速训练。

需要注意的是,当你想测试小球时,一定要关闭GA脚本,或者将Train Unit置空,否则一运行就会又重新训练Train Unit中的个体。比如这里,花了4分钟训练出了一个能走圈的小球,保存训练结果,就要先勾上Is End Train,再终止运行,而后取消启用GA;这时再运行,会发现小球可以自动绕圈走了:

四、尾声

完整的训练视频在项目中有,如果了解神经网络,或许这篇就好看懂些。大伙感兴趣就尝试下项目吧,也可以尝试更复杂的赛道,更庞大的网络。

文末,再次感谢狐王驾虎 的分享, 作者主页:https://home.cnblogs.com/u/OwlCat, 如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群: 793972859 )。

近期精彩回顾

【学堂上新】

【厚积薄发】

【学堂上新】

【厚积薄发】