在 MATLAB 里做前馈神经网络建模,绕不开feedforwardnet这个函数。很多人第一次接触它,是被一句net = feedforwardnet(10)带进门的——看起来简单得不像话,可真正跑起来之后,误差曲线不收敛、验证集反弹、换个数据预测结果离谱,问题一个接一个。我在几个工程回归和信号拟合的项目里反复用过它,从最开始的“照着示例改参数”,到后来能把每一层、每一个训练参数为什么这么设讲清楚,中间踩的坑不算少。这篇就围绕着feedforwardnet这个函数展开,把它生成的对象结构、关键参数的取值逻辑、完整可复现的实操流程,以及那些文档里不太会写的排查经验,一次讲透。不管你是刚接触神经网络工具箱、只会跑示例的新手,还是已经调过几轮参数但总感觉“玄学”的同行,下面这些内容应该都能直接拿去用。
1. feedforwardnet 到底是什么:先把对象模型搞清楚
1.1 一行调用背后生成的东西
feedforwardnet的本质是一个“网络对象工厂”。你给它一个隐层规模的向量和一个训练函数名,它返回一个配置好的network对象,里面包含了层结构、连接关系、激活函数、训练算法、性能函数、数据划分方式、输入输出预处理规则等一整套默认设定。理解这一点很关键:它不是“训练函数”,也不是“模型”,它只是一个还没接触过任何数据的空壳。真正的训练发生在train里,真正的推理发生在net(x)或sim里。
调用形式有两种,带参数和不带参数:
net = feedforwardnet; % 隐层 10 个神经元,trainlm net = feedforwardnet([15 8]); % 两个隐层,分别 15 和 8 个神经元 net = feedforwardnet([15 8], 'trainscg'); % 指定训练算法返回对象里最值得先看一眼的几个字段是:net.numLayers(层数,等于隐层个数加 1)、net.layers{i}.size(每层神经元数)、net.layers{i}.transferFcn(激活函数,隐层默认tansig,输出层默认purelin)、net.inputConnect和net.layerConnect(连接矩阵,决定谁连谁)、net.trainFcn、net.performFcn、net.divideFcn。我习惯在建网之后先view(net)打一眼结构图,再disp(net)看一遍这些字段,确认没有想当然的地方——尤其是当你不止一个隐层的时候,层与层的索引很容易记混。
有一点必须提醒:这个对象在configure或train之前,输入输出的维度是未知的。也就是说,你没法在建网阶段就知道net.IW{1,1}是几行几列。维度是在第一次喂数据时根据x的行数和t的行数自动确定的。这个“惰性配置”的设计很灵活,但也意味着如果你中途换了特征数量,必须重新configure,否则报错会来得莫名其妙。
1.2 与 newff、patternnet、fitnet 的关系与选型
老版本的 MATLAB 里有个newff,很多老教程还在用。它和feedforwardnet的差别不在于网络能力,而在于配置方式:newff允许你在建网时一次性把所有东西都指定完,参数表长而杂;feedforwardnet走的是“先建默认壳,再改属性”的路线,代码更清爽,也更符合后来工具箱的整体设计风格。如果你的代码里还留着newff,功能上继续跑没问题,但迁移到feedforwardnet之后可读性会好很多,尤其是多人协作的时候。
同一族里还有两个常用的“特化版本”,选错会很别扭:
| 函数 | 输出层激活 | 性能函数 | 适用任务 |
|---|---|---|---|
feedforwardnet | purelin | mse | 回归、拟合、一般映射 |
fitnet | purelin | mse | 回归(本质是 feedforwardnet 的封装) |
patternnet | softmax | crossentropy | 分类(输出为概率) |
cascadeforwardnet | purelin | mse | 回归,输入直连到各层 |
选型的判断标准其实就一条:你的标签是连续值还是类别。连续值走feedforwardnet或fitnet,类别走patternnet。我见过有人拿feedforwardnet做三分类,输出层用purelin硬套,然后对输出取整——能跑通,但损失函数和输出含义都不对,训练效率和稳定性都吃亏。分类任务用patternnet,输出层是 softmax,配合交叉熵,梯度性质好得多,这是省事又省心的做法。
1.3 为什么默认配置长这样
默认隐层 10 个神经元、trainlm、dividerand七三分、mse、mapminmax归一化——这套默认值不是随手定的,背后有相当明确的工程考量。10 个神经元是一个“不太容易立刻过拟合、又有一定表达力”的中庸起点,适合中小规模数据;trainlm(Levenberg-Marquardt)在中小规模问题上收敛极快,往往几十个 epoch 就能把梯度压下去;dividerand自动切出验证集,是为了让你在完全不写额外代码的情况下就能看到早停效果;mapminmax把输入输出压到[-1, 1],是因为tansig在这个区间里梯度最健康,超出范围很快饱和。
明白了这套默认值的意图,你才知道什么时候该动它、往哪个方向动。比如数据量上了几万条,trainlm的内存开销就会变成瓶颈,该换trainscg;比如样本只有几十条,随机七三分会导致每次划分差异巨大,该改成固定索引;比如特征里存在大量常数列,removeconstantrows会悄悄删掉它们,维度对不上就是这么来的。这些后面会逐个展开。
2. 关键参数逐个拆:隐层、训练函数、数据划分、预处理
2.1 隐层规模:从经验公式到试错收敛
隐层神经元数量是问得最多、也最没有标准答案的一个参数。市面上流传的经验公式大致有这么几个:sqrt(m+n)+a(m 输入维数,n 输出维数,a 取 1 到 10)、2m+1、log2(m)等等。我的看法是:这些公式只能用来定一个搜索起点,不能当结论用。它们没有考虑数据的噪声水平、样本量、特征之间的相关性,而这些恰恰是决定模型容量的关键因素。
真正靠谱的做法是网格试错,但要有章法。我的习惯是先固定一个较小的范围,比如[4 8 12 16 24],每个配置跑 5 次不同的随机初始化(用不同的rng种子),记录验证集上的均方误差,取“均值好且方差小”的那个区间,再在区间内细化。之所以要跑多次,是因为单次训练的结果受初始权重影响很大,一次跑得好不代表这个结构好,很可能只是运气。
下面这段是我常用的搜索脚本骨架,直接改数据就能用:
rng(2024); candidates = [4 8 12 16 24]; results = zeros(numel(candidates), 5); for i = 1:numel(candidates) for k = 1:5 rng(100 + k); net = feedforwardnet(candidates(i), 'trainlm'); net.trainParam.showWindow = false; net.trainParam.epochs = 500; [net, tr] = train(net, x, t); yv = net(x(:, tr.valInd)); results(i, k) = mean((yv - t(:, tr.valInd)).^2, 'all'); end end还有一个常被忽略的点:隐层的“宽度”比“深度”更值得先调。在表格型数据和常规信号拟合里,两三个隐层往往并不比一个宽隐层带来实质提升,反而让训练更不稳定、更容易卡在局部极小。我一般先在一个隐层上把宽度调到合理区间,如果验证误差还有明显下降空间,再考虑加第二层,而且第二层的神经元数通常要少于第一层,比如[24 12]这种递减结构,符合“逐层抽象、逐层压缩”的直觉。
注意:隐层数增加会显著增加
trainlm的雅可比矩阵规模,内存占用是平方级增长的。数据量一大,两层网络就可能在普通工作站上直接内存溢出。
2.2 训练函数的选择:trainlm 不是万能的
trainlm在中小规模回归问题上的表现确实好,收敛快、精度高,这也是它成为默认值的原因。但它的代价是每一轮迭代都要构造并求解一个规模为“参数总数 × 样本数”的雅可比矩阵,参数量和样本量任一变大,内存和时间都会迅速失控。经验上的分界线大概是:参数加样本的总规模在几千以内,trainlm很舒服;上万之后就该考虑换算法了。
常用的替代方案有这么几个:
trainscg(量化共轭梯度):内存占用线性,适合大样本,收敛慢一些但很稳,是我在数据量上万之后的首选。trainrp(弹性反向传播):只关心梯度的符号不关心大小,对梯度消失不敏感,适合深一点的网络。trainbr(贝叶斯正则化):自带正则效果,小样本高噪声场景下泛化往往更好,但训练时间长,且会把有效参数数压缩,最后网络的实际自由度比你设定的小。
切换训练函数只要改一个参数,但要注意不同训练函数的trainParam字段是不一样的。比如trainlm有mu、mu_dec、mu_inc,trainscg有sigma、lambda,trainbr会把goal的解释改成“有效参数数目标”。写通用脚本的时候不要硬编码这些字段,改完trainFcn之后重新读一遍net.trainParam再赋值,不然会静默失效。
net = feedforwardnet(16, 'trainscg'); net.trainParam.epochs = 2000; net.trainParam.max_fail = 20; % 大样本下早停耐心要放大2.3 数据划分的坑:dividerand 与可复现性
dividerand默认按 70/15/15 随机切分训练、验证、测试集。这个机制的好处是全自动,坏处是每次train都会重新随机切一次,如果你的样本量不大,两次运行的结果可能差出好几个百分点,这时候你去对比“改了这个参数到底有没有用”,其实对比的是随机噪声。
解决办法有三个层次。最轻的是在train之前固定随机种子rng(42),至少保证同一次会话里可复现。中等的是把划分固定下来,用divideind显式指定索引:
n = size(x, 2); idx = randperm(n); net.divideFcn = 'divideind'; net.divideParam.trainInd = idx(1:round(0.7*n)); net.divideParam.valInd = idx(round(0.7*n)+1:round(0.85*n)); net.divideParam.testInd = idx(round(0.85*n)+1:end);最彻底的是做交叉验证,把数据切成 K 折,每折轮流当验证集,最后看平均指标。样本量小于 200 的时候我基本都会走这条路,因为单次划分的方差太大了,结论不可信。
还有一个容易忽略的细节:dividerand是在训练开始前一次性切好并且之后不再变化的,不会每个 epoch 重新切。这一点和某些框架的“每轮重新采样验证集”不同,所以验证曲线是平滑可比的一条线,早停判断才有意义。
提示:如果你的数据本身有时间顺序(比如按时间采样的传感器序列),随机切分会造成“用未来预测过去”的信息泄漏,验证指标会虚高。这种场景必须用
divideblock或者手动按时间切。
2.4 预处理:mapminmax 的隐藏维度陷阱
feedforwardnet默认给输入和输出都挂了两步预处理:removeconstantrows和mapminmax。前者删掉取值恒定的行,后者把每行线性映射到[-1, 1]。这两步在训练时自动完成,在推理时也自动反向应用,用起来无感——但一旦你要把模型导出、或者手动处理新数据,问题就来了。
第一个陷阱是维度变化。假设你有 8 个特征,其中 2 个在训练集里是常数,removeconstantrows会把它们删掉,网络实际看到的是 6 维输入。这时候你拿一个 8 维的新样本直接喂给net,它内部会按训练时记录的规则再删一次同样的行,所以能跑通;但如果你自己手动做了特征筛选,又传给网络,就可能删多了或者删错了位置。排查方法很简单,看这个字段:
net.inputs{1}.processSettings里面记录了每一步处理的参数,包括被删除的行索引和mapminmax的gain、xoffset。第二个陷阱是归一化参数不一致。新数据的归一化必须沿用训练集的gain和xoffset,不能对新数据重新算一遍 min/max,否则分布对不上,预测必然偏。手动应用的正确写法是:
[xn, ps] = mapminmax(x, -1, 1); % 训练用 xn xnew_n = mapminmax('apply', xnew, ps); % 关键:用 ps用net直接推理时这些都不用操心,它内部会调processSettings。但一旦你用genFunction导出了独立函数,或者把权重导到别的语言里,这些参数就必须跟着一起走,漏一个就会导致线上结果和离线结果对不上,这类问题排查起来非常费时间。
3. 从零跑通一个可用的模型:完整实操
3.1 数据组织:列是样本,别搞反
MATLAB 神经网络工具箱的约定是列优先:输入矩阵x的尺寸是特征数 × 样本数,标签矩阵t的尺寸是输出维数 × 样本数。这和 Python 生态里常见的样本数 × 特征数正好相反,是新手最容易踩的第一个坑,而且报错信息往往很含糊,比如提示某层输入维度不匹配,但不会直说你把矩阵转置了。
我习惯在脚本最前面加一行断言,把这个问题扼杀在开头:
assert(size(x, 2) == size(t, 2), '样本数不一致,检查是否转置');另外,标签如果是单输出的行向量,注意保持二维形式,也就是1 × N而不是1 × N被压成一维。虽然很多情况下能自动兼容,但在计算perform或者手写损失的时候容易出维度广播的意外,t(:)'这类操作能省则省,老老实实保持二维最稳。
数据质量上,有两件事必须在建模前做掉:缺失值处理和异常值检查。工具箱不会自动处理NaN,一旦数据里有NaN,训练会直接失败或者输出全是NaN,报错信息还很难定位。异常值的影响更隐蔽——mapminmax是按最大最小值做线性映射的,一个离群点就能把其余 99% 的正常数据压到一个极窄的区间里,网络的表达能力被白白浪费掉。我的做法是先把超过 3 倍标准差的点标记出来,人工确认是噪声还是真实极值,前者剔除或截断,后者保留但考虑换zscore标准化。
3.2 建网、配置、训练的完整脚本
下面这套脚本是我常用的模板,从数据准备到训练评估一气呵成,改改数据源和几个参数就能直接跑。注意每个配置项都写了注释说明意图,不是照抄示例。
rng(2024); % 固定随机性 % ---- 数据组织 ---- % x: 特征数 x 样本数 t: 输出维数 x 样本数 assert(size(x,2) == size(t,2)); n = size(x,2); % ---- 数据划分:固定索引,保证可比 ---- idx = randperm(n); nTrain = round(0.7*n); nVal = round(0.15*n); % ---- 建网 ---- net = feedforwardnet([20 10], 'trainlm'); net.layers{1}.transferFcn = 'tansig'; net.layers{2}.transferFcn = 'tansig'; net.layers{3}.transferFcn = 'purelin'; % 回归任务,输出层线性 % ---- 划分方式 ---- net.divideFcn = 'divideind'; net.divideParam.trainInd = idx(1:nTrain); net.divideParam.valInd = idx(nTrain+1:nTrain+nVal); net.divideParam.testInd = idx(nTrain+nVal+1:end); % ---- 训练参数 ---- net.trainParam.epochs = 1000; net.trainParam.goal = 1e-5; net.trainParam.max_fail = 10; % 连续10次验证不降则停 net.trainParam.min_grad = 1e-7; net.trainParam.showWindow = false; % 批处理时关掉窗口 % ---- 正则化(可选,小样本高噪时开)---- net.performParam.regularization = 0.01; % ---- 训练 ---- [net, tr] = train(net, x, t); % ---- 推理与评估 ---- y = net(x); yTr = y(:, tr.trainInd); yVal = y(:, tr.valInd); yTe = y(:, tr.testInd); rmse = @(a,b) sqrt(mean((a-b).^2, 'all')); fprintf('Train RMSE: %.4f\n', rmse(yTr, t(:,tr.trainInd))); fprintf('Val RMSE: %.4f\n', rmse(yVal, t(:,tr.valInd))); fprintf('Test RMSE: %.4f\n', rmse(yTe, t(:,tr.testInd)));regularization这个参数值得单独说一句:它加在性能函数上,等价于权重衰减,取值一般在 0.001 到 0.1 之间。它的作用是抑制权重过大,从而压低过拟合。但不要盲目开,因为它会让训练集误差也一起上升,如果你的模型本来就欠拟合(训练集误差都很高),开正则只会更糟。
3.3 收敛曲线怎么读,什么时候该停
训练结束后tr结构里存了完整的历史,tr.perf、tr.vperf、tr.tperf分别是训练、验证、测试集在每轮的性能值。画出来看形状,比看最终数字信息量大得多。
figure; semilogy(tr.epoch, tr.perf, '-', tr.epoch, tr.vperf, '--', tr.epoch, tr.tperf, ':'); legend('Train','Validation','Test'); xlabel('Epoch'); ylabel('MSE'); grid on;几种典型形态对应不同的问题。三条线一起平滑下降然后趋于平缓,是最理想的情况,说明模型容量和数据结构匹配。训练线持续下降但验证线在某个点之后掉头向上,是教科书式的过拟合,tr.best_epoch会告诉你最优点在哪,网络实际使用的就是那一轮的权重,所以你不用手动回滚,工具箱已经帮你做了。三条线都降得很慢,最后停在比较高的位置,是欠拟合,该加宽隐层或者增加特征。训练线剧烈震荡,多半是学习率相关的参数太激进,trainlm的话可以调小初始mu,或者换trainscg试试。
还有一个指标容易忽略:tr.best_vperf和tr.best_epoch。如果best_epoch出现在很靠前的位置(比如第 20 轮就早停了),而epochs设的是 1000,说明网络很快就过拟合了,这时候与其加大epochs,不如减参数或者加正则。反过来,如果训练跑满了 1000 轮验证集还在降,那就是epochs设小了,得往上加。
3.4 误差评估与结果可信度判断
只看一个 RMSE 数字是不够的,得看误差的分布形态和随预测值的变化趋势。我固定会看两张图:误差直方图和预测值对真实值的散点图。
figure; histogram(yTe - t(:,tr.testInd), 20); xlabel('残差'); ylabel('频数'); title('测试集残差分布'); figure; scatter(t(:,tr.testInd), yTe, 20, 'filled'); hold on; plot([min(t(:)) max(t(:))], [min(t(:)) max(t(:))], 'r--'); xlabel('真实值'); ylabel('预测值'); grid on;残差如果近似对称地分布在 0 附近,说明模型没有系统性偏差;如果整体偏移,说明输出层的偏置没拟合好,或者训练被早停截断在了不充分的位置。散点如果在大值区域明显偏离对角线(预测偏小),在小值区域也偏(预测偏大),是典型的“向均值回归”现象,说明模型容量不足以刻画极值,得加宽网络或者补充能区分极值的特征。
R² 也顺手算一个,尤其是给非技术同事汇报的时候,比 RMSE 好懂:
R2 = 1 - sum((yTe - t(:,tr.testInd)).^2, 'all') / ... sum((t(:,tr.testInd) - mean(t(:,tr.testInd), 2)).^2, 'all');还有一件事:测试集只能用一次。这个纪律要守住。如果你反复根据测试集结果调参数,测试集就变成了验证集,报出来的指标就不再是泛化能力的无偏估计。我的做法是把测试集在最后评估阶段才拿出来,调参全程只看验证集。
3.5 导出为独立函数并落地调用
训练好的net对象可以存成.mat,但更清爽的方式是用genFunction把它转成一个独立的 MATLAB 函数,摆脱对工具箱运行时对象的依赖,部署和版本管理都更方便。
genFunction(net, 'predictModel', 'MatrixOnly', 'yes');生成的predictModel.m里包含了完整的权重、偏置和预处理参数,调用方式就是y = predictModel(x),输入输出都是矩阵,不带任何对象封装的痕迹。MatrixOnly这个选项很关键,加上之后生成的是纯矩阵接口,可以直接嵌入到别的工程里,也能作为对照基准用来验证其他语言的移植是否正确。
移植到别的语言时,需要从net里取出这几样东西:net.IW{1,1}(输入层到第一隐层的权重)、net.LW{i,j}(层间权重)、net.b{i}(各层偏置),以及net.inputs{1}.processSettings里的归一化参数。矩阵乘法的方向和 MATLAB 里是一致的,W * x + b,激活函数tansig定义是2/(1+exp(-2n)) - 1,注意这个系数 2 容易被漏掉,用tanh(n)也能得到等价结果,但别写成1/(1+exp(-n)),那是 sigmoid,不是tansig。
4. 踩坑实录:常见报错与效果异常的排查
4.1 维度不匹配类报错
这类报错的信息通常长得像“Inputs and targets have different numbers of samples”,或者“Number of inputs does not match net.numInputs”。前者基本就是x和t的列数不一致,回头看 3.1 那条断言。后者往往出现在改动了输入特征数量之后没有重新配置网络,比如训练时用了 8 个特征,后来数据源升级成 10 个,直接train就会报错。
正确的处理是重新配置:
net = configure(net, xNew, tNew);configure会重新确定输入输出维度,但要注意它会重置已训练的权重,所以这个操作只能在训练前做。如果你想保留结构、只换数据规模,那就得重新建网。
还有一种隐蔽的情况:removeconstantrows把某一行删掉了,导致实际输入维度小于你看到的维度。如果你手动算过权重矩阵的尺寸再去做矩阵运算,就会对不上。判断方法是看numel(net.inputs{1}.processSettings)里对应的处理步骤,确认哪些行被删了。
4.2 训练不收敛、误差卡住
误差曲线一开始就几乎不动,通常是这几个原因之一。输入没有归一化,量纲差异巨大,tansig一上来就饱和,梯度接近零,这时候哪怕trainlm也救不回来。检查方法是看net.inputs{1}.processFcns里是否有mapminmax,正常情况下默认是有的,除非你手动清空了net.inputs{1}.processFcns。
学习率类参数过于激进,trainlm里对应的是mu的初值和mu_inc。如果误差曲线上下剧烈跳,可以试试把mu从0.001调到0.01,让初始步长更保守。
目标值本身不可学习。这种情况在真实项目里并不罕见——特征里根本不含能解释标签的信息,或者标签采集过程有严重噪声。表现是训练集误差也降不下去,验证集等于随机猜。这时候不要再调网络了,回去查数据和特征工程,网络容量再大也变不出信息。
梯度消失,多层网络用tansig堆叠时比较常见。缓解手段是减层、换trainrp(对梯度幅值不敏感),或者给每层加批量归一化——不过feedforwardnet本身不直接支持 BN 层,需要自己在前处理里做标准化来实现类似效果。
4.3 过拟合与验证集误差反弹
过拟合的典型信号是训练集 RMSE 很低但验证集明显高出一截,两者差距超过一个数量级就很可疑了。工具箱的早停机制会帮你停在最优点,但那只是止血,不是治病。根治的手段按效果排序,我一般的处理顺序是:
第一,加数据。这一条永远最有效,但往往做不到。第二,砍容量,减隐层神经元、减层数。第三,开正则,net.performParam.regularization设到0.01到0.05,从小往大试。第四,降特征维度,用相关性分析或者主成分把冗余特征去掉,很多时候特征之间的强共线性是过拟合的重要来源。
有一点要提醒:调隐层大小和开正则这两件事不要同时做,不然你无法判断改进来自哪一项,下次遇到类似问题还是抓瞎。一次只改一个变量,这是调参的基本纪律。
4.4 新数据预测结果离谱
离线评估一切正常,一上线就崩,这个问题我遇到过两次,原因各不相同但都值得记下来。第一次是归一化参数没跟着走,线上代码对新数据重新算了一遍 min/max,分布和训练时对不上。第二次是特征顺序变了,数据管道重构之后列的顺序调整了,但模型还是按老的顺序读的,网络看到的是一堆错位的输入。这类问题的排查办法是拿一批训练数据回灌线上管道,如果线上输出和离线输出不一致,问题一定在预处理环节,不在模型本身。
还有一种情况是外推失效。神经网络本质上是插值器,训练数据覆盖范围之外的点,预测值会倾向于饱和或者发散,没有物理意义。如果你的业务场景必然要面对超出训练分布的新数据,那要么扩充训练集的覆盖范围,要么在输出后加一层基于物理约束的截断。这不是feedforwardnet的缺陷,是所有数据驱动模型的共性,心里得有数。
4.5 问题速查表
把上面这些整理成一张表,出问题的时候可以直接对着查,比翻文档快得多。
| 现象 | 最可能的原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 报错提示样本数不一致 | x与t列数不同 | 打印两者size | 统一转置,加断言 |
| 训练一开始误差就不动 | 输入未归一化 / 特征无信息 | 查processFcns,看训练集误差 | 开mapminmax,回查数据 |
| 训练误差剧烈震荡 | mu初值过大 | 观察tr.perf曲线 | 调小mu或换trainscg |
| 验证集很早就反弹 | 模型容量过大 / 样本太少 | 看tr.best_epoch位置 | 减隐层、开正则、加数据 |
| 训练跑满 epoch 仍在降 | epochs设小了 | 看曲线末端斜率 | 提高epochs到 2000+ |
| 内存溢出 | trainlm+ 大样本 | 看参数与样本规模 | 换trainscg或减层 |
| 上线结果与离线不符 | 预处理参数未同步 | 回灌训练数据对比 | 导出并复用归一化参数 |
| 大值预测偏小、小值偏大 | 容量不足,向均值回归 | 画预测-真实散点 | 加宽网络、补特征 |
| 每次运行结果都不同 | 未固定随机种子 | 检查有无rng | 训练前rng(seed) |
这张表里的每一条都是我自己或者身边同行真实碰到过的,不是从文档里抄的清单。实际排查的时候,先确认数据管道没问题,再去怀疑模型,这个顺序能省掉一大半无效的调参时间。我见过太多情况是一直在改网络结构,最后发现是数据里混进了一列全零的特征。
最后分享一个我在实际项目里养成的小习惯:每次训练完,除了存net对象,顺手把tr结构、随机种子、数据划分索引、以及当次的超参数配置一起打包存到一个带时间戳的.mat里。看起来有点啰嗦,但当你两周后想复现某次“效果特别好”的结果,却发现早就不记得当时改了什么的时候,这个习惯能救命。模型调参这件事,可复现性本身就是生产力。