简介:这份Python源码演示了基于神经网络的小车倒立摆平衡控制,面向自动控制、机器人及机器学习方向的初学者与开发者,帮助理解不稳定系统的实时反馈控制策略与智能控制方法。压缩包内共1个py文件,大小仅2KB,虽文件精炼,却覆盖了倒立摆系统模型、神经网络控制器结构、训练流程与仿真主循环等核心部分,可在Pygame或Gym等模拟环境中运行,实时观察平衡控制效果。资源已有553人次学习浏览,代码中明确呈现了网络层数与节点设置、优化器选用、损失函数计算及超参数配置等关键细节,方便在此基础上继续调参与改进。通过该脚本,能够清晰看到状态输入到控制信号输出的完整映射过程,并理解如何用仿真数据训练网络,从而沿用至真实倒立摆平台的部署尝试。总体而言,这是一份融合控制理论、Python编程与神经网络的精简可运行实例,适合希望快速上手智能控制仿真的读者。
1. 小车倒立摆为什么值得用神经网络控制:从PID的手忙脚乱说起
小车倒立摆(hwv1这类常见实验平台)是自动控制里最经典的“不稳定系统”教材,却也是神经网络控制最容易出成果的试验田。用传统PID去稳定一根立在运动小车上的摆杆,调试四个增益参数就能让人怀疑人生:角度环快了位置发散,位置环稳了摆杆又抖起来。而神经网络控制器只吃四个状态量——位置、速度、摆角、角速度——就能直接输出推力,训练得当甚至可以比手调PID更鲁棒。这篇文章要解决的问题很具体:用Python从零搭一个能训练、能验证、能往真机迁移的小车倒立摆神经网络控制器。适合刚接触智能控制的工程师、做自动化毕设的学生,以及想验证神经网络到底能不能脱离黑匣子身份扛起真实控制任务的开发者。
2. 先搭仿真环境:用Python写一个能反复翻车的小车倒立摆
2.1 状态空间与动力学模型:写代码前先写方程
倒立摆的动力学模型本质是一个二阶非线性系统。小车状态用四个量描述:位移x、速度x_dot、摆角theta(规定垂直向上为0,逆时针为正)、角速度theta_dot。控制输入是作用在小车上的水平推力F,摆杆通过旋转关节连接在小车上。
动力学方程(忽略摩擦和阻尼的简化模型)为:
(M + m)·x_ddot + m·l·theta_ddot·cos(theta) - m·l·theta_dot²·sin(theta) = F
m·l·x_ddot·cos(theta) + (I + m·l²)·theta_ddot - m·g·l·sin(theta) = 0
其中 M 是小车质量,m 是摆杆质量,l 是摆杆半长(质心到转轴距离),I 是摆杆转动惯量。这里最容易埋坑的是 l 的取值——用全长还是半长,算出来的角加速度会差一倍。我习惯把所有物理参数集中放在一个字典里,方便后面对照真机硬件做修正。
# cartpole_params.py import numpy as np PARAMS = { "mass_cart": 0.5, # 小车质量 kg "mass_pole": 0.2, # 摆杆质量 kg "pole_length": 0.6, # 摆杆全长 m(注意:动力学方程中 l 取半长) "gravity": 9.81, # 重力加速度 m/s^2 "inertia": 0.006, # 摆杆转动惯量 kg*m^2 "force_mag": 12.0, # 最大推力幅值 N "dt": 0.02, # 控制周期 s(对应 50Hz 控制频率) }逻辑说明:把物理参数集中在字典里,是为了后半段把神经网络从仿真往真机搬的时候,只改参数、不动网络结构。pole_length存全长,但在动力学计算里除以2得到半长,这样注释里写清楚,避免三个月后自己回来看代码时骂娘。控制周期 0.02 秒意味着推理频率 50Hz,这个频率也和后面训练数据采集、模型部署保持对齐。
2.2 最小可跑的仿真循环:四阶龙格库塔推进
动力学方程是连续时间系统,而控制是离散的。仿真推进我不用欧拉积分——倒立摆对积分精度比较敏感,欧拉法在50Hz控制频率下会让训练出来的网络在仿真里“看起来能立住”,一细看角度曲线全是锯齿,换到真机上更是不敢想。
# env.py import numpy as np from cartpole_params import PARAMS def dynamics(state, force, p): """返回状态导数 dx/dt = [x_dot, x_ddot, theta_dot, theta_ddot]""" x, xd, theta, thetad = state M, m = p["mass_cart"], p["mass_pole"] l = p["pole_length"] / 2.0 # 半长 g, I = p["gravity"], p["inertia"] sinT, cosT = np.sin(theta), np.cos(theta) # 解 2x2 线性方程组,得到 x_ddot 和 theta_ddot A = np.array([[M + m, m * l * cosT], [m * l * cosT, I + m * l**2]]) b = np.array([force - m * l * thetad**2 * sinT, m * g * l * sinT]) ddx, ddtheta = np.linalg.solve(A, b) return np.array([xd, ddx, thetad, ddtheta]) def step(state, force, p=PARAMS): """用 RK4 推进一个控制周期,返回新状态""" dt = p["dt"] k1 = dynamics(state, force, p) k2 = dynamics(state + 0.5 * dt * k1, force, p) k3 = dynamics(state + 0.5 * dt * k2, force, p) k4 = dynamics(state + dt * k3, force, p) return state + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)逻辑说明:dynamics里用np.linalg.solve解2x2线性方程组,比手动推导出 x_ddot 和 theta_ddot 的闭式表达式更不容易出错,而且以后想加摩擦力、加杆的质量分布时,只改矩阵里的系数就行。RK4 每步调用四次dynamics,在纯Python里跑50Hz仿真完全够用,不需要上C扩展或JIT,免得把环境搞复杂。
2.3 为什么要自建环境而不是直接用现成库
市面上有 OpenAI Gym 的 CartPole 环境,很多新手上来就gym.make("CartPole-v1")。但我建议至少把自建环境先跑通,原因有三个。
第一,Gym 的 CartPole 默认是离散动作空间(左或右),而真实小车倒立摆的推力是连续的。用离散动作训练出来的网络策略,搬到连续推力的电机驱动上,控制品质会明显下降。第二,自建环境里你能精确控制每个物理参数——摩擦系数、采样延迟、传感器噪声都可以随时加进去,后面对照真机做“仿真到实物迁移”时,才能定位模型误差出在哪一环。第三,亲手写一遍动力学方程,后面调网络时心里有底:输出层该不该用 tanh、奖励函数里角度权重怎么定,其实都跟方程结构直接相关。
如果后面想做深度强化学习,自建环境同样可以封装成 Gym 接口,在step外面包一层reset、observation_space、action_space,PPO 或 SAC 的训练库直接就能吃,不需要换代码。
3. 神经网络控制器:前馈网络如何学会稳定小车
3.1 输入输出与网络结构:状态特征决定控制上限
神经网络控制器的任务映射是:四个状态量 → 一个推力。输入是[x, x_dot, theta, theta_dot],输出是F,范围在[-force_mag, force_mag]之间。第一个关键决定是激活函数的选择。
我的经验是:隐藏层用 ReLU,中间一到两层、每层64到128个神经元就够。倒立摆状态维度低、非线性程度有限,不需要更深更宽的网络——加大网络容量只会让训练变慢,并不会提升控制性能。输出层一定要用 tanh,把网络输出限制在[-1, 1],再乘上force_mag得到实际推力。这里有个细节:如果输出层用线性激活,训练初期梯度爆炸的概率会大增,推力动不动就冲到几百牛,仿真直接崩掉。
不用 LSTM 或 CNN 的原因也顺带说清楚:倒立摆状态是马尔可夫的——当前状态加当前控制量就能推出下一时刻状态,不存在长时序依赖,LSTM 在这里只会增加训练难度和数据需求;CNN 更不用说,那是为图像输入准备的。前馈神经网络(BP网络)是这类控制任务最对口的工具,反向传播一遍走完,结构简单,问题好排查。
3.2 训练范式:监督学习还是强化学习
这里需要做一个选型决策。做倒立摆神经网络控制,主流路线分三类。
第一类是行为克隆(监督学习):先用传统控制器(LQR 或 PD)采集一批“状态-动作”数据,然后训练神经网络去拟合这个策略。优点是非常稳,训练快,用标准反向传播流程就能搞定;缺点是性能上限不会超过专家策略,学不到比专家更聪明的控制方式。
第二类是强化学习(PPO / SAC / DQN):网络自己跟环境交互,通过奖励信号学策略。优点是不需要设计专家控制器,理论上限更高,还能学到一些反直觉的、非线性的控制策略;缺点是训练时间长、奖励函数设计像玄学,第一次跑大概率遇到“奖励一直不上涨”的翻车现场。
第三类是两者结合:先用行为克隆做预训练,再用强化学习做微调。对 hwv1 这类真实硬件,我建议先走“行为克隆跑通全流程”,再上强化学习提升性能。理由很现实:行为克隆的训练曲线是收敛的、可预期的,适合建立信心;强化学习第一次跑,你很难判断是算法写错了、超参数不合适,还是环境有问题。
3.3 生成训练数据:PD控制器采集与数据清洗
行为克隆的第一步是采集专家数据。常见做法是用 PD 控制器先稳定倒立摆,同时记录状态和对应控制力。PD 增益要调得足够硬,保证控制器能把摆角快速压到接近零,否则采进来的废数据会让网络学到一个“无力的策略”。
# collect_data.py import numpy as np from env import step from cartpole_params import PARAMS def pd_controller(state, kp_theta=50.0, kd_theta=8.0, kp_x=2.0, kd_x=1.0): """PD控制器:同时稳定摆角和小车位置,返回推力""" x, xd, theta, thetad = state # 角度环为主,位置环为辅 force = -kp_theta * theta - kd_theta * thetad - kp_x * x - kd_x * xd return np.clip(force, -PARAMS["force_mag"], PARAMS["force_mag"]) def collect_trajectories(episodes=500, steps_per_episode=200): """采集专家轨迹,返回状态数组和力数组""" states, forces = [], [] for _ in range(episodes): # 初始摆角给一个小扰动,模拟真实系统中的偏差 state = np.array([0.0, 0.0, np.random.uniform(-0.1, 0.1), 0.0]) for _ in range(steps_per_episode): force = pd_controller(state) states.append(state.copy()) forces.append(force) state = step(state, force, PARAMS) return np.array(states), np.array(forces)逻辑说明:PD控制器四个增益分成两组——kp_theta和kd_theta管摆角,kp_x和kd_x管位置。初始摆角只给 ±0.1 弧度的小扰动,因为PD控制器在较大摆角(比如超过30度)时本来就拉不回来,那些数据采进来就是纯噪声。采集完成后有一个重要的数据清洗动作:遍历轨迹,把“摆角绝对值持续增大”的片段丢掉——说明PD控制器已经失效,这段轨迹后面全是无效样本,网络学了只会学歪。
4. 训练流程与超参数:让反向传播真正把杆子立住
4.1 损失函数与标签设计:不是所有误差都值得学
行为克隆的损失函数看起来就是 MSE:loss = mean((pred - label)^2)。但有个细节会让新手踩坑:标签是PD控制器输出的力,如果数据里混入大量摆角接近零、力也接近零的样本,网络会被“平均值”带偏,学成一个偏向输出小力的保守策略。真正需要大推力去纠正偏差时,网络反应迟钝,杆子就会慢慢倒下去。
我一般会在采样时做困难样本加权:把摆角较大(比如超过0.05弧度)的状态复制两到三份,直接改变数据分布,让网络把“大偏差对应大推力”这个映射学得更扎实。这属于典型的“数据分布决定性能上限”问题——模型结构只是下限。
另一个关键点是输出的分布。PD控制器输出的力在 ±12N 之间分布很不均匀,大部分时间在 ±2N 附近。如果直接拟合原始力值,网络学到的是“大多数时候输出小力”。缓解办法是对力标签做缩放:除以force_mag,让标签分布在[-1, 1],和 tanh 输出的范围对齐,训练收敛会更快更稳。
4.2 训练循环:PyTorch最小实现
# train.py import torch import torch.nn as nn from torch.utils.data import TensorDataset, DataLoader class PoleNet(nn.Module): """前馈神经网络控制器:4维状态输入,1维推力输出""" def __init__(self, input_dim=4, hidden_dim=128): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1), nn.Tanh() # 输出归一化到 [-1, 1],推理时乘 force_mag ) def forward(self, x): return self.net(x) def normalize_data(states, forces): """对状态做标准化,对力做缩放;返回统计量供推理时使用""" state_mean = states.mean(axis=0) state_std = states.std(axis=0) + 1e-8 states_n = (states - state_mean) / state_std forces_n = forces / PARAMS["force_mag"] return states_n, forces_n, state_mean, state_std states_n, forces_n, state_mean, state_std = normalize_data(states, forces) model = PoleNet() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) dataset = TensorDataset(torch.tensor(states_n, dtype=torch.float32), torch.tensor(forces_n, dtype=torch.float32)) loader = DataLoader(dataset, batch_size=256, shuffle=True) for epoch in range(200): epoch_loss = 0.0 for s, f in loader: pred = model(s).squeeze(-1) loss = nn.functional.mse_loss(pred, f) optimizer.zero_grad() loss.backward() optimizer.step() epoch_loss += loss.item() if epoch % 20 == 0: print(f"epoch {epoch}, loss {epoch_loss / len(loader):.4f}")逻辑说明:网络输出接 Tanh 把力限制在 ±1,推理时乘 12N 就是物理推力。这个设计让训练初期不会出现“推力瞬间爆表”的梯度爆炸问题,这是 BP 网络训练里常见的翻车点。学习率 1e-3 是 Adam 在低维回归问题上的稳定起手值,不要一上来就用 1e-2——loss 经常会先降到很低,然后突然跳回高位,看起来像训练过程出了 bug,其实只是步子迈太大。batch_size 选 256 因为数据量本身不大(500条轨迹 × 200步 = 10万条样本),大 batch 能减小梯度随机波动。
4.3 训练监控:loss曲线下降不代表控制成功
行为克隆里有个很常见的假象:训练 loss 降到 1e-4,看起来完美收敛,把模型接到仿真环境里一跑,杆子一秒就倒。原因是 loss 衡量的是“预测力与标签力的误差”,而控制任务关心的是“这个力作用在物理系统上能不能稳住”。这两个指标在数据分布健康的时候正相关,数据分布稍偏(比如大部分样本都是小摆角小推力),就会脱钩。
我每训练 50 个 epoch 就把当前模型接到仿真环境里做一次闭环验证,记录 200 步内的最大摆角。具体做法:把网络输出乘上force_mag作为推力,输入step()推进,每步把网络输出的力记录下来。如果最大摆角超过 0.2 弧度,说明当前模型策略太软,要么是数据不够、要么是困难样本太少。这个闭环验证指标,比 loss 曲线靠谱得多。
一个容易忽略的细节:模型推理频率必须和仿真步进频率一致,都是 50Hz。训练时如果每步推理用时超过 2ms,后面上真机时控制回路带宽就不够了——这个问题到第5章避坑部分还会单独讲。
5. 神经网络倒立摆控制最常翻车的5个坑:现象、原因与解决
5.1 摆杆持续震荡,幅度越来越大,停不下来
现象:仿真里摆杆在 ±0.2 弧度之间来回摆动,不倒下但也立不直,震荡频率接近控制周期。原因:采集数据用的 PD 控制器增益调得太软,控制力不足以抑制角速度,网络继承了这套“无力策略”。解决:把采集用的 PD 增益调硬。起手参数是kp_theta=50, kd_theta=8,先确认专家控制器本身能快速把摆角压到 0.01 弧度以内,再开始采集数据。如果 PD 都稳不住,采集多少数据都是废的。
5.2 训练收敛,但仿真里连一秒都撑不住
现象:loss 降到 0.002 以下,看起来很成功,把模型放进step循环,杆子在 0.5 秒内倒下。原因:这是行为克隆最容易踩的坑——推理输入没做和数据采集时相同的标准化。训练代码里对状态做了(s - mean) / std,推理时忘了这一步,模型输入分布和训练时完全对不上。解决:把state_mean、state_std和模型一起保存,推理代码里先标准化再入网络。我习惯把标准化逻辑写进模型封装类里,外部调用只管传原始状态。
5.3 输出推力高频抖动,小车在轨道上来回疯狂变向
现象:摆角控制住了,但推力波形是高频锯齿状,像白噪声,小车在轨道上来回窜。原因:状态归一化没做到位。x的数值范围可能是[-3, 3],而theta只有[-0.3, 0.3],网络为了压低大数值维度的误差,梯度更新把权重推向一个对位置超敏感的工作点,导致输出抖动。解决:对所有输入维度做统一的标准化,把位置和速度也缩放到和角度同一个量级。这一点在 4.2 的normalize_data里已经做了,但在手工构造推理输入时很容易漏掉。
5.4 换一台电脑,或者换一段随机种子,效果完全不一样
现象:本地训练的网络搬到另一台机器上,摆角控制明显变差,甚至直接倒下。原因:模型的state_mean和state_std没随模型一起迁移。这个坑和 5.2 本质相同但更隐蔽——你重跑了采集数据的随机种子,数据分布变了,标准化统计量也变了,但模型权重还是旧的。解决:固定随机种子采集数据(np.random.seed(42)),并把标准化统计量序列化到模型文件里,让网络自带归一化,不依赖外部调用。
5.5 仿真稳稳当当,上真机两秒就倒
现象:仿真里随便拉初始角度都能立住,真机上电之后小车冲向轨道尽头,完全没有控制迹象。原因:仿真环境里没建模摩擦力、电机响应延迟、传感器噪声,真机的状态采样频率往往低于仿真设定——仿真里按 100Hz 采数据训练的模型,换到真机只能跑到 50Hz,控制回路带宽直接缺了一半。解决:真机先用传统 PD 做闭环基准,如果 PD 都在真机上稳不住,神经网络更稳不住。先用 PD 调通真机,再切到网络输出,切的时候可以做一个“力度渐变”的过渡:force = alpha * net_force + (1-alpha) * pd_force,alpha 从 0 慢慢调到 1。
这五条是从 hwv1 这类常见小车倒立摆上验证过的共性现象。遇到问题不要先怀疑算法,按“数据 → 归一化 → 频率 → 物理参数”的顺序排查,90% 的问题出在这四个环节里。
6. 从训练到部署:把神经网络模型接到真小车上的验证闭环
行为克隆模型训练完成后,真正的考验在部署环节。在 hwv1 这类硬件上(单片机或树莓派推理,电机驱动加底层电流环),我通常分三步走完闭环。
第一步是模型导出与推理验证。PyTorch 模型转成 TorchScript 或 ONNX,在一个独立的 Python 进程里做 200 次随机状态的推理,记录单次推理耗时和输出范围。如果平均推理耗时超过 2ms,就要考虑裁剪网络宽度(比如把 128 降到 64);如果输出饱和在 ±12N 的比例超过 30%,说明输入分布和训练时偏差较大,先检查归一化统计量是否一致。导出时把state_mean、state_std、force_mag三个数值写进模型文件的元数据里,部署代码直接读取,不给手写传参留出错空间。
第二步是闭环测试矩阵。我不建议直接全状态上手,而是按等级递进:第一级固定摆角 ±0.05 弧度,目标只求不倒;第二级摆角 ±0.15 弧度,考察鲁棒性;第三级加外部扰动(推一下摆杆),观察恢复到直立的时间。每个等级跑 20 次,记录最大摆角、恢复时间、小车越界次数。这个矩阵能帮你快速定位问题出在“控制器策略不行”还是“执行机构响应慢”。
第三步是安全措施。所有真机实验先跑在限位轨道内,小车两端留足物理限位缓冲,力幅值上限设为训练时的 70%——真实电机和仿真理想推力的差距,往往就是这一下打折扣换回来的。第一次切网络控制时,旁边一定放一个紧急停车按钮,代码里也写好保险逻辑:if abs(theta) > 0.6: force = 0,先让系统安全停下来,再分析原因。这是血泪经验,原来总觉得没必要,直到亲眼看着小车冲向轨道端头。
神经网络倒立摆控制这个方向,值得投入的点在于:它是验证“数据 → 策略 → 物理系统”闭环成本最低的项目。哪怕最后发现行为克隆的性能打不过调好的 LQR,这一趟走下来对状态反馈、时序对齐、仿真到实物的理解,远比多调几个 PID 参数值钱。希望帮到你。
本文还有配套的精品资源,点击获取