Unity中利用遺傳演算法訓練MLP

狐王驾虎發表於2024-08-13

Unity中利用遺傳演算法訓練MLP

梯度下降法訓練神經網路通常需要我們給定訓練的輸入-輸出資料,而用遺傳演算法會便捷很多,它不需要我們給定好資料,只需要隨機化多個權重進行N次“繁衍進化”,就可以得出效果不錯的網路。

這種訓練方式的好處就是不需要訓練用的預期輸出資料,適合那類可以簡單透過環境互動判斷訓練好壞的神經網路AI。當然,壞處就是訓練的時間可能需要很長很長,尤其是神經網路比較龐大時。

完整專案gitee連結:點選這裡

用Compute Shader實現神經網路

神經網路的計算一般都用矩陣最佳化,像python語言者學習實現神經網路時,通常會藉助numpy的torch進行計算,加速運算過程。

image

個人曾經嘗試過以單個神經元為最小單位實現的神經網路,但其實這種做法並不好。後來嘗試過使用C#的MathNet庫中的矩陣,但它發現並沒有在硬體層面對矩陣運算進行加速。雖說對於小規模網路,即便不加速計算也不會太影響效能,但總覺得得考慮得更長遠些。

想到神經網路的預測過程中,其實我們只關心輸入層與輸出層,而隱藏層的那些計算結果其實根本不在乎。欸~這似乎很適合用Compute Shader來完成!

隱藏層計算的結果完全可以只在留在ComputeBuffer,只有輸入層需要將資料寫入以及輸出層將結果讀取,CPU與GPU間資料的傳遞並不會很多;而且Compute Shader強大的平行計算能力也可以加速我們的運算過程。

但由於本文注意還是像講遺傳演算法,就不喧賓奪主了,具體的實現會包含到文末的專案連結中。

遺傳演算法

在中學生物課本有提到達爾文的自然選擇學說四個主要觀點:過度繁殖、生存競爭、遺傳和變異、適者生存。遺傳演算法就是借鑑了其中的思想,它的整個流程及其相似:

初始化種群

在本例中,我們想要獲取神經網路中各層合適的權重與偏置的值,來使神經網路的輸出符合預期,所以我們將整個神經網路的所有權重與偏置視為一個個體

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

namespace JufGame
{
	[CreateAssetMenu(menuName = ("JufGame/AI/ANN/WeightBias"), fileName = ("WeightAndBias_"))]
	public class WeightBiasMemory : ScriptableObject
	{
		[Serializable]
		public struct LayerWeightAndBias
		{
			public int inputCount;
			public int outputCount;
			public float[] weights;
			public float[] 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;
			}
		}

		/// <summary>
		/// 隨機初始化權重和偏置
		/// </summary>
		/// <param name="WeiBiasArray">被隨機化的數層權重和偏置</param>
		/// <param name="minRandValue">最小隨機值</param>
		/// <param name="maxRandValue">最大隨機值</param>
		/// <param name="isRandomBias">偏置是否也要隨機化,如果false則置0</param>
		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;
				}
			}
		}

		/// <summary>
		/// 深複製所有層的權重與偏置
		/// </summary>
		/// <param name="source">複製源</param>
		/// <param name="target">目標處</param>
		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];
				}
			}
		}

		/// <summary>
		/// 交換所有層的權重與偏置
		/// </summary>
		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
{
    //遺傳演算法中的個體,具體邏輯需繼承該類擴充套件
	public class GAUnit : MonoBehaviour
	{
		public WeightBiasMemory memory;
		public float FitNess;
		public bool isOver;
		public virtual void ReStart()
		{
			isOver = false;
			FitNess = 0;
		}
	}
}

然後初始化指定數量的該類個體作為初始種群,擔任原始父本,並讓個體權重與偏置隨機化。這樣一來,每個個體就都是不同的了,至於它們中誰具有更好的潛質,就需要透過競爭得知了。

競爭

我們讓遊戲中的使用神經網路決策的AI個體,分別應用種群中各個體作為神經網路的權重與偏置,並直接應用神經網路進行決策。由於這些權重與偏置都是隨機的,執行的效果幾乎都不堪入目。

private void FixedUpdate()
{
    if(isEndTrain) //如果選擇結束訓練,則保留當前最好的個體
    {
        SaveBest();
    }
    else if(TrainUnit.isOver) //如果當前訓練單位的訓練結束
    {
        parents[curIndex].fitness = TrainUnit.FitNess;
        TrainUnit.ReStart();
        //輪流將當前父本中個體權重與偏置賦給訓練單位進行決策
        if(++curIndex < AllPopulation)
        {
            WeightBiasMemory.DeepCopyAllLayerWB(ref parents[curIndex].WB, ref TrainUnit.memory.WeiBiasArray);
        }
        //……
    }
}

但我們需要“矮子裡拔高個”,設計一個評估函式計算每個個體的適應度。比如評估一個小車,我們就可以透過它行駛的距離、速度等進行加權和得到一個適應度。總之,要確保評估函式的計算結果能合理表達出決策結果的好壞。

繁殖與變異

現在,我們要隨機從原始父本中選出兩個不同的個體,進行繁殖得到兩個新的個體。

這個繁殖的過程很簡單,與染色體互換的過程極其相似。對於新權重和偏置,隨機從兩個作為父本的個體選擇一個,選取其對應部分的值。每個位置都這麼做一遍,就得到了兩個新個體(子代)。

image

但值得注意的是,如果是自然界,其實更優秀的個體會擁有更大的繁殖機會。所以,我們可以使用一種叫輪盤賭的隨機選擇方式,代替之前的純隨機選擇。這樣,就可以讓適應度更高的個體有更大機會變成父本,但也保留弱小個體被選中的可能。

image

以上圖藍色段被選中的機會為例,原本它應當為0.4,也就是生成一個0~1的隨機數,如果隨機數的值小於0.4,那麼藍色就被選中。

而轉化為輪盤賭後,藍色段的部分為0.227~0.59,也就是隻有隨機值落在這個範圍內時,它才會被選中。如果是其它值,就留給其它段了。

可以明顯看出,這樣的選擇更照顧整體,原本大的值會有更大機率被選中,但小的也有機會。程式碼實現也非常簡單:

//計算輪盤賭機率分佈
private void CalcRouletteWheel()
{
    float totalFitness = 0f;
    for (int i = 0; i < parents.Length; i++)
    {
        totalFitness += parents[i].fitness;
    }
    float cumulativeSum = 0f;
    for (int i = 0; i < cumulativeProbabilities.Length; i++)
    {
        cumulativeSum += (parents[i].fitness / totalFitness);
        cumulativeProbabilities[i] = cumulativeSum;
    }
}

//輪盤賭隨機下標
private int GetRouletteRandom()
{
    float rand = Random.value;
    // 選擇個體
    for (int i = 0; i < cumulativeProbabilities.Length; i++)
    {
        if (rand < cumulativeProbabilities[i])
        {
            return i;
        }
    }
    // 如果沒有找到,返回最後一個個體(通常不會發生)
    return cumulativeProbabilities.Length - 1;
}

現在還有一個問題,僅僅只是交叉互換,那麼最終得到的最優個體也只會囿於初始種群。如果初始種群中無論怎麼交叉互換都無法得到優良個體又該怎麼辦?這時就得靠變異了。

變異的手段並不固定,只要能做到突破就可以。我的做法就是在原本數值的基礎上隨機增減一個小數值。但變異通常不能太頻繁發生,我們要為它規定一個較小的機率,否則大規模的變異反而會破壞優良父本的傳承。

變異的發生可以與繁殖放在一起:

private void GetChild()
{
    int p1, p2;
    for(int i = 0; i < parents.Length; i += 2)
    {
        p2 = p1 = GetRouletteRandom();
        var curWB = parents[i].WB;
        while(p1 == p2 && parents.Length > 1)
        {
            p2 = GetRouletteRandom();
        }
        for(int j = 0; j < curWB.Length; ++j)
        {
            var curW = curWB[j].weights;
            for (int k = 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);
                }
            }
            var curB = curWB[j].bias;
            for (int k = 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);
                }
            }
        }
    }
}

優勝劣汰

在繁殖得到新的一批子代後,我們將這些子代也進行一次競爭,這樣所有的父代、子代就都有各自的適應度了。我們將它們一起根據適應度進行排序,顯然,如果父代的數量是N,那麼總共就有2N個個體。在排序後我們選擇前N個個體做為本輪的優勝者,也是下輪的新父本。

//在父代和子代組成的整體中選出適應度高的新父代
private void GetBest()
{
    for(int i = 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

image

然後設定具體結構,這次要完成的工作比較簡單,就是訓練一個可以繞圈跑的小球,所以網路結構比較簡單。兩個隱藏層足矣(對應Wei Bias Array的兩個元素),這個神經網路接受三個輸入,輸出兩個資料。

image

至於中間其它引數的設計要符合神經網路的結構,具體來說就是:每一層的Weights數量要等於InputCount * OutputCount;除了第一層外,其它層的InputCount要等於上一層的OutputCount。(如果你對神經網路有所瞭解,那就能理解這些

Affine固定使用同名的Compute Shader,至於Activate FuncLoss Func其實可以不管,因為遺傳演算法訓練用不著。

2. 建立遺傳個體

場景中已有一個球形物體,掛載了繼承GAUnitCar指令碼 (原本是想做成車的

image

神經網路的3個輸入資料就來自小球的三條射線檢測:

private void CheckEnv()
{
	totalSensor = 0;
	for(int i = 0; i < direactions.Length; ++i)
	{
		var dir = 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 void CalculateFitness()
{
	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;
	}
}

private void OnCollisionEnter(Collision other)
{
	if(!isOver && hitMask.ContainLayer(other.gameObject.layer))
	{
		isOver = true; //碰到牆上,直接結束
		rb.velocity = Vector3.zero;
	}
}

這樣,個體的設定就搞定了,它將作為訓練時的執行個體。

3. 遺傳演算法訓練器

在場景中任意啟用的物體上,掛載GA指令碼,並將Car拖拽在指定位置:

image

這個指令碼中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;這時再執行,會發現小球可以自動繞圈走了:

image

尾聲

完整的訓練影片在專案中有。這篇寫的比較爛捏,如果瞭解神經網路,或許這篇就好看懂些。大夥感興趣就把玩下專案吧,也可以嘗試更復雜的賽道,更龐大的網路 (估計訓練會很久

相關文章