ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

神经网络结合遗传算法:中国象棋AI评估与搜索实战解析

神经网络结合遗传算法:中国象棋AI评估与搜索实战解析 简介一份融合神经网络与遗传算法的中国象棋AI程序源自毕业设计与算法课程项目适合学习人工智能与博弈算法的学生、开发者和游戏编程爱好者参考。压缩包内文件数量很多共六百九十三个文件整体大小约十五兆字节资源以C#编写的脚本二百零五个和Unity工程资产为主体包含模型、贴图、着色器、预制件、场景配置等构成完整可运行的游戏项目并附带动态链接库、可执行文件及项目配置文档便于直接体验或修改学习。目前已有142人学习浏览。程序重点展示了神经网络评估棋局、遗传算法搜索走法以及两者结合决策的实现路径涵盖数据准备、网络结构设计、训练参数调优、适应度函数、选择交叉变异和实时性能优化等关键环节同时包含反向传播训练、种群进化搜索与棋局评估等可复用代码方便理解从模型训练到实际对弈的完整链路。文件目录结构清晰对完成毕业设计、课程作业或进一步研究AI博弈算法均有直接帮助。1. 为什么中国象棋AI要从“评局”和“搜步”两头入手这个《利用神经网络算法和遗传算法作为AI的中国象棋程序》资源包拆开来看是一套完整的AI决策链路先用神经网络评估棋局好坏再用遗传算法搜索可行的走法序列两者配合才形成最终的落子决策。它不是一个单算法demo而是把两个经典算法真正塞进了Unity工程里从ProjectSettings.asset到InputManager.asset这些工程配置都是现成的。对于做课程作业或毕业设计的同学它省掉了从零搭建Unity项目、自己写棋盘交互的体力活对于想搞懂AI决策流程的开发者它又给了一个可直接运行的实验场。你拿到手先别急着抠网络结构把对局跑起来看两次AI自我博弈比读十篇原理文章都管用。2. 棋盘编码与神经网络评估函数从90个交叉点到一局胜率2.1 为什么评估是AI决策的第一块基石中国象棋的状态空间非常大一步走完后对手有多种应法再往后推若干步就是指数级膨胀。AI做决策的本质是在“当前局面下选择最有利的落子”而“最有利”需要一个量化标准——这就是评估函数。传统做法是人工设计评分表比如车值9分、马值4分再加上位置加分项。但这个项目用神经网络替代人工设计让网络自己从对局数据中学习“什么样的局面更接近胜利”。这样做的好处是你不需要精修评分公式只需要提供足够多的带胜负结果的棋局网络通过反向传播自动调整权重让评估值逼近真实胜率。它的边界也很明确——神经网络评估的近似的优劣取决于训练数据质量和网络容量一局棋的最终胜负还受搜索深度影响评估值只是给搜索提供一个排序依据。2.2 棋盘编码把棋子位置翻译成网络能读的浮点数神经网络吃不了“红方车在九宫左上角”这样的自然语言描述你需要把棋盘状态编码成定长浮点数组。中国象棋棋盘是9×10的交叉点共90个点。每个点要表达“空”或“红/黑某一类棋子”我采用one-hot编码// BoardEncoder.cs —— 把棋局状态转换成神经网络输入向量 using System; using UnityEngine; public class BoardEncoder { // 90个交叉点每个点用12维one-hot表示 // 约定0空, 1红帅, 2红仕, 3红相, 4红马, 5红车, 6红炮, 7红兵 // 8黑将, 9黑士, 10黑象, 11黑马, 12黑车, 13黑炮, 14黑卒 // 实际每个点最多命中一个编号即输入向量为 90*121080 维 public const int BoardPoints 90; public const int FeatureDim 12; public static float[] Encode(BoardState board) { float[] input new float[BoardPoints * FeatureDim]; for (int pos 0; pos BoardPoints; pos) { Piece piece board.GetPieceAt(pos); int offset pos * FeatureDim; if (piece null) { input[offset] 1f; // 空位标记 continue; } // 棋子编号在1~14之间直接放到对应维度 int typeIndex piece.TypeId; if (typeIndex FeatureDim) { input[offset typeIndex] 1f; } } return input; } }这个编码方式的关键点是每个交叉点的状态是互斥的即一个点不可能同时是红车又是黑马所以每个点恰好有一个维度为1其余为0。代码里的TypeId如果大于等于FeatureDim说明该棋子类型编码越界这是初始化棋子类型时要检查的。2.3 网络结构三个全连接层足够完成棋局拟合我在这类棋类项目中常用的网络结构是输入层1080维中间两层128个神经元输出层1个神经元输出值经过tanh压缩到[-1,1]区间。正值表示红方优势负值表示黑方优势。这个网络结构不算深原因是训练数据量有限太深的网络容易过拟合。// NnEvaluator.cs —— 前向计算与反向传播训练 using System; using UnityEngine; public class NnEvaluator { private int inputDim 1080; private int hiddenDim 128; private float[] w1; // 输入层到隐藏层权重: inputDim * hiddenDim private float[] b1; // 隐藏层偏置: hiddenDim private float[] w2; // 隐藏层到输出层权重: hiddenDim private float b2; // 输出层偏置 public float LearningRate 0.005f; public float Forward(float[] input) { float[] hidden new float[hiddenDim]; for (int h 0; h hiddenDim; h) { float sum b1[h]; for (int i 0; i inputDim; i) sum input[i] * w1[h * inputDim i]; hidden[h] (float)Math.Tanh(sum); // tanh激活输出范围[-1,1] } float output b2; for (int h 0; h hiddenDim; h) output hidden[h] * w2[h]; return (float)Math.Tanh(output); } // 训练入口target来自棋局真实结果红胜1黑胜-1和棋0 public void TrainStep(float[] input, float target) { // 前向计算 float[] hidden new float[hiddenDim]; float[] hiddenPreAct new float[hiddenDim]; for (int h 0; h hiddenDim; h) { float sum b1[h]; for (int i 0; i inputDim; i) sum input[i] * w1[h * inputDim i]; hiddenPreAct[h] sum; hidden[h] (float)Math.Tanh(sum); } float outputPreAct b2; for (int h 0; h hiddenDim; h) outputPreAct hidden[h] * w2[h]; float output (float)Math.Tanh(outputPreAct); // 输出层误差反向传播 float dOut (1 - output * output) * (output - target); // tanh导数 float[] dHidden new float[hiddenDim]; for (int h 0; h hiddenDim; h) dHidden[h] (1 - hidden[h] * hidden[h]) * dOut * w2[h]; // 更新权重 for (int h 0; h hiddenDim; h) { w2[h] - LearningRate * dOut * hidden[h]; for (int i 0; i inputDim; i) w1[h * inputDim i] - LearningRate * dHidden[h] * input[i]; } b2 - LearningRate * dOut; for (int h 0; h hiddenDim; h) b1[h] - LearningRate * dHidden[h]; } }这段代码把反向传播拆得很直白便于课程设计答辩时逐行解释。LearningRate直接影响收敛速度设太大会震荡不收敛太小则训练速度慢常见做法是0.001~0.01之间起调观察loss曲线再微调。训练数据用“红胜1、黑胜-1、和棋0”作为target比直接拟合“胜率百分比”更稳定。2.4 训练数据从残局库到自我对弈生成训练数据来源有两条路一是收集现成的中国象棋对局记录清洗后按局面切分每条数据存下“局面编码最终结果”二是让AI自己和自己下每局结束后把中间所有局面和最终胜负关系绑定。第一条路数据质量高但量有限第二条路数据量大但噪声大因为早期自我对弈走的棋很劣质。我一般先跑几十局随机走法的“霸王棋”做初筛再用训练好的评估函数做后续对弈数据质量逐步提高。3. 遗传算法走棋引擎编码、适应度与种群进化3.1 为什么搜索部分不用Alpha-Beta剪枝神经网络只回答“当前局面好不好”但不回答“该走哪一步”。你可以在当前局面的所有合法走法中逐一模拟、逐一评估然后选择评估值最高的那个——这就是贪心搜索。但贪心有个致命缺陷只看一步不考虑对手后续的反击。而Alpha-Beta剪枝在搜索树足够深时棋力很强但它需要明确的搜索顺序优化棋步生成器要写出完整可用的走法枚举工程量较大。遗传算法在这个项目里的定位是“搜索优化器”把若干步走法拼成一个个体种群并行进化用神经网络评估值作为适应度指标通过多代演化找到一条相对好的走法序列。它不需要枚举所有分支天然支持并行实现难度对毕业设计也更友好。3.2 个体编码一个染色体就是一串走法遗传算法操作的对象是“个体”。这里的个体不是一条棋盘走法而是从当前局面开始的若干步走法序列。走法本身包含起点和终点我用fromPos * 90 toPos编码成一个0~8099的整数这样一个走法用两个int就能表示整个序列是这个int组成的数组。// GaSearch.cs —— 遗传算法主流程 using System; using System.Collections.Generic; using UnityEngine; public class GaSearch { public int PopulationSize 60; // 种群规模 public int MaxGenerations 25; // 最大进化代数 public int ChromosomeLength 6; // 每个个体包含的走法步数 public float MutationRate 0.1f; // 变异概率 private System.Random _rng new System.Random(); // 返回值当前局面的最佳走法 public Move Search(BoardState board, NnEvaluator evaluator) { // 第一步生成初始种群 ListIndividual population new ListIndividual(); for (int i 0; i PopulationSize; i) { Individual ind new Individual(ChromosomeLength); ind.RandomInit(board, _rng); population.Add(ind); } for (int gen 0; gen MaxGenerations; gen) { // 第二步评估适应度 foreach (var ind in population) { ind.Fitness EvaluateFitness(ind, board, evaluator); } // 第三步选择 population Selection(population); // 第四步交叉 population Crossover(population); // 第五步变异 foreach (var ind in population) ind.Mutate(board, MutationRate, _rng); } // 最终返回适应度最高的个体的第一个走法 float bestFitness float.MinValue; Move bestMove null; foreach (var ind in population) { if (ind.Moves.Count 0 ind.Fitness bestFitness) { bestFitness ind.Fitness; bestMove ind.Moves[0]; } } return bestMove; } }这段主流程里有个细节值得注意Search里我们只取第一个走法作为实际落子而不是把整条染色体走完。因为对手会用真实棋盘回应你预先算好的后续走法大概率不成立。所以每次决策只执行第一步下一回合重新跑一遍GA。这是棋类AI的通用做法叫“滚动时域优化”。3.3 适应度函数用神经网络评估值做差分适应度函数决定进化方向。如果把当前局面评估为v0某一步走完后的新局面评估为v1这一单步的适应度就是v1 - v0。但对一条包含6步的染色体需要模拟这6步全部生效后的局面评估值vEnd还要考虑对手中间可能的反击——模拟时让对手走“当前局面的最优应手”即轮流用评估贪心选点。这样计算出来的vEnd - v0就是染色体适应度。// Individual.cs —— 个体类含交叉变异实现 public class Individual { public ListMove Moves; public float Fitness; public Individual(int length) { Moves new ListMove(length); } // 随机生成合法走法序列 public void RandomInit(BoardState board, System.Random rng) { BoardState clone board.Clone(); for (int i 0; i Moves.Capacity; i) { ListMove legalMoves clone.GetLegalMoves(); if (legalMoves.Count 0) { Moves.Add(null); continue; } Move m legalMoves[rng.Next(legalMoves.Count)]; Moves.Add(m); clone.ApplyMove(m); } } // 单点交叉前一段来自父本后一段来自母本 public void Crossover(Individual other, System.Random rng) { int point rng.Next(1, Moves.Count); for (int i point; i Moves.Count; i) { if (i other.Moves.Count other.Moves[i] ! null) Moves[i] other.Moves[i].Clone(); } } // 变异以一定概率替换某个位置的走法 public void Mutate(BoardState board, float rate, System.Random rng) { for (int i 0; i Moves.Count; i) { if (rng.NextDouble() rate) { ListMove legalMoves board.Clone().GetLegalMoves(); if (legalMoves.Count 0) Moves[i] legalMoves[rng.Next(legalMoves.Count)]; } } } }交叉点选在整条染色体长度范围内前段的“开局思路”保留后段的“残局应变”交换。这一步如果完全随机交叉很容易破坏染色体内部走法的合法性——比如父本的前3步走完母本的第4步放在当前棋盘上可能是非法走法所以变异和交叉后都要做合法校验。这里board.Clone().GetLegalMoves()就是重新取合法走法牺牲了一些性能但保证了不出错。3.4 参数表一组可以照抄的起点配置参数名推荐值含义与影响PopulationSize50~80种群越大探索越充分但每代耗时线性增加MaxGenerations20~30代数太少没收敛太多浪费时间25代左右够用ChromosomeLength5~8预演步数越长越深但模拟耗时指数上升MutationRate0.08~0.15变异率过高会让搜索退化为随机走法适应度计算方式vEnd - v0差分方式避免当前局面本身优劣干扰评估这套参数是常见做法不是唯一解。如果你的棋盘走法模拟函数很快比如预先生成了走法掩码表可以把ChromosomeLength调到10搜索出的棋步明显更具攻击性。反之如果跑起来掉帧严重先砍PopulationSize再砍MaxGenerations最后才动ChromosomeLength。4. Unity工程组装把训练好的AI接入完整对局循环4.1 从工程配置文件看项目结构资源包里包含ProjectSettings.asset、InputManager.asset、QualitySettings.asset等一堆Unity工程配置文件这是Unity生成项目的标志。InputManager.asset定义了鼠标点击交互的输入轴AudioManager.asset负责棋盘落子音效GraphicsSettings.asset控制渲染管线。这些文件直接决定了你能不能双击打开工程就能跑而不是重新建项目挨个配参数。你拿到手后打开Unity Hub选择旧版本建议2019.4 LTS或2020.3 LTS打开这个目录等C#脚本编译通过再进Play模式。4.2 AI决策与Unity生命周期的桥接AI不能放在Update里每帧调用那是灾难。正确做法是把AI作为一个状态机的分支轮到AI走棋时禁用玩家输入调用异步搜索协程等搜索结果返回后执行落子动画。// AiController.cs —— 挂载到棋盘GameObject上的控制器 using System.Collections; using UnityEngine; public class AiController : MonoBehaviour { public BoardState board; public NnEvaluator evaluator; public GaSearch searcher; public bool isRedSide true; private bool _isThinking false; // 由GameManager在轮到时调用 public void OnTurnStart() { if (_isThinking) return; StartCoroutine(ThinkAndMove()); } private IEnumerator ThinkAndMove() { _isThinking true; // 让棋盘UI先进入“思考中”状态 UIManager.Instance.SetStatusText(AI思考中...); // 用协程分帧执行搜索避免阻塞主线程 // 这里每帧只跑3次GA迭代保证界面不卡死 SearchResult result new SearchResult(); int iterationsPerFrame 3; int completedGenerations 0; var pop searcher.InitializePopulation(board); while (completedGenerations searcher.MaxGenerations) { for (int i 0; i iterationsPerFrame; i) { searcher.EvolveOneGeneration(pop, board, evaluator); completedGenerations; if (completedGenerations searcher.MaxGenerations) break; } yield return null; // 让出主线程一帧 } Move bestMove searcher.GetBestMove(pop); // 执行走子 board.ApplyMove(bestMove); UIManager.Instance.PlayMoveAnimation(bestMove); _isThinking false; GameManager.Instance.SwitchTurn(!isRedSide); } }这段代码的核心思路是把GA的逐代进化拆到多个帧里去执行而不是一帧内算完。yield return null就是给Unity一次渲染和响应输入的机会。每帧执行3代是经验值如果棋盘逻辑复杂导致单次模拟耗时高可以改成每帧1代代价是轮到AI走时会多等一两秒但总比定住强。4.3 自我对弈数据回流让AI自己当自己的老师神经网络训练不能只靠外部棋谱因为棋谱覆盖面有限很多冷门残局是搜不到的。我在这类项目里会加一个自我对弈模式让当前版本的AI内部用贪心搜索而非完整GA与一个“上一代”AI下棋每局结束后把中间局面胜负结果追加到训练数据文件里然后周期性微调神经网络。// SelfPlayManager.cs —— 批量自我对弈并导出训练样本 using System.IO; using UnityEngine; public class SelfPlayManager : MonoBehaviour { public int TotalGames 200; public string ExportPath ./data/chinese_chess_games.csv; public void RunSelfPlay() { StreamWriter writer new StreamWriter(ExportPath); for (int game 0; game TotalGames; game) { BoardState board BoardState.CreateInitial(); NnEvaluator redEvaluator new NnEvaluator(); NnEvaluator blackEvaluator new NnEvaluator(); bool redTurn true; int moveCount 0; while (!board.IsGameOver() moveCount 300) { var evaluator redTurn ? redEvaluator : blackEvaluator; // 自我对弈时用贪心搜索枚举所有合法走法的评估值取最高 Move best GreedySearch(board, evaluator, redTurn); board.ApplyMove(best); redTurn !redTurn; moveCount; } float result board.GetGameResult(); // 1红胜 / -1黑胜 / 0和 // 把对弈过程中所有局面及最终结果写入文件 foreach (var sample in board.GetAllSnapshots()) writer.WriteLine(${sample.BoardCode},{result}); } writer.Close(); Debug.Log($自我对弈完成数据已导出至 {ExportPath}); } }导出的CSV每行是“棋盘编码, 结果”后续训练时直接读文件解析成float[] input, float target。这比在内存里维护队列更稳训练脚本可以独立运行不必每次重跑对弈。5. 训练与运行避坑四个必踩的坑及排查记录5.1 训练loss不降反升评估值全是0.5附近现象神经网络训练几十轮后输出层评估值全部收敛到0附近loss曲线在0.8上下横盘。原因最常见的是输入编码的one-hot被破坏——棋子在TypeId映射时重复占用同一维度导致网络无法区分不同棋子。另一个高频原因是训练数据里红胜和黑胜样本比例严重失衡网络学到“永远输出0”算是最优解。解决先打印input向量检查每个90点段内是否恰好有一个1再把训练数据随机打乱按红胜/黑胜/和棋各占1/3重采样。我用这个检查脚本定位过两次都是数据问题而不是网络结构问题。5.2 GA搜索的走法每步都在变毫无连贯性现象同一局面连续调用10次搜索返回的走法每次都不一样甚至出现走出一步后立即被吃回来的低级失误。原因MutationRate设到了0.3以上变异过于激进把已经收敛的个体彻底打碎。另一个原因可能是适应度函数没有区分“进攻性走法”和“保守走法”导致所有个体适应度都接近选择压力不够。解决把变异率压到0.1以内同时在适应度上加一个“走法惩罚项”——如果序列中存在被对方吃掉的子额外扣分。这样进化会更倾向于保留有防守意识的组合。5.3 Unity里点Play后AI思考时界面卡死现象轮AI走时鼠标转圈几秒后恢复品质设置里的阴影和抗锯齿全开了。原因GA搜索在单帧内全部算完且评估函数里每次BoardState.Clone()都深拷贝了棋盘数组开销翻倍。QualitySettings.asset的高画质设置又加剧了渲染卡顿。解决按第4章的方式改成协程分帧执行BoardState.Clone()改成浅拷贝只复制差异部分比如维护一个落子记录栈回退时pop即可。如果你不想动代码把QualitySettings里的阴影关掉也能缓解但治标不治本。5.4 AI开局总是走飞相而且变化极少现象前几步永远只走“相三进五”这类固定招法一旦被针对性应对就进入劣势。原因训练数据的开局部分采集自少量棋谱网络对常见开局过拟合了。遗传算法搜索受ChromosomeLength限制预演6步里没办法规划完整开局套路所以只能捡网络评估最高的第一个动作——而这个动作恰好是数据里最常见的。解决在神经网络训练数据中加入开局扰动——对每条对局的前8步做随机平移制造等效但不同的开局局面。另外可以把开局单独做成一个“开局库查表”在游戏前8步不走GA直接查表8步之后再切换到GA搜索。注意缝合。6. 验证AI棋力三局定式测试与耗时控制技巧6.1 三局定式测试法调参后怎么知道AI有没有变强我的习惯是压上三局固定测试第一局用“中炮对屏风马”经典定式让AI走红方第二局让AI走黑方应对同样的开局第三局让AI自我对弈从初始局面开始。对比指标不是胜负而是前30步的“杀力指数”——即AI主动将军、捉子、兑子的次数占比。这样测试能反映NN评估函数和GA搜索的配合度且可重复性高。6.2 耗时控制把单步决策压到2秒以内实际对局体验里AI单步决策超过3秒玩家就会焦躁。如果GA搜索耗时长优先砍MaxGenerations而不是砍PopulationSize。种群缩小会明显损失搜索广度但代数减少可以用局部早停弥补——如果连续5代适应度最优值不再提升直接提前结束进化返回当前最优。这个技巧对在线实时对局特别有效。从那以后我每次调完参数都会强制走一遍三局定式测试再顺手看一眼单步耗时日志双指标都达标才提交。这套流程帮我省掉了无数次一局棋下到中盘才发现AI行为异常的半夜排查。希望帮到你。本文还有配套的精品资源点击获取
返回列表