简介:这是一份面向高校学生、控制算法学习者及工程调试人员的 Simulink 自动调参示例资料,重点解决 S 函数模块参数靠手工反复试凑、效率偏低的问题。压缩包共 4 个文件,主要由 3 个 m 脚本和 1 个 slx 模型组成,整体约 24KB;其中脚本承担仿真时间设置、变量变化组织与批量运行,模型文件用于承载待调参的 S 函数模块。已有 3819 人学习或下载,具备一定的参考与复用价值。读者可从中理解 M 文件驱动 Simulink 仿真的组织方式,掌握参数扫描、结果比较和较优参数筛选的基本流程,并借鉴变量配置与自动运行思路,迁移到自己的控制系统建模与调参任务中。
1. 从手调到脚本:S 函数的参数没必要靠人一次次试
一个只有三个参数的 S 函数块,比例增益、时间常数、初始值,在 Simulink 里手调了两天:改一次、跑一次、把 yout 拖出来跟参考曲线比一遍,再凭感觉往下拧。这件事的本质其实很朴素——仿真模型本身就是一个黑箱目标函数,输入是参数向量,输出是误差标量。既然 MATLAB 的 M 文件天生会循环、会调函数、会算指标,那就没必要让人去当那个循环体。
这里要落地的是:在 MATLAB 中用 M 文件对 Simulink 里的 S 函数做自动调参数。把 S 函数块的参数入口整理成外部可写的具名变量,用脚本反复改参、仿真、算指标,再挂一个寻优器收敛。适合已经能跑通 S 函数、但还在手工试参数的人;也适合想把调参从“经验”变成“可复查流程”的人。下面从参数到底存在哪里开始拆。
2. 参数到底写在哪:S 函数块的三层结构与 get_param 取值链路
自动调参失败的第一大原因,不是寻优器不行,是脚本改的那个地方根本不是仿真真正读的那个地方。所以动手之前必须把参数的存放层级摸清楚,否则后面全是玄学。
2.1 编译期参数、运行期参数与 Mask 变量
一个 S 函数块身上的“参数”至少有三种身份,混用就会出问题。
第一种是对话框参数,也就是块对话框里 S-function parameters 那一栏填的东西。它本质是一段字符串表达式,Simulink 在模型编译阶段把它求值,然后按顺序传给 S 函数的block.DialogPrm(k).Data。这段字符串一旦进了编译流程,值就被固定下来了,运行中改它不会立刻生效。
第二种是运行期参数,由 M 文件 S 函数里的block.DialogPrmsTunable声明。声明成'Tunable'的参数,允许在仿真运行过程中通过set_param修改并被下一步读到;声明成'Nontunable'的,只能在编译期确定。写 C-MEX S 函数的人对应的是ssSetSFcnParamTunable(S, idx, SS_PRM_TUNABLE),语义一样。
第三种是Mask 参数,是盖在 S 函数块上面的一层外壳。Mask 参数名会作为变量注入到块的对话框表达式里,所以底层 Parameters 字段可以写成Kp, Tau, Y0,而这三个名字由 Mask 提供。这一层是自动调参最需要的,因为只有具名参数才能被setBlockParameter按名字寻址。
| 层级 | 物理位置 | 读取 API | 运行中可改 |
|---|---|---|---|
| 对话框参数串 | 块字段Parameters | get_param(blk,'Parameters') | 否,需重新编译 |
| 运行期参数 | S 函数内部DialogPrm | block.DialogPrm(k).Data | 声明 Tunable 后可改 |
| Mask 参数 | Mask 定义 | get_param(blk,'MaskNames')/'MaskValues' | 是,触发重编译 |
| 硬编码常量 | M 文件函数体 | 无 | 否,改文件才行 |
表格最后一行是最容易忽略的:不少人的 S 函数里增益是直接写死在Outputs里的常数,外面套多漂亮的 Mask 都没用。
2.2 用 get_param 摸清参数的真实名字
拿到一个别人建的模型,别急着写脚本。先跑一遍下面这几行,把块路径和参数名确认下来。
% 找到模型里所有 S-Function 块,打印它们的参数串 mdl = 'demoTune'; blks = find_system(mdl, 'LookUnderMasks','all', 'FollowLinks','on', ... 'BlockType','S-Function'); for k = 1:numel(blks) fprintf('块路径 : %s\n', blks{k}); fprintf('函数名 : %s\n', get_param(blks{k}, 'FunctionName')); fprintf('参数串 : %s\n', get_param(blks{k}, 'Parameters')); % 若该块被 Mask 包过,把 Mask 参数名和当前值也打出来 names = get_param(blks{k}, 'MaskNames'); if ~isempty(names) vals = get_param(blks{k}, 'MaskValues'); for j = 1:numel(names) fprintf(' mask %-8s = %s\n', names{j}, vals{j}); end end endLookUnderMasks和FollowLinks两个开关一定要开,否则find_system只看顶层,被 Mask 或库链接包住的块会整片漏掉——这是最常见的“我明明有五个块,脚本只找到一个”。get_param(blk,'Parameters')返回的是原始字符串,如果里面有逗号就要小心:S 函数的参数是按逗号切分的,写1, 2, 3是三个参数,写[1 2 3]是一个参数,两者在对话框里长得差不多,语义完全不同。
2.3 两条写入路线:重写参数串,还是走 SimulationInput
确认了参数位置,写入有两条路。
路线 A:直接重写 Parameters 字符串。适用于没有 Mask 的裸 S 函数块。
blk = 'demoTune/msf_TunableLag'; oldStr = get_param(blk, 'Parameters'); % 例如 '1.0, 0.05, 0' newStr = sprintf('%.6g, %.6g, %.6g', 2.4, 0.05, 0); set_param(blk, 'Parameters', newStr);这条路能跑,但很脆。参数一多,字符串拼接就容易错位;参数里一旦混进变量名(比如Kp, Tau, Y0),set_param写字符串等于把变量名覆盖成字面量,下次打开模型就再也回不去了。血泪经验是:只在临时验证时用它,正式流程不要用。
路线 B:走Simulink.SimulationInput。这是 R2017b 之后推荐的做法,一次仿真一份配置,不污染模型本身。
in = Simulink.SimulationInput('demoTune'); in = in.setBlockParameter('demoTune/msf_TunableLag', 'Kp', '2.4'); in = in.setBlockParameter('demoTune/msf_TunableLag', 'Tau', '0.05'); so = sim(in);setBlockParameter的第二个参数必须是参数名,所以这条路强依赖 Mask。没有 Mask 的块只能传'Parameters'加一整串字符串,又退回路线 A 的坑里。这也是下一章要把 Mask 建起来的原因:不是为了界面好看,是为了让参数有名字。
3. 把 S 函数改造成可调参形态:Level-2 骨架与 Mask 入口
自动调参能不能顺利,八成取决于建模阶段有没有把入口留好。这一章给一个最小可用、参数全部具名、行为确定的 M 文件 S 函数,再把它包成 Mask。
3.1 一个能被外部改参的 Level-2 M 文件 S 函数骨架
先明确一点:Level-1 的 M 文件 S 函数(函数签名是[sys,x0,str,ts] = f(t,x,u,flag,p1,p2))虽然还能用,但它的参数处理方式和调参工具链配合得很差,新写的一律用 Level-2。下面这个例子是一阶惯性环节加可调增益,固定步长 0.01 秒,三个参数。
function msf_TunableLag(block) %MSF_TUNABLELAG 一阶惯性 + 可调增益,固定步长,参数全部具名 setup(block); end function setup(block) block.NumInputPorts = 1; block.NumOutputPorts = 1; block.SetPreCompInpPortInfoToDynamic; block.SetPreCompOutPortInfoToDynamic; block.InputPort(1).Dimensions = 1; block.InputPort(1).DirectFeedthrough = true; % 输出直接依赖输入 block.OutputPort(1).Dimensions = 1; block.SampleTimes = [0.01 0]; % 固定步长,保证可复现 block.NumDialogPrms = 3; block.DialogPrmsTunable = {'Tunable','Tunable','Nontunable'}; block.RegBlockMethod('CheckParameters', @CheckPrms); block.RegBlockMethod('PostPropagationSetup', @DoPostPropSetup); block.RegBlockMethod('Start', @Start); block.RegBlockMethod('Outputs', @Outputs); endSampleTimes = [0.01 0]这一行是有意为之。变步长求解器在参数变化时会自适应调整步长,两次仿真的时间栅格对不齐,误差指标就没法直接相减;固定步长把这件事钉死,代价是精度靠步长换。DialogPrmsTunable里前两个标'Tunable',第三个初始值标'Nontunable'——初始值中途改了没有物理意义,标成不可调可以省掉一类难查的异常。
3.2 CheckParameters 与 Start:把非法参数挡在仿真之外
寻优器不会体谅你,它会把参数往负数、往零、往 1e6 上推。如果不在入口处拦住,报错信息会发生在很深的地方,排查成本极高。
function CheckPrms(block) kp = block.DialogPrm(1).Data; tau = block.DialogPrm(2).Data; y0 = block.DialogPrm(3).Data; validateattributes(kp, {'double'}, {'scalar','real','finite'}, mfilename, 'Kp'); validateattributes(tau, {'double'}, {'scalar','real','finite','>',0}, mfilename, 'Tau'); validateattributes(y0, {'double'}, {'scalar','real','finite'}, mfilename, 'Y0'); end function DoPostPropSetup(block) block.NumDworks = 1; block.Dwork(1).Name = 'y'; block.Dwork(1).Dimensions = 1; block.Dwork(1).DatatypeID = 0; % double block.Dwork(1).Complexity = 'Real'; block.Dwork(1).UsedAsDiscState = true; % 参与离散状态管理 end function Start(block) block.Dwork(1).Data = block.DialogPrm(3).Data; end function Outputs(block) Ts = block.SampleTimes(1); kp = block.DialogPrm(1).Data; tau = block.DialogPrm(2).Data; u = block.InputPort(1).Data; y = block.Dwork(1).Data; a = Ts / (tau + Ts); % 后向欧拉离散,恒稳定 y = y + a * (kp * u - y); block.Dwork(1).Data = y; block.OutputPort(1).Data = y; endvalidateattributes比手写if tau <= 0, error(...)好在报错信息里自带参数名和约束条件,寻优跑到一半炸了,日志里一眼能看出是哪个参数越界。离散化用后向欧拉而不是前向欧拉,是因为Ts/(tau+Ts)对任意正的tau都落在 0 到 1 之间,不会因为寻优器把tau推到小于Ts而发散——这算是给目标函数加了一层保险。
3.3 用 Mask 把 Kp、Tau、Y0 暴露成具名入口
有了骨架,下一步是让setBlockParameter能按名字寻址。顺序很关键:先写 Parameters 字段,再加 Mask。
blk = 'demoTune/msf_TunableLag'; % 第一步:把底层对话框参数改成变量名表达式 set_param(blk, 'Parameters', 'Kp, Tau, Y0'); % 第二步:建 Mask(已存在则复用) m = Simulink.Mask.get(blk); if isempty(m) m = Simulink.Mask.create(blk); end m.addParameter('Type','edit', 'Name','Kp', 'Prompt','比例增益 Kp'); m.addParameter('Type','edit', 'Name','Tau', 'Prompt','时间常数 Tau (s)'); m.addParameter('Type','edit', 'Name','Y0', 'Prompt','初始输出 Y0'); % 第三步:给一组保守初值,保证模型一打开就能跑 set_param(blk, 'Kp', '1.0', 'Tau', '0.05', 'Y0', '0'); % 第四步:验证具名寻址是否真的通了 fprintf('MaskNames = %s\n', strjoin(get_param(blk,'MaskNames'), ', ')); fprintf('MaskValues = %s\n', strjoin(get_param(blk,'MaskValues'), ', '));第四步的打印不能省。如果MaskNames打出来是空的,说明 Mask 没挂到目标块上,后面所有setBlockParameter都会静默失败或者报“参数不存在”。
提示:某些版本里 Mask 建好之后,直接
set_param写底层Parameters字段会被拦。稳妥做法是先把 Parameters 设好,再建 Mask;已经建反了就把 Mask 删掉重来,别硬顶。
到这里,模型侧的准备就结束了:参数有名字、有校验、有边界、行为确定。剩下的活全在 M 文件里。
4. 自动调参主循环:目标函数、寻优器与 parsim 调度
自动调参的骨架只有三件事:把参数变成仿真、把仿真变成标量、把标量交给寻优器。听着简单,但每一环都有能让流程静默失效的细节。
4.1 目标函数:把一次仿真压成一个标量
目标函数是整个流程的核心,它的健壮性直接决定寻优器会不会原地打转。
function J = costTunableLag(p, mdl, blk, ref) % p = [Kp, Tau];ref 含字段 t、y(参考时间序列) J = 1e6; % 失败惩罚值,把寻优器推离这个区域 try in = Simulink.SimulationInput(mdl); in = in.setBlockParameter(blk, 'Kp', sprintf('%.6g', p(1))); in = in.setBlockParameter(blk, 'Tau', sprintf('%.6g', p(2))); in = in.setModelParameter('StopTime', sprintf('%.6g', ref.t(end)), ... 'SimulationMode', 'normal', ... 'SaveOutput', 'on', ... 'OutputSaveName', 'yout', ... 'SaveFormat', 'Dataset'); so = sim(in); y = localGetSignal(so, 'yout'); yr = interp1(ref.t, ref.y, y.Time, 'linear', 'extrap'); % 统一到仿真时间栅格 e = y.Data - yr; J = sqrt(mean(e.^2)); % RMSE,也可换成 IAE / 超调加权 catch ME warning('cost:simFail', 'p=[%.4g %.4g] 仿真失败: %s', p(1), p(2), ME.message); end end function y = localGetSignal(so, name) v = so.get(name); if isa(v, 'timeseries') y = v; elseif isa(v, 'Simulink.SimulationData.Dataset') y = v.getElement(1).Values; else error('cost:badSignal', 'yout 类型不支持:%s', class(v)); end end几个参数值得说清楚。J的初值设为1e6而不是Inf:多数寻优器对Inf的处理是直接放弃该方向,对一个大而有限的值则还能继续试探,收敛行为更平滑。sprintf('%.6g', ...)是为了避免num2str默认四位有效数字把0.000123456截成0.0001235,参数精度被吃掉的后果是寻优器在极小步长上反复横跳却始终没有改进。SimulationMode显式设成'normal',是为了防止模型上次被人开着加速模式或快速重启,导致参数改了但没重新编译。
最后那段interp1是必须的。参考数据的时间和仿真输出的时间通常对不齐,直接相减要么报维度错,要么因为 MATLAB 的隐式扩展算出一个毫无意义的数。统一插值到仿真时间栅格上,误差才有物理意义。
注意:
yout的格式在不同版本里可能是timeseries、Dataset或struct,别硬编码其中一种。localGetSignal这层封装看着啰嗦,但换机器、换版本时能省掉半天排查。
4.2 寻优器选型:网格、fminsearch、patternsearch 的边界
同样是“自动调参数”,选错寻优器的代价可能是几十倍的计算量。
网格扫描适合一到三个参数、且大概知道量级的情况。它的价值不在精度,在于先把参数空间的形状画出来——目标函数是不是单峰、有没有大片平台区、边界在哪。做一次粗网格,后面所有方法都心里有底。
fminsearch(Nelder-Mead 单纯形)适合目标函数连续、维度低、不需要约束的场景。它的优点是零依赖、几行就能跑;缺点是遇到仿真失败造成的目标函数跳变时会直接崩掉单纯形,而且它不支持上下界,参数被推到非物理区间是常态。
patternsearch(模式搜索)是这类“仿真即目标函数”问题的默认选择。它不要求可导,支持上下界和线性约束,Cache选项能避免重复评估同一点——这在仿真里价值极大,因为一次仿真可能要几秒到几十秒。
p0 = [1.0, 0.05]; lb = [0, 1e-3]; ub = [20, 1.0]; opts = optimoptions('patternsearch', ... 'Display', 'iter', ... 'PollMethod', 'GPSPositiveBasis2N', ... 'MaxFunctionEvaluations', 120, ... 'MeshTolerance', 1e-3, ... 'Cache', 'on', ... 'UseParallel', false); obj = @(p) costTunableLag(p, 'demoTune', 'demoTune/msf_TunableLag', ref); [pBest, JBest] = patternsearch(obj, p0, [], [], [], [], lb, ub, [], opts); fprintf('最优 Kp = %.6g, Tau = %.6g, RMSE = %.6g\n', pBest(1), pBest(2), JBest);MaxFunctionEvaluations = 120是经验值:二维问题通常 40 到 80 次评估就能收敛到工程可用的精度,留一倍余量用于处理失败重试。MeshTolerance = 1e-3是停止阈值,设太小会让寻优器在最后一点点改进上耗掉一半时间,而这点改进在工程上根本看不出来。GPSPositiveBasis2N每次评估 2N 个方向点,二维就是 4 个,比GPSPositiveBasisNp1稳但更贵,参数维度高的时候可以换回来。
4.3 并行与批量:parsim 把网格铺到多核
网格扫描天生可并行。parsim接受一个SimulationInput数组,把每个配置分发到一个 worker,回来的时候按顺序给你结果。
gridKp = 0.5:0.5:8; gridTau = [0.01 0.02 0.05 0.1 0.2]; [KP, TA] = ndgrid(gridKp, gridTau); inputs = Simulink.SimulationInput.empty(0, numel(KP)); for k = 1:numel(KP) inputs(k) = Simulink.SimulationInput('demoTune'); inputs(k) = inputs(k).setBlockParameter('demoTune/msf_TunableLag', ... 'Kp', sprintf('%.6g', KP(k)), ... 'Tau', sprintf('%.6g', TA(k))); inputs(k) = inputs(k).setModelParameter('StopTime', '5', ... 'SaveOutput', 'on', 'OutputSaveName', 'yout', ... 'SaveFormat', 'Dataset'); end outs = parsim(inputs, 'UseParallel', true, 'ShowProgress', 'off', ... 'TransferBaseWorkspaceVariables', 'on'); J = zeros(size(outs)); for k = 1:numel(outs) if isempty(outs(k).ErrorMessage) y = localGetSignal(outs(k), 'yout'); yr = interp1(ref.t, ref.y, y.Time, 'linear', 'extrap'); J(k) = sqrt(mean((y.Data - yr).^2)); else J(k) = NaN; % 该点仿真失败,标记而非中断 end endTransferBaseWorkspaceVariables这个开关必须开。模型里但凡有一个变量是从基础工作区取值的(比如simin、Ts、查表数据),worker 上看不到它,仿真会直接报“未定义变量”。开了之后parsim会把基础工作区整体打包送过去,代价是传输开销,变量很大时可以改用setVariable只传需要的。
失败点用NaN标记而不是让脚本中断,是为了能一次性看到整张参数地图。扫完之后imagesc(J)一画,哪片区域是稳定域、哪片全炸,一目了然。
4.4 日志与断点续跑
调参跑一次可能是半小时,中途被人打断、被内存杀掉都很正常。所以每评估一次就把结果落盘,别攒着最后一起写。
logFile = 'tuneLog.mat'; if exist(logFile, 'file') load(logFile, 'records'); % records: struct array,含 p、J、timestamp else records = struct('p', {}, 'J', {}, 'timestamp', {}); end function records = appendRecord(records, p, J, logFile) rec.p = p(:)'; rec.J = J; rec.timestamp = datestr(now, 'yyyy-mm-dd HH:MM:SS'); records(end+1) = rec; save(logFile, 'records'); end把records也接进目标函数,就自动获得了断点续跑能力:重跑时先ismember查一下这个参数组合评过没有,评过就直接返回缓存值。这一招在patternsearch的Cache之外再加一层,跨进程也能复用。
5. 避坑:改完参数结果不变等五类翻车现场
下面这五条都是实际踩过的,按“现象 → 原因 → 解决”写,遇到时直接对号入座。
第一类:改了参数,仿真曲线一模一样。
现象:脚本里setBlockParameter打印出新的参数值,仿真也跑完了,但yout跟改之前逐点相同。 原因:模型的快速重启(Fast Restart)还开着,Simulink 复用上一次编译好的模型,参数写进去也没触发重编译;或者参数在 S 函数里本来就是硬编码常量,压根没走DialogPrm。 解决:在SimulationInput里显式加in.setModelParameter('FastRestart','off');顺手用Simulink.BlockDiagram.getChecksum('demoTune')在set_param前后各取一次,两次一致就说明改动没进模型,早点发现比跑完二十次才发现强。
第二类:报“S-function 的参数个数不匹配”。
现象:模型单独打开能跑,一进脚本就报参数数量错误。 原因:Parameters字段里的逗号被当成了参数分隔符。比如想把一个 1×3 向量当一个参数传进去,写成了1, 2, 3,Simulink 认为你要传三个参数,而block.NumDialogPrms还是 1。 解决:向量参数一律用方括号包起来写成[1 2 3],或者更稳一点,写成单个变量名w,让 Mask 提供这个变量。改完再核一遍get_param(blk,'Parameters')里逗号的个数是否等于NumDialogPrms。
第三类:parsim 在 worker 上大量报“未定义变量”。
现象:本地sim正常,一开并行就整片失败,错误信息指向模型里某个工作区变量。 原因:基础工作区的变量没跟着SimulationInput走到 worker 上。 解决:优先用setVariable显式声明每个外部变量,比整体TransferBaseWorkspaceVariables更可控;实在变量太多就开整体传输,但要检查变量里有没有包含大数组——那会把传输时间拖到比仿真本身还长。
第四类:寻优器在某个点上反复评估,指标一直是 1e6。
现象:patternsearch的迭代输出里同一个点被评估好几次,或者在某个区域长时间不动。 原因:目标函数里catch把所有异常都吞了,warning又被Display关掉,外面看不到;同时惩罚值给得不够大,寻优器以为那里有改进空间。 解决:把失败的参数组合单独打到failedLog.txt里,跑完一次性看;惩罚值要明显大于任何合理解的目标函数值(本例里 RMSE 的量级在个位数,1e6 是够的),但也不能用Inf,否则模式搜索会直接放弃方向。
第五类:参数被推到完全不符合物理的区间。
现象:Kp收敛到 1e4,Tau收敛到 1e-9,指标确实很小,但模型在实物上不可能这么工作。 原因:只给了目标函数,没给约束。 解决:patternsearch的lb/ub一定要填,而且要在CheckParameters里再校验一次——两层防线不冗余,因为寻优器在试探边界时可能传进来一个刚好越界的值,validateattributes会给出带参数名的报错,比在仿真深处炸掉好查得多。
6. 让结果可回归:一次调参怎么变成能复查的流程
调到一组好参数很容易,难的是三个月后有人问你“这组参数怎么来的、换台机器还能不能复现”。我的做法是给每次调参留三组对照,跑完一起看。
| 对照组 | 来源 | 用途 | 看什么 |
|---|---|---|---|
| 手调基准 | 建模阶段人工试出的参数 | 下限,自动结果不能比它差 | RMSE 差值 |
| 自动结果 | patternsearch 输出 | 主结果 | RMSE、迭代次数 |
| 扰动组 | 对最优参数每个维度 ±5% | 判断解是否落在平坦区 | RMSE 变化幅度 |
第三组最有价值。如果 ±5% 扰动后 RMSE 只变化 1% 到 2%,说明这组参数处在一个平坦的盆地底部,实际部署时容差够;如果某个维度动 5% 指标就翻倍,那这个参数是被“顶”在刀刃上的,工程上不可靠,宁可换一组稍差但更平缓的。
扰动组的代码很短,可以直接挂在前一章的parsim框架上:
pBest = [2.4, 0.05]; pert = [1.05 1.00; 0.95 1.00; 1.00 1.05; 1.00 0.95]; P = pBest .* pert; % 4 行 2 列 Jp = zeros(size(P,1),1); for k = 1:size(P,1) Jp(k) = costTunableLag(P(k,:), 'demoTune', 'demoTune/msf_TunableLag', ref); end fprintf('基准 RMSE = %.5g\n', costTunableLag(pBest, 'demoTune', 'demoTune/msf_TunableLag', ref)); fprintf('扰动后 RMSE 范围 = [%.5g, %.5g]\n', min(Jp), max(Jp));复现性还有两件事必须钉死。一是随机源:S 函数内部但凡用了rand,在Start里加一行rng(20240101)之类的固定种子,或者干脆把随机部分拿出来改成外部输入。二是求解器:把模型的Solver显式写成'FixedStepDiscrete'、FixedStep写成'0.01',别依赖模型文件里存的上一次设置——那个设置经常在别人打开模型时被顺手改掉。
set_param('demoTune', 'Solver','FixedStepDiscrete', 'FixedStep','0.01', ... 'StopTime','5', 'SaveOutput','on', ... 'SaveFormat','Dataset', 'FastRestart','off');这行set_param我一般写在调参脚本的第一行,而不是依赖模型本身的配置。因为模型文件是会被人改的,脚本不会。
从那以后我每次做 S 函数调参,都强制走一遍“跑之前先 print 参数串、跑之后先比 checksum”这两步,哪怕模型是我自己刚建的。看着像多余动作,但它拦下过的无效仿真,比任何一条优化技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取