Python神经网络控制倒立摆:从仿真到实车的完整实践
发布时间:2026/10/1 5:29:41来源:尧图网络
简介这份资源面向控制理论、机器学习与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, T10.0, dt0.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_episodes200, T5.0, dt0.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(axis0) std X.std(axis0) 1e-6 X_norm (X - mean) / std逻辑说明网络输出直接是力不加激活因为力有正负且范围不限。归一化用训练集的均值和标准差推理时必须用同一组值否则输入分布对不上输出全乱。参数说明隐藏层 64 是起点样本超过 20 万可以加到 128ReLU 比 tanh 收敛快但输出层附近用 tanh 有时更平滑可以试。3.3 训练循环与损失曲线怎么看训练用 MSE 损失Adam 优化器学习率 1e-3batch 256跑 200 个 epoch 基本够。X_t torch.tensor(X_norm, dtypetorch.float32) Y_t torch.tensor(Y, dtypetorch.float32).view(-1, 1) net Net() opt torch.optim.Adam(net.parameters(), lr1e-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:i256] 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-4batch 256 在几万样本下每 epoch 迭代几百次速度合适。损失曲线正常应该在前 20 个 epoch 快速下降之后缓慢收敛。如果损失卡在某个值不动先查归一化再查数据里有没有大量重复样本。4. 把训练好的网络接到仿真闭环先别急着上车4.1 闭环推理的代码骨架与状态归一化一致性训练完的网络要放回仿真里闭环跑这一步最容易出的问题是归一化不一致——训练时用了 mean/std推理时忘了减。def nn_controller(state): s (state - mean) / std s_t torch.tensor(s, dtypetorch.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, dtypetorch.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 增益调低到刚好立不住看残差网络能不能补回来能补回来说明学到了真东西。我自己的习惯是每换一个硬件平台先花半天做系统辨识把质量、长度、摩擦测准再决定是重训还是直接迁移。倒立摆这个对象仿真和实车的差距主要就在摩擦和延迟把这两个补上神经网络控制的落地成功率会高很多。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网