news 2026/10/6 18:58:48

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
神经网络结合遗传算法:中国象棋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*12=1080 维 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) { // 第一步:生成初始种群 List<Individual> population = new List<Individual>(); 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 List<Move> Moves; public float Fitness; public Individual(int length) { Moves = new List<Move>(length); } // 随机生成合法走法序列 public void RandomInit(BoardState board, System.Random rng) { BoardState clone = board.Clone(); for (int i = 0; i < Moves.Capacity; i++) { List<Move> 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) { List<Move> 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行为异常的半夜排查。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 18:54:53

第083篇 作用域函数五兄弟:let、run、with、apply、also

let、run、with、apply、also 这五个作用域函数,语法上都是"把一段代码包起来",但它们的组合有 22 两种维度共四种,加上 with 构成五兄弟。答这题靠背表格没用——从定义出发(参数式 vs 接收者式、返回旧对象 vs 返回新值)就能自己推出这五个的行为,不需要背。…

作者头像 李华
网站建设 2026/10/6 18:50:57

Java类与继承

Java 面向对象&#xff1a;类的继承&#xff08;extends&#xff09;与重写 前言 这是学习Java类与继承的笔记&#xff0c;用动物作为父类&#xff0c;狗&#xff0c;猫作为子类进行一个示范。本篇记录继承相关的基础知识。 环境 JDK 17IntelliJ IDEA纯 Java 项目&#xff08;未…

作者头像 李华
网站建设 2026/10/6 18:29:56

工业测距传感器选型实战:激光/超声波/毫米波三大方案深度对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华