简介:本资源为基于Unity ML-Agents release_15开发的自行车机器人智能躲避同伴项目工程,面向人工智能、强化学习与游戏开发方向的学生及开发者,可用于毕业设计、课程设计、大作业、工程实训及学科竞赛等场景。压缩包共564个文件,约34.08MB,包含C#脚本、Unity场景与预制体、ONNX与PT模型文件、TensorBoard训练日志、材质贴图及说明文档等,覆盖从训练配置到推理部署的完整链路。目前已有43人学习关注。项目代码经过测试运行,功能完整可复现,读者可据此复刻出相同效果的智能体,也可在现有工程基础上修改奖励函数、观测空间或场景布局,扩展出新的避障与协同行为;设计报告与训练日志亦可作为撰写论文与实验分析的参考素材。
1. 自行车机器人躲避同伴:这个毕设题目到底在做什么
一辆只有两个轮子的自行车机器人,在 Unity 场景里自己保持平衡、自己找路,还要实时躲开另一个同样在移动的同伴机器人——这就是这个题目的核心。它属于强化学习 + 物理仿真的交叉方向,用 Unity ML-Agents release_15 作为训练框架,把自行车机器人的平衡控制、目标导航和动态避障塞进同一个决策网络里。适合做毕设、课设、实训或竞赛的同学,也适合想入门具身智能(Embodied AI)但不想从零搭仿真环境的工程师。
很多人第一反应是「自行车机器人平衡都难,还要躲同伴,这不是自找麻烦吗」。恰恰相反,平衡和避障放在一起训练,反而比分开做更容易收敛——因为躲避动作本身会带来姿态扰动,模型被迫学会在动态中保持稳定,泛化能力比单一任务强得多。这个方案能解决三个具体问题:一是用 ML-Agents 的 PPO 算法训练连续控制策略;二是用 Unity 物理引擎模拟真实的自行车动力学;三是用课程学习(Curriculum Learning)让躲避难度逐步升级。读完你能自己搭出一个可训练、可复现、可调参的完整工程。
2. 环境搭建与 ML-Agents release_15 的版本对齐
2.1 为什么 release_15 不能随便换版本
ML-Agents 的版本迭代非常快,release_15 对应的是 Unity 2021 LTS 之后的稳定分支,它的 Python 包mlagents和 Unity 包com.unity.ml-agents必须严格对应。我见过太多人用 release_20 的 Python 包去连 release_15 的 Unity 工程,结果UnityEnvironment初始化直接抛The Unity environment took too long to respond,查半天以为是端口问题,其实是通信协议不匹配。
常见做法是:Unity 端通过 Package Manager 安装com.unity.ml-agents@2.0.0(release_15 对应的版本),Python 端用pip install mlagents==0.28.0。这两个版本号是绑定的,不要看网上随便一个教程就照抄。如果你用的是 Unity Hub 安装的编辑器,注意安装时勾选 Linux Build Support(即使你在 Windows 上训练,ML-Agents 的 headless 模式也需要它来构建可执行文件)。
注意:release_15 的
mlagents-learn命令默认使用 5004 端口,如果被占用会静默失败,训练日志里只显示Couldn't connect to trainer,不会告诉你端口冲突。
2.2 创建工程与导入 ML-Agents 包
第一步,用 Unity Hub 创建一个 3D 工程,命名BikeBot_Dodge。打开后进入 Window > Package Manager,点击左上角加号,选择 Add package from git URL,输入:
# 这是 release_15 对应的 Unity 包地址,不要改成 main 分支 https://github.com/Unity-Technologies/ml-agents.git?path=com.unity.ml-agents#release_15等包导入完成后,在 Project 窗口里应该能看到Packages/com.unity.ml-agents。接着创建 Python 虚拟环境,这一步是为了隔离依赖,避免和你系统里的其他 Python 包打架:
# 建议用 Python 3.8 或 3.9,release_15 对 3.10+ 支持不完善 python -m venv mlagents_env # Windows 激活 mlagents_env\Scripts\activate # macOS/Linux 激活 source mlagents_env/bin/activate # 安装对应版本,注意 torch 要指定 1.8.1 左右 pip install mlagents==0.28.0 pip install torch==1.8.1安装完成后用mlagents-learn --help验证,如果能看到--run-id、--train等参数说明,说明环境通了。这里有个血泪经验:如果你之前装过其他版本的 mlagents,一定要先pip uninstall mlagents mlagents-envs再重装,否则残留的gym依赖会导致import mlagents时报cannot import name 'Space'。
2.3 自行车机器人的物理配置与碰撞体设置
自行车机器人的核心是 Rigidbody + 两个 WheelCollider。前轮负责转向,后轮负责驱动,车架用 CapsuleCollider 近似。在 Unity 里新建一个空物体命名BikeBot,添加 Rigidbody,Mass 设为 10,Drag 设为 0.05,Angular Drag 设为 0.1。然后建两个子物体FrontWheel和RearWheel,各挂 WheelCollider。
关键参数在 WheelCollider 上:前轮的Steer Angle通过脚本控制,范围 -30 到 30 度;后轮的Motor Torque控制驱动力,范围 -200 到 200。轮胎的Forward Friction和Sideways Friction用默认的曲线就行,但Extremum Slip要调到 0.4 左右,否则自行车在低速时容易侧滑翻车。
// BikeController.cs 核心片段 using UnityEngine; public class BikeController : MonoBehaviour { public WheelCollider frontWheel; public WheelCollider rearWheel; public Transform frontMesh; public Transform rearMesh; [Range(-30f, 30f)] public float steerInput; [Range(-200f, 200f)] public float driveInput; void FixedUpdate() { // 转向只作用在前轮 frontWheel.steerAngle = steerInput; // 驱动力只作用在后轮 rearWheel.motorTorque = driveInput; // 同步轮子模型的位置和旋转,否则视觉上轮子不动 UpdateWheelMesh(frontWheel, frontMesh); UpdateWheelMesh(rearWheel, rearMesh); } void UpdateWheelMesh(WheelCollider col, Transform mesh) { Vector3 pos; Quaternion rot; col.GetWorldPose(out pos, out rot); mesh.position = pos; mesh.rotation = rot; } }这段代码的逻辑是:FixedUpdate里每物理帧更新一次转向和驱动力,steerInput和driveInput后续会被 ML-Agents 的 Agent 脚本覆盖。参数说明:steerAngle超过 30 度会导致前轮与车架碰撞体穿插,motorTorque超过 200 会让后轮空转打滑。UpdateWheelMesh是必须的,否则 WheelCollider 在动但视觉模型不动,调试时你会以为物理没生效。
3. Agent 脚本与观测空间设计:让机器人「看见」同伴
3.1 观测向量里该放什么、不该放什么
Agent 的观测空间决定了模型能学到什么。自行车机器人的观测分三块:自身状态、目标信息、同伴信息。自身状态包括车架的世界坐标(3 维)、四元数旋转(4 维)、前轮转速(1 维)、后轮转速(1 维)、当前速度向量(3 维),共 12 维。目标信息是目标点的相对位置(3 维)。同伴信息是同伴机器人的相对位置(3 维)和相对速度(3 维),共 6 维。总计 21 维连续观测。
不要放的东西:绝对时间、帧率、场景里无关物体的坐标。我试过把场景里所有障碍物的坐标都塞进去,结果观测维度飙到 80 多,训练 50 万步还在原地打转。观测不是越多越好,冗余信息会让 PPO 的价值网络难以收敛。
// BikeAgent.cs 观测收集部分 public override void CollectObservations(VectorSensor sensor) { // 自身状态 12 维 sensor.AddObservation(transform.position); // 3 sensor.AddObservation(transform.rotation); // 4 sensor.AddObservation(frontWheel.rpm / 1000f); // 1,归一化 sensor.AddObservation(rearWheel.rpm / 1000f); // 1 sensor.AddObservation(rb.velocity / 20f); // 3,归一化 // 目标信息 3 维 Vector3 toTarget = (target.position - transform.position) / 50f; sensor.AddObservation(toTarget); // 同伴信息 6 维 Vector3 toPeer = (peer.position - transform.position) / 50f; Vector3 peerVel = peerRb.velocity / 20f; sensor.AddObservation(toPeer); sensor.AddObservation(peerVel); }归一化是必须的。rpm除以 1000、速度除以 20、距离除以 50,都是让输入落在 -1 到 1 附近。如果不归一化,位置坐标可能是几百,速度可能是几十,神经网络的第一层权重会被大数值主导,小数值的特征直接被淹没。这是新手最容易翻车的地方,训练日志里Policy Loss一直不降,八成是观测没归一化。
3.2 动作空间:连续控制还是离散控制
自行车机器人的动作空间用连续型(Continuous),两个维度:转向 [-1, 1] 映射到 [-30°, 30°],驱动力 [-1, 1] 映射到 [-200, 200]。不要用离散型,因为离散动作会把转向切成几个固定角度,自行车在避障时需要平滑的微调,离散动作会导致「一顿一顿」的抖动,物理引擎处理这种抖动时容易让车翻掉。
在 Agent 脚本里这样写:
// 在 Initialize() 里设置 public override void Initialize() { // 连续动作,2 个维度 // 第一个维度控制转向,第二个控制驱动力 // 注意:release_15 里连续动作默认范围是 [-1, 1] }然后在OnActionReceived里接收:
public override void OnActionReceived(ActionBuffers actions) { float steer = actions.ContinuousActions[0]; float drive = actions.ContinuousActions[1]; // 映射到物理参数范围 bikeController.steerInput = steer * 30f; bikeController.driveInput = drive * 200f; // 每步给微小惩罚,鼓励尽快完成任务 AddReward(-0.001f); }参数说明:steer * 30f里的 30 是最大转向角,drive * 200f里的 200 是最大驱动力。这两个值要和 WheelCollider 的物理参数匹配,改了一个另一个也要改。AddReward(-0.001f)是时间惩罚,让模型学会「尽快到达目标」,不加这个惩罚模型会学会在原地转圈拖时间。
3.3 奖励函数设计:躲避同伴的奖励怎么给
奖励函数是这个题目的灵魂。我见过有人只给「到达目标 +1,撞到同伴 -1」,结果模型学到的是「慢慢挪,不撞就行」,训练 100 万步还在起点附近晃。正确的做法是分层奖励:
| 奖励项 | 触发条件 | 数值 | 说明 |
|---|---|---|---|
| 到达目标 | 距离目标 < 2m | +1.0 | 回合结束 |
| 撞到同伴 | 与同伴碰撞 | -1.0 | 回合结束 |
| 靠近目标 | 每步距离减少 | +0.01 | 连续奖励 |
| 远离同伴 | 距离同伴 < 3m 时 | -0.02/步 | 惩罚危险接近 |
| 保持平衡 | 车架倾角 < 15° | +0.005/步 | 鼓励稳定 |
| 时间惩罚 | 每步 | -0.001 | 防止拖延 |
public override void OnActionReceived(ActionBuffers actions) { // ... 动作映射代码 ... // 靠近目标的连续奖励 float distToTarget = Vector3.Distance(transform.position, target.position); if (distToTarget < lastDistToTarget) AddReward(0.01f); lastDistToTarget = distToTarget; // 远离同伴的惩罚 float distToPeer = Vector3.Distance(transform.position, peer.position); if (distToPeer < 3f) AddReward(-0.02f); // 保持平衡的奖励 float tiltAngle = Vector3.Angle(transform.up, Vector3.up); if (tiltAngle < 15f) AddReward(0.005f); // 到达目标 if (distToTarget < 2f) { AddReward(1.0f); EndEpisode(); } // 撞到同伴 if (distToPeer < 0.8f) { AddReward(-1.0f); EndEpisode(); } }这段代码的关键在于「连续奖励」和「稀疏奖励」的配比。到达目标 +1 是稀疏奖励,靠近目标 +0.01 是连续奖励,后者让模型在早期就能获得梯度信号。distToPeer < 3f的惩罚范围要调,太小了模型学不会提前避让,太大了模型会不敢靠近目标。我一般从 3m 开始试,如果模型太怂就降到 2m,如果总是撞就升到 4m。
4. 课程学习与训练配置:让躲避难度逐步升级
4.1 课程学习的三个难度阶段
课程学习(Curriculum Learning)是 ML-Agents 里最实用的功能之一。自行车躲避同伴这个任务,如果一上来就让同伴高速乱窜,模型根本学不会。我一般分三个阶段:
第一阶段:同伴静止不动,自行车只需要学会平衡和到达目标。这个阶段大概 20 万步就能稳定。 第二阶段:同伴沿固定路径低速移动(速度 2 m/s),自行车需要绕开。这个阶段 30 万步左右。 第三阶段:同伴随机游走,速度 5 m/s,并且会主动朝自行车方向移动。这个阶段 50 万步以上。
在 Unity 里通过Academy或Agent的OnEpisodeBegin来切换难度,但更规范的做法是用 ML-Agents 的Curriculum配置文件。在config/目录下新建bike_curriculum.yaml:
BigWall: measure: progress thresholds: [0.3, 0.6, 0.9] min_lesson_length: 100 parameters: peer_speed: [0.0, 2.0, 5.0] peer_random: [0.0, 0.3, 1.0]然后在 Agent 脚本里读取这些参数:
// 在 Initialize() 里 peerSpeed = curriculumConfig["peer_speed"]; peerRandom = curriculumConfig["peer_random"];参数说明:measure: progress表示用「到达目标的成功率」作为难度提升的指标,thresholds是三个阶段的阈值,min_lesson_length是每个阶段最少跑多少个回合才允许切换。peer_speed和peer_random是自定义参数,分别控制同伴速度和随机程度。
4.2 PPO 超参数怎么调:学习率、批次大小、网络结构
ML-Agents 的 PPO 默认参数对自行车任务来说偏保守,我一般会改这几个:
# config/bike_trainer.yaml behaviors: BikeAgent: trainer_type: ppo hyperparameters: batch_size: 2048 # 默认 1024,自行车任务观测维度高,加大批次 buffer_size: 20480 # 默认 10240,经验池加大 learning_rate: 3.0e-4 # 默认 3.0e-4,保持 beta: 5.0e-3 # 熵系数,默认 5.0e-3,鼓励探索 epsilon: 0.2 # PPO 裁剪系数,默认 0.2 lambd: 0.95 # GAE 系数,默认 0.95 num_epoch: 3 # 默认 3,不要超过 5,否则过拟合 network_settings: normalize: true # 必须开,观测归一化 hidden_units: 256 # 默认 128,自行车任务用 256 num_layers: 2 # 默认 2 max_steps: 2.0e6 # 总训练步数 200 万 time_horizon: 64 # 默认 64 summary_freq: 10000 # 每 1 万步输出一次日志batch_size和buffer_size的关系是:buffer_size必须是batch_size的整数倍,且buffer_size要大于time_horizon。我见过有人把batch_size设成 4096 但buffer_size还是 10240,结果训练直接报Buffer size must be larger than batch size。hidden_units从 128 提到 256 是因为 21 维观测 + 2 维动作的映射关系比简单任务复杂,128 个神经元不够用,训练曲线会卡在某个奖励值上不去。
4.3 启动训练与 TensorBoard 监控
配置写好后,在命令行启动训练:
# 先激活虚拟环境 mlagents_env\Scripts\activate # 启动训练,--run-id 是这次训练的名字 mlagents-learn config/bike_trainer.yaml --run-id=bike_dodge_v1 --time-scale=20--time-scale=20是把 Unity 仿真速度调到 20 倍,训练时不用实时看画面。启动后按 Play 按钮,Unity 会开始跑仿真,命令行会输出类似:
[INFO] BikeAgent. Step: 10000. Mean Reward: 0.23. Std of Reward: 0.45. [INFO] BikeAgent. Step: 20000. Mean Reward: 0.31. Std of Reward: 0.52.同时开一个终端跑 TensorBoard:
tensorboard --logdir results在浏览器打开localhost:6006,重点看三个曲线:Environment/Cumulative Reward应该稳步上升,Policy/Learning Rate应该平稳,Policy/Entropy应该缓慢下降。如果Cumulative Reward在 0 附近震荡超过 10 万步,检查奖励函数是不是太稀疏;如果Entropy掉到 0.1 以下,说明模型不探索了,把beta调大。
5. 避坑与排查:训练不收敛、机器人翻车、同伴穿模
5.1 训练 50 万步奖励不涨,观测里混入了未归一化的值
现象:TensorBoard 里Cumulative Reward一直在 -0.5 到 0.5 之间震荡,Policy Loss不降。
原因:观测向量里某个维度的数值范围是几百甚至几千,神经网络第一层的权重被这个大数值主导,其他小数值特征被淹没。最常见的是transform.position没除以场景尺度,或者rb.velocity没除以最大速度。
解决:在CollectObservations里逐项检查,每个AddObservation的值都应该在 -5 到 5 之间。位置除以场景对角线长度,速度除以最大设计速度,角度用四元数(本身就是 -1 到 1)。如果懒得逐项算,在 Agent 的Initialize里开normalize: true,ML-Agents 会自动做 running mean/std 归一化,但手动归一化效果更稳定。
5.2 自行车在训练初期频繁翻车,WheelCollider 参数太激进
现象:回合刚开始 1 秒内自行车就侧翻,EndEpisode被频繁调用,模型学不到任何有效策略。
原因:WheelCollider 的Sideways Friction曲线默认Extremum Slip是 0.2,对自行车这种窄轮胎来说太小,稍微一转向就侧滑。另外Rigidbody的Center of Mass默认在几何中心,自行车重心应该偏低。
解决:把Sideways Friction的Extremum Slip调到 0.4,Extremum Value调到 1.0。在Start()里手动设置重心:
void Start() { rb = GetComponent<Rigidbody>(); // 重心降低到车架底部上方 0.2m rb.centerOfMass = new Vector3(0, -0.2f, 0); }重心降低后,自行车在 15 度以内的倾斜都能自动回正,训练初期的翻车率会大幅下降。
5.3 同伴机器人穿模,碰撞检测被物理引擎跳过
现象:自行车和同伴明明重叠了,但碰撞事件没触发,OnCollisionEnter不执行,奖励也没扣。
原因:Unity 的碰撞检测分 Discrete 和 Continuous 两种。Discrete 模式下,如果两个物体速度很快,物理引擎在两帧之间会「跳过」碰撞。同伴速度 5 m/s 时,每帧移动 0.1m(按 50Hz 物理帧率算),如果碰撞体厚度小于 0.1m,就会穿模。
解决:把自行车和同伴的Rigidbody的Collision Detection都设为Continuous Dynamic,并且把Interpolate设为Interpolate。另外碰撞体不要用 MeshCollider,用 CapsuleCollider 或 BoxCollider,MeshCollider 在 Continuous 模式下性能开销大且容易出 bug。
5.4 课程学习卡在第二阶段,阈值设置不合理
现象:peer_speed从 0 升到 2.0 后,奖励突然从 0.8 掉到 0.2,然后一直不涨,课程也不切到第三阶段。
原因:thresholds设得太高,第二阶段要求 60% 成功率才切第三阶段,但同伴速度 2 m/s 时模型成功率只有 40%,永远达不到阈值。另外min_lesson_length设得太小,模型还没适应新难度就被判定为「失败」。
解决:把thresholds改成[0.2, 0.5, 0.8],min_lesson_length改成 200。如果还是卡住,在第二阶段多跑 20 万步,或者把peer_speed的中间值从 2.0 降到 1.5,让难度曲线更平滑。
5.5 TensorBoard 曲线正常但实际测试时机器人原地转圈
现象:训练时Cumulative Reward涨到 0.9,但用mlagents-learn --resume加载模型测试时,自行车在原地转圈不前进。
原因:训练时用了--time-scale=20,物理帧率被拉高,模型学到的策略依赖高帧率下的微小时间差。测试时time-scale=1,物理帧率恢复正常,策略失效。另外Time.fixedDeltaTime在训练和测试时不一致也会导致这个问题。
解决:训练时--time-scale不要超过 10,并且把Time.fixedDeltaTime固定为 0.02(50Hz)。在 Agent 的Initialize里加一行Time.fixedDeltaTime = 0.02f;,确保训练和测试的物理步长一致。如果已经训练完了,用--inference模式测试,不要用--resume。
6. 模型导出与 ONNX 推理:把训练好的策略塞进 Unity
训练完成后,results/bike_dodge_v1/BikeAgent.onnx就是导出的模型文件。ML-Agents 会自动把.nn文件转成.onnx,但 release_15 的转换有时候会失败,报ONNX export failed。我一般手动转:
# 先找到 .nn 文件 ls results/bike_dodge_v1/BikeAgent/BikeAgent.nn # 用 mlagents 自带的转换工具 mlagents-convert results/bike_dodge_v1/BikeAgent/BikeAgent.nn转换成功后,在 Unity 里把BikeAgent.onnx拖到 Agent 脚本的Model字段上,把Behavior Type设为Inference Only。这时候按 Play,自行车就会用训练好的策略自己跑,不再需要 Python 端。
但这里有个坑:ONNX 推理的数值精度和 Python 端不完全一致,有时候模型在 Python 里表现很好,导出后自行车会轻微抖动。解决方法是把Decision Period从默认的 5 改成 3,让模型每 3 个物理帧决策一次,给物理引擎更多时间平滑。另外Inference Device选CPU还是GPU也有讲究:CPU 推理延迟低但吞吐小,GPU 吞吐大但延迟高。自行车任务只有一个 Agent,用 CPU 就够了。
// 在 Agent 脚本的 Inspector 里设置 // Behavior Parameters: // Behavior Name: BikeAgent // Vector Observation Space Size: 21 // Continuous Actions: 2 // Model: BikeAgent.onnx // Inference Device: CPU // Decision Period: 3最后说一个我自己的习惯:每次改完奖励函数或观测空间,先跑 5 万步看Cumulative Reward有没有上升趋势,没有就立刻停,不要硬跑 200 万步。我最多的一次跑了 300 万步才发现观测里有个维度忘了归一化,白白浪费了两天。训练日志里的Mean Reward比 TensorBoard 曲线更敏感,命令行输出连续 10 次不涨,就该回头查配置了。希望帮到你。
本文还有配套的精品资源,点击获取