我在实际做数据分类项目时发现,很多入门者拿到"深度残差网络"就一头扎进图像分类里,但实际上它在表格数据、传感器时序数据、多特征融合分类场景里同样能打。尤其是手里只有Matlab、又不想切Python生态的人,完全可以用Deep Learning Toolbox把DRN搭起来,做多特征输入、多分类输出的模型。这篇文章我会把DRN的核心原理拆开讲清楚,再给出一套能在Matlab里直接落地的多特征多分类实现方案,包括数据预处理、网络搭建、训练配置和评估指标,最后把我踩过的坑和排查经验一并整理出来。不管你是做故障诊断、生物信号识别,还是工业过程分类,这套思路都能复用。
1. 为什么是DRN:从残差思想到多分类问题的适配
1.1 深度网络退化问题与残差连接的核心逻辑
深度学习模型在分类任务上表现好,很大程度靠的是深。层数一多,参数表达能力确实上去了,但这种"堆层数"的做法在传统前馈网络里很快就会撞墙——不是过拟合,而是训练集上的准确率都上不去。卷积网络也好、全连接网络也好,一旦层数超过某个临界点,梯度回传时逐层相乘,信息衰减得厉害。浅层权重几乎学不到东西,深层权重又在乱跳,整个训练过程变成拉锯战。这个现象在文献里叫"退化"(degradation),它不是因为过拟合,纯粹是深层网络难以优化。
残差网络给出的解法非常直接:与其让网络直接拟合目标映射H(x),不如让它拟合残差F(x)=H(x)-x,然后把输入x跨层加到输出上。这样一来,每一层只需要负责学习"比输入大了多少"这个增量,而不用从头学着去拟合完整映射。反向传播时,跨层连接为梯度提供了一条"高速公路",即使中间某一段的梯度接近0,这条恒等路径也能把梯度送到前面的层。这就是DRN能在几十层上百层里稳定训练的根本原因。
放到Matlab里,这个机制最简单就能用addLayer和connectLayers搭一个残差块。核心代码大概长这样:
% 残差块:Conv -> BN -> ReLU -> Conv -> BN 再与输入相加 layers = [ convolution2dLayer(3, 16, 'Padding', 'same', 'Name', 'conv1') batchNormalizationLayer('Name', 'bn1') reluLayer('Name', 'relu1') convolution2dLayer(3, 16, 'Padding', 'same', 'Name', 'conv2') batchNormalizationLayer('Name', 'bn2') ]; lgraph = addLayers(lgraph, layers); % 将输入绕后接到bn2之后,做逐元素相加 lgraph = addLayers(lgraph, additionLayer(2, 'Name', 'add')); lgraph = connectLayers(lgraph, 'input', 'conv1'); lgraph = connectLayers(lgraph, 'input', 'add/in1'); lgraph = connectLayers(lgraph, 'bn2', 'add/in2'); lgraph = connectLayers(lgraph, 'add', 'relu2');有一点要注意,如果输入通道数和卷积输出通道数不一致,"相加"这个操作就做不了。这时候得在恒等路径上加一个1x1卷积,把通道数先对齐。对于表格特征这类非图像输入,其实更常用的做法是先用全连接层把特征维度扩展到和残差路径一致的维度,再走加法层。等会儿在实操部分我会给完整结构。
1.2 多特征输入的融合策略与DRN结构设计
所谓的"多特征",在很多实际项目里并不是指多通道图像,而是指把一个样本描述成多个特征组。比如设备故障诊断里,一个振动信号样本可以同时提取时域统计量(均方根、峰值因子)、频域特征(频谱峰、边频带能量)、时频域特征(小波包能量比),这三种特征的性质完全不同,量纲也差很多。如果直接拼成一个长向量塞进网络,数值大的特征会主导计算,模型也很容易学到"只看某几个维度"的偷懒策略。
所以多特征融合需要一个合理的结构设计。我常用的方案是"分组输入 + 并行特征提取 + 融合分类":每个特征组走一条独立的浅层残差子网络,提取出各自的高层表示,然后在融合层拼接或者相加,最后接全连接层和softmax做多分类。这样做的好处有三个:每组特征按自己的分布做归一化,不会互相干扰;残差块能充分挖掘组内特征之间的非线性关系;融合层又给了模型学习跨特征组交互的机会。
在Matlab里搭建这种结构,核心是把特征组分别作为不同的输入,用多个featureInputLayer接收,然后通过独立的lgraph分支,最后用depthConcatenationLayer拼接。这里提示一下,Matlab的network训练时通常只支持单一输入,但用dlnetwork配合自定义训练循环就可以处理多输入了。如果你不想写自定义循环,也可以把所有特征组先各自归一化再拼成一个矩阵,只用单input,这种简化方案也能跑,只是跨组交互可能学得不充分。我的建议是:如果特征组之间的物理含义差异很大(比如一个组是频谱,另一组是统计量),就用多输入分支结构;如果特征来源相近,直接拼接问题不大。
2. Matlab环境下的模型搭建实操
2.1 数据准备与特征预处理
先把数据准备好。假设现在任务是一个10分类问题,每个样本有3组特征,分别是统计特征(20维)、频域特征(64维)、时频特征(128维)。标准做法是每个样本保存为一个cell,里面放3个数值向量,标签单独存。对于特征数据,网络的第一层应该用featureInputLayer,而不是imageInputLayer。很多新手拿表格数据直接硬套图像分类模板,明明特征是一维向量,却要先reshape成图像,这既没语义,又把模型搞复杂了。
特征预处理里最重要的一步是归一化。常规分类任务首选z-score标准化,就是每个维度减去均值再除以标准差。要注意的是,均值和标准差必须从训练集统计出来,验证集和测试集也沿用训练集的统计量,不能拿全集去做标准化,否则就引入了未来信息,评估结果会虚高。Matlab里接受标准化参数的方式是给featureInputLayer传Normalization为'zscore',并用Mean和StandardDeviation字段指定训练集统计量。做法是这样的:
% 训练集特征统计 [trainMu, trainStd] = deal(mean(XTrain(:,1)), std(XTrain(:,1))); layer = featureInputLayer(20, 'Name', 'feat1', ... 'Normalization', 'zscore', ... 'Mean', 0, 'StandardDeviation', 1); % 这里用常量占位,真实的每个维度统计量需单独处理实际上featureInputLayer的Mean和StandardDeviation如果填向量,可以对每个维度独立标准化。所以我会先算好所有维度的mu和sigma,再塞进layer里。这里有个坑:如果某维特征在所有样本上取值都一样,也就是方差为0,z-score会得到NaN,必须提前把这些常数维度删除或者加上一个极小epsilon,否则训练直接崩。
数据整理的伪代码我放在下面,思路很清晰:先切分训练/验证/测试,再分别统计训练集均值方差,再对三个集合同步转换。
rng(42); % 假设X_all是N x (20+64+128)的矩阵,Y_all是N x 1分类向量 % 按比例切分 cvp = cvpartition(Y_all, 'Holdout', 0.2); idxTest = test(cvp); idxTrainVal = training(cvp); % 再切验证集 cvp2 = cvpartition(Y_all(idxTrainVal), 'Holdout', 0.2); idxVal = idxTrainVal & test(cvp2); % 这里是示意,实际需针对原索引调整 idxTrain = idxTrainVal & training(cvp2);切分完成后,把特征按语义切成三个组,逐组标准化。我用一个简单函数std_normalize_inplace处理训练集并返回mu, sigma,然后把mu、sigma套到验证集和测试集上。多分类标签用categorical存储,这样可以直接喂给classificationLayer。
2.2 构建DRN分类模型
现在构建模型。我建议用layerGraph组织残差块,避免一层层手动connect到怀疑人生。先写一个函数MakeResBlock,接收输入层名和输出通道数,返回一个能嵌入lgraph的残差块。对表格特征,残差块内的卷积用全连接替代其实更自然,但因为我们要保持"残差"这个核心思想,可以直接用fullyConnectedLayer,再接batchNormalizationLayer和reluLayer。
注意一个细节:batchNormalizationLayer在训练和推理时行为不一样,推理时用的是训练期间累计的均值和方差。如果你在训练时改了网络结构,BatchNorm的累计状态可能没对齐,经常会导致验证集上莫名其妙很差劲。这个后面问题排查会细说。
一个中等深度的DRN结构大致是:
- 特征1输入(20维)
- 全连接扩展至64,BN,ReLU
- 残差块A:64 -> 64 -> 64
- 残差块B:64 -> 128(恒等路径加全连接升维再相加)
- 特征2输入(64维)
- 全连接扩展至128,BN,ReLU
- 残差块C:128 -> 128 -> 128
- 特征3输入(128维)
- 全连接扩展至128,BN,ReLU
- 残差块D:128 -> 128 -> 128
- 三条支路融合:拼接到384维,或相加到128维
- 全连接 256,BN,ReLU,Dropout 0.5
- 全连接 numClasses,softmax,classification
用代码表示主要节点:
lgraph = layerGraph(); % 特征1分支 lgraph = addLayers(lgraph, featureInputLayer(20, 'Name', 'in_feat1', 'Normalization', 'none')); lgraph = addLayers(lgraph, fullyConnectedLayer(64, 'Name', 'fc1_a')); lgraph = addLayers(lgraph, batchNormalizationLayer('Name', 'bn1_a')); lgraph = addLayers(lgraph, reluLayer('Name', 'relu1_a')); % 特征2分支类似 % 融合层 lgraph = addLayers(lgraph, depthConcatenationLayer(3, 'Name', 'concat')); % 分类头 lgraph = addLayers(lgraph, fullyConnectedLayer(256, 'Name', 'fc_fusion')); ...每次addLayers之后还要用connectLayers把节点依次接起来。手写比较啰嗦,所以我会封装一个connectSequence函数,把source和targets做成一组cell对,循环连接。在这个阶段最容易出的错是拼错层名,尤其是不同分支里的层名必须唯一,否则会报重复名称错误。我的习惯是所有层名都带分支前缀,比如feat1_fc1、feat2_fc1、feat3_fc1,一目了然。
2.3 训练配置与超参数选择
Matlab里训练这类模型,两条路:一是用trainNetwork,二是用trainnet(R2023a及以后)。trainNetwork需要网络最后一层是分类层,并且要指定训练选项;trainnet更灵活,尤其适合多输入或自定义损失。对于上面这种纯单输入拼接的方案,trainNetwork完全够用。但如果真用了多输入分支,那就必须走dlnetwork + 自定义训练循环,或者借助categorical输入序列之类的trick。
最省事的配置是:
options = trainingOptions('adam', ... 'InitialLearnRate', 3e-4, ... 'MaxEpochs', 120, ... 'MiniBatchSize', 64, ... 'ValidationData', {XVal, YVal}, ... 'ValidationFrequency', 30, ... 'Shuffle', 'every-epoch', ... 'L2Regularization', 1e-4, ... 'Plots', 'training-progress', ... 'Verbose', true);这里说下关键超参数的选择逻辑。初始学习率3e-4是残差类网络的常见起点,比普通网络偏小,因为BN加残差会让损失面变陡峭,学习率太大容易震荡。MaxEpochs我一般先给100到150,配合早停策略观察,但如果验证损失连续20个epoch不降,就提前终止。MiniBatchSize受显存限制,如果OOM就减半。
还有一个关键细节:对于表格特征,我习惯不在第一个全连接前做Dropout,而是放在融合之后、最终分类之前。原因是特征提取阶段需要保留完整的梯度信息,过早的Dropout会让并行的分支学不到稳定表示。融合之后的Dropout反而能起到集成效果,压制分类头对某个特征分支的过度依赖。
3. 多分类评估指标与混淆矩阵实践
3.1 混淆矩阵与多分类指标的计算
多分类不能只看准确率,尤其是类别不平衡的时候。准确率90%但某一类完全没分对,这种模型在生产环境里就是一票否决。所以在Matlab里,我每次训练完都会算混淆矩阵和四个派生指标:精确率、召回率、F1分数、宏平均F1。
Matlab里的计算非常直接:
% predict或classify得到预测标签Ypred confMat = confusionmat(YTest, YPred); % 也可以可视化 figure; confusionchart(YTest, YPred);从混淆矩阵计算每个类别的precision和recall有两种写法,第一种是把二分类公式套用到每个类别上,第二种是用内置的classpermetrics。classpermetrics可以直接返回多分类的宏平均指标,我比较推荐:
cp = classpermetrics(YTest, YPred, 'macro'); cp.CorrectRate cp.Precision cp.Recall % 但F1需要自己算F1为什么需要自己算?因为Matlab某些版本的classpermetrics没有直接给F1的宏平均,但可以通过micro和macro的precision/recall推出来。宏平均F1是所有类别F1的算术平均,它的特点是给少数类相同的权重,所以如果模型在罕见类上表现差,宏F1会明显拉低,比准确率诚实得多。
在实际项目里,我会同时报告微平均和宏平均:如果两者差距大,说明类别不平衡拖了后腿。微平均F1和准确率在数学上等价,所以微平均F1高不代表每一类都好,一定要配合宏平均看。
3.2 从验证集到测试集的完整评估流程
很多初学者把验证集和测试集混为一谈。验证集是用来调超参数和做早停的,测试集只能在整个训练流程全部结束后用一次。如果反复拿测试集调参,测试集就变成了新的验证集,泛化性能估计就没有意义了。我通常在数据划分时锁定一个random seed,把20%数据作为测试集锁死在保险箱里,剩下80%再切出15%做验证集,剩下的65%训练。
训练完成之后,评估流程是这样的:
先看训练曲线上的验证损失有没有降到平稳,再在验证集上计算混淆矩阵和宏F1,如果有明显短板类别,回到网络设计迭代(比如加深分支网络、加注意力、调整损失权重)。等模型在验证集上满意了,才最后用测试集算一次完整指标。此时除了准确率、宏F1,还要记录每个类别的单独准确率,方便后期做badcase分析。
对于多分类问题,我还会画一个按类别归一化的混淆矩阵热力图。Matlab里confusionchart的'Normalization'选项可以设为'row-normalized',这样每行之和为1,能直观看到"某一类被错分到哪一类"的比例。例如在故障诊断里,如果多个故障类型互相混在一起,大多是被分到特征相近的类里,那么下一步就该考虑是不是应该把类别合并,或者增加更细分的特征来拉大类别间距。可视化对指导模型迭代非常有用。
4. 常见问题与排查技巧实录
4.1 训练损失不下降或者干脆NaN
这是我在残差网络里遇到最多的坑。如果第一轮迭代损失就是NaN,八九不离十是数据里有NaN或Inf——z-score标准化时常数维导致除零,或者某类特征缺失值没处理。先检查数据:
sum(any(isnan(XTrain), 2)); sum(any(isinf(XTrain), 2));如果是训练几轮之后才出现NaN,就要怀疑学习率过大,导致梯度更新把某些权重推到数值溢出。此时先调小初始学习率到1e-4,再把梯度裁剪打开。Matlab的trainingOptions从R2022a开始有'GradientThreshold'参数,设为1或5都能有效防止梯度爆炸。
另外残差块里的BatchNorm偶尔也会成为NaN的制造者。当批数量太少,比如MiniBatchSize为8,BN统计量方差极不稳定,很容易产生异常。尤其表格特征本身维度有限,BN的稳定性比图像场景更敏感。我一般不会让MiniBatchSize小于16,实在显存不够就减少通道数而不是继续砍batch。
4.2 类别不平衡问题怎么处理
刚开始做分类的人一遇到类别不平衡,第一反应就是过采样或者SMOTE。但在深度学习+多分类里,更直接的干预手段是修改损失函数。Matlab的trainNetwork里要改损失就得自定义,但我们可以用trainnet写一个带权重交叉熵的损失函数。简洁写法是给每个类别指定权重,让少数类的错误贡献更大。
% 自定义加权交叉熵损失(示意) function loss = weightedCE(y, t, classWeights) % y是预测概率,t是one-hot标签 ce = -sum(t .* log(y + eps), 2); w = t * classWeights; loss = mean(ce .* w); endclassWeights怎么设?常用方法是取训练集各类别样本数量的倒数,再归一化让最小类权重为1。例如某个任务中类别A有5000样本,类别B只有50,那么B的权重可以设为100左右。注意权重设太大会放大噪声,少数类迭代几次就可能过拟合,所以要结合早停。还有一个笨但有效的土办法:训练时用DataStore做带权重的采样,让每个mini-batch里各类样本数量大致相当。这两种方法可以组合使用,效果往往比单用好得多。
4.3 内存不足和训练速度慢
Matlab在深度学习上跑表格数据一般不会太吃显存,但如果特征组特别多、残差块堆得深,中间激活量同样会让显卡吃不消。遇到OOM,优先级是这样的:先减批大小,再减网络宽度,最后减深度。减宽度最直接,把64改成32或48,不会伤筋动骨;减深度要小心,残差块数量从4个减到2个,网络表达能力可能明显下降。
如果CPU训练慢到让人想放弃,优先考虑把数据整理成单个mat矩阵而不是cell数组,因为cell数组的按索引取子集的效率远低于矩阵切片。同时对featureInputLayer的数据,Matlab内部会自动做很多优化,只要你的数据是数值矩阵、标签是categorical,trainNetwork会隐藏很多数据搬运的开销,别自己写循环喂batch。
纯CPU训练时,深度残差网络的BN和全连接计算量会让epoch时间成倍增加。一个取巧的办法是先用一个小规模子集验证网络能收敛,确认结构没问题后,再用全量数据在GPU上跑。如果本地没有GPU,可以考虑Matlab Online或者云主机,但换来换去要注意数据路径管理,我吃过亏:本地预处理好的.mat文件传到服务器,发现v7.3格式存储导致读取极慢,换成v7格式就快多了。
4.4 输入特征形态差异太大导致训练失效
这是多特征分类最容易踩的隐性坑。统计特征可能取值范围在0.1到1000,频域特征在1e-5到1e-4,时频特征做完对数变化后又能落到-10到5。这时如果第一层都用同一套标准化参数,或者干脆不做处理直接拼,网络会花大量迭代去学习"特征之间的尺度关系",真正有价值的非线性特征反而学不到位。
我的习惯是每个特征组分别做标准化,并记录各自的mu和sigma。更深一层的处理是用Power Transformer或者取对数,对于频域能量类特征,log1p通常效果显著;对于统计特征,有时用鲁棒缩放(中位数、IQR)比z-score更稳,因为它们常常受离群点影响很大。Matlab里RobustScaler没有内置,但手写并不难:
% 中位数归一化示意 med = median(XTrain, 1); iqrVal = iqr(XTrain, 1); XScaled = (XTrain - med) ./ (iqrVal + eps);预处理对DRN的影响比网络结构还大。很多情况下,你不改网络,只是把特征预处理做细一点,宏F1能从0.75升到0.88。这一点经常被写博客的人忽略,但我实测下来,它比调学习率划算得多。
5. 进一步扩展的思路与个人体会
做多特征多分类模型,没必要一开始就追求大而全的网络。DRN最大的优势是"深度可控、残差结构天然抗退化",很适合做特征工程的载体。你可以先用浅一点的残差块快速验证特征组合有效性,再逐步加深。另外,Matlab里还有几个配套工具箱能让开发效率更高:Experiment Manager可以批量搜超参数;Deep Network Designer可以做可视化编辑;Feature Engineeering App做探索性特征分析。如果能搭配使用,整个流程会顺畅很多。
我个人在实际项目中的体会是,多分类混淆矩阵看多了之后,你会发现很多"错误"其实来自标签噪声和数据边界模糊。这不是网络能解决的问题,反而提醒我要回头检查标注一致性。模型结构合理、特征处理到位,DRN的分类效果通常不会差。但千万别迷信网络层数——我有一次把残差块从3个加到8个,宏F1反而掉了0.03,因为深层网络需要更多数据来喂饱,小数据集上过拟合来得比收益快得多。这时候正确做法不是继续堆深度,而是加强正则化和特征质量。
最后分享一个实用小技巧:每次试验结束,记得把训练选项、数据划分索引、特征标准化参数和最终混淆矩阵一起存成结构体,命名带日期时间。等你要复现一个三个月前的结果时,会发现这套记录比任何文档都靠谱。分类模型探索本质上就是大量实验的组合,没有完整的实验记录,再好的模型也只是一团离散的代码。希望这篇短文能给你在Matlab里实现DRN多分类提供一条清晰的路。