简介:面向机器人控制与强化学习入门者的实践型代码包,围绕乐高积木与伺服器搭建的六轴机械臂展开,重点演示如何借助 TensorFlow.js 在浏览器端训练模型,使机械臂末端逐步抵达可触及范围内的三维目标点。压缩包共 11 个文件,以网页、脚本、说明文档和配置文件为主,另含三维模型与样式文件,整体约 1.88MB,便于直接查看二维与三维测试页面及其交互逻辑。目前已有 1262 人学习,适合具备 JavaScript 基础的前端开发者、机器人爱好者与强化学习初学者参考。读者可从中了解 10×10 二维地图和 10×10×10 三维点地图的奖励设计思路,查看依据新位置与目标点距离逐步奖励寻路的实现,并结合 README、页面和脚本梳理从二维到三维的试验过程,为自建六轴机械臂控制或浏览器端小型强化学习实验提供可复用的代码骨架与调试参照。
1. 从浏览器里跑起一条 6 轴臂:tensorflow-robot-arm 到底在练什么
第一次打开 tensorflow-robot-arm 的页面,画面里是一条六轴机械臂在来回抖,末端离桌子上的红色小球还差一大截,几秒后抖动幅度变小,末端开始朝球的方向靠。这个场景基本说明了项目的全部野心:不做轨迹规划,不写逆运动学解析解,直接让智能体在浏览器里靠试错把末端送到目标点。对想入门强化学习的 JavaScript 开发者来说,它把「环境、智能体、渲染、训练循环」四件事压进一个前端工程里,不用配 GPU 集群,也不用装 Python 侧的训练依赖,一台能开 Chrome 的机器就能跑。适合谁?写过 JS、看得懂矩阵、但一直没亲手把 DQN 或 DDPG 跑通过的人。 downside 也很直接:浏览器单线程,训练效率和 Python 侧差着量级,它更像一个能看见过程的实验台,不是一个能出论文结果的训练框架。
读完这份源码,你至少能拿到三样东西:一套六轴臂的正向运动学实现、一份能跑的经验回放训练循环、一个可以换成自己任务的渲染壳子。下面按「骨架 → 数学 → 训练 → 踩坑 → 改造」的顺序拆。
2. 技术选型与工程骨架:TensorFlow.js 负责算,Three.js 负责看见
这个项目最核心的选型决定只有一个:训练和渲染都在浏览器里。听起来是为了炫技,实际上是冲着「看得见」去的。强化学习最难的地方不是写网络,是你不知道智能体到底在学什么,早期策略乱走、奖励曲线上下翻飞,纯命令行日志很难判断是环境写错了还是超参不对。把机械臂画出来,问题就直观了——末端在乱飞,说明动作空间没裁剪;末端贴地不动,说明奖励函数没给对方向。
2.1 目录结构与模块边界
这类项目一般会把代码切成四层,我按常见做法列一下,你拿到源码后可以对照着看:
| 目录 | 职责 | 关键文件 |
|---|---|---|
src/env/ | 环境定义:状态、动作、奖励、reset/step | armEnv.js |
src/kinematics/ | 六轴臂 DH 参数与正向运动学 | dh.js、forward.js |
src/agent/ | 网络结构、经验回放、训练步 | dqn.js、replayBuffer.js |
src/render/ | Three.js 场景、关节网格、目标点 | scene.js |
src/main.js | 串起训练循环与帧调度 | main.js |
模块边界的关键约定是:env不认识 TensorFlow,agent不认识 Three.js。env 只暴露reset()和step(action)两个方法,返回纯数组;agent 只吃数组吐数组;渲染层从 env 里读当前关节角,反过来只用来显示。这条边界守住了,你才能在不改训练代码的前提下换渲染,或者把 env 换成一个真实机械臂的仿真接口。
典型的 env 接口长这样:
// src/env/armEnv.js —— 环境只认数组,不认张量和网格 export class ArmEnv { constructor({ dh, jointLimits, actionScale, target }) { this.dh = dh; // 六组 DH 参数 this.limits = jointLimits; // [[min,max], ...] 六个关节 this.actionScale = actionScale; // 每步动作的最大增量,单位弧度 this.target = target; // 目标末端位置 [x,y,z] this.q = new Array(6).fill(0); // 当前关节角 } reset() { // 常见做法:在限位内均匀采样一个初始姿态,避免每次都从同一姿势起步 this.q = this.limits.map(([min, max]) => min + Math.random() * (max - min)); return this.getState(); } getState() { // 状态 = 六轴关节角 + 末端位置:既有配置信息,也有任务信息 const ee = forwardKinematics(this.dh, this.q); return [...this.q, ee[0], ee[1], ee[2]]; } step(action) { // 动作是关节增量,不是绝对角,训练更稳 for (let i = 0; i < 6; i++) { this.q[i] += action[i] * this.actionScale; this.q[i] = Math.min(this.limits[i][1], Math.max(this.limits[i][0], this.q[i])); } const ee = forwardKinematics(this.dh, this.q); const dist = Math.hypot(ee[0] - this.target[0], ee[1] - this.target[1], ee[2] - this.target[2]); const done = dist < 0.02; // 2cm 内算到达 const reward = 1 - dist + (done ? 5 : 0); // 距离越近奖励越高,到达额外加分 return { state: this.getState(), reward, done }; } }这段代码有三个设计决定值得注意。状态里同时塞了关节角和末端坐标,是因为只给关节角,网络很难直接推断「我现在离目标多远」;只给末端坐标,网络又不知道该往哪个方向转关节。动作定义成增量而不是绝对角,是因为增量的取值范围小且对称,网络输出更容易落在合理区间,副作用是每个 episode 需要更多步才能走远,所以actionScale这个参数要调——太小训练慢,太大会一步跨过头来回震荡。奖励里加到达加分,是为了在稀疏的「到达」信号和稠密的「距离」信号之间做个折中。
2.2 依赖安装与本地起服务
工程一般是 Vite 或者 Parcel 起步,因为 TensorFlow.js 和 Three.js 都是 ESM 包,需要打包器处理。安装部分没什么玄学:
# 安装依赖,注意 three 和 @tensorflow/tfjs 都是运行时依赖 npm install # 启动开发服务器,浏览器打开终端里打印的本地地址 npm run dev # 需要产出静态文件时 npm run build命令本身不复杂,容易翻车的是浏览器环境。TensorFlow.js 默认走 WebGL 后端,如果显卡驱动或者浏览器把 WebGL 禁了,它会静默回退到 CPU 后端,训练速度直接掉一个量级,但页面不会报错,你只会觉得「怎么这么慢」。排查办法是在控制台里手动打印当前后端:
// 启动时确认后端,别等到训练半小时才发现跑在 CPU 上 import * as tf from '@tensorflow/tfjs'; console.log('tf backend:', tf.getBackend()); // 期望是 webgl如果打印出来是cpu,先看浏览器设置里的硬件加速有没有开,再看控制台有没有 WebGL context lost 的警告。这个检查我建议直接写进main.js的启动流程里,比事后猜要省事。
2.3 状态、动作、奖励的接口约定
三个量的维度必须在工程里对齐,否则报错信息会非常难读。状态是 9 维(6 关节角 + 3 末端坐标),动作是 6 维(每个关节一个增量),奖励是标量。网络输入层写 9,输出层写 6,中间层宽度常见做法是 256 和 128 两层,激活用 relu。这个尺寸对浏览器来说刚好,再大显存和帧率都会难受。
// src/agent/dqn.js —— 网络结构,输入 9 维状态,输出 6 维动作价值 import * as tf from '@tensorflow/tfjs'; export function buildQNet(stateDim = 9, actionDim = 6) { const model = tf.sequential(); model.add(tf.layers.dense({ units: 256, activation: 'relu', inputShape: [stateDim] })); model.add(tf.layers.dense({ units: 128, activation: 'relu' })); model.add(tf.layers.dense({ units: actionDim })); // 每个关节一个 Q 值 model.compile({ optimizer: tf.train.adam(1e-3), loss: 'meanSquaredError' }); return model; }inputShape写错是最常见的第一个坑,写成[6]会在fit时报形状不匹配,而错误信息只会告诉你最后一维对不上,不会告诉你状态里多塞了末端坐标。输出层不加激活函数,是因为 Q 值本身没有上下界,加了 sigmoid 会把价值压到 0 到 1 之间,训练会卡住不动。优化器用 adam、学习率 1e-3 是这类小网络的常规起点,真正要调的是后面的探索率和回放容量。
3. 核心链路拆解:DH 参数连乘出末端位姿,奖励函数决定学不学得会
六轴臂的数学部分其实不复杂,难的是把它写对。正向运动学说白了就是六个 4×4 齐次变换矩阵连乘,从基座一路乘到末端法兰,取最后一列的前三个分量就是末端位置。项目里如果这部分算错,后面的训练全部白搭,而且症状很隐蔽:奖励一直是负的,你会以为是网络的问题,其实是手臂的几何模型就是错的。
3.1 DH 参数与矩阵连乘
标准 DH 参数每组四个:连杆长度a、连杆扭角alpha、连杆偏距d、关节角theta。前三个是机械臂的固定几何参数,theta是变量。写成一个函数,六组参数循环乘一遍就行:
// src/kinematics/forward.js —— 标准 DH 变换矩阵 export function dhMatrix(a, alpha, d, theta) { 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 矩阵乘法,手写比引库更可控,也方便调试 function matMul(A, B) { const C = Array.from({ length: 4 }, () => new Array(4).fill(0)); for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) for (let k = 0; k < 4; k++) C[i][j] += A[i][k] * B[k][j]; return C; } // 输入六组 DH 参数和六个关节角,返回末端位置 [x, y, z] export function forwardKinematics(dh, 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, thetaOffset } = dh[i]; T = matMul(T, dhMatrix(a, alpha, d, q[i] + thetaOffset)); } return [T[0][3], T[1][3], T[2][3]]; // 平移分量即末端位置 }thetaOffset这个字段容易被忽略。真实机械臂的零位和 DH 模型里的零位往往不重合,厂家给的参数里会带一个偏置角,训练时如果不加,机械臂在页面上的姿态和真实设备对不上。这个项目如果只做可视化,偏置全填 0 也能跑,但你要拿它对接真机,这一列必须按实际标定值填。
矩阵连乘的顺序不能反。DH 变换是「先绕 z 转、再沿 x 移、再绕 x 转、再沿 z 移」的复合,写成矩阵就是上面那个形式,左乘右乘搞反了,末端位置会跑到机械臂背后去,而渲染层看起来只是「姿势怪」,不会报错。
3.2 动作空间裁剪与关节限位
六轴臂每个关节都有物理限位,超了就是穿模或者撞自己。训练早期策略接近随机,动作幅度大,不裁剪的话关节角会迅速飞到 ±180° 之外,正向运动学算出来的位置根本没有参考意义。裁剪要在两个地方做:一是step里累加完增量之后裁,二是网络输出侧用tanh把动作压到 [-1, 1] 再乘actionScale。
// src/agent/policy.js —— 带探索噪声的动作选择 export function selectAction(model, state, epsilon, actionDim = 6) { // epsilon-greedy:小概率随机探索,大概率用网络 if (Math.random() < epsilon) { return Array.from({ length: actionDim }, () => Math.random() * 2 - 1); } const out = model.predict(tf.tensor2d([state])).dataSync(); // tanh 压缩到 [-1,1],避免网络输出过大导致关节一步跨过头 return Array.from(out).map(v => Math.tanh(v)); }epsilon的衰减策略直接决定训练曲线好不好看。常见做法是从 1.0 线性降到 0.05,跨度大约 2000 到 5000 个 episode。降得太快,智能体还没探索到目标附近就开始贪心,会卡在局部最优;降得太慢,后期一直在随机乱动,奖励上不去。这个参数没有标准答案,得看你的动作维度和 episode 长度,我一般先按 3000 个 episode 跑一版曲线,看奖励在第几个 episode 开始抬头,再回头调。
限位裁剪的另一层意义是安全。哪怕你后面把这套代码接到真实设备上,关节限位也是最后一道保险,代码里必须有,不能指望上层策略永远输出合理值。
3.3 奖励函数整形:距离、步数惩罚与动作平滑
奖励设计是这个项目里最像手艺活的部分。只用「到达 +1,否则 0」的稀疏奖励,六维动作空间里随机探索命中的概率极低,训练基本不会收敛。只用「负距离」的稠密奖励,智能体会学到「停在原地小幅抖动」这种局部最优,因为抖动带来的距离变化最小、惩罚最低。
常见的折中是把三项加起来:
// src/env/reward.js —— 三项加权:靠近目标、少走冤枉路、动作别抖 export function computeReward({ prevDist, currDist, action, prevAction, done, step }) { const progress = (prevDist - currDist) * 10; // 靠近给正,远离给负 const stepPenalty = -0.01; // 每步固定小惩罚,鼓励尽快到达 const smoothPenalty = -0.05 * l2(action, prevAction); // 动作突变惩罚 const reachBonus = done ? 5 : 0; // 到达一次性加分 return progress + stepPenalty + smoothPenalty + reachBonus; }progress用前后两帧的距离差而不是绝对距离,是为了让奖励信号与「这一步做得好不好」直接挂钩,绝对值奖励在不同初始姿态下尺度不一致,网络很难学。系数 10 是放大信号用的,距离差通常在 0.01 量级,不放大就被步数惩罚淹没了。stepPenalty不能太大,-0.01 意味着 500 步累计 -5,和到达奖励 5 打平,再大就会让智能体宁愿早点结束也不愿多走几步靠近目标。smoothPenalty是给真实设备准备的,仿真里可加可不加,但加上之后策略动作会明显更连贯,迁移到实机时抖动小很多。
这三项的比例调不好,训练曲线会出现典型症状:progress 权重过大,智能体在目标附近反复横跳刷奖励;stepPenalty 过大,智能体学会原地不动尽快结束 episode;smoothPenalty 过大,动作幅度被压得极小,根本走不到目标。调参顺序我一般是从 progress 和 reachBonus 开始,确认能到达,再逐步加后两项。
4. 强化学习训练循环:经验回放、目标网络与浏览器帧调度
网络和奖励都对了,剩下的是训练循环怎么在浏览器里跑得不卡。这件事在 Python 里是几行for循环,在浏览器里要跟渲染抢主线程,写法完全不一样。项目里最值得看的就是这部分,它决定了你是能一边看机械臂动一边训练,还是页面直接卡死。
4.1 经验回放的环形缓冲区
回放池的作用是打乱样本相关性。机械臂连续几步的状态高度相似,直接拿当前步训会让网络过拟合到最近的轨迹上。环形缓冲区实现简单,容量到了就覆盖最老的样本,避免内存无限增长。
// src/agent/replayBuffer.js —— 定长环形缓冲区 export class ReplayBuffer { constructor(capacity = 50000) { this.capacity = capacity; this.buffer = []; this.pos = 0; } push(s, a, r, s2, done) { const item = { s, a, r, s2, done }; if (this.buffer.length < this.capacity) { this.buffer.push(item); } else { this.buffer[this.pos] = item; // 覆盖最老样本 } this.pos = (this.pos + 1) % this.capacity; } sample(batchSize) { const batch = []; for (let i = 0; i < batchSize; i++) { const idx = Math.floor(Math.random() * this.buffer.length); batch.push(this.buffer[idx]); } return batch; } get size() { return this.buffer.length; } }容量 50000 是浏览器里的折中值。每条样本是 9 维状态加 6 维动作加标量,算下来几十 MB,再大浏览器内存吃不消。pos指针的取模写法有个细节:缓冲区没满的时候buffer.length < capacity,此时pos还在增长,一旦满了之后pos回绕,覆盖顺序才是正确的先进先出。这个逻辑写反了不会报错,但样本分布会变成「总是覆盖最新的」,训练效果会明显变差。
sample用的是有放回随机采样,实现简单,代价是同一批里可能有重复样本。批量小的时候影响不大,批量大了建议换成无放回,性能开销也很低。
4.2 目标网络与软更新
DQN 容易训崩的一个原因是自举:用正在更新的网络算目标值,目标跟着参数一起动,训练就像追自己的尾巴。目标网络的作用是把目标值固定一段时间,常见做法是每隔 N 步硬拷贝一次,或者每步软更新一点点。
// src/agent/target.js —— 软更新,tau 越小目标越稳 export function softUpdate(targetModel, onlineModel, tau = 0.005) { const tw = targetModel.getWeights(); const ow = onlineModel.getWeights(); const newWeights = tw.map((t, i) => tf.tidy(() => t.mul(1 - tau).add(ow[i].mul(tau))) ); targetModel.setWeights(newWeights); // 中间张量由 tidy 自动释放,setWeights 之后旧权重也要手动清 tw.forEach(t => t.dispose()); ow.forEach(o => o.dispose()); }tau取 0.005 意味着目标网络大约 200 步跟随一次完整更新,够稳也够跟得上。硬拷贝的写法是每 500 步setWeights一次,两种都行,软更新曲线更平滑,硬拷贝实现更简单。要注意的是getWeights返回的是张量引用,setWeights之后旧张量不会自动释放,不dispose的话每步都在漏内存,跑几千步页面就开始卡。
训练步本身的逻辑是标准的 DQN:从回放池采样一批,用目标网络算r + gamma * max(Q(s2)),和在线网络的Q(s, a)算均方误差,反向传播更新。
// src/agent/train.js —— 单步训练 export async function trainStep(onlineModel, targetModel, batch, gamma = 0.99) { const states = tf.tensor2d(batch.map(b => b.s)); const nextStates = tf.tensor2d(batch.map(b => b.s2)); const actions = batch.map(b => b.a); const rewards = batch.map(b => b.r); const dones = batch.map(b => (b.done ? 1 : 0)); const nextQ = targetModel.predict(nextStates); // [B, 6] const maxNextQ = nextQ.max(1).dataSync(); // 每个样本取最大 Q const targets = rewards.map((r, i) => r + gamma * maxNextQ[i] * (1 - dones[i])); const qValues = onlineModel.predict(states); // [B, 6] // 只对实际执行的动作位置算损失,其余位置不参与梯度 const mask = tf.oneHot(tf.tensor1d(actions.map(a => a[0]), 'int32'), 6); const loss = tf.losses.meanSquaredError( tf.tensor1d(targets), qValues.mul(mask).sum(1) ); await onlineModel.optimizer.minimize(() => loss); // 显式释放,浏览器里内存比 GPU 时间金贵 [states, nextStates, nextQ, qValues, mask, loss].forEach(t => t.dispose()); }这里的动作是连续值,严格来说该用 DDPG 或者 TD3,DQN 需要把动作离散化。项目如果用的是 DQN,常见做法是每个关节离散成 5 档(-1、-0.5、0、0.5、1),动作空间变成 5^6,这个量级网络输出层撑不住,所以更可能用的是 DDPG 那种 actor-critic 结构。上面这段代码给的是离散版本的骨架,你拿到源码后看输出层维度就能判断是哪种:6 维输出是 DDPG 的 actor,30 维以上是离散化的 critic。
gamma取 0.99 意味着智能体大约看未来 100 步的回报。机械臂到达目标通常需要几十到几百步,这个折扣率合适。太小会短视,太大会让价值估计方差变大,训练不稳。
4.3 用 requestAnimationFrame 分片训练
浏览器里最忌讳的是在一个循环里连跑几百步训练再渲染。主线程被占住,页面直接假死,用户以为崩了。正确做法是把训练拆成小片,每帧跑固定步数,剩下的时间还给渲染。
// src/main.js —— 每帧只训几步,渲染优先 import { ArmEnv } from './env/armEnv.js'; import { buildQNet } from './agent/dqn.js'; import { ReplayBuffer } from './agent/replayBuffer.js'; import { selectAction } from './agent/policy.js'; import { trainStep } from './agent/train.js'; import { renderScene } from './render/scene.js'; const env = new ArmEnv({ /* dh, limits, target */ }); const online = buildQNet(); const target = buildQNet(); target.setWeights(online.getWeights()); const buffer = new ReplayBuffer(50000); let episode = 0; let state = env.reset(); let epsilon = 1.0; async function frame() { // 每帧与环境交互若干步,步数少一点,保证渲染不卡 for (let i = 0; i < 4; i++) { const action = selectAction(online, state, epsilon); const { state: next, reward, done } = env.step(action); buffer.push(state, action, reward, next, done); state = done ? env.reset() : next; if (buffer.size >= 1000) { const batch = buffer.sample(32); await trainStep(online, target, batch); // 异步训练,让出主线程 if (i === 0) softUpdate(target, online, 0.005); } if (done) { episode++; epsilon = Math.max(0.05, 1.0 - episode / 3000); // 线性衰减到 0.05 } } renderScene(env.q, env.target); // tf.nextFrame 内部走 requestAnimationFrame,兼顾渲染节奏 await tf.nextFrame(); } frame();几个参数的分工要清楚:每帧 4 步环境交互,是为了让策略更新和渲染交替进行;batchSize32 是小网络的常规值,再大单步训练时间变长,帧率会掉;回放池攒到 1000 条才开始训,是因为样本太少时相关性极强,早期训练会崩;epsilon按 episode 线性衰减,这个曲线比按步衰减更直观。
await tf.nextFrame()是关键的一行,它让训练回到事件循环,浏览器有机会重绘。少了它,即使代码逻辑对,页面也会卡成幻灯片。这也是为什么trainStep要写成async,中间任何一次await都在给渲染让路。
5. 避坑与排查:浏览器里训练机械臂最容易翻车的五个地方
这套东西我照着改过一版,踩的坑比想象中多。下面五条是我自己的记录,按出现频率排,现象、原因、解决分开写,你对照着看能省掉不少时间。
第一条,页面跑几分钟之后内存暴涨、帧率断崖。现象是任务管理器里浏览器标签页占用从几百 MB 爬到几个 G,最后页面崩掉。原因基本是张量没释放,predict、tensor2d、oneHot这些调用每次都会新建张量,TensorFlow.js 不会自动回收,尤其在训练循环里每秒几十次调用,累积得非常快。解决办法是把所有前向计算包进tf.tidy,或者手动dispose。我习惯在trainStep结尾统一forEach(t => t.dispose()),把这一步里建的所有张量列出来逐个释放,虽然啰嗦但不会漏。注意getWeights返回的张量不能随便释放,那是模型的参数引用,释放了模型就废了。
第二条,奖励一路涨到某个值就不动了,末端在目标附近来回晃。现象是曲线前 500 个 episode 上升明显,之后平台期,末端离目标始终差那么几厘米。原因通常是奖励尺度问题:progress项在近距离时距离差极小,奖励信号接近零,网络学不到精细调整。解决办法是把 progress 改成非线性,比如1 - dist或者分段奖励,近距离区间放大权重;另一种做法是给动作加噪声衰减,训练后期减少探索。还有一种可能是关节限位刚好卡住,末端物理上到不了目标点,这时候要看目标点是不是采样在工作空间内。
第三条,关节角飞到限位之外,机械臂在画面上穿模。现象是渲染时连杆穿过基座或者自己,视觉上很明显。原因是动作没裁剪,网络输出的增量乘上actionScale之后把关节推出了合法区间,而正向运动学照样按这个角度算位置。解决办法是在step里累加之后立刻裁剪,网络输出侧加tanh,两道保险都要有。裁剪之后如果发现智能体学不到东西,那是因为裁剪把梯度截断了,可以考虑把裁剪写成平滑函数,或者减小actionScale让超限概率本身就很低。
第四条,训练速度慢得离谱,一晚上跑不了几个 episode。现象是帧率只有个位数,控制台打印的后端是cpu。原因是 WebGL 后端没启用或者中途丢失了上下文。先查浏览器硬件加速设置,再看有没有别的标签页在抢 GPU,最后确认代码里没有在主线程做同步的dataSync大循环。dataSync会把 GPU 上的张量拷回 CPU 并阻塞,训练循环里能不用就不用,用.array()或者await .data()替代。
第五条,同一个种子跑两次结果差很多,调参没法对比。现象是你改了一个超参,两次曲线完全不同,分不清是超参的功劳还是随机性。原因是初始姿态、目标点、探索噪声、回放采样全都没固定随机源。解决办法是写一个可复现的随机数生成器,把所有随机调用都走同一个种子,然后在main.js开头允许从 URL 参数读种子。这件事在调试阶段能省掉大量时间,我后来每个这类项目都会先把这个开关做好再动别的。
6. 二次开发:把到达任务换成轨迹跟随,先改奖励再动网络
拿到这份代码之后,最自然的改造方向是把「末端到达一个点」换成「末端沿一条轨迹走」。前者是单目标,后者是序列任务,改动量比想象中小,因为环境和训练循环的骨架都不用动,主要改奖励函数和目标点采样。
做法是把固定的target换成一个随时间推进的轨迹函数,每一步根据当前 episode 内的步数算出期望位置,奖励从「到目标的距离」改成「到当前期望点的距离加上轨迹进度奖励」。轨迹可以用参数方程写,比如在 XY 平面上画圆:
// src/env/trajectory.js —— 圆轨迹,t 是归一化时间 [0,1] export function circleTarget(t, radius = 0.3, height = 0.2) { const angle = t * Math.PI * 2; return [radius * Math.cos(angle), radius * Math.sin(angle), height]; } // 环境里按步数推进 t,走完一圈算 done export function updateTarget(env, step, totalSteps) { const t = Math.min(1, step / totalSteps); env.target = circleTarget(t); return t >= 1; }奖励要相应改成「离当前期望点越近越好,同时鼓励进度」。只追当前点的话,智能体会在轨迹起步位置反复刷奖励,不往前走,所以进度项必须加。一个可用的形式是每一步给一个小额进度奖励,乘以当前步的归一化值,越往后权重越大,逼着智能体跟上节奏。
改造之后别急着调网络。先用固定种子跑 200 个 episode,把末端实际轨迹和目标轨迹画在同一张图上,看偏移是系统性的还是随机的。系统性偏移说明正向运动学或者坐标系有问题,随机偏移才是策略没学好。判断收敛我一般看两个指标:最近 50 个 episode 的平均末端误差,以及误差曲线是否还在下降。两个都平了,再去动学习率和网络宽度。
还有一个更省事的改造,是把actionScale换成随训练进度衰减的值,前期大步探索,后期小步精调。这个改动只在环境里加一行,但对收敛速度的影响比调网络结构大得多。我那会儿是先花了三天改网络结构,结果还不如把这个参数从 0.2 降到 0.05 来得明显。从那以后我每次动网络之前,都会先把奖励、动作尺度、探索率这三个环境侧的旋钮走一遍,确认它们不是瓶颈。希望帮到你。
本文还有配套的精品资源,点击获取