简介:该资源是一个基于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}{M+m}}{\frac{4}{3}l - \frac{m l \cos^2\theta}{M+m}}$$
注意这个公式里用到了摆杆转动惯量的等效值 $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_cart=1.0, mass_pole=0.1, length=0.5, dt=0.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_clip=10.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)动力学更新用的是半隐式欧拉,即先更新速度再更新位置。这里数值积分步长和控制周期容易混淆,仿真步长dt=0.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_dim=4, hidden_dim=24, output_dim=1, lr=0.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, axis=0, keepdims=True) da2 = np.dot(dz3, self.W3.T) dz2 = da2 * tanh_derivative(z2) dW2 = np.dot(a1.T, dz2) db2 = np.sum(dz2, axis=0, keepdims=True) da1 = np.dot(dz2, self.W2.T) dz1 = da1 * tanh_derivative(z1) dW1 = np.dot(state.T, dz1) db1 = np.sum(dz1, axis=0, keepdims=True) # 梯度裁剪,防止梯度爆炸导致权重变成 NaN for grad in (dW1, db1, dW2, db2, dW3, db3): np.clip(grad, -1.0, 1.0, out=grad) 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 初始化。原因在于这个网络层数只有三层,且激活函数是tanh,0.5 的系数能让初始输出落在 [-0.5, 0.5] 附近,对应约 ±5N 的力,正好是一个“试探性”的控制力范围。如果初始力度太大,小车会被推得来回狂奔;太小的化,杆子倒下去的速度超过网络能纠正的速率,第一轮训练就全面发散。
这个自定义实现没有用 tensorflow 或 pytorch,手动实现反向传播的好处是能看清梯度流动过程。整个训练过程的核心是不断调用forward得到控制力,丢进仿真环境,返回新状态和奖励,再用策略梯度或者类似方式更新网络。项目里用到的训练模式更接近“先采样再更新的批量模式”,后续会在第 4 章详细拆解。
4. 训练与优化:让网络从“乱推”到“会扶”
4.1 训练循环的设计与实现
训练的核心难点在于:倒立摆是一个连续控制问题,网络每输出一个力,环境就给出一个新的状态。如果像分类任务那样把训练数据预先准备好,是没有意义的——因为数据的分布会随着网络权重的变化而变化。这也是为什么训练循环必须把“采样”和“更新”交织在一起。常见做法是使用回合制训练:每回合从随机初始状态出发,网络控制小车直到杆子超出角度阈值或者小车越界,回合结束。收集这一整条轨迹上的状态和控制量,用轨迹的最终表现来计算每个时刻的“好”与“坏”。
训练逻辑的流程是:先跑 20 个采样回合收集数据,再统一做一轮梯度更新,然后再跑 20 个回合,如此反复。这种批量更新的模式比每步都更新更稳定,网络不会因为单个极端状态而剧烈抖动。代码示意如下:
import numpy as np def train(controller, env, episodes=100, samples_per_update=20, gamma=0.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(f"Episode {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 更新了 API,
env.step()的返回值从 4 个变成了 5 个(多了truncated)。这是 Python 生态里非常典型的版本兼容问题。 - 解决:不必非得追最新版 gym,安装固定版本更省事。常见做法是
pip install gym==0.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 维持步数”的曲线稳定在一个较宽区间,才敢说这个神经网络控制器真的可用。希望帮到你。
本文还有配套的精品资源,点击获取