简介:面向污水处理异常工况识别与深度学习实践需求,这套MATLAB资源包将数据集、迁移学习模型、训练测试脚本与文献材料整合在一起,适合环境工程、自动化及人工智能方向的学习者开展工况分类实验与算法复现,对课程设计、科研入门和论文方法复现较为友好。压缩包共约2000个文件、425.65MB,主体为1871张jpg图像数据,另含21个m脚本、17个mat模型或数据文件、12个fig图表、64篇pdf及少量docx、txt、caj等文献与说明,覆盖从样本组织、模型加载到结果可视化的完整链路。已有473人学习下载。读者可依据目录说明定位数据与代码,直接运行train.m完成测试并输出测试集图片结果,也可参考best1.mat中的迁移学习网络结构、PRC/ROC等fig图及约50篇论文,理解异常工况诊断的实验设计、评价指标与调参思路,便于在此基础上开展复现、对比与二次开发。
1. 凌晨三点的溶解氧曲线:这套 MATLAB 异常工况识别程序在拦什么
凌晨三点,好氧池的溶解氧曲线开始有规律地上下抖,值班的人两小时后才发现——那时污泥膨胀已经成型,后面两周都在调回流比。这套用 MATLAB 写的污水处理异常工况识别程序,干的就是把这件盯曲线的活交给网络:11 路传感器时序进,6 类工况标签出。人工智能在这里不是噱头,是把老师傅“看曲线不对”的直觉固化成一个可复现的推理过程。
资源里给的是完整一套:现场导出的原始数据、打好标签的事件记录、滑窗切分的预处理脚本、CNN 加双向 LSTM 的训练脚本,以及几个专门负责出图的 .m 文件。适合三类人:做环境工程方向课题、需要拿出混淆矩阵和训练曲线的人;做工业过程监测、想先跑通一个可用的基线再谈优化的人;手里有现场数据但不知道从哪下手切窗、怎么打标签的开发者。代码结构是“一个 .m 管一件事”,改参数不用翻整个工程。
2. 技术栈与工程骨架:一个 .m 文件管一件事的目录设计
拿到一份 MATLAB 工程,最怕的是所有逻辑塞进一个 main.m,两千行里找不着归一化在哪。这套程序的骨架是刻意拆开的:每个脚本只负责一个阶段,中间产物落到磁盘上,跑挂了能从任意一步接着来。
2.1 为什么这条流水线放在 MATLAB 里而不是 Python
先说结论:这条链路的规模撑不起上 PyTorch 的复杂度。按 5 分钟采样算,一天 288 个点,一年不到 11 万行,切完窗也就几万个样本,特征 11 路。这个体量在 MATLAB 的 Deep Learning Toolbox 里单机跑,一张消费级显卡几个小时能出结果,而现场工程师能自己改脚本这件事,价值比多几个百分点的精度大得多。
具体到工具箱能力,sequenceInputLayer加lstmLayer从 R2018a 就有了,一维卷积convolution1dLayer也是老面孔,配套的maxPooling1dLayer要到 R2021b 才补齐。所以这套程序的运行下限卡在 R2021b,不是随便写的。如果手头只有更早的版本,替代方案是用convolution2dLayer配1×k的卷积核,把输入摆成1×T×C,能跑但网络定义要重写一遍,不划算。
MATLAB 在这条链路上的真实优势有三个。第一是数据不落地:readtable读进来就是 table,summary一眼看缺失,画图的时候直接plot(T.time, T.DO),不用在 pandas 和 matplotlib 之间来回倒。第二是出图:confusionchart、tiledlayout、exportgraphics三个函数能把论文和汇报要的图全部搞定,300 dpi 一行参数。第三是部署:MATLAB Compiler打包成独立 exe 扔给中控室,或者MATLAB Coder生成 C++ 嵌进组态软件,这条路在现场是走得通的。
劣势也得说清楚:数据量上到几十 GB、需要多卡并行、或者要做自定义损失函数的高级变体时,MATLAB 的datastore加parfor就不如 PyTorch 生态顺手。我一般的判断线是——窗口数量在 10⁴ 到 10⁶ 之间、特征不到 100 路,MATLAB 单机足够;超过这个量级,训练挪到 Python,MATLAB 只负责预处理和结果可视化,两边用 .mat 或 csv 交接。
2.2 目录结构:从原始数据到出图一共 7 个入口
工程目录长这样,多看一眼就知道每个文件干嘛:
wwtp-anomaly-matlab/ ├─ data/ │ ├─ raw/ % 现场导出的原始 csv,一天一个文件 │ │ ├─ d0301.csv │ │ └─ ... │ ├─ labels/ │ │ └─ event_log.csv % 工况记录:起止时间 + 工况编码 + 备注 │ └─ processed/ │ └─ dataset.mat % 切好窗的 X/Y + 归一化参数,训练直接 load ├─ src/ │ ├─ s01_build_dataset.m % 清洗 + 打标 + 滑窗 + 划分 │ ├─ s02_train.m % 网络定义 + trainingOptions + trainNetwork │ ├─ s03_evaluate.m % 混淆矩阵 / 逐类召回 / ROC │ ├─ s04_plot_signals.m % 原始曲线 + 工况色块 │ └─ +wwtp/ % 包目录,公共函数 │ ├─ cleanTable.m │ ├─ makeWindows.m │ └─ buildNet.m ├─ models/ │ └─ cnn_bilstm_v3.mat % 训好的 net + mu/sg + 类别顺序,一起存 └─ figs/几个设计上的取舍值得说。+wwtp用包目录而不是散装函数,好处是s02_train.m里直接wwtp.buildNet(...),名字不冲突,也不怕路径。models/*.mat里存的不只是net,还有归一化的mu、sg和类别顺序——这三样不跟着模型走,部署那天必然出事,这是踩过坑之后的规矩。data/processed/dataset.mat作为中间产物,意味着调网络结构的时候不用每次重跑清洗,省下的时间很可观。
s01到s04的编号不是装饰,它规定了执行顺序,也暗示了依赖关系:s02依赖s01的输出,s03依赖s02的模型,s04只依赖原始数据、可以随时单独跑。
2.3 数据集字段:11 路传感器信号与 6 类工况标签
原始 csv 一行是一个时刻,列是各路信号。这套程序覆盖的字段如下,实际用的时候按现场点位增删,但至少要有进水、生化池、出水三段的信息,否则很多工况区分不开:
| 列名 | 含义 | 单位 | 备注 |
|---|---|---|---|
| Q_in | 进水流量 | m³/h | 判断冲击负荷的第一手信号 |
| COD_in | 进水 COD | mg/L | 采样频率常低于流量,需插补 |
| NH3_in | 进水氨氮 | mg/L | 毒性抑制的关键指标 |
| pH_in | 进水 pH | — | 剧烈波动常伴随工业废水偷排 |
| DO | 好氧池溶解氧 | mg/L | 鼓风控制的直接反馈 |
| MLSS | 污泥浓度 | g/L | 污泥膨胀的核心观测量 |
| ORP | 氧化还原电位 | mV | 厌氧/缺氧段的过程指示 |
| COD_out | 出水 COD | mg/L | 滞后指标,别用来做超前预警 |
| NH3_out | 出水氨氮 | mg/L | 同上 |
| Blower_Hz | 鼓风机频率 | Hz | 执行机构反馈,用于排除设备故障 |
| Reflux_ratio | 内回流比 | % | 工况调整时会阶跃变化 |
标签来自event_log.csv,一行是一个事件的起止时间和工况编码,一共 6 类:
| 编码 | 工况 | 典型特征 |
|---|---|---|
| NORM | 正常 | 各信号在工艺设定区间内小幅波动 |
| SHOCK | 进水冲击负荷 | 流量和进水 COD 同时快速抬升 |
| BULK | 污泥膨胀 | MLSS 上升、DO 下降、出水 COD 缓慢爬升 |
| FOAM | 泡沫浮渣 | 表面状态无法直接测,靠 MLSS 与回流的组合间接体现 |
| TOX | 毒性抑制 | 氨氮去除率骤降、ORP 异常 |
| DO_LOW | 曝气不足 | DO 长期低于阈值,鼓风机频率已拉满 |
采样周期 5 分钟,一天 288 个点。窗口长度取 60 个点,也就是 5 小时的过程上下文——短于 30 个点看不到膨胀这类慢过程的趋势,长于 120 个点又会把工况切换前后的数据混进同一个窗口里。步长取 10,相邻窗口有重叠,既扩了样本量又不至于完全重复。
2.4 环境准备与一键跑通的顺序
需要的工具箱就三个:Deep Learning Toolbox、Signal Processing Toolbox(medfilt1用得上,没有的话换movmedian)、Statistics and Machine Learning Toolbox(perfcurve和分层抽样用)。有 GPU 的话 Parallel Computing Toolbox 也装上,trainingOptions里ExecutionEnvironment设成'auto'就会自己挑。
跑通顺序就四行:
addpath(genpath(pwd)); % 让 +wwtp 包装载进来 s01_build_dataset % 清洗 + 打标 + 切窗,产出 dataset.mat s02_train % 训练,产出 models/cnn_bilstm_v3.mat s03_evaluate % 出混淆矩阵和逐类指标在一台八核、16 GB 内存、带一张 8 GB 显卡的机器上,s01处理一年数据大概两三分钟,s02三十个 epoch、几万个窗口,二十分钟左右,s03秒出。如果s02卡在第一个 epoch 超过十分钟,多半是MiniBatchSize太大或者数据还在内存和显存的搬迁上打转,先降到 32 试试。
s04_plot_signals可以随时单独跑,它只读data/raw和data/labels,用来在动手建模型之前先肉眼看一遍数据——这一步别省,很多标签错位的问题肉眼一看曲线和色块对不上就露馅了。
3. 数据链路:从原始 CSV 到能喂进网络的 C×T 张量
模型效果的上限基本由这一段决定。实际项目里,调网络的收益远不如把清洗和标签对齐做对。这一章按清洗、切窗、划分三步走,每步都落到具体代码。
3.1 清洗:缺失值、毛刺和停机段的处理
原始数据有四种脏法:时间列格式不统一、有重复行、传感器掉线留下连续 NaN、检修停机时所有值接近零。这四种要分开处理,用一套逻辑糊过去会出问题。
function T = cleanTable(T, featIdx, lo, hi) % 输入 T 至少含 time 列和 featIdx 指定的特征列 % lo/hi 是与 featIdx 等长的物理量程上下限 % 1) 时间轴统一、去重、升序 T.time = datetime(T.time, 'InputFormat', 'yyyy-MM-dd HH:mm:ss'); T = sortrows(T, 'time'); [~, ia] = unique(T.time, 'stable'); T = T(ia, :); % 2) 停机段整段剔除:30 分钟内最大进水流量仍低于 20 m3/h q = T.Q_in; off = movmax(q, 6) < 20; T(off, :) = []; % 3) 量程外的点先置 NaN,再线性插补,最多连补 3 个点 for c = featIdx v = T{:, c}; v(v < lo(c) | v > hi(c)) = NaN; T{:, c} = fillmissing(v, 'linear', 'MaxGap', 3); end % 4) 毛刺:中值滤波对比,偏离 3 倍标准差就替换 for c = featIdx v = T{:, c}; vm = medfilt1(v, 5, 'omitnan'); d = v - vm; sd = std(d(~isnan(d))); bad = abs(d) > 3 * sd; v(bad) = vm(bad); T{:, c} = v; end end第 1 步去重用的是'stable',保留第一次出现的行,因为现场导出的重复行通常是补传的历史点,值不如第一次准。第 2 步是关键:movmax(q, 6)取的是 30 分钟滑动窗口内的最大值,只有这个最大值都低于 20 才判定为停机,这样单点流量抖动不会误杀正常数据。停机段必须整段删掉而不是补零,原因在避坑那章会展开讲。
第 3 步里MaxGap设成 3,意思是连续缺失超过 3 个点(15 分钟)就保留 NaN,不硬插值。传感器掉线一小时的段,插值出来的平滑直线会被网络当成真实趋势学进去。这些残留 NaN 的窗口在切窗那一步整窗丢弃。
第 4 步的medfilt1窗口取 5,对应 25 分钟,比工艺过程的时间常数小得多,不会把真实的过程变化当毛刺抹掉。判据用 3 倍标准差而不是固定阈值,是因为各路信号量纲差太多,DO 的波动是零点几,流量的波动是几十。
3.2 滑窗切分:窗口长度、步长和标签众数
时序分类和图像分类最大的不同在于样本要自己造。切窗这一步有三个参数要想清楚:窗口长度、步长、以及窗口内标签怎么定。
function [X, Y] = makeWindows(T, featIdx, winLen, stride) % T 为清洗后的 table,label 列存工况编码 % 返回 X: numWin×1 cell,每个元素是 numFeat×winLen 的 double % Y: numWin×1 categorical F = table2array(T(:, featIdx)); % n × numFeat L = categorical(T.label); % 与 F 行对齐 cats = categories(L); n = size(F, 1); numWin = floor((n - winLen) / stride) + 1; X = cell(numWin, 1); Y = repmat(L(1), numWin, 1); % 预分配,类别集合与 L 一致 keep = true(numWin, 1); for k = 1:numWin idx = (k-1)*stride + (1:winLen); if any(isnan(F(idx, :)), 'all') % 含未插补的缺口,整窗丢弃 keep(k) = false; continue end X{k} = F(idx, :)'; % 转置成 numFeat×winLen cnt = countcats(L(idx)); [~, ci] = max(cnt); Y(k) = categorical(cats(ci), cats); % 窗口标签取众数 end X = X(keep); Y = Y(keep); end转置那一步是新手最容易翻车的地方:MATLAB 的序列输入约定是C×T(通道在前),也就是numFeat×winLen,而不是 numpy 习惯的T×C。喂反了不会报错,网络照样训,只是把时间轴当成通道轴,准确率卡在随机猜附近,查半天查不出来。
标签取众数而不是取最后一个点,是因为标注的事件起止时间通常有一两个采样点的抖动,末尾那个点可能刚好落在事件外面。取众数的前提是一个窗口内工况基本不变——这也是窗口不能设太长的原因。如果某个窗口内众数占比不到 60%,说明这个窗口跨越了工况切换,实际项目里我一般直接丢掉,让模型只在干净的样本上学。
步长取 10 意味着相邻窗口共享 50 个点,重叠率 83%。这个重叠是为了扩样本,但也带来一个问题:训练集里的两个窗口可能几乎是同一段数据,划分的时候必须把重叠考虑进去,这就是下一节的事。
3.3 归一化与划分:把数据泄漏挡在门外
这两件事写在一节里,因为它们是同一个决定的两面:归一化的统计量只能来自训练集,划分又必须保证训练集里的信息不流到验证集和测试集。
% 按时间切三刀,不随机打乱 n = height(T); iTr = 1 : floor(0.70*n); iVa = floor(0.70*n)+1 : floor(0.85*n); iTe = floor(0.85*n)+1 : n; % 归一化参数只用训练段算,然后冻结 mu = mean(T{iTr, featIdx}, 1, 'omitnan'); sg = std(T{iTr, featIdx}, 0, 1, 'omitnan'); sg(sg < 1e-6) = 1; % 常数列保护,避免除零 T{:, featIdx} = (T{:, featIdx} - mu) ./ sg; % 全表用同一组参数 % 三段各自切窗,窗口不跨段 [Xtr, Ytr] = wwtp.makeWindows(T(iTr,:), featIdx, 60, 10); [Xva, Yva] = wwtp.makeWindows(T(iVa,:), featIdx, 60, 10); [Xte, Yte] = wwtp.makeWindows(T(iTe,:), featIdx, 60, 10); save('data/processed/dataset.mat', ... 'Xtr','Ytr','Xva','Yva','Xte','Yte', ... 'mu','sg','featIdx','-v7.3');按时间切而不是随机切,这是时序问题的铁律。随机切会让 3 月 1 日的窗口进训练、3 月 2 日的相邻窗口进测试,两段数据高度相关,测试准确率虚高十几个点,上线就原形毕露。'omitnan'用在mean和std的对应参数位上,缺失值不参与统计。
sg(sg < 1e-6) = 1这一行看着不起眼,但现场确实会有整月不变的列(比如某路泵频一直没调过),标准差是 0,除下去整列变 Inf 或 NaN,训练直接崩。这个保护必须有。
三段各自切窗是另一个细节:如果先切窗再按索引划分,位于划分边界上的窗口会同时含训练段和验证段的数据,等于把验证集的信息漏给了模型。按段切完再拼,边界上会损失几十个窗口,这点代价换来的是干净的评估。
mu、sg和featIdx一起存进dataset.mat,后面训练脚本把它们原样存进模型文件。部署的时候如果忘了用同一组参数归一化,输入分布和训练分布差一个量级,网络输出全是同一类,现场表现就是“一直报同一个警”。
4. 模型与训练:Conv1D + BiLSTM 的搭建与调参
网络结构这块没什么花活,一维卷积提局部形态,双向 LSTM 抓长程依赖,最后接一个分类头。真正决定效果的是维度对齐和那几个训练参数。
4.1 网络层结构与维度对齐
选这个结构的原因:卷积负责识别“溶解氧在 2 小时内连续下探”这类局部形态,LSTM 负责记住“这已经是第三天 MLSS 缓慢上升”,两者互补。膨胀这类慢过程靠卷积看不出来,冲击负荷那种突变靠 LSTM 又太慢。
function lgraph = buildNet(numFeat, numClass, winLen) lgraph = layerGraph(); layers = [ sequenceInputLayer(numFeat, 'Name','in', 'MinLength', winLen) convolution1dLayer(5, 32, 'Padding','same', 'Name','conv1') batchNormalizationLayer('Name','bn1') reluLayer('Name','relu1') maxPooling1dLayer(2, 'Stride',2, 'Name','pool1') convolution1dLayer(3, 64, 'Padding','same', 'Name','conv2') batchNormalizationLayer('Name','bn2') reluLayer('Name','relu2') maxPooling1dLayer(2, 'Stride',2, 'Name','pool2') bilstmLayer(64, 'OutputMode','last', 'Name','bilstm') dropoutLayer(0.3, 'Name','drop') fullyConnectedLayer(numClass, 'Name','fc') softmaxLayer('Name','softmax') classificationLayer('Name','out') ]; lgraph = addLayers(lgraph, layers); end维度走一遍心里就有数了:输入11×60→ 第一层卷积核 5、32 个滤波器、'same'填充 →32×60→ 池化 2 →32×30→ 第二层卷积核 3、64 个滤波器 →64×30→ 池化 →64×15→ 双向 LSTM 64 单元 →128×1('OutputMode','last'只取最后时刻)→ dropout → 全连接 6 → softmax。
几个参数的选择理由。第一层卷积核 5 对应 25 分钟的形态窗口,够抓冲击负荷的上升沿;第二层 3 是为了在降采样后再提一层组合特征。'same'填充保证卷积不缩短序列,长度变化全部交给池化层控制,这样调层数的时候不用重算MinLength。bilstmLayer的输出维度是 128 不是 64,因为双向各 64 后拼接,这一点在接全连接层时最容易写错。
dropoutLayer(0.3)放在 LSTM 之后而不是每个卷积之后,是因为卷积层后面已经有 BN 在做正则,再叠 dropout 会让收敛变慢。0.3 这个值不是拍的,训练集和验证集的 loss 曲线张开超过 15% 就往上加,两条曲线贴太近就往下减。
如果手头是 R2021a 或更早的版本,maxPooling1dLayer不存在,替代写法是把输入摆成1×T×C,用convolution2dLayer([1 5], 32, 'Padding','same')和maxPooling2dLayer([1 2], 'Stride',[1 2]),其余照抄。能跑,但要多写一层reshapeLayer,不优雅。
4.2 trainingOptions 里真正影响结果的六个参数
trainingOptions有几十个可选项,实际会动的就那么几个。下面这份配置是调过若干轮之后稳定下来的:
opts = trainingOptions('adam', ... 'MaxEpochs', 30, ... 'MiniBatchSize', 64, ... 'InitialLearnRate', 1e-3, ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropPeriod', 10, ... 'LearnRateDropFactor', 0.3, ... 'GradientThreshold', 1, ... 'L2Regularization', 1e-4, ... 'Shuffle', 'every-epoch', ... 'ValidationData', {Xva, Yva}, ... 'ValidationFrequency', 50, ... 'ValidationPatience', 8, ... 'Plots', 'training-progress', ... 'Verbose', false, ... 'ExecutionEnvironment', 'auto'); net = trainNetwork(Xtr, Ytr, wwtp.buildNet(11, 6, 60), opts);参数说明按重要性排:
MiniBatchSize设 64。样本是 cell 数组,每个元素11×60的 double,64 个一批大概 340 KB,显存压力很小,梯度也够稳。样本总量上万之后可以往上提到 128,但超过 256 收敛会变慢。
InitialLearnRate1e-3 配 Adam 是标准起点。判断依据看训练曲线前 200 次迭代:loss 抖动剧烈说明太大,降到 3e-4;loss 下降太慢说明太小,往上试 3e-3。
LearnRateSchedule用'piecewise',每 10 个 epoch 乘 0.3。工况数据的不平衡会让模型在后半程一直在边界上打转,分段衰减比余弦退火更好控。
GradientThreshold设 1,这个是 LSTM 的必需品。时序里偶发的极端值(比如一次仪表跳变)会产生很大的梯度,不裁剪的话一次就把权重打飞,表现出来就是训练到一半 loss 突然变成 NaN。
L2Regularization1e-4 是弱正则,主要配合 dropout 用。如果验证集准确率在第 5 个 epoch 就到顶然后往下掉,先试着提到 1e-3,别急着改网络。
ValidationPatience设 8,连续 8 次验证没改善就停。30 个 epoch 的上限配合这个早停,实际跑下来通常在第 18 到 22 个 epoch 停下,省下的时间够多试几组超参。
Shuffle必须设成'every-epoch'。默认值'once'会让每个 epoch 的批次顺序完全一样,模型对批次内的样本顺序产生依赖,验证指标会有明显的周期性波动——这个现象很玄学,但原因就这么简单。
4.3 训练、验证与四张结果图
训练完不是看一个准确率就完事。这套程序的s03_evaluate.m出四张图:混淆矩阵、逐类召回柱状图、某一类的 ROC、以及一条实际时序上的预测叠加。
YPred = classify(net, Xte, 'MiniBatchSize', 64); acc = mean(YPred == Yte); figure('Name','混淆矩阵'); cm = confusionchart(Yte, YPred, ... 'RowSummary', 'row-normalized', ... 'ColumnSummary', 'column-normalized', ... 'Title', '测试集工况混淆矩阵'); cm.FontSize = 10; % 逐类召回:高危工况的漏报比总体准确率更重要 cats = categories(Yte); recall = zeros(numel(cats), 1); for i = 1:numel(cats) recall(i) = sum(YPred == cats{i} & Yte == cats{i}) / sum(Yte == cats{i}); end figure('Name','逐类召回'); bar(categorical(cats), recall); ylim([0 1]); ylabel('召回率'); grid on;'RowSummary','row-normalized'这个设置很重要:默认的行汇总给的是样本数,不平衡数据下正常类有上千个样本、毒性抑制只有几十个,看绝对数完全看不出问题,归一化之后每一行加起来是 1,哪一类漏得多一目了然。
逐类召回那段循环别嫌土,它比confusionmat返回的矩阵好读。重点看两个数:BULK(污泥膨胀)和TOX(毒性抑制)的召回。前者漏了意味着提前两周的干预窗口没了,后者漏了可能直接导致出水超标。正常类的精确率反而可以放宽一点,误报一次的代价是值班人员多看两眼曲线。
ROC 用perfcurve一对其余地画:
for i = 1:numel(cats) yTrue = (Yte == cats{i}); scores = predict(net, Xte, 'MiniBatchSize', 64); [fpr, tpr, ~, auc] = perfcurve(yTrue, scores(:, i), true); plot(fpr, tpr, 'DisplayName', sprintf('%s AUC=%.3f', cats{i}, auc)); hold on; end legend('Location','southeast'); xlabel('假正率'); ylabel('真正率');scores(:, i)取的是第 i 类的 softmax 概率,perfcurve的第三个参数true指定正类标签。六个类别画在一张图上会有点挤,实际报告里通常只画高危的两三类。
第四张图是时序叠加,把测试段某一天的曲线画出来,底下用色块标真实工况,上面用散点标预测结果,时间错位一眼就能看见。这张图比任何指标都更能说服现场的人——他们看得懂曲线。
5. 避坑与排查:这套程序最容易翻车的六个地方
下面六条全部来自实际跑这个工程时踩出来的,按“现象 → 原因 → 解决”写,碰到对应症状直接对号入座。
5.1 数据与标签层面的三个坑
坑一:验证集准确率 98%,现场部署后满屏误报。
现象是离线评估漂亮得不像话,一上线就发现几乎所有时段都在报警。原因在划分方式:用了cvpartition或者randperm做随机划分,而切窗时步长只有 10、相邻窗口重叠 50 个点,随机划分必然让高度重叠的窗口分别落进训练集和测试集,模型等于在背答案。改成按时间顺序切三段的做法(3.3 节那段代码),准确率会掉到 85% 左右,但那个数才是真的。如果想更严格,按“事件”划分:同一个工况事件产生的所有窗口只能进同一个集合,这样连事件内部的相似性也隔离掉了。
坑二:训练 loss 一直不降,准确率卡在 0.78 不动。
现象是每个 epoch 结束的准确率都稳定在 0.78 附近,一点不动。原因是类别极度不平衡——正常工况占 78%,模型学会了全猜 NORM,梯度就躺平了。先看countcats(Ytr)确认分布,然后两条路一起走:一是用'Shuffle'配合分层采样,让每个 batch 里各类都有;二是给分类层加权。加权在 MATLAB 里要自定义一层:
classdef weightedClassificationLayer < nnet.layer.ClassificationLayer properties ClassWeights end methods function layer = weightedClassificationLayer(weights, name) layer.ClassWeights = weights; layer.Name = name; end function loss = forwardLoss(layer, Y, T) w = layer.ClassWeights(:); w = w / sum(w); loss = -sum(w .* sum(T .* log(Y + eps), 1), 'all') / size(Y, 4); end end endClassWeights一般按类频率的倒数设,正常类给 0.3,稀有类给 3 到 5。这个权重别调太狠,超过 10 倍会让模型把正常工况也全报成异常,精确率崩到没法用。
坑三:模型把停机段也判成污泥膨胀。
现象是检修期间(进水流量为 0)曲线平直,模型却输出 BULK 且置信度很高。原因有两个叠加:停机时所有传感器读数接近零,归一化后变成远离均值的极端值;而训练集里几乎没有停机样本,模型没见过这个分布,就归到了特征最接近的那一类。解法在清洗阶段,movmax(q, 6) < 20那段整段剔除。如果停机样本本身有分析价值,另一种做法是在标签体系里单独加一个OFFLINE类,让模型学着区分“没水”和“水质异常”——但要注意,这个类的样本不能太多,否则会稀释其他类的学习信号。
5.2 训练与推理层面的三个坑
坑四:换了一批数据,模型全线失灵。
现象是同一套模型,换季或者换批次的数据进来,所有样本都被判成同一类。原因基本锁定在归一化参数:要么部署时忘了归一化,要么归一化用了新数据自己的均值和标准差。后一种更隐蔽,因为代码能跑、不报错,只是输入分布和训练时差了十几倍。规矩是:mu和sg在训练阶段算完就冻结,存进models/*.mat,推理时从模型文件里读,绝不用现场数据重新算。这条听起来简单,但我见过不止一个项目栽在这。如果现场工况本身发生了长期漂移(比如工艺改造后 MLSS 基准值变了),正确做法是重新采集一段带标签的数据做微调,而不是偷偷改归一化参数。
坑五:同一个窗口连续预测,结果每次都不一样。
现象是拿同一条样本反复喂给网络,输出的类别会跳。原因在batchNormalizationLayer和dropoutLayer的推理/训练模式。用classify(net, X)时 MATLAB 会自动切到推理模式,BN 用滑动统计量、dropout 关闭,不会出这个问题;但如果自己用predict或者activations手动做前向,又没指定'Acceleration'和 batch 维度,就可能走进训练模式。另一个更常见的原因是维度摆错:样本是T×C而不是C×T,classify不报错,但卷积是在时间轴上滑,结果自然乱。防御写法是在推理函数开头加一行断言:
assert(size(X{1}, 1) == numFeat, '输入维度应为 C×T,当前是 %d×%d', ... size(X{1},1), size(X{1},2));坑六:训练跑一晚上,内存爆了。
现象是s01跑完好好的,s02一加载dataset.mat就 OOM。原因是把所有窗口一次性cat成了四维数组numWin×C×1×T,几万个窗口乘起来就是几个 GB。正确做法是保持 cell 数组,trainNetwork接受 cell 输入并按 batch 内部分配,内存占用只有单个 batch 的量级。如果样本量继续涨到几十万,就上arrayDatastore或自定义minibatchqueue,让数据从磁盘按批读。另外save的时候记得加'-v7.3',默认格式存不下超过 2 GB 的变量,会在保存那一刻报错——这个错报在训练之后,一晚上的计算白费。
6. 二次开发:换工艺段、加工况、接在线数据的三条改造路径
这套代码最容易被问的问题是“我厂里的点位和它不一样,能用吗”。答案是能,但要改的地方得心里有数。下面三条路径按改动量从小到大排。
6.1 换一套传感器:改三个地方就够
改动集中在三处,漏一处就白改。第一处是featIdx和归一化,在s01_build_dataset.m顶部把列名列表换掉,同时给lo和hi两个量程向量重新赋值——长度必须和新特征数一致,否则cleanTable里的循环会越界。第二处是buildNet的第一个参数numFeat,在s02_train.m里调用时同步改掉。第三处是sequenceInputLayer的MinLength,如果你也想改窗口长度,winLen要同时改s01的切窗调用和buildNet的入参,两边不一致时trainNetwork报的错很难懂。
改完先别急着训练,跑s04_plot_signals把新点位的数据画出来看一眼。我一般会重点确认两件事:新加的点位在时间轴上有没有大片缺口(这会让有效窗口数骤减),以及它的量程是不是覆盖了现场的极端值(量程设窄了,正常波动会被当成毛刺抹平)。
6.2 增量工况:从分类头到代价敏感损失
要加一个新工况,比如“进水含油”,动的地方是标签表、numClass、分类层的Classes顺序。第三点最容易出错:classificationLayer的类别顺序由训练标签的categories决定,如果训练集里没出现过这个新类,网络输出维度对不上,classify返回的 categorical 类别集合也会跟测试集不匹配,混淆矩阵直接报错。稳妥做法是在s01里显式指定类别全集:
cats = {'NORM','SHOCK','BULK','FOAM','TOX','DO_LOW','OIL'}; Ytr = setcats(Ytr, cats); Yva = setcats(Yva, cats); Yte = setcats(Yte, cats);新类的样本通常很少,配合 5.1 里的加权分类层,权重先按1/frequency给,再人工压一压。这里有个取舍:新类权重给高了,误报率会上升;给低了,新类根本学不出来。判断标准看验证集上新类的召回,能到 0.7 以上就算可用,剩下的靠连续确认机制(下一节)压误报。
6.3 上线前的离线验证清单
在线推理的骨架就是一个环形缓冲加连续确认,抖动靠“连续 N 次同结论才报警”压住:
buf = zeros(numFeat, winLen); % C×T 环形缓冲 hit = 0; confirmN = 3; while isRunning x = readOneSample(); <p> <a href="https://download.csdn.net/download/qq_43158059/84242536" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>