
简介这份资源是一份基于Python与神经网络的小车倒立摆控制项目源码面向控制理论初学者、机器人爱好者及机器学习入门者演示了如何用神经网络控制器维持倒立摆的稳定平衡帮助理解动态系统稳定性与反馈控制策略的结合。压缩包内共1个文件以单个py脚本形式完整包含系统动态建模、神经网络结构定义、训练流程与主仿真循环整体仅2KB轻量精炼便于直接阅读和复现。资源已有553人学习浏览适合作为结合编程与控制理论的实践案例其中涉及的仿真环境搭建、网络超参数调整与损失函数优化等细节可帮助学习者在实际运行中观察平衡效果并进一步提升控制性能。相比传统PID控制方案该项目提供了利用机器学习自适应性解决非线性控制问题的一种思路尤其适合希望在真实硬件部署前用仿真快速验证算法的读者。1. 为什么小车倒立摆是神经网络控制的入门标杆如果要把控制领域里“理论能讲、仿真能跑、硬件能验证”的问题排一个座次小车倒立摆一定在前三。一根摆杆铰接在一台沿直线轨道运动的小车上电机左右推动小车让摆杆保持在竖直位置这个系统的状态只有四个小车位移、小车速度、摆角、摆角速度。它简单到可以用几行微分方程描述又难到线性控制器的参数一调就是半天难到任何人第一次看到摆杆倒下都会忍不住想“神经网络到底能不能学会它”。选择神经网络方案来解决这个经典 benchmark不是成本最低的路径却是理解强化学习和非线性控制关系的最好入口你不用手工设计 LQR 增益不用推导雅可比矩阵只需要给出状态转移和反馈信号让网络自己找策略。对刚接触深度强化学习的工程师来说小车倒立摆也是第一个能在普通 CPU 上、十几分钟内训练出可用策略的问题。本文按“运动方程 → Python 仿真环境 → PyTorch 前馈 Q 网络 → 参数调试与验收”这条路线把每个环节的命令、参数和失败模式说透。2. 小车倒立摆运动方程推导与状态空间能控性验证网络再强大也不会替你修正错误的物理模型。训练环境如果和真实系统差太远训练出来的策略只能在一个虚假的世界里转圈。所以在写神经网络之前先把小车倒立摆的动力学表达清楚并用一组可验证的数值检验系统是不是“能控”的。2.1 拉格朗日方程推导出的倒立摆动力学设小车质量为 M摆杆质量为 m摆杆质心到转轴距离为 L绕质心转动惯量为 I小车与轨道之间的摩擦系数为 b。以垂直向上为 θ0小车水平位置为 xF 为施加在小车上的水平外力。摆杆质心的水平速度分量是 ẋLθ̇cosθ垂直速度分量是 -Lθ̇sinθ于是系统的动能可以写成T ½Mẋ² ½m[(ẋLθ̇cosθ)² (Lθ̇sinθ)²] ½Iθ̇²势能只有一个重力项V m·g·L·cosθ把拉格朗日量 LT−V 代入欧拉-拉格朗日方程整理后得到两个二阶方程(Mm)ẍ m·L·θ̈·cosθ − m·L·θ̇²·sinθ F − b·ẋm·L·ẍ·cosθ (Im·L²)·θ̈ − m·g·L·sinθ 0第一个方程描述小车方向的力平衡第二个描述摆杆绕转轴的力矩平衡。验证这个推导最直接的方法就是把摆角锁在 θ0。此时第二个方程变成 (Im·L²)·θ̈ 0没有外力矩第一个方程退化为 (Mm)ẍ F−b·ẋ也就是普通小车受恒定外力时的运动。2.2 平衡点线性化与状态空间矩阵神经网络控制器当然可以处理非线性方程但能控性分析必须在平衡点附近做线性化。令 sinθ≈θ、cosθ≈1忽略 θ̇² 高阶项得到(Mm)ẍ m·L·θ̈ F − b·ẋm·L·ẍ (Im·L²)·θ̈ m·g·L·θ定义状态向量 s[x, ẋ, θ, θ̇]ᵀ输入 uF。把上面两个方程联立解出 ẍ 和 θ̈令惯性耦合行列式 Den(Mm)(Im·L²)−(m·L)²则状态空间矩阵如下A [[0, 1, 0, 0], [0, −(Im·L²)b/Den, −m²·g·L²/Den, 0], [0, 0, 0, 1], [0, m·L·b/Den, (Mm)m·g·L/Den, 0]]B [0, (Im·L²)/Den, 0, −m·L/Den]ᵀ注意矩阵中第二行第三列的负号来自重力在小车位移通道上的间接作用第四行第三列的正号来自重力对摆角的“推倒”作用。这两个符号在整个控制问题里是最容易出错的地方后面仿真环境里如果推力方向反了神经网络大概率学不出来。2.3 用 numpy 验证能控性矩阵的秩能控性检验的意义在于确认“任务本身有没有解”。构造能控性矩阵 C[B, AB, A²B, A³B]如果满秩说明存在一个连续的控制输入能把系统从任意初始态拉回平衡点。直接用 numpy 验证import numpy as np M, m, b, L, I, g 1.0, 0.1, 0.0, 0.5, 0.0, 9.8 Den (M m) * (I m * L * L) - (m * L) ** 2 A np.array([ [0., 1., 0., 0.], [0., -(I m*L*L) * b / Den, -m*m * g * L*L / Den, 0.], [0., 0., 0., 1.], [0., m*L * b / Den, (M m) * m * g * L / Den, 0.] ]) B np.array([[0.], [(I m*L*L) / Den], [0.], [-m*L / Den]]) def ctrb(A, B): n A.shape[0] P B for i in range(1, n): P np.hstack((P, np.linalg.matrix_power(A, i) B)) return P C ctrb(A, B) print(rank:, np.linalg.matrix_rank(C), n:, A.shape[0])能控性矩阵的构建就是把每一次幂乘上 B 的列拼接起来然后用matrix_rank判断是否满秩。运行结果 rank4系统在平衡点附近完全能控。这意味着后续用神经网络做控制不是“无解硬解”只是换一种方式去逼近那个理论上存在的控制律。提示这里用的 A、B 是线性化状态空间矩阵和后面仿真器里对质量矩阵np.linalg.solve的解法不要混淆。前者是分析手段后者是数值推进手段。3. 用 Python 搭建小车倒立摆仿真环境与实验变量设计环境是训练和验证共用的一套接口。OpenAI Gym 里虽然有 CartPole-v1但它把摆杆惯量直接并入 m·L²且源码耦合了 Gym 的版本接口。自己实现环境的好处是每个变量都可控后面做参数扫描、换摩擦模型、调整恢复力方向都更方便。3.1 用四阶 Runge-Kutta 做离散积分仿真器的核心是数值积分。选择 RK4 而不是简单欧拉积分是因为欧拉法在高频摆动下会引入明显的能量漂移10 秒的仿真可能累积出肉眼可见的误差。RK4 在每个时间步内采样 4 个斜率相位误差小得多。import numpy as np class CartPoleDynamics: def __init__(self, M1.0, m0.1, b0.0, L0.5, I0.0, g9.8): self.params M, m, b, L, I, g def deriv(self, state, F): x, x_dot, theta, theta_dot state M, m, b, L, I, g self.params sin_th np.sin(theta) cos_th np.cos(theta) A_q np.array([ [M m, m * L * cos_th], [m * L * cos_th, I m * L * L] ]) rhs np.array([ F - b * x_dot m * L * theta_dot**2 * sin_th, m * g * L * sin_th ]) q_ddot np.linalg.solve(A_q, rhs) return np.array([x_dot, q_ddot[0], theta_dot, q_ddot[1]]) def step_rk4(self, state, F, dt0.02): k1 self.deriv(state, F) k2 self.deriv(state 0.5 * dt * k1, F) k3 self.deriv(state 0.5 * dt * k2, F) k4 self.deriv(state dt * k3, F) return state (dt / 6.0) * (k1 2*k2 2*k3 k4)这里的 A_q 是广义惯性矩阵不是前面能控性里的 A 矩阵。deriv中先组装质量矩阵和右侧力向量再调用np.linalg.solve解出加速度这种方式比手工展开表达式更不容易出错。右侧向量第一项里的m*L*theta_dot**2*sin_th是摆杆转动带来的法向加速度贡献保留它才能正确模拟大角度摆动的行为。dt0.02s 对应 50Hz 的控制周期这是实际电机控制中很常见的频率。如果你后面要部署到真实硬件把 dt 改成真实控制周期并把动作对应的推力幅值换成电机的额定推力范围。3.2 回合协议与终止条件reset、step 和 done把动力学包装成标准的强化学习交互接口。动作空间只有两个离散值向左推或向右推推力大小固定为 ±10N。这种方式和 DQN 的离散输出天然匹配。class CartPoleEnv: def __init__(self, max_steps500, force_mag10.0): self.dynamics CartPoleDynamics() self.state np.zeros(4) self.step_count 0 self.max_steps max_steps self.force_mag force_mag def reset(self, theta_initNone): self.step_count 0 theta theta_init if theta_init is not None else np.random.uniform(-0.1, 0.1) self.state np.array([0.0, 0.0, theta, 0.0]) return self.state.copy() def step(self, action): F self.force_mag if action 1 else -self.force_mag self.state self.dynamics.step_rk4(self.state, F) self.step_count 1 x, _, theta, _ self.state done False if abs(x) 2.4 or abs(theta) 0.209 or self.step_count self.max_steps: done True return self.state.copy(), 1.0, done, {}终止条件包含三个部分小车超出 ±2.4 米轨道范围、摆角超过 ±12°0.209 rad、或者坚持到 500 步10 秒。只要满足任意一个就判 done。奖励这里固定返回 1.0不论终止如何。这一点常被新手忽视如果终止时给一个大的负惩罚价值函数会被“尽早结束”的错误梯度主导模型反而学得越快死得越快。reset中 θ 初始值做一个 ±0.1 rad 的小扰动是为了让每个回合不完全相同避免网络只在固定初始条件下记住动作序列。3.3 用 matplotlib 可视化训练过程训练过程中直接看数值 reward 曲线不够直观把小车和摆杆画出来能很快发现“推力方向是否反了”“初始扰动是否合适”这类问题。import matplotlib.pyplot as plt from matplotlib.patches import Rectangle def render_cartpole(ax, state): x, _, theta, _ state ax.cla() cart_w, cart_h 0.6, 0.2 ax.add_patch(Rectangle((x - cart_w/2, -cart_h/2), cart_w, cart_h, fc#7799cc)) pole_len 2.0 pole_x x pole_len * np.sin(theta) pole_y pole_len * np.cos(theta) ax.plot([x, pole_x], [0, pole_y], k-, lw3) ax.plot([pole_x], [pole_y], ro, ms6) ax.set_xlim(-3.0, 3.0) ax.set_ylim(-1.0, 2.5) ax.set_aspect(equal)渲染逻辑里摆杆末端的位置由 x 2.0·sinθ 和 2.0·cosθ 决定竖直向上时位于 (x, 2.0)。运行随机策略几轮你会看到摆杆毫无意外地倒下这正是探索阶段需要覆盖的状态分布。4. 用 PyTorch 训练前馈 Q 网络控制器DQN的关键参数环境已经就绪接下来是控制器的训练。这里选择 DQN 而不是策略梯度或 PPO是因为小车倒立摆的动作空间只有两个离散值DQN 的结构最直接训练也更稳定。策略梯度方法在这个任务上也能收敛但需要花更多精力处理方差问题不适合作为第一个实验。4.1 为什么用前馈网络而不是卷积或循环网络小车倒立摆的状态就是四维连续向量不涉及图像或序列。常见的做法是直接用多层感知机做 Q 网络输入层 4 个节点对应 [x, ẋ, θ, θ̇]输出层 2 个节点对应两个动作的 Q 值。网络结构选 [4, 64, 64, 2] 已经足够再大的层数在这个任务上不仅增加计算量还容易过拟合噪声。import torch import torch.nn as nn class QNetwork(nn.Module): def __init__(self, obs_dim4, action_dim2, hidden64): super().__init__() self.net nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim) ) def forward(self, obs): return self.net(obs)隐藏层激活函数用 ReLU输出层不加激活函数因为 Q 值本身是一个实数不需要限制范围。如果输出层加了 sigmoid 或 tanhQ 值会被限制在固定区间导致策略评估失真。4.2 状态归一化四个维度必须落在同一量级状态向量里 x 以米为单位可能达到 ±2.4θ 以弧度为单位通常只有 ±0.2两者相差一个数量级。不做归一化直接作为网络输入时第一层线性层在 θ 维度上的梯度会占主导x 维度几乎学不到有效信息。import numpy as np def normalize_state(s): bounds np.array([2.4, 3.0, 0.5, 3.0]) return s / bounds四个维度的归一化上限分别取 2.4、3.0、0.5、3.0对应小车位置、小车速度、摆角、摆角速度的现实边界。θ 的 0.5 比终止阈值 0.209 大给网络留出训练初期的探索空间。归一化后的向量理论范围在 ±1 附近这个操作必须在环境产出状态之后、送入网络之前完成同时注意把归一化后的状态也存进回放缓冲区。4.3 经验回放、目标网络与更新循环DQN 的两个设计机制是经验回放和目标网络。经验回放打破连续样本之间的时间相关性目标网络让 TD 目标在训练过程中保持相对稳定。import random from collections import deque def update_step(q_net, target_net, replay, optimizer, batch_size64, gamma0.99): batch random.sample(replay, batch_size) states torch.FloatTensor([t[0] for t in batch]) actions torch.LongTensor([t[1] for t in batch]).reshape(-1, 1) rewards torch.FloatTensor([t[2] for t in batch]).reshape(-1, 1) next_states torch.FloatTensor([t[3] for t in batch]) dones torch.FloatTensor([t[4] for t in batch]).reshape(-1, 1) q_values q_net(states).gather(1, actions) with torch.no_grad(): q_next target_net(next_states).max(1, keepdimTrue)[0] q_target rewards gamma * q_next * (1 - dones) loss nn.MSELoss()(q_values, q_target) optimizer.zero_grad() loss.backward() optimizer.step()gather按照 actions 索引取出实际执行动作对应的 Q 值。目标值由 target_net 计算下一状态的最大 Q乘以 γ 后加上当前奖励。(1 - dones)保证回合结束时下一状态的价值为 0避免把已经结束的回合继续外推。目标网络并不是每一步都更新一般每隔固定步数从 q_net 复制一次参数。如果每一步都复制等价于没有目标网络损失函数的目标会随网络参数漂移。完整训练循环如下def train(env, total_steps60000): q_net QNetwork() target_net QNetwork() target_net.load_state_dict(q_net.state_dict()) optimizer torch.optim.Adam(q_net.parameters(), lr3e-4) replay deque(maxlen40000) state env.reset() epsilon 1.0 eps_end, decay_steps 0.02, 15000 for step in range(total_steps): epsilon max(eps_end, epsilon - (1.0 - eps_end) / decay_steps) if random.random() epsilon: action random.randint(0, 1) else: with torch.no_grad(): s_norm torch.FloatTensor(normalize_state(state)).unsqueeze(0) action q_net(s_norm).argmax().item() next_state, reward, done, _ env.step(action) replay.append((normalize_state(state), action, reward, normalize_state(next_state), done)) state next_state if done: state env.reset() if len(replay) 64 and step % 2 0: update_step(q_net, target_net, replay, optimizer) if step % 500 0: target_net.load_state_dict(q_net.state_dict())每 2 个环境步做一次梯度更新目标网络每 500 步同步。ε 从 1.0 线性衰减到 0.02前期的完全随机探索用于覆盖状态空间后期几乎贪婪地利用学到的 Q 值。下面对应训练中的关键参数表参数取值说明回放容量40000足够覆盖探索阶段的整段轨迹batch_size64CPU 训练也能接受的规模学习率3e-4Adam 的常用量级过大容易发散γ0.99让网络充分考虑长期存活ε 起始1.0前期完全随机探索ε 结束0.02后期保留极少量探索目标网络同步500 步兼顾稳定性和跟踪速度每回合最大步数50010 秒不倒下视为成功5. 训练不收敛时的三条排查路径与模型稳定性验证DQN 在这个任务的收敛率很高但不收敛也不是没见过。大部分问题和网络结构无关而是出现在状态归一化、奖励设计和超参数组合上。5.1 状态归一化与推力方向第一件要确认的事是状态归一化。如果 x 用原值进入网络而 θ 也原值进入训练初期损失函数会被 θ 维度主导。检查方式是打印训练最初 1000 步的 loss 数值如果 loss 一开始就在 1e3 量级跳跃多半是归一化没生效或推力方向反了。推力方向反了的典型表现是ε 已经衰减到低位网络仍然坚持朝错误方向用力摆杆几乎每次都倒向同一侧。可以在环境里手动做一个冒烟测试给定一个小的正摆角 θ0分别记录左右两个动作下状态变化的方向。正确的物理响应应该是朝 θ 减小的方向修正。5.2 奖励塑形与学习率检查奖励设计最常见的两个坑是负惩罚过重、奖励函数过于密集。负惩罚过重会导致 Q 值整体偏悲观让网络学会“站着不动等结束”而非积极修正。正确的做法是固定每步 1终止不额外扣分。学习率偏高时损失曲线会规律性出现尖峰而不是平滑下降。可以先降到 1e-4同时把回放容量从 40000 降到 20000减少旧数据和新策略之间分布不匹配的时间窗口。这个组合大概率能把训练拉回正轨。5.3 用统一评估协议验收训练结果训练结束后不能只看一次回放。在 200 个随机初始条件下跑固定评估统计成功保持 500 步不倒的比例和平均回合回报def evaluate(env, q_net, episodes200): success 0 rewards [] for _ in range(episodes): s env.reset(theta_initnp.random.uniform(-0.1, 0.1)) R 0.0 for _ in range(env.max_steps): with torch.no_grad(): s_norm torch.FloatTensor(normalize_state(s)).unsqueeze(0) a q_net(s_norm).argmax().item() s, r, done, _ env.step(a) R r if done: break if R env.max_steps: success 1 rewards.append(R) return success / episodes, np.mean(rewards), np.std(rewards) acc, avg_r, std_r evaluate(env, q_net)这里的初始角在 ±0.1 rad 内随机比训练时的扰动范围略宽用来检验网络的泛化能力。一个收敛良好的模型200 回合内成功率应该在 98% 以上如果低于 85%优先检查奖励与探索逻辑而不是盲目加宽网络。最后再看std_r它衡量的是策略的稳定性正常训练收敛后回合回报的方差应该非常小说明抵抗初始条件扰动的能力是一致的。本文还有配套的精品资源点击获取