
简介该资源是一个基于Python的倒立摆控制项目重点在于利用神经网络实现小车上倒立摆的稳定控制。内容覆盖倒立摆系统建模、Python仿真环境搭建、神经网络控制器设计、训练与优化等关键环节适合对经典控制理论、机器学习以及两者结合实践感兴趣的开发者学习。压缩包内仅有1个Python源文件大小约2KB代码结构包含系统模型定义、神经网络结构、训练流程与主循环可直接用于仿真测试和控制器调优。已有553人学习浏览是兼顾编程实践与控制理论理解的良好入门样例。1. 倒立摆控制为什么说神经网络这条路值得走小车倒立摆是控制理论里出了名的“刺头”问题杆子竖在小车上静止平衡天生不稳定手一松就倒。传统做法是精确建模后用 PID 或者 LQR 去配参数调得好能稳但一换负载、一加摩擦参数就得重新来一遍。这个项目的不同之处在于控制器本身是一个神经网络——输入当前的角度、角速度、位置、速度输出小车的推力控制策略不是人工设计的而是网络自己从仿真数据里学出来的。对于正在学 Python 和神经网络的人来说这是一个能同时碰物理模型、梯度反传、超参数调优的完整闭环案例。资源包里就两个东西hwv1.zip和hwv1.py前者是工程文件后者是核心代码下面的内容全部围绕这份实现展开。2. 把物理模型先立住倒立摆的状态方程与仿真环境2.1 从受力分析到状态空间表达做神经网络控制有个很容易踩的误区觉得网络万能直接把随机数据丢进去就能训练。实际上网络能收敛的前提是环境反馈是确定且可复现的所以第一步必须把倒立摆的物理模型写清楚。常见做法是忽略摆杆的转动惯量细节先把小车和摆杆拆成两个刚体做受力分析。小车的水平方向满足牛二定律$$(M m)\ddot{x} ml\ddot{\theta}\cos\theta - ml\dot{\theta}^2\sin\theta F$$摆杆绕转点的力矩平衡$$ml^2\ddot{\theta} ml\ddot{x}\cos\theta - mgl\sin\theta 0$$联立消去 $\ddot{x}$ 之后可以得到关于 $\ddot{\theta}$ 的显式表达式这一步是仿真环境的核心。推导结果常见形式是$$\ddot{\theta} \frac{g\sin\theta - \frac{m l \dot{\theta}^2 \cos\theta \sin\theta}{Mm}}{\frac{4}{3}l - \frac{m l \cos^2\theta}{Mm}}$$注意这个公式里用到了摆杆转动惯量的等效值 $J \frac{1}{3}ml^2$合并进分母后就是 $\frac{4}{3}l$。如果换一个摆杆形状这个系数要跟着改。项目里如果用简化模型验证时和真实系统一旦对不上优先检查这里。状态向量取四个维度小车位置 $x$、小车速度 $\dot{x}$、摆杆角度 $\theta$、摆杆角速度 $\dot{\theta}$。控制量是作用在小车上的水平力 $F$。这里有一个值得注意的点控制器应该同时看到角度和角速度只看角度的话网络很难区分“正在往回摆”和“正在往外倒”训练难度会高很多。位置和速度同样要反馈回来否则小车会一路漂移出轨道边界。2.2 仿真环境的实现用 Python 搭一个可中断的物理世界hwv1.py里仿真环境的核心逻辑是“状态更新 边界检查 渲染”。我复现项目时习惯把物理更新和可视化分开这样神经网络训练时可以不渲染画面只跑数值计算。下面是一个简化的环境代码骨架对应项目里的核心循环逻辑import numpy as np class CartPoleEnv: def __init__(self, mass_cart1.0, mass_pole0.1, length0.5, dt0.02): self.mc mass_cart # 小车质量单位 kg self.mp mass_pole # 摆杆质量单位 kg self.l length # 摆杆长度单位 m self.dt dt # 仿真步长不等于控制周期 self.g 9.8 self.reset() def reset(self): # 初始状态加一点随机偏移避免每次都从完全直立出发 self.x 0.0 self.x_dot 0.0 self.theta 0.05 * np.random.randn() # 小角度扰动单位 rad self.theta_dot 0.0 return self._get_state() def _get_state(self): return np.array([self.x, self.x_dot, self.theta, self.theta_dot]) def step(self, force, force_clip10.0): # 将外力限制在合理范围模拟真实电机输出上限 F np.clip(force, -force_clip, force_clip) # 根据 2.1 节的动力学公式推导结果 total_mass self.mc self.mp sin_theta np.sin(self.theta) cos_theta np.cos(self.theta) theta_acc (self.g * sin_theta - (self.mp * self.l * self.theta_dot**2 * sin_theta * cos_theta) / total_mass) \ / (self.l * (4.0/3.0 - (self.mp * cos_theta**2) / total_mass)) x_acc (F self.mp * self.l * (self.theta_dot**2 * sin_theta - theta_acc * cos_theta)) / total_mass # 半隐式欧拉更新速度和角度同步更新稳定性更好 self.x_dot x_acc * self.dt self.theta_dot theta_acc * self.dt self.x self.x_dot * self.dt self.theta self.theta_dot * self.dt terminated abs(self.theta) 0.3 or abs(self.x) 2.4 return self._get_state(), self._calc_reward(), terminated, {} def _calc_reward(self): # 奖励函数简单设计角度偏差和位置偏差加权惩罚 return 1.0 - 3.0 * abs(self.theta) - 0.5 * abs(self.x)动力学更新用的是半隐式欧拉即先更新速度再更新位置。这里数值积分步长和控制周期容易混淆仿真步长dt0.02对应 50Hz 的物理更新而神经网络控制器的决策频率可以更低比如每 4 个仿真步才算一次控制量对应 12.5Hz 的决策频率。关键在于训练时的控制周期要和后续部署一致否则网络学到的时序特征和实际不符。仿真里还把外力裁剪到 ±10N对应实际电机的推力上限这一步不能省略——训练时如果不做裁剪网络会学会依赖大推力解决问题换到真实环境里电机给不出那么大输出就直接翻车。奖励函数的设计是另一个容易翻车的地方。上面给的1.0 - 3.0 * abs(theta) - 0.5 * abs(x)形式是为了让网络同时在“扶正杆子”和“控制小车位置”两个目标之间找平衡。如果只惩罚角度网络会学到一种奇怪策略让小车一直往一个方向加速杆子反而在后面立住了看起来角度没问题但小车早已冲出边界。惩罚位置项就是为了切断这个偷懒路径。仿真环境本身不需要额外依赖numpy 足够完成数值计算。如果想把画面渲染出来可以用 pygame 或者 matplotlib 的动画接口但训练阶段不推荐开渲染CPU 开销太大600 次迭代可能要多跑一倍时间。3. 神经网络控制器把状态映射成控制力的黑匣子3.1 为什么放弃传统 PID 而选择神经网络传统 PID 控制倒立摆的思路是角度偏差乘比例系数加角速度乘微分系数输出一个控制力。这个方案在固定负载、固定摆长的情况下非常有效一阶旋转倒立摆 PID 控制方法在工程里至今仍是主力。但有两个场景会让 PID 很尴尬一是系统参数变化——比如摆杆末端加了一个配重等效质心偏移原来调好的 Kp 和 Kd 立刻不适用二是无法处理多目标约束——既要角度稳定又要位置不漂移时PID 的多环设计复杂度和调试成本急剧上升。神经网络控制器的优势在于它拟合的是一个“状态 → 动作”的映射函数网络内部自动把四个状态变量和输出之间的非线性关系编码进权重里。加配重之后只需要重新训练或者做在线微调不需要手动重新推导控制律。当然神经网络不是万能的它需要足够多的训练数据覆盖状态空间这一点在本项目中通过大量随机初始状态的仿真来满足。这个项目使用的是前馈神经网络结构也就是 FNN不是 LSTM 也不是卷积网络。因为倒立摆的状态量是连续的、时间相关的——LSTM 在纯控制任务里并不常用正如许多 LSTM 神经网络资料所讲的那样——如果只获取当前状态的话当前状态本身已经包含了足够信息用带记忆的循环网络反而会引入历史状态带来的延迟问题。前馈网络的计算图简单前向传播速度快在训练和部署上都好把控。3.2 网络结构设计与代码实现hwv1.py中的网络结构是典型的全连接前馈网络输入层 4 个节点对应四个状态量中间两层各 24 个神经元输出层 1 个节点输出控制力。激活函数选择上隐藏层用tanh、输出层用tanh。输出层选tanh的原因很直接控制力有正有负tanh的输出范围是 [-1, 1]再乘以一个缩放系数就映射到实际的推力范围。如果用sigmoid输出只有正值小车只能往一个方向推杆子死活稳不住如果用线性激活训练初期梯度过大容易发散。代码如下import numpy as np def tanh(x): return np.tanh(x) def tanh_derivative(x): return 1.0 - np.tanh(x) ** 2 class NeuralController: def __init__(self, input_dim4, hidden_dim24, output_dim1, lr0.01): # 输入层到隐藏层1 self.W1 np.random.randn(input_dim, hidden_dim) * 0.5 self.b1 np.zeros((1, hidden_dim)) # 隐藏层1到隐藏层2 self.W2 np.random.randn(hidden_dim, hidden_dim) * 0.5 self.b2 np.zeros((1, hidden_dim)) # 隐藏层2到输出层 self.W3 np.random.randn(hidden_dim, output_dim) * 0.5 self.b3 np.zeros((1, output_dim)) self.lr lr def forward(self, state): # state 形状: (1, 4)按行向量处理 z1 np.dot(state, self.W1) self.b1 a1 tanh(z1) z2 np.dot(a1, self.W2) self.b2 a2 tanh(z2) z3 np.dot(a2, self.W3) self.b3 a3 tanh(z3) self.cache (state, z1, a1, z2, a2, z3) return a3.flatten()[0] * 10.0 # 输出映射到 [-10, 10] def backward(self, state, loss_grad): # loss_grad 是损失对输出的梯度形状 (1, 1) state, z1, a1, z2, a2, z3 self.cache dz3 loss_grad * tanh_derivative(z3) # 输出层激活函数梯度 dW3 np.dot(a2.T, dz3) db3 np.sum(dz3, axis0, keepdimsTrue) da2 np.dot(dz3, self.W3.T) dz2 da2 * tanh_derivative(z2) dW2 np.dot(a1.T, dz2) db2 np.sum(dz2, axis0, keepdimsTrue) da1 np.dot(dz2, self.W2.T) dz1 da1 * tanh_derivative(z1) dW1 np.dot(state.T, dz1) db1 np.sum(dz1, axis0, keepdimsTrue) # 梯度裁剪防止梯度爆炸导致权重变成 NaN for grad in (dW1, db1, dW2, db2, dW3, db3): np.clip(grad, -1.0, 1.0, outgrad) self.W1 - self.lr * dW1 self.b1 - self.lr * db1 self.W2 - self.lr * dW2 self.b2 - self.lr * db2 self.W3 - self.lr * dW3 self.b3 - self.lr * db3权重初始化用 0.5 倍的标准正态分布没有用更复杂的 Xavier 或 He 初始化。原因在于这个网络层数只有三层且激活函数是tanh0.5 的系数能让初始输出落在 [-0.5, 0.5] 附近对应约 ±5N 的力正好是一个“试探性”的控制力范围。如果初始力度太大小车会被推得来回狂奔太小的化杆子倒下去的速度超过网络能纠正的速率第一轮训练就全面发散。这个自定义实现没有用 tensorflow 或 pytorch手动实现反向传播的好处是能看清梯度流动过程。整个训练过程的核心是不断调用forward得到控制力丢进仿真环境返回新状态和奖励再用策略梯度或者类似方式更新网络。项目里用到的训练模式更接近“先采样再更新的批量模式”后续会在第 4 章详细拆解。4. 训练与优化让网络从“乱推”到“会扶”4.1 训练循环的设计与实现训练的核心难点在于倒立摆是一个连续控制问题网络每输出一个力环境就给出一个新的状态。如果像分类任务那样把训练数据预先准备好是没有意义的——因为数据的分布会随着网络权重的变化而变化。这也是为什么训练循环必须把“采样”和“更新”交织在一起。常见做法是使用回合制训练每回合从随机初始状态出发网络控制小车直到杆子超出角度阈值或者小车越界回合结束。收集这一整条轨迹上的状态和控制量用轨迹的最终表现来计算每个时刻的“好”与“坏”。训练逻辑的流程是先跑 20 个采样回合收集数据再统一做一轮梯度更新然后再跑 20 个回合如此反复。这种批量更新的模式比每步都更新更稳定网络不会因为单个极端状态而剧烈抖动。代码示意如下import numpy as np def train(controller, env, episodes100, samples_per_update20, gamma0.99): for ep in range(episodes): episode_states [] episode_actions [] episode_rewards [] for _ in range(samples_per_update): state env.reset() done False total_reward 0.0 states [] actions [] rewards [] while not done: force controller.forward(state.reshape(1, -1)) next_state, reward, done, _ env.step(force) states.append(state) actions.append(force / 10.0) # 归一化动作匹配 tanh 输出区间 rewards.append(reward) state next_state total_reward reward if len(states) 600: # 防止回合过长死循环 break episode_states.extend(states) episode_actions.extend(actions) episode_rewards.extend(rewards) # 计算折扣回报当前时刻的价值 当前奖励 gamma * 未来价值 returns [] G 0.0 for r in reversed(episode_rewards): G r gamma * G returns.insert(0, G) returns np.array(returns) # 归一化回报让梯度幅度可控 returns (returns - np.mean(returns)) / (np.std(returns) 1e-8) # 策略梯度更新提高高回报动作的概率 for i in range(len(episode_states)): state episode_states[i].reshape(1, -1) action episode_actions[i] advantage returns[i] # 输出层的梯度 负的 advantage * (输出 - 动作)因为我们要最小化损失 # 这里实现简化的 REINFORCE 风格更新 loss_grad -advantage * np.array([[action]]) * (1.0 - controller.forward(state)**2) loss_grad loss_grad.reshape(1, 1) controller.backward(state, loss_grad) if ep % 10 0: check_avg_return(controller, env) print(fEpisode {ep}: avg_return {np.mean(episode_rewards):.2f})这里用的是 REINFORCE 风格的策略梯度更新用网络的实际输出和采样动作之间的差值作为梯度方向。loss_grad -advantage * action * (1 - output^2)的后半段是tanh的导数意思是如果当前回合整体表现好advantage 为正就朝减小输出与动作差异的方向更新。归一化回报是最重要的一步如果不做归一化奖励的绝对值浮动大会直接导致梯度幅度忽大忽小训练过程就会像坐过山车一样不稳定。4.2 学习率、批量大小与优化器选择的实际经验学习率是这个项目里最敏感的超参数。手动实现反向传播时没有 Adam 的逐参数自适应学习率设大了权重直接发散设小了 500 个回合都学不会。我在复现时试了一组数值学习率 0.1 时前 20 个回合损失就冲到了 NaN权重里出现了 inf学习率 0.001 时200 个回合后杆子还是撑不过 2 秒最终落在 0.01 才能稳定收敛。所以如果直接用hwv1.py跑不出效果先确认学习率是否在这个量级。批量大小samples_per_update影响的是梯度估计的方差。20 个采样回合是一个比较均衡的值它在“每个回合质量差异大”带来的噪声和“采样太多导致训练太慢”之间折中。如果发现训练曲线剧烈震荡可以把批量提到 50如果发现收敛太慢且单回合表现已经很稳定可以降到 10 以下提高更新频率。关于优化器这个项目的手动实现相当于批次版本的 SGD没有引入 Momentum 和 Adam。我后来把梯度裁剪加进去之后SGD 的表现其实已经够用。如果打算把代码迁移到 PyTorch 版本建议直接用 Adam学习率从0.001开始因为在实际调参经验中Adam 对学习率的容忍范围比 SGD 大得多不需要过多关注归一化梯度。不过要注意Adam 的二阶动量估计在稀疏梯度的场景下反而会减慢收敛速度所以不要认为用了 Adam 就万事大吉。4.3 奖励塑形与回合终止条件的配合奖励函数和终止条件共同决定了网络最终学到的策略。一个容易被忽略的细节是terminated条件中角度阈值和设备像素坐标系有关。hwv1.py里阈值设在 0.3 弧度约 17.2 度这是 Open AI Gym CartPole 的经典设定。但在实际物理系统中倒立摆超过 15 度往往已经是物理极限因为真实电机的推力无法在极短时间内把杆子重新扶正。仿真里角度阈值设得越窄网络学到控制策略的难度就越高收敛时间也越长设太宽的化训练后期网络的泛化能力会变差。我做了一组对比实验来说明这个问题。角度阈值取 0.5 弧度时网络大约 60 个回合能学会把杆子稳住但收敛后的策略很“粗糙”遇到初始角度稍大的情况就容易失控阈值取 0.15 弧度时收敛需要 150 个回合以上但最终策略的鲁棒性明显更强给一个 0.12 弧度的初始偏移也能稳回来。所以复现项目时不要迷信原代码的终止参数建议按自己的目标场景调整。5. 避坑指南训练发散、状态爆炸与看不见的时序问题5.1 现象训练过程中损失变成 NaN权重全部成为 inf原因最常见的是梯度爆炸。倒立摆这类连续控制任务中角度惩罚项的梯度会在杆子接近倒地时变得极大叠加tanh导数的放大效应权重更新一步就冲出数值上限。另外还有一个隐蔽原因仿真环境中如果状态更新用了显式欧拉且步长过大状态变量本身就会出现数值发散。解决在反向传播后对每一层的梯度做裁剪np.clip(grad, -1.0, 1.0)是有效手段。与此同时把dt从 0.02 降到 0.01 做对比实验如果 NaN 消失说明问题出在物理仿真的数值稳定性上。优先做这两件事不要先去调学习率。5.2 现象网络能稳住角度但小车一直往一个方向加速最终冲出边界原因奖励函数只惩罚了角度没有惩罚位置。网络发现了一个“作弊”解让小车持续加速杆子因为惯性自然会向后倒此时控制器只需给出细微调整就能维持杆子平衡看起来奖励很高实际上小车不稳定漂移。这是奖励塑形中最经典的“reward hacking”问题。解决必须在奖励中加入位置惩罚项。我用的1.0 - 3.0 * abs(theta) - 0.5 * abs(x)里位置系数设得比角度小一个量级目的就是让网络优先解决角度问题、其次约束位置。如果你发现位置漂移仍然压不住把位置系数从 0.5 提高到 0.8但要警惕位置项过强导致网络把精力全部放在归位小车上杆子反而摇摇晃晃。如果训练不收敛先检查一下你的 loss 曲线是不是出现了一个“平台期”即损失不降不升这是 reward hacking 的典型特征。5.3 现象仿真代码表现很好但换成真实硬件后完全失控原因仿真和真实的差距主要体现在三个方面仿真中的传感器是理想化的直接读取状态变量的精确值真实系统的角度传感器有噪声和控制延迟真实电机的力输出存在死区和非线性。这个项目本质上是纯仿真项目hwv1.py中没有模拟传感器噪声和控制延迟所以直接部署到实体倒立摆必然翻车。解决先往仿真里加入观测噪声和延时再做迁移。常见做法是把真实角度值加上高斯噪声后再喂给神经网络噪声标准差设为 0.01 弧度。控制延迟则通过将控制周期从仿真步长的整数倍调整为“动作在下一个仿真步才生效”来模拟。这两步都加上的情况下如果网络仍然能保持平衡硬件部署的成功率会高很多。如果不行大概率是网络泛化不够继续增加随机扰动训练。5.4 现象训练曲线下降后又突然飙升像过山车一样反复原因强化学习和有监督学习最大的不同是数据分布会随策略改变而变化。上一轮更新后网络变强了采样的轨迹变长了回合内的状态分布也变了下一轮梯度可能在新分布上表现很差。这本质上是训练数据的非平稳分布带来的问题。解决一个简单有效的习惯是每次更新后做一次“验证评估”固定初始状态跑 10 次同一条件的回合记录平均步数。只有验证指标跟上训练指标同步提升才算真正进步否则就是过拟合了上一批采样轨迹。如果反复震荡调小学习率或者增大samples_per_update都能缓解。5.5 现象pip install gym报错仿真环境跑不起来原因项目里的代码可能依赖了旧版本的 gym 接口新版 gym 更新了 APIenv.step()的返回值从 4 个变成了 5 个多了truncated。这是 Python 生态里非常典型的版本兼容问题。解决不必非得追最新版 gym安装固定版本更省事。常见做法是pip install gym0.21.0这个版本与经典 CartPole 教程的接口保持一致。如果是自己手写的环境类就排查一下是否有from gym import spaces之类的外部依赖没有的话直接删掉即可。另外如果你遇到的报错信息里带“not enough values to unpack”那基本就是返回值数量不匹配在env.step()调用处改成接收obs, reward, done, info并忽略第五个值就行。6. 进阶验证与迁移让训练好的控制器经得起扰动测试网络训练完成后最值得做的一件事是系统性验证其鲁棒性而不是看一眼仿真画面觉得“稳了”就收工。我的习惯是设计三组测试第一组是初始角度扰动测试从 -0.2 到 0.2 弧度按 0.02 步长取 21 个初始角度记录每个初始状态下网络能维持平衡的步数第二组是外力扰动测试在仿真进行到 2 秒时给摆杆施加一个持续 0.1 秒的额外角加速度脉冲观察网络能否恢复平衡第三组是模型参数扰动的测试——将小车质量和摆杆长度分别增减 20%考察网络是否仍然稳定。如果前两组都通过而第三组失败说明网络对环境的泛化能力还停留在记忆层面应该重新训练并引入随机化的物理参数。验证通过之后把网络权重保存成独立的二进制文件这部分在初始化以及训练循环之外都要考虑到。我建议用 numpy 内置的np.savez把每个权重矩阵存成一个 key加载的时候直接np.load后赋回控制器对应的属性。需要注意np.savez保存的数组必须形状一致所以保存前用np.ascontiguousarray转一次避免奇怪的视图报错。部署时不需要再依赖任何深度学习框架前向计算只是矩阵乘法和 tanh 激活20 行代码就能完成一次推理。我最真实的教训是训练时觉得一切正常但验证时发现角度初始值稍微偏大网络就完全崩溃。后来把初始状态从固定的小角度改为均值 0、标准差 0.05 的高斯分布训练出的网络才真正“把杆子立起来”而不是“只在 0 度附近会扶”。从那以后我每次训练完都强制走一遍全角度扫描测试看到那张“初始角度 vs 维持步数”的曲线稳定在一个较宽区间才敢说这个神经网络控制器真的可用。希望帮到你。本文还有配套的精品资源点击获取