ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

BP神经网络与增量式PID结合的PyTorch在线参数整定

BP神经网络与增量式PID结合的PyTorch在线参数整定 三年前接手一台热风炉出口温度的控制改造标的很简单把温度稳在设定值正负两度以内超调别超过五度。当时用的一套固定增益PID冷态启动时调得挺好等炉体热透了、风量一变曲线立刻开始画波浪。后来我把增量式PID的三个增益交给一个小型BP神经网络去在线调整用PyTorch写下来一共不到两百行。这篇文章就把这套BP神经网络与PID控制算法结合的做法完整拆一遍包括网络为什么这么设计、被控对象怎么建模、梯度从哪来、以及我调试时踩过的那些坑。仿真和实物的差异、环境怎么配也一并写清楚。1. 固定增益PID在时变对象上为什么总是差一口气1.1 一条温度曲线的三段时间里藏着三种对象先复盘那条曲线。冷态启动阶段炉膛和物料都是凉的对象增益小、时间常数大PID给一个相对激进的参数也不会超调上升沿很漂亮。等温度爬到设定值附近炉体蓄热释放同样的输出增量带来的温升明显变快这时候原参数就显得过冲超调五度、八度往上走。再往后进入保温阶段环境散热和负载波动让对象呈现慢时变特性固定增益要么响应迟钝、稳态误差回来要么为了消除误差把积分项调大结果在扰动来的时候又开始振荡。这三个阶段的对象特性用一阶惯性加纯滞后模型去拟合参数根本不是同一套。传统做法是按最恶劣工况整定牺牲动态性能换稳定代价就是升温慢、能耗高。这就是固定增益PID的先天短板它假设对象是线性时不变的而真实的温度、流量、压力对象几乎没有一个是。1.2 增量式PID里三个参数各自的脾气要把参数交给网络去调先得清楚每个参数在干什么。我们用增量式PID公式是Δu(k) Kp·[e(k) - e(k-1)] Ki·e(k) Kd·[e(k) - 2e(k-1) e(k-2)] u(k) u(k-1) Δu(k)这里面 Ki 在工程上一般写作 Kp·T/TiKd 写作 Kp·Td/TT 是采样周期。三个量的职责其实差异很大Kp决定对当前误差的反应强度。调大能加快响应但会放大测量噪声也更容易把系统推向振荡边缘。Ki负责消除稳态误差。它是个记性很好的量误差只要不为零就一直累加所以既能把静差磨平也会因为累积滞后造成积分饱和。Kd是刹车片看的是误差的变化趋势。它能在超调发生前提前减速但对噪声极其敏感采样周期一乱微分项就会输出一堆毛刺。很多人第一次做PID整定会按先比例、再积分、后微分的顺序试凑这在对象固定时够用。可一旦对象参数漂移你会发现三个量要重新洗牌而且它们之间是耦合的——单独调好一个另外两个的关系又变了。1.3 把参数整定这件事从人手里交给网络的思路既然人工整定是离线找一组折中解那能不能让系统在线地、连续地找当前工况下的较优解这就是神经网络介入的切入点。思路很直白把这三个增益视为对象当前状态的函数用一个BP神经网络去拟合这个映射关系。网络的输入是误差相关的状态量输出是 Kp、Ki、Kd 三个数然后用某种性能指标最常用的就是误差平方去反向指导网络权重更新。对象漂了误差序列的形状就变了网络跟着调整输出增益系统自己找回来。我第一次在MATLAB里跑通这个结构时最大的感受不是效果多惊艳而是这套东西居然真的能自己收敛。第二感受是它收敛得很脆几个参数没设对就直接发散。这个脆字后面第5章会专门展开。2. 让网络输出Kp/Ki/Kd而不是直接输出控制量2.1 直接让网络输出控制量会撞上两个硬钉子有一种做法是让神经网络直接输出控制量 u(k)跳过PID。听起来更简洁实际用起来问题很大。第一是稳态精度。纯网络是拟合出来的函数它没有积分那种误差不为零就持续修正的机制。给定值附近那一小段误差网络很可能输出一个稳定的值就停住了静差就这么留着。而PID里的积分项天然具备无条件消除静差的能力。第二是可解释性和兜底能力。直接输出控制量工程师没法判断这个输出是否合理出了事也无从下手。而让网络输出增益后面还接着一层标准PID结构你可以随时冻结网络权重、切回人工整定的一组固定参数系统仍然是可控的。工业现场最看重的就是这个退得回去。所以主流方案是用网络的非线性拟合能力去做参数整定用PID的积分结构去保证稳态性能各取所长。2.2 增量式PID与网络输出的结合点在哪结合的方式很具体。网络前向算出一组增益代码里就三行Kp, Ki, Kd net(x) Δu Kp*(e1 - e2) Ki*e1 Kd*(e1 - 2*e2 e3) u u_prev Δu注意 e1、e2、e3 分别是 e(k)、e(k-1)、e(k-2)。用增量式而不是位置式是因为增量式天然抗积分饱和输出只和增量有关而且能平滑过渡到手动/自动切换现场调试时这一点非常省事。网络每采样周期前向一次、更新一次权重增益就跟着当前误差形态实时变化。误差大且变化快的时候网络倾向于输出较大的 Kp 和较小的 Kd误差进入小范围波动时网络会抬高 Ki、压低 Kp开始精细磨平静差。这个行为模式不是人为写死的规则而是误差平方指标反向传播后自己涌现出来的。2.3 性能指标J与梯度回传的完整路径网络要往哪个方向学取决于你定义的目标函数。最常用的是单步误差平方J(k) 0.5 * e(k)^2 0.5 * (r(k) - y(k))^2要让 J 变小就要沿负梯度方向更新权重w(k1) w(k) - η · ∂J/∂w关键在那个偏导怎么展开。用链式法则拆一层∂J/∂w ∂J/∂e · ∂e/∂y · ∂y/∂u · ∂u/∂w其中 ∂J/∂e e∂e/∂y -1所以 ∂J/∂w -e · (∂y/∂u) · (∂u/∂w)。∂u/∂w 这一项由PID结构决定可以解析写出来。以 Kp 为例∂Δu/∂Kp e1 - e2Ki 对应 e1Kd 对应 e1 - 2e2 e3。∂y/∂u 是被控对象的雅可比也就是输出对输入的敏感度。这一项是整个算法里最要命的因为真实对象的这个量是未知的、时变的。后面第4章会专门讲这一项怎么处理。先记住结论这一项要么用模型求要么近似成常数处理方式不同代码结构完全不同。3. 用PyTorch把NN-PID控制器落地3.1 网络结构输入层到底该喂什么变量输入层选什么直接决定网络能不能感知到对象的动态。常见有三类喂法一是误差序列即 e(k)、e(k-1)、e(k-2)。这是最小配置能反映误差大小、变化速度、变化加速度刚好对应PID三个增益的物理意义我个人最推荐从这个开始。二是误差序列加参考值多喂一个 r(k)。适合设定值大幅变化的场景网络能区分设定值在动和对象在动避免在设定值跳变时误判成扰动而乱调增益。三是误差序列加上一拍输出或上一拍控制量。多了这两个量网络相当于拿到了部分对象状态收敛更快但也更容易过拟合到你仿真用的那个特定对象上迁移到实物时反而变差。我用的是第二种三个误差项加一个设定值四输入。隐层节点数在6到12之间扫过一圈8个节点在收敛速度和稳定性上最均衡。激活函数方面隐层用 tanh 比 sigmoid 更好因为 tanh 输出零中心、梯度更健康收敛快一大截。3.2 输出层映射与增益范围设定输出层有三个神经元但不能直接当增益用。原因有两个一是线性输出会跑到负值负增益对PID来说是完全不合理的二是没有边界的输出在训练初期很容易给出几百几千的增益系统一步就炸。标准做法是用 sigmoid 先把输出压到 (0,1)再做线性映射到物理区间Kp Kp_min (Kp_max - Kp_min) * σ(o1)Kp_max、Kp_min 这些边界不是拍脑袋定的而是先用固定PID在现场或仿真里粗调一遍看看这组对象能接受的增益大概是什么量级然后在这个量级上下各留一倍余量。比如我那次温度对象粗调得到 Kp≈0.6就设成 0.15 到 1.2。注意这个上下限同时也是安全边界。哪怕网络发疯增益也不会跑到危险区间这是工程上必须留的物理护栏。3.3 被控对象模型与闭环仿真环境网络再好没有对象练也没用。所以先搭一个离散对象模型。温度这类对象最常用的是一阶惯性加纯滞后G(s) K / (T·s 1) · e^(-τ·s)按采样周期 Ts 离散化后递推形式是y(k) a·y(k-1) b·u(k-1-d) a exp(-Ts/T) b K·(1 - a) d round(τ/Ts)滞后项用长度为 d1 的缓冲区来实现每步推入新的 u、弹出最老的 u。这个模型的好处是参数少、物理意义清楚把 T 和 τ 调一调就能模拟升温段、保温段、扰动段的不同工况。class FOPDT: 一阶惯性加纯滞后对象的离散递推模型 def __init__(self, K1.0, T40.0, tau10.0, Ts1.0): self.a float(np.exp(-Ts / T)) self.b K * (1 - self.a) self.d int(round(tau / Ts)) self.buf [0.0] * (self.d 1) def reset(self): self.buf [0.0] * (self.d 1) self.y 0.0 def step(self, u): self.buf.append(u) u_delay self.buf.pop(0) # 滞后 d 拍的输入 self.y self.a * self.y self.b * u_delay return self.y这里的 self.y 初始化为 0.0reset 里要显式补上。用这个对象跑一圈开环阶跃把响应曲线和现场录的数据比一比形状对得上再拿它去训练网络。3.4 训练循环的代码骨架网络和对象都齐了主循环就是观测误差→算增益→算控制量→推对象→算损失→反传更新。用PyTorch写关键是把对象模型也用张量表达让整条链路的计算图能连起来import numpy as np import torch import torch.nn as nn torch.manual_seed(7) class NNPID(nn.Module): def __init__(self, n_in4, n_hidden8): super().__init__() self.fc1 nn.Linear(n_in, n_hidden) self.fc2 nn.Linear(n_hidden, 3) # 增益物理边界[min, max]由粗调结果确定 self.register_buffer(lim, torch.tensor([ [0.15, 1.20], # Kp [0.00, 0.12], # Ki [0.00, 0.25], # Kd ])) def forward(self, x): h torch.tanh(self.fc1(x)) o torch.sigmoid(self.fc2(h)) # (0, 1) lo, hi self.lim[:, 0], self.lim[:, 1] g lo (hi - lo) * o # 映射到物理区间 return g[0], g[1], g[2] def train_one_episode(net, plant, sp1.0, steps600, lr0.01): opt torch.optim.Adam(net.parameters(), lrlr) plant.reset() sp_t torch.tensor(sp) e1 e2 e3 torch.zeros(()) u torch.zeros(()) hist {y: [], u: [], kp: [], ki: [], kd: []} for k in range(steps): y torch.tensor(plant.y) # 观测值切断历史图 e1 sp_t - y x torch.stack([e1, e2, e3, sp_t]) kp, ki, kd net(x) du kp * (e1 - e2) ki * e1 kd * (e1 - 2 * e2 e3) u torch.clamp(u du, -5.0, 5.0) # 执行器限幅 y_next torch.tensor(plant.step(u.detach().item())) J 0.5 * (sp_t - y_next) ** 2 opt.zero_grad() J.backward() opt.step() e3, e2 e2, e1 hist[y].append(y_next.item()) hist[u].append(u.item()) return hist这段能跑但有一处必须点出来plant.step(u.detach().item())这一步把对象的响应从计算图里切断了。也就是说这里其实并没有走完整闭环反传梯度在对象这一环就断了。这样网络是怎么学的靠的是 u 里带的增益梯度以及 J 对 y_next 的依赖关系——但 y_next 是 disconnect 的常数J 对网络参数的梯度其实会变成零。这是个很典型的初学者陷阱。要让梯度真正回传plant.step必须用张量、且不能 detach。这就引出了下一章的核心问题梯度到底能不能、以及要不要穿过对象。4. 梯度怎么来autograd可用与手工回传的两条路4.1 对象模型可微时让PyTorch接管整条闭环如果被控对象是我们在代码里写的差分方程那它是完全可微的。这时最省事的做法是让对象也参与计算图class FOPDT_torch: def __init__(self, K1.0, T40.0, tau10.0, Ts1.0): self.a float(np.exp(-Ts / T)) self.b K * (1 - self.a) self.d int(round(tau / Ts)) self.reset() def reset(self): self.y torch.zeros(()) self.buf [torch.zeros(()) for _ in range(self.d 1)] def step(self, u): # 滞后拍上的量 detach避免跨步重复反传 self.buf.append(u if self.d 0 else u.detach()) u_delay self.buf.pop(0) self.y self.a * self.y self.b * u_delay return self.y然后把主循环里那行改成y_next plant.step(u)J.backward()就能一路传回网络。整个链路是J → y_next → u → 增益 → 网络输出层 → 隐层 → 权重。这时候你会发现前面2.3节里那个难缠的 ∂y/∂uPyTorch 自动帮你算出来了而且算的是当前模型真实的那一个值不是一个拍脑袋的近似常数。这是用PyTorch做这事最大的优势——你不用手推那个偏导也不会推错。4.2 对象未知或强非线性时手动链式法则与雅可比近似实物上的对象没有解析模型∂y/∂u 拿不到。这时候只能用经典的手工回传把 ∂y/∂u 近似成一个常数或者用差分估计。经典文献里常取 1 或者符号函数 sign(∂y/∂u)。写出来是这样的# 手动更新示意未走 autograd dy_du 1.0 # 或在线差分估计 e float(sp - y) dKp (e1 - e2); dKi e1; dKd (e1 - 2*e2 e3) for p, dg in zip([net.out_kp, net.out_ki, net.out_kd], [dKp, dKi, dKd]): p.data - lr * (-e * dy_du * dg)这条路走起来稳不需要对象可微代价是每步方向可能不够准、收敛慢一些。网上不少 MATLAB 版本就是直接取 dy_du 1 在跑效果也够用说明这个近似在很多对象上并不致命。4.3 两条路的实测差异与选择建议同一组对象、同一个网络我用两种方式各跑了500个回合观察指标是后半段误差绝对值的均值。方式收敛回合数稳态平均绝对误差前50回合是否发散过autograd 闭环反传约1200.0041偶尔手动回传dy_du近似1约2600.0068很少autograd版本收敛快、精度高但前期更激进因为梯度方向真更新步长在大误差阶段会很大容易在头几十回合冲出稳定域。手动版本像个保守的老工程师走得慢但很少摔跤。我的选择是仿真验证阶段用autograd快速调网络结构和超参部署到实物前换成手动回传 在线差分估计。差分估计的写法是记住上一步的 (u, y)用 (y_k - y_{k-1})/(u_{k-1} - u_{k-2}) 粗略估计雅可比再做一个滑动平均比死用1要贴合实际。提示切换回传方式后学习率一定要重新整定不能沿用原来那套。autograd版本的等效梯度幅值通常更大直接套过来必炸。5. 数值稳定与调参跑通之后真正的坎5.1 输出层激活函数与增益上下限的配合前面说过输出层用 sigmoid。但 sigmoid 有两个性质要注意一是饱和区梯度接近零如果 lim 的边界设得太宽网络输出很容易停在 σ ≈ 0 或 σ ≈ 1 的平坦区权重几乎不更新表现为网络好像死掉了增益一直不变。二是 sigmoid 输出恒正意味着三个增益永远为正这对PID是合理的。处理饱和死亡的办法把 lim 的区间设得略窄一点让常用工作点落在 σ 的线性段大约0.2到0.8之间。换句话边界不要刚好卡住理想增益而是留出余量让网络有活动空间。Kd 这一项因为对噪声敏感我通常把它的上限压得比较低宁可让微分弱一点也不要它出来捣乱。5.2 学习率、采样周期、初始增益的三方耦合这三个量是耦合的分开调永远调不好。采样周期 Ts是根。它决定了误差序列相邻点之间的相关性。Ts 太大误差变化剧烈微分项变成噪声放大器网络学到的梯度也全是噪声Ts 太小相邻误差几乎一样网络输入像一串常数学不到动态。经验是让对象纯滞后 τ 大致等于 5 到 20 个 Ts误差序列才既有区分度又不太抖。学习率 η和采样周期反向相关。Ts 小意味着单位时间更新次数多η 就要小。Ts 从1秒改成0.1秒η 量级上要缩一到两个数量级。初始增益影响前几十步的误差量级从而影响梯度幅值。用一个合理粗调的固定PID参数去初始化最省事的方法是把它作为偏置加到网络输出上让网络只学修正量。这样起点就在稳定域附近不容易一上来就发散。我踩过一次很典型的坑把 Ts 从1秒改成0.2秒其余参数没动结果网络在三十步内把增益顶到上限系统持续满输出。原因就是更新频率涨了5倍等效步长大了5倍。5.3 一次典型发散事故的完整排查链路那次事故我记了排查步骤写出来给遇到同类问题的朋友参考先看控制量 u 的曲线。如果 u 一路顶到限幅值不动说明增益已经失控或者负反馈方向搞反了。打印三个增益随时间的变化。如果三个增益都顶到各自的上下限说明梯度一直在往同一个方向推多半是梯度符号错了。我把 dy_du 设成 -1 试了一次立刻稳定说明我手动估计的雅可比符号反了。检查误差符号约定。e r - y 还是 y - r这决定了 ∂J/∂e 的符号很容易在代码里写反。这个错误的表现和上一条一模一样。缩小学习率十倍重跑。如果缩小后不发散了说明是步长问题不是结构问题回去重调 η。把上限收紧一半。如果收敛了说明物理护栏设得太宽网络有空间跑偏。降低网络初始权重规模。PyTorch 默认初始化在输入维度小的时候权重偏大用nn.init.uniform_(w, -0.1, 0.1)手动压一遍开头那几十步会温柔很多。这六步基本能覆盖九成以上的跑着跑着就炸的情况。顺序别乱先从最外层的输出看起不要一上来就改网络结构。5.4 积分饱和与输出限幅的工程处理即便网络稳定还有两个工程细节必须兜住。第一是输出限幅。执行器有物理行程阀门只有0到100加热器功率有上限。用增量式PID已经缓解了积分饱和但网络给出的增益如果整体偏大u 仍然会超。直接在 u 上加 clamp 是最简单有效的护栏而且限幅值要设在合理范围不要设成理论极值。第二是积分分离。当误差绝对值大于某个阈值时暂时把 Ki 的作用压住避免大误差阶段积分项疯狂累加。在NN-PID里实现这点的自然做法是把这个阈值信息也喂给网络让网络自己去学什么时候该收着点。但工程上更保险的是显式地在代码里加一层判断不完全依赖网络的自觉。注意任何依赖网络自主性的保护措施都不如一行硬性判断可靠。该写死的写死。6. 从仿真曲线到实物工况中间隔着什么6.1 噪声、量化与执行器死区仿真里 y 是干净的浮点数实物上 y 是传感器采样加滤波、再经过AD量化后的值。这两个差异对微分项是致命的仿真里 Kd 能不能起作用取决于网络实物上你给的 Kd 稍大一点微分输出就会被噪声顶得乱跳。我的处理方式是先滤波再进网络。一阶低通滤波器对传感器原始值做滑动平均或指数平滑让进入误差计算的信号本身足够干净网络再去学整定。滤波器的时间常数取采样周期的3到5倍既能压噪又不会引入太大滞后。执行器死区是另一个容易忽略的点。阀门在小开度下不动、变频器在低频段扭矩不足这些都会让给了控制量但对象没反应成为常态。网络看到误差不降会继续加大增益然后突然越过死区输出冲出去一大截。解决办法是在死区附近给控制量加一个最小有效增量或者干脆在对象模型里把死区建进去让网络在仿真阶段就见过这个特性。6.2 在线微调与离线预训练的取舍网络权重是离线训好再上线还是上线后一直在线学两种都用过各有边界。离线预训练 上线后小步长在线微调是我最终采用的方案。离线阶段用仿真对象把网络训到收敛权重固化下来上线后保持一个很小的学习率继续微调让它适应现场的真实对象。小到什么程度大概比离线学习率小一到两个数量级慢到人眼看不出来但长期能让网络慢慢贴合现场工况。纯在线学习风险太大现场不可能容忍系统在头几百个采样点里频繁试探。纯离线又有适应性的天花板对象季节性漂移就管不住了。折中方案在两者之间找到了平衡点。还有一个折中细节在线学习应该设置使能条件。只有系统处于相对稳定的工况误差在一定范围内、没有大的设定值跳变才允许更新权重扰动剧烈时冻结网络只用当前增益做控制。这样能避免网络在瞬态过程中被带偏。6.3 和串级、前馈这类结构怎么分工NN-PID不是要取代所有控制结构。在有明确可测扰动比如燃料热值、环境温度的场合前馈控制能提前补偿效果比让网络慢慢学要快得多。前馈负责预判NN-PID负责闭环微调两者叠加往往能把误差压下去一大截。串级结构同理。外环温度、内环流量这种双回路可以把网络放在外环做增益整定内环仍然用固定参数或者简单PI因为内环对象通常快而稳定不太需要神经网络凑热闹。把网络安在真正需要它的那个慢、变、非线性的环节上是最经济的用法。7. 环境搭建这套控制网络对版本真没那么挑7.1 conda创建环境与PyTorch安装的实际操作网络就四输入八隐层三输出参数量不到一百说句实话用哪台电脑都不构成压力。我的配置流程是conda create -n nnp控制 python3.10 -y conda activate nnp控制 # CPU 版本安装包小、下载快足够用 pip install torch --index-url https://download.pytorch.org/whl/cpu pip install numpy matplotlib第一条命令建环境指定3.10是因为这个版本在各类科学计算包上兼容性最广。第二条激活环境。第三条从官方源装CPU版PyTorch包体比GPU版小得多装上去也就几十兆几分钟搞定。最后补上numpy和matplotlib画曲线要用。需要注意的是用 conda 装PyTorch有时会因为频道解析慢得让人抓狂换成 pip 配合官方索引会快很多。装完之后按官方文档给的验证方式跑一行 import 和一次小张量运算确认没报错就行。7.2 到底要不要装GPU版算一笔账。这套网络单次前向是一次4×8的矩阵乘加一次8×3的矩阵乘总共不到80次浮点乘加加上一次反向传播单步开销在CPU上大概是微秒级。而控制系统的采样周期通常是秒级甚至百毫秒级。也就是说CPU的算力是需求的几万倍GPU在单步推理这个场景上毫无优势反而多了数据在内存和显存之间搬运的开销。GPU真正有意义的场景是批量训练多个初始条件、多种工况下的网络一次前向几百上千个样本并行。这种情况下GPU能把训练时间从几十分钟压到几分钟。所以我的建议是部署和日常调试用CPU版批量扫参或做大规模仿真对比时再考虑GPU。为了跑几十个参数的控制器去折腾CUDA驱动性价比太低。7.3 版本兼容与小环境的维护PyTorch版本更新比较快但这个项目用到的API都是最基础的 nn.Linear、nn.Module、torch.optim这些从早期版本到现在几乎没变过所以对版本并不敏感。真正容易出问题的是和其他包的组合比如numpy版本和matplotlib版本对不上会报一些莫名其妙的错。我习惯用conda env export env.yml把能跑通的环境导出成文件需要复现的时候按这个文件建环境省去逐个猜版本的时间。项目代码只依赖 torch、numpy、matplotlib 三个包尽量不引入其他重型依赖这样环境迁移的成本最低。提示控制类项目最怕在我电脑上能跑把环境文件一起提交比写一堆安装说明管用得多。另外如果平时用VS Code做开发装好Python和Jupyter两个扩展配合conda环境选择内核画曲线、改参数、重跑训练都在一个窗口里完成调试效率比纯命令行高不少。变量面板里能直接看到每一拍的网络输出和执行器动作追发散问题的时候特别顺手。我在实际使用中最大的体会是这套方法真正的价值不在于网络有多深、算法有多新而在于它把参数整定从一个需要老师傅经验的手艺变成了一个有明确目标函数、可以持续自优化的过程。网络结构简单到极点反而是它能在工控机上稳定跑几年的前提。每次现场调试之前我都会先在仿真里把对象模型调到和现场曲线形状基本一致然后才让网络接触真实数据——这一步偷懒省下的时间后面往往要加倍还回去。
返回列表