资讯动态

浏览器端TensorFlow.js强化学习六轴机械臂DDPG训练沙盒

发布时间:2026/10/24 4:38:56 来源:尧图企业网站定制
简介围绕六轴机器人手臂的强化学习测试这套代码包面向具备JavaScript与TensorFlow.js基础、希望动手理解机械臂目标点控制与奖励式训练的开发者及学习者。项目以在二维10×10点阵和三维10×10×10点阵中寻找从起点到目标点的路线为入门实验通过逐步距离奖励训练模型并保留向LEGO EV3与伺服机构联动的实体六轴手臂延伸的空间。压缩包共11个文件、约1.88MB含3个html演示页、2个js脚本、2个md说明、2个json配置以及glb三维模型和css样式分别对应交互展示、训练逻辑、项目记录、依赖配置与模型可视化。代码结构把二维与三维测试分开便于对照实验差异并逐步增加控制维度。已有1262人学习读者可据此获得强化学习奖励函数设计、TensorFlow.js模型搭建、三维空间实验组织、训练入口排查及机械臂控制迁移的参考适合按模块阅读和二次实验。1. 浏览器里那条机械臂抖了三秒才碰到球六轴机械臂第一次在页面上动起来时六个关节像抽筋一样抖了将近三秒才勉强蹭到目标点。我第一反应是 Three.js 渲染掉帧打开控制台才发现渲染稳在 60 帧抖动的是策略网络本身——它压根没收敛。这个项目干的事情说穿了不复杂把一台 6 轴串联机械臂的运动学与简化动力学塞进 JavaScript用 TensorFlow.js 在浏览器里训练一个连续动作空间的强化学习智能体让它学会把末端执行器送到随机生成的目标位姿。整条链路不依赖 PythonNode 和 Chrome 都能跑Three.js 把训练过程实时画出来模型直接落进 IndexedDB。它适合两类人想摸强化学习又不想折腾 CUDA 环境的前端和全栈以及需要快速验证奖励函数设计的机器人仿真原型开发者。它是个可交互的训练沙盒不是开箱即用的抓取产品。2. 技术栈与目录结构TensorFlow.js 怎么撑起一套 RL 训练环境2.1 运行时边界与后端选择整个项目的计算只有两个来源TensorFlow.js 负责所有张量运算和网络更新Three.js 负责把关节角度画出来。中间没有服务端没有 WebSocket也没有 Python 进程在背后算梯度。这个取舍决定了它的天花板和地板地板极低npm install完就能在浏览器里看到机械臂动天花板也明确浏览器单线程加上 WebGL 的算子覆盖跑 DDPG 这种小网络够用换成大规模并行环境采样就会卡。后端选择是第一个要弄清楚的点。TensorFlow.js 在浏览器里默认走webgl把矩阵乘法映射到 GPU 纹理上cpu后端是纯 JS 实现慢但算子齐全Node 侧如果装了tensorflow/tfjs-node会走 libtensorflow 的 C 绑定速度介于两者之间。这份代码在入口处做了一个运行时探测逻辑大致是优先webgl拿不到 WebGL2 上下文就退回cpu跑在 Node 里则加载 node 后端。这个探测看起来不起眼但它决定了你后面调试时的性能基线——同一个训练脚本webgl和cpu的每回合耗时可能差五到八倍用cpu跑出来「训练不收敛」的结论往往只是样本量不够。提示调试奖励函数时我会强制切到cpu后端因为它每一步都是确定性的webgl在部分集成显卡上浮点精度会有细微差异同一颗随机种子跑两次结果不完全一致排查问题时容易误判。构建工具用的是 Vite好处是 HMR 快而且它把tensorflow/tfjs当成普通 ESM 依赖处理不用额外配 worker 打包规则。渲染层没有用现成的机器人可视化库就是 Three.js 的Group加Mesh每个关节一个Object3D父子节点串成一条链正运动学算出来的角度直接写进rotation。这个做法土但改起来方便——想加一个夹爪挂个子节点就行。2.2 目录结构与模块职责源码包的组织是典型的「环境 / 智能体 / 渲染」三层切分跟 Gym 那套env与agent分离的思路一致只是搬到了 ES Module 里。robot-arm-rl/ ├── public/ │ └── models/ # 导出的权重文件tfjs layers 格式 ├── src/ │ ├── env/ │ │ ├── kinematics.js # DH 参数表 正运动学纯函数无副作用 │ │ ├── arm.js # 机械臂状态机reset / step / 关节限位 │ │ └── reward.js # 奖励函数单独一个文件方便反复调 │ ├── agent/ │ │ ├── ddpg.js # Actor / Critic 网络定义与更新逻辑 │ │ ├── replayBuffer.js # 经验回放环形数组实现 │ │ └── noise.js # 高斯探索噪声 噪声衰减 │ ├── render/ │ │ ├── scene.js # 场景、相机、光照 │ │ └── robotMesh.js # 六个关节的 Mesh 与父子绑定 │ ├── train.js # 训练入口Node 与浏览器共用 │ └── main.js # 演示入口加载模型跑推理 ├── index.html ├── package.json └── vite.config.jsenv/这一层最大的特点是纯函数化。kinematics.js里没有任何全局状态输入六个关节角输出一个 4x4 齐次变换矩阵arm.js才持有当前关节角、角速度这些可变状态。这个分法在二次开发时很值钱你想换一套 DH 参数只动kinematics.js里的表你想改动作的物理约束只动arm.js的step。两者不会互相污染。agent/这一层跟渲染完全解耦。ddpg.js里不 import 任何 Three.js 的东西这意味着同一份智能体代码可以拿去跑无头训练Node 里render那一层根本不加载。我一般会保留这个边界哪怕一开始只是在浏览器里玩——等你哪天想开四个标签页并行采样这个解耦就是救命的设计。train.js和main.js是两个入口前者训练后者加载已保存的模型做推理演示。两个入口共用env和agent区别只在于train.js每个 episode 结束会调一次agent.update()main.js只调actor.predict()。2.3 从零跑通的三条命令环境要求不复杂Node 18 以上用到原生 ESM 和structuredCloneChrome 或 Edge 任意较新版本。package.json里的脚本大致长这样{ scripts: { dev: vite, train: node src/train.js --episodes 500 --no-render, train:browser: vite --open /?modetrain, build: vite build, preview: vite preview } }npm install之后先跑npm run dev浏览器打开会是演示模式机械臂按预设轨迹动或者加载public/models/下已有的权重跑推理。想自己从头训走npm run train:browser这个地址带?modetrain参数入口里读到它就切训练分支页面上会多出一块面板显示回合数、累计奖励、当前探索噪声 σ。命令行训练用npm run train--episodes控制回合数--no-render关掉渲染Node 环境本来也没有 canvas。这里有个细节值得注意Node 侧如果不装tensorflow/tfjs-nodetrain.js会自动 fallback 到tensorflow/tfjs的纯 JS 后端速度慢一个数量级。我的经验是命令行训练一定要装 node 绑定否则 500 回合能跑一晚上。# 完整安装包含 Node 原生绑定 npm install npm install tensorflow/tfjs-node --save-optional # 无头训练 500 回合每 50 回合存一次权重 node src/train.js --episodes 500 --save-every 50 --no-render--save-every这个参数是我加进脚本的习惯强化学习的训练曲线不是单调的第 300 回合的成功率可能比第 200 回合还低只存最后一版等于把中间那段运气好的策略扔了。参数解析在train.js顶部手写了一个极简 parser没有引入 commander 之类的库因为参数就五六个加依赖不划算。跑通之后回到浏览器npm run dev加?modetrain会看到机械臂在随机抖动前几十个回合基本是乱挥第 80 到 120 回合开始出现「先抬肩再转底座」这种有意图的动作这个阶段最值得盯着看因为奖励函数的毛病往往在这时候暴露——比如它学会了把末端停在空中某个固定点刷「距离不再变远」的分而不是真的往目标去。3. 运动学与 RL 建模把一台机械臂翻译成智能体能读的向量3.1 DH 参数表与正运动学实现六轴串联臂的几何关系用标准 DH 参数描述最省事。项目里这张表是硬编码在kinematics.js顶部的六个关节依次是底座旋转、肩、肘、腕滚转、腕俯仰、末端旋转关节a (m)α (rad)d (m)关节范围J1 底座0π/20.15±170°J2 肩0.3000-120° ~ 60°J3 肘0.2600-150° ~ 60°J4 腕滚0π/20.12±180°J5 腕俯0-π/20.08±120°J6 末端000.06±180°单关节变换用的是标准 DH 形式θ是变量其余三个是常量// src/env/kinematics.js export const DH [ { a: 0, alpha: Math.PI / 2, d: 0.15 }, { a: 0.30, alpha: 0, d: 0 }, { a: 0.26, alpha: 0, d: 0 }, { a: 0, alpha: Math.PI / 2, d: 0.12 }, { a: 0, alpha: -Math.PI / 2, d: 0.08 }, { a: 0, alpha: 0, d: 0.06 }, ]; // 单关节变换矩阵 T(i-1 - i) export function dhMatrix(theta, a, alpha, d) { const ct Math.cos(theta), st Math.sin(theta); const ca Math.cos(alpha), sa Math.sin(alpha); return [ [ct, -st * ca, st * sa, a * ct], [st, ct * ca, -ct * sa, a * st], [0, sa, ca, d], [0, 0, 0, 1], ]; } // 4x4 矩阵连乘链式求出末端位姿 export function forwardKinematics(q) { let T [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]; for (let i 0; i 6; i) { const { a, alpha, d } DH[i]; T matmul(T, dhMatrix(q[i] JOINT_OFFSET[i], a, alpha, d)); } return T; // T[0..2][3] 是末端位置左上 3x3 是姿态 }q是长度 6 的关节角数组单位弧度。JOINT_OFFSET是每个关节的零位偏置因为建模软件导出的模型零位跟 DH 表不一定对齐这个偏移量我一般是在第一次渲染时肉眼对准后再写死的。返回的T是 4x4 齐次矩阵T[0][3]、T[1][3]、T[2][3]就是末端在基座坐标系下的 xyz。这里刻意没有实现逆运动学。RL 的思路是让智能体自己学到一个从状态到关节增量的映射绕开 IK 的多解和奇异点问题。代价是训练慢好处是奖励函数怎么写它就怎么学不用考虑解析解的约束。3.2 状态空间与动作空间的具体维度观测向量一共 21 维拼的顺序在arm.js的getObservation()里固定// 6 关节角 6 关节角速度 3 末端位置 3 目标位置 3 相对位移 getObservation() { const q this.jointAngles; // 弧度 const dq this.jointVelocities; // 弧度/秒限幅到 ±2 const T forwardKinematics(q); const ee [T[0][3], T[1][3], T[2][3]]; const goal this.goalPosition; const rel [goal[0]-ee[0], goal[1]-ee[1], goal[2]-ee[2]]; return [...q, ...dq, ...ee, ...goal, ...rel]; }把相对位移显式放进去是因为神经网络对这种线性组合的推断能力有限尤其在小网络上。给了rel之后策略要学的其实只是一个「往 rel 方向动的增益」收敛速度肉眼可见地快。这是个便宜的工程技巧算不上什么创新但省下来的训练回合是实打实的。动作空间是 6 维连续量含义是每个关节的目标角速度增量不是力矩。选角速度而不是力矩是因为这一层没有做完整的刚体动力学质量矩阵、科氏力、重力补偿都没上用角速度做控制更稳定不会因为积分误差累积导致机械臂飞出去。动作经过tanh压到 [-1, 1]再乘以每关节的最大速度 0.8 rad/s// 动作 - 关节速度先裁剪再积分 applyAction(action) { const MAX_VEL 0.8; for (let i 0; i 6; i) { const v Math.max(-1, Math.min(1, action[i])) * MAX_VEL; this.jointVelocities[i] v; this.jointAngles[i] v * this.dt; // dt 1/60 // 关节限位撞到边界就把速度清零避免持续顶死 if (this.jointAngles[i] LIMITS[i][0] || this.jointAngles[i] LIMITS[i][1]) { this.jointAngles[i] clamp(this.jointAngles[i], LIMITS[i][0], LIMITS[i][1]); this.jointVelocities[i] 0; } } }dt固定 1/60不跟着requestAnimationFrame的真实间隔走。这一点很关键如果按真实帧间隔积分同一份策略在 120Hz 屏幕上和 60Hz 屏幕上行为会不一样训练环境和演示环境就对不上了。固定步长加上累加器accumulator才是正确做法渲染帧率和物理步长分离掉帧的时候补跑几次物理步。3.3 奖励函数四项加权和里的每一项都在防一种作弊奖励函数是整个项目里改动最频繁的文件四个组成部分各管一件事// src/env/reward.js export function computeReward(prevEE, nextEE, goal, dq, opts {}) { const { wDist 1.0, // 距离进展权重 wSmooth 0.05, // 动作平滑惩罚 wStep 0.01, // 每步固定代价 wSuccess 10, // 成功稀疏奖励 goalRadius 0.02 // 成功判定半径 2cm } opts; const dPrev dist(prevEE, goal); const dNext dist(nextEE, goal); const progress (dPrev - dNext) * wDist; // 靠近给正远离给负 const smooth -wSmooth * dq.reduce((s, v) s v*v, 0); // 关节速度平方和 const stepCost -wStep; const success dNext goalRadius ? wSuccess : 0; return progress smooth stepCost success; }progress用的是势能差而不是绝对距离。直接拿-dNext当奖励智能体会发现「站着不动距离不变」也能拿一个稳定负分而动起来有可能更远于是它选择躺平。改成dPrev - dNext之后不动拿 0 分靠近拿正分远离拿负分梯度方向明确多了。这是强化学习里 shaping 的标准做法但真正踩过「智能体学会原地不动」这个坑的人才会记住为什么。smooth惩罚关节速度平方和作用是把抽搐式的动作压掉。没有这一项策略会倾向于高频小幅抖动——因为每一步都往目标方向挪一点点抖动在数值上确实能提升 progress但真机上这种动作会把减速器打死。stepCost是个很小的负数逼它尽快结束不然智能体会绕远路慢慢晃过去反正每步都有 progress 拿。success是稀疏项只有末端进到 2cm 球体内才给 10 分。半径选 2cm 是权衡太大了随便伸一下就算成功学不到精度太小了稀疏奖励几乎触发不了训练前期完全没有正反馈。2cm 大约是这套 DH 参数下末端重复定位精度的三到四倍属于「稍微努努力能够到」的量级。注意wDist和wSuccess的比例关系比绝对值重要。wSuccess设得过高比如 100智能体会为了那一下的巨额奖励反复冲刺成功前的过程完全不管设得过低比如 2它发现细水长流地刷 progress 更划算永远不真正到位。我一般让wSuccess约等于「平均每回合 progress 总和的 5 到 10 倍」。arm.js的step()把这些串起来应用动作 → 前向积分 → 算新末端位置 → 调computeReward→ 判断是否done成功或超过 200 步→ 返回{ observation, reward, done }。标准 Gym 接口形状方便以后想接别的前端。4. 训练循环与模型导出一条样本从进缓冲到变成策略的路径4.1 经验回放与批采样DDPG 是 off-policy必须有个回放池。项目里用环形数组实现容量默认 10 万条存的是普通 JS 数组而不是张量——这点很重要如果直接往 buffer 里塞tf.Tensor几万步之后浏览器的显存就爆了。存原始数值采样的时候再转张量// src/agent/replayBuffer.js export class ReplayBuffer { constructor(capacity 100000) { this.capacity capacity; this.buffer new Array(capacity); this.pos 0; this.size 0; } push(s, a, r, s2, done) { this.buffer[this.pos] { s, a, r, s2, done }; this.pos (this.pos 1) % this.capacity; this.size Math.min(this.size 1, this.capacity); } sample(batchSize) { const idx []; for (let i 0; i batchSize; i) { idx.push(Math.floor(Math.random() * this.size)); } const batch idx.map(i this.buffer[i]); // 只在采样这一瞬间转张量用完立刻 dispose const s tf.tensor2d(batch.map(b b.s)); const a tf.tensor2d(batch.map(b b.a)); const r tf.tensor2d(batch.map(b [b.r])); const s2 tf.tensor2d(batch.map(b b.s2)); const d tf.tensor2d(batch.map(b [b.done ? 1 : 0])); return { s, a, r, s2, d }; } }batchSize默认 64。这个值在浏览器里别往上调太多128 在webgl后端上会让每步更新时间翻倍而小网络对小 batch 并不敏感。容量 10 万条对应大约 500 回合的样本量够 DDPG 用了。采样返回的五个张量调用方必须负责dispose()。这里没有用tf.tidy包住因为返回的张量要跨函数使用tidy会在函数返回时把它们全回收掉。常见的写法是在更新函数里用一个外层tidy包住中间计算最后只返回 loss 的标量。4.2 Actor-Critic 更新与目标网络软更新Actor 是三层全连接隐层 256 和 128输出 6 维接tanhCritic 把状态和动作分别过一层再拼接最后输出一个 Q 值标量。两个网络各配一份 target 网络用polyak系数做软更新。// src/agent/ddpg.js更新片段 async update(buffer, batchSize 64, gamma 0.99, tau 0.005) { const { s, a, r, s2, d } buffer.sample(batchSize); // ---- Critic 更新让 Q(s,a) 逼近 r γ * Q(s, μ(s)) ---- const lossC this.optimizerC.minimize(() { const targetA this.targetActor.apply(s2); const qNext this.targetCritic.apply([s2, targetA]); const y tf.tidy(() r.add(gamma.mul(tf.scalar(1).sub(d)).mul(qNext)).stopGradient() ); const qNow this.critic.apply([s, a]); return tf.losses.meanSquaredError(y, qNow); }); // ---- Actor 更新最大化 Q(s, μ(s))即最小化 -Q ---- const lossA this.optimizerA.minimize(() { const action this.actor.apply(s); return this.critic.apply([s, action]).mean().neg(); }); // ---- 目标网络软更新 ---- this.softUpdate(this.targetActor, this.actor, tau); this.softUpdate(this.targetCritic, this.critic, tau); [s, a, r, s2, d].forEach(t t.dispose()); return { lossC: lossC.dataSync()[0], lossA: lossA.dataSync()[0] }; } softUpdate(target, source, tau) { const tw target.getWeights(); const sw source.getWeights(); const next tw.map((t, i) t.mul(1 - tau).add(sw[i].mul(tau)) ); target.setWeights(next); tw.concat(next).forEach(w w.dispose()); // 中间张量必须手动回收 }lossC里y那一行加了stopGradient()这是 DDPG 最容易写错的地方。目标值y是常量不能对 target 网络的参数求梯度漏了这一步会让梯度从 target 网络一路传回主网络训练直接发散而 loss 曲线看起来还挺正常属于血泪级别的隐蔽 bug。softUpdate里张量回收这段写起来啰嗦但省不掉。每个t.mul(1-tau)都产生一个新张量setWeights之后旧的tw和中间的next都成了垃圾浏览器环境下不手动 dispose 就是内存泄漏。探索噪声用高斯噪声σ 从 0.3 线性衰减到 0.05衰减周期是总回合数的 60%。前 60% 的回合里噪声大样本多样性好后 40% 噪声小让 critic 能收敛到比较精确的 Q 值。噪声加在actor.predict()的输出上然后一起 clip 到 [-1, 1]。训练循环本身的骨架很朴素外层 for 回合内层 while 不到 done每步env.step、buffer.pushbuffer 大小超过batchSize之后再每步调一次update。--save-every指定的回合数到了就await actor.save()。4.3 模型保存、IndexedDB 与推理加载TensorFlow.js 的模型持久化有三条路localstorage://、indexeddb://、downloads://。这个项目默认走 IndexedDB因为 localStorage 有 5MB 上限两个网络加起来很容易超downloads://用来导出给演示页用。// 训练结束或达到存档点时保存 await this.actor.save(indexeddb://arm-actor-v1); await this.critic.save(indexeddb://arm-critic-v1); // 同时导出一份可下载文件方便分发 await this.actor.save(downloads://arm-actor-v1); // 演示入口加载 const actor await tf.loadLayersModel(indexeddb://arm-actor-v1); actor.compile({ optimizer: adam, loss: meanSquaredError }); // 推理其实不用 compile推理时只加载 actorcritic 完全不需要这也是把两个网络分开存的原因——演示页面加载体积能小一半。加载回来的模型有个坑新版本 TF.js 的loadLayersModel在某些环境下会因为batch_input_shape与当前输入维度不一致报错。项目里在main.js加载之后立刻跑一遍 dummy 输入做校验const probe tf.zeros([1, 21]); const out actor.predict(probe); if (out.shape[1] ! 6) throw new Error(模型输出维度与动作空间不符); probe.dispose(); out.dispose();这一行检查省不了。我见过太多次「改了观测维度但忘了重新训」的情况报错信息藏在一堆张量警告里查半天。5. 避坑与排查训练不收敛时先看这几处5.1 现象loss 一直在降机械臂却原地抽搐原因通常是奖励函数里的progress项与smooth项的权重失衡。wSmooth太小时critic 很快学会了「高频抖动能让 progress 稍微正一点」这个模式Q 值稳步上升loss 曲线漂亮但策略实际是在原地摩擦。解决办法是先把wSmooth提到 0.2 左右跑 50 回合看行为确认抖动被压住之后再往下调。我的习惯是初始训练永远从偏大的平滑惩罚开始等策略能稳定朝目标移动了再逐步降回来换精度。5.2 现象训练到两百多回合页面突然白屏或标签页崩溃原因是张量没回收。浏览器webgl后端的显存不像 CUDA 那样有明确的 OOM 报错往往是静默地失败然后整页崩掉。最常见的位置是softUpdate里的中间张量和predict的返回值——这两处最容易被忽略因为它们不在 loss 计算的主链路上。排查手法是在训练循环里定期打tf.memory().numTensors正常应该在几百到几千之间波动如果它单调上涨不回落就是有地方漏了 dispose。找到之后用tf.tidy包住纯计算段跨函数返回的张量手动dispose。5.3 现象训练速度忽快忽慢同一份代码在两台机器上结果完全不同原因是物理步长跟着真实帧率走了。requestAnimationFrame的间隔在后台标签页会被压到 1Hz在前台高刷屏又是 120Hz如果dt取的是真实间隔同一个策略的行为就完全不可复现。解决办法是固定dt 1/60用累加器补步每帧算出 elapsed累加到 accumulatorwhile (accumulator dt)就跑一次物理步。另外训练时把标签页保持在前台或者干脆用 Node 无头训练别指望后台标签页能稳定跑。5.4 现象成功率卡在 30% 上不去从曲线上看像是到了瓶颈这种情况多数不是网络容量问题而是success半径设得太小加上探索噪声衰减太快。噪声在 60% 回合就降到 0.05后面基本是 exploitation如果那时候策略还没摸索到 2cm 球体附近就再也过不去了。处理办法是把衰减周期拉长到 80%或者改成「连续 20 回合成功率不涨就把 σ 弹回 0.15」这种自适应策略。另外临时把goalRadius放大到 4cm 跑一轮确认策略有能力接近目标再缩回去——如果能过 4cm 但过不了 2cm那是精度问题不是探索问题方向就清楚了。5.5 现象加载模型后推理输出全是 NaN两个来源。一是加载时没做输入校验观测向量里混进了undefined张量构造时静默变成 NaN二是训练时关节角速度没限幅某一步积分炸了模型权重里带了 Inf。第二种情况在训练日志里能看出来累计奖励会突然变成Infinity或-Infinity。前者的解法就是 4.3 里那段 probe 校验外加在getObservation返回前加一句Number.isFinite的断言。后者要在applyAction里对关节角速度做硬限幅并且在step里检查 reward 是否有限一旦出现非有限值就reset()并打一条警告。别让 NaN 悄悄进到回放池里一条污染样本能毁掉整个 batch 的梯度。6. 二次开发换奖励目标之后怎么验证它真的变好了改这个项目最常见的诉求是换奖励目标比如从「尽快到位」改成「到位且省电」。改动本身不难在computeReward里加一项关节力矩平方和或者速度平方和就行难的是判断改完到底有没有变好。我的做法是固定三件事再对比随机种子、目标点序列、评估回合数。// src/train.js 里预留的评估入口 async function evaluate(agent, env, { episodes 20, seed 42 } {}) { const rng mulberry32(seed); // 确定性伪随机 agent.setNoise(0); // 评估时关掉探索噪声 let success 0, energy 0, steps 0; for (let i 0; i episodes; i) { env.reset(rng()); // 目标点由固定种子生成 let done false; while (!done) { const a agent.act(env.getObservation(), { explore: false }); const { reward, done: d } env.step(a); energy a.reduce((s, v) s v * v, 0); // 用动作平方和近似能耗 steps 1; done d; } if (env.reachedGoal) success 1; } return { successRate: success / episodes, energy, avgSteps: steps / episodes }; }这段评估函数是我在每次调完奖励之后必跑的。三个指标一起看成功率、能耗代理量、平均步数。只加能耗惩罚的话成功率往往会掉这时候要看掉多少——掉 5 个点换 30% 的能耗下降值掉 20 个点说明惩罚权重给大了往回退。改动点需要同步修改的位置验证指标加能耗惩罚项reward.js的computeReward参数能耗代理量下降、成功率不掉超 5 点加第七维夹爪动作arm.js的applyAction、actor 输出层、观测维度动作维度校验通过、无 NaN换目标为位姿而非位置getObservation加四元数、computeReward改姿态距离姿态误差收敛曲线调 DH 参数kinematics.js的DH表与JOINT_OFFSET渲染与真实几何对齐、末端可达范围还有一个技巧值得说改奖励之前先把当前版本的评估结果存下来当 baseline别凭记忆对比。强化学习的随机性太大人的记忆又偏向记住「上次好像挺好的」没有数字兜底很容易在几个版本之间来回横跳最后改了个寂寞。我一般会在public/models/旁边放一个eval-log.json每次评估追加一条记录带上改动描述和环境指纹。另外一个容易忽略的点是观测归一化。如果新加的奖励项依赖某个量级很大的输入比如末端到目标的毫米级距离别忘了在喂进网络前除一个尺度因子。小网络对输入尺度很敏感21 维里混进一个量级是 1000 的维度前面 20 维的信号基本就被淹了表现为「改了奖励但策略行为没变化」查半天以为是奖励权重的问题。从那以后我每次动奖励函数或者观测维度都会强制先跑一遍固定种子的 20 回合评估把成功率、能耗、平均步数三个数写进 log再决定这个改动留不留。这个习惯救过我很多次——至少有三次凭直觉觉得「这版肯定更好」的改动在数字面前原形毕露。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑