☰
Python神经网络控制倒立摆:从仿真到实车的完整实践
2026/10/1 5:29:39 网站建设 项目流程

简介:这份资源面向控制理论、机器学习与Python编程的学习者,聚焦小车倒立摆这一经典不稳定系统,用神经网络作为控制器实现平衡控制。倒立摆涉及动态系统稳定性、反馈控制策略与自适应学习,是理论结合实践的典型案例。压缩包内共1个文件,为单个py脚本,整体约2KB,代码中应包含库导入、系统模型定义、神经网络结构、训练过程与主循环等关键部分,便于读者直接阅读与运行。目前已有553人学习下载,说明该案例在控制与机器学习入门群体中具有一定参考价值。通过研读代码,读者可以理解如何用Python搭建仿真环境、设计神经网络控制器、调整网络架构与优化器超参数,并思考从仿真到真实系统的部署思路,适合希望把控制理论与神经网络实践结合起来的中级学习者。

1. 从一阶倒立摆说起:为什么用 Python 和神经网络做小车控制值得投入

小车倒立摆是控制领域最经典的被控对象之一:一根摆杆铰接在小车上,小车左右移动,目标是把摆杆稳在竖直向上的位置。它的状态空间只有四个量——小车位置、小车速度、摆杆角度、摆杆角速度,但动力学是非线性的,在竖直平衡点附近才近似线性。传统做法是 LQR 或 PID,参数调得好也能立住,可一旦轨道有摩擦、摆杆质量分布不均、电机有死区,线性控制器就开始抖、开始漂。

神经网络控制这条路线,核心价值在于它不依赖精确模型。用 Python 搭一个前馈网络或 RNN,把状态映射到控制力,训练数据来自仿真或实测轨迹,训练完直接部署到小车上跑。适合谁?适合已经会一点 Python、想从仿真跨到实物、又不想啃完一整本非线性控制教材的工程师。这一篇就把从建模、造数据、训网络到上车的完整路径拆开讲,参数怎么设、哪里会翻车,都写清楚。

2. 倒立摆的动力学建模与仿真环境搭建:先让摆杆在屏幕里立起来

2.1 用拉格朗日方程写出四状态非线性模型

倒立摆的动力学推导不复杂,但符号容易错。我一般直接用拉格朗日方程,取小车位置 x 和摆杆与竖直方向夹角 θ 为广义坐标,得到两个耦合的二阶方程。整理成状态空间形式后,状态向量是 [x, ẋ, θ, θ̇],输入是作用在小车上的水平力 F。

下面这段 Python 用 numpy 把动力学写成可积分的函数,参数按常见的小车倒立摆实验台取值:小车质量 0.5 kg,摆杆质量 0.2 kg,摆杆半长 0.3 m,重力 9.81。

import numpy as np # 物理参数 M = 0.5 # 小车质量 kg m = 0.2 # 摆杆质量 kg l = 0.3 # 摆杆质心到转轴距离 m g = 9.81 # 重力加速度 b = 0.1 # 小车摩擦系数 def dynamics(state, F): x, x_dot, theta, theta_dot = state sin_t = np.sin(theta) cos_t = np.cos(theta) # 分母项,来自拉格朗日方程整理 denom = M + m - m * cos_t**2 # 摆杆角加速度 theta_ddot = (g * sin_t - cos_t * (F - b * x_dot + m * l * theta_dot**2 * sin_t) / (M + m)) / \ (l * (4/3 - m * cos_t**2 / (M + m))) # 小车加速度 x_ddot = (F - b * x_dot + m * l * (theta_dot**2 * sin_t - theta_ddot * cos_t)) / (M + m) return np.array([x_dot, x_ddot, theta_dot, theta_ddot])

逻辑说明:denom 和 theta_ddot 的表达式是把两个耦合方程消元后得到的,摆杆按均匀细杆处理,转动惯量取 (4/3)ml² 的等效形式。参数说明:M 和 m 影响系统惯性,l 决定摆杆回复力矩大小,b 是轨道摩擦,实际台架上这个值往往比 0.1 大,需要辨识。改参数时优先动 b 和 l,它们对控制难度影响最直接。

2.2 用四阶龙格库塔积分并搭一个最小仿真循环

有了动力学函数,下一步是积分。欧拉法步长稍大就发散,我习惯用 RK4,步长 0.01 s,控制周期 0.02 s,控制力在两次更新之间保持零阶保持。

def rk4_step(state, F, dt): k1 = dynamics(state, F) k2 = dynamics(state + 0.5 * dt * k1, F) k3 = dynamics(state + 0.5 * dt * k2, F) k4 = dynamics(state + dt * k3, F) return state + dt / 6.0 * (k1 + 2*k2 + 2*k3 + k4) def run_sim(controller, T=10.0, dt=0.01): state = np.array([0.0, 0.0, 0.05, 0.0]) # 初始偏离竖直 0.05 rad log = [] for step in range(int(T / dt)): F = controller(state) F = np.clip(F, -10.0, 10.0) # 电机力限幅 state = rk4_step(state, F, dt) log.append([step*dt, *state, F]) return np.array(log)

逻辑说明:rk4_step 是标准四阶积分,run_sim 里每步调用控制器拿力,再限幅。参数说明:dt 取 0.01 是精度和速度的折中,再大摆杆在倒下瞬间会数值发散;力限幅 ±10 N 对应常见直流电机加减速箱的输出,实际按你的电机堵转力矩设。初始角度给 0.05 rad 而不是 0,是为了让控制器一开始就有活干,方便看收敛过程。

2.3 先跑一个 PD 控制器确认仿真可信

在训网络之前,一定先用 PD 把仿真跑通。如果 PD 都立不住,说明模型或积分有问题,别急着上神经网络。

def pd_controller(state): x, x_dot, theta, theta_dot = state # 角度环为主,位置环为辅 kp_theta, kd_theta = 40.0, 6.0 kp_x, kd_x = -2.0, -1.5 return kp_theta * theta + kd_theta * theta_dot + kp_x * x + kd_x * x_dot log = run_sim(pd_controller) print("final theta:", log[-1, 3])

逻辑说明:角度项让摆杆回正,位置项把小车拉回原点,符号相反是因为小车要往摆杆倒的方向追。参数说明:kp_theta 太小立不住,太大高频抖;kd_theta 抑制振荡,一般取 kp 的 0.1 到 0.2 倍。跑完看 final theta 是否接近 0,如果发散,先查 dynamics 里的符号和 denom。

3. 训练数据的采集与神经网络结构选型:让网络学会“什么时候该往哪推”

3.1 用 PD 加噪声造出覆盖状态空间的轨迹

神经网络控制本质是拟合一个从状态到力的映射。数据从哪来?最省事的做法是用调好的 PD 控制器跑大量随机初始状态,把每一步的 [x, ẋ, θ, θ̇] 和对应的 F 存下来。但纯 PD 数据分布太窄,网络学不到大角度恢复,所以要加噪声和随机扰动。

def collect_data(n_episodes=200, T=5.0, dt=0.01): X, Y = [], [] for _ in range(n_episodes): state = np.array([ np.random.uniform(-0.5, 0.5), np.random.uniform(-0.5, 0.5), np.random.uniform(-0.3, 0.3), np.random.uniform(-1.0, 1.0) ]) for step in range(int(T / dt)): F = pd_controller(state) # 加探索噪声,让数据覆盖更广 F_noisy = F + np.random.normal(0, 0.5) F_noisy = np.clip(F_noisy, -10, 10) X.append(state.copy()) Y.append(F_noisy) state = rk4_step(state, F_noisy, dt) if abs(state[2]) > 1.2: # 摆杆倒下就重来 break return np.array(X), np.array(Y) X, Y = collect_data() print(X.shape, Y.shape)

逻辑说明:每条轨迹随机初始化,用带噪声的 PD 力去驱动,噪声让状态偏离 PD 的舒适区,网络才能见到大角度样本。参数说明:n_episodes 200 条大约产生几万到十几万样本,够一个小网络用;噪声标准差 0.5 N 是经验值,太大轨迹很快倒下,太小覆盖不够。角度超过 1.2 rad 就截断,因为那个区域已经非线性到 PD 也救不回来,硬塞进去反而污染数据。

3.2 前馈网络够用,但输入归一化不能省

结构选型上,倒立摆状态只有四维,输出一维,两层隐藏层、每层 64 个神经元的前馈网络(也就是常说的 BP 神经网络)足够。别一上来就上 LSTM 或 Transformer,那类结构适合时序预测,这里每步决策只依赖当前状态,前馈网络推理快、部署简单。

关键是输入归一化。四个状态的量纲和范围差很多,角度在 ±0.3,角速度能到 ±1.0,不归一化网络收敛极慢。

import torch import torch.nn as nn class Net(nn.Module): def __init__(self): super().__init__() self.fc = nn.Sequential( nn.Linear(4, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 1) ) def forward(self, x): return self.fc(x) # 归一化统计量从训练集算 mean = X.mean(axis=0) std = X.std(axis=0) + 1e-6 X_norm = (X - mean) / std

逻辑说明:网络输出直接是力,不加激活,因为力有正负且范围不限。归一化用训练集的均值和标准差,推理时必须用同一组值,否则输入分布对不上,输出全乱。参数说明:隐藏层 64 是起点,样本超过 20 万可以加到 128;ReLU 比 tanh 收敛快,但输出层附近用 tanh 有时更平滑,可以试。

3.3 训练循环与损失曲线怎么看

训练用 MSE 损失,Adam 优化器,学习率 1e-3,batch 256,跑 200 个 epoch 基本够。

X_t = torch.tensor(X_norm, dtype=torch.float32) Y_t = torch.tensor(Y, dtype=torch.float32).view(-1, 1) net = Net() opt = torch.optim.Adam(net.parameters(), lr=1e-3) loss_fn = nn.MSELoss() for epoch in range(200): perm = torch.randperm(len(X_t)) for i in range(0, len(X_t), 256): idx = perm[i:i+256] pred = net(X_t[idx]) loss = loss_fn(pred, Y_t[idx]) opt.zero_grad(); loss.backward(); opt.step() if epoch % 20 == 0: print(epoch, loss.item())

逻辑说明:每个 epoch 打乱数据,小批量梯度下降。参数说明:学习率 1e-3 是 Adam 的常用起点,损失震荡就降到 3e-4;batch 256 在几万样本下每 epoch 迭代几百次,速度合适。损失曲线正常应该在前 20 个 epoch 快速下降,之后缓慢收敛。如果损失卡在某个值不动,先查归一化,再查数据里有没有大量重复样本。

4. 把训练好的网络接到仿真闭环:先别急着上车

4.1 闭环推理的代码骨架与状态归一化一致性

训练完的网络要放回仿真里闭环跑,这一步最容易出的问题是归一化不一致——训练时用了 mean/std,推理时忘了减。

def nn_controller(state): s = (state - mean) / std s_t = torch.tensor(s, dtype=torch.float32).unsqueeze(0) with torch.no_grad(): F = net(s_t).item() return np.clip(F, -10, 10) log = run_sim(nn_controller) print("final theta:", log[-1, 3], "max |theta|:", np.abs(log[:, 3]).max())

逻辑说明:nn_controller 把状态归一化后送进网络,输出限幅。参数说明:mean 和 std 必须是训练集那一组,建议存成 npz 文件,部署时加载。跑完看 max |theta|,如果超过 0.2 rad 说明网络在某些状态区没学好,回去补数据。

4.2 闭环表现和 PD 对比:差在哪,为什么

把 PD 和网络的轨迹画在一起,通常会发现网络在初始阶段响应稍慢,但稳态误差更小,因为它学到了 PD 里没显式建模的摩擦补偿。如果网络抖得厉害,多半是训练数据里噪声太大,或者网络过拟合了 PD 的噪声。这时候可以降低数据噪声重训,或者在输出后加一个低通滤波。

4.3 从仿真到实车的三个接口问题

上车不是把仿真代码拷过去就行。第一,控制周期:仿真 0.01 s,实车单片机可能只能做到 0.02 s,网络推理要在这个周期内完成,前馈网络 4-64-64-1 在 STM32 上跑一次大约几百微秒,够用。第二,状态获取:角度用编码器或 IMU,速度用差分,噪声比仿真大,输入归一化前最好加个一阶滤波。第三,力到 PWM 的映射:仿真输出的是牛顿,实车要标定推力曲线,通常近似线性,但死区要补。

5. 避坑与排查:倒立摆神经网络控制里最容易翻车的五件事

5.1 现象:仿真里立得住,上车就倒

原因:仿真没建模电机死区和编码器量化误差,实车小角度时电机不响应,网络输出的微小力被吃掉。解决:在仿真里给力加死区模型,小于 0.3 N 的输出置零,重新训一版;实车标定时把死区补偿写进驱动。

5.2 现象:训练损失很低,闭环却发散

原因:数据里状态分布和闭环实际访问的状态不匹配,网络在训练集上过拟合,到了没见过的状态就乱输出。解决:用 DAgger 思路,拿当前网络跑闭环,把跑飞前的状态记下来,用 PD 标注正确力,补进数据集重训,迭代两三轮。

5.3 现象:摆杆高频抖动,电机发热

原因:网络输出对输入敏感,状态噪声被放大。解决:输入加一阶低通滤波,截止频率 20 Hz 左右;或者在损失里加输出平滑项,惩罚相邻时刻输出差。

5.4 现象:小车慢慢漂出轨道

原因:网络只学了角度稳定,没学好位置控制,因为训练数据里位置项权重低。解决:采集数据时让初始位置分布更宽,损失里对位置相关样本加权,或者干脆在输出上叠加一个小的位置 PD 项做修正。

5.5 现象:换一根摆杆就要重训

原因:网络把物理参数隐式编码进了权重,质量或长度一变,映射就偏了。解决:把摆杆长度、质量作为额外输入维度一起训,网络就能适应参数变化;或者用域随机化,训练时随机化物理参数,学一个鲁棒策略。

6. 进阶技巧:用域随机化和残差学习把网络做得更抗造

如果你已经跑通了上面的流程,下一步值得投入的是让网络对物理参数变化更鲁棒。我常用的做法是域随机化加残差学习。域随机化是在采集数据时,每条轨迹随机抽一组物理参数,比如摆杆质量在 0.15 到 0.25 kg 之间、长度在 0.25 到 0.35 m 之间、摩擦系数在 0.05 到 0.2 之间,把这些参数和状态拼在一起作为网络输入。这样训出来的网络见到新摆杆也能立住,代价是网络输入从 4 维变成 7 维,训练数据量要翻倍。

残差学习是另一个思路:不让网络直接输出力,而是让它输出对 PD 控制器的修正量。PD 先给一个基础力,网络学“还差多少”。这样网络只需要学小量修正,训练更容易,而且即使网络输出异常,PD 兜底也不会让摆杆立刻倒下。

def residual_controller(state, net, mean, std): F_pd = pd_controller(state) s = (state - mean) / std s_t = torch.tensor(s, dtype=torch.float32).unsqueeze(0) with torch.no_grad(): dF = net(s_t).item() return np.clip(F_pd + dF, -10, 10)

逻辑说明:残差网络训练时,标签是 PD 力减去实际需要的力,也就是让网络学 PD 的误差。参数说明:残差输出的幅值一般限制在 ±3 N 以内,太大就失去兜底意义。验证方法很简单:把 PD 增益调低到刚好立不住,看残差网络能不能补回来,能补回来说明学到了真东西。

我自己的习惯是,每换一个硬件平台,先花半天做系统辨识,把质量、长度、摩擦测准,再决定是重训还是直接迁移。倒立摆这个对象,仿真和实车的差距主要就在摩擦和延迟,把这两个补上,神经网络控制的落地成功率会高很多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询