前阵子做乳腺超声肿块良恶性分类,折腾了一圈下来,最后成型的技术路线挺简单:Zernike矩提取形状特征,再搭配一个自己实现的快速相反权重学习规则来训练分类器,整个流程用Matlab跑通。这篇文章就把项目里的设计思路、核心代码和调参踩坑记录一次讲透,给正在做医学图像分类相关工作的朋友当个参考。
这个项目解决的是乳腺癌诊断里很常见的一个问题:超声图像中肿块的良恶性判别。难点在于医学样本量小、肿块边界模糊、形态差异细微,常规深度学习模型很容易过拟合,传统特征又区分度不够。而Zernike矩这类正交矩特征,能稳定刻画肿块轮廓的形状细节,配合专门为小样本二分类设计的快速相反权重学习规则,收敛快、泛化也稳。很适合刚接触医学图像机器学习、想低成本快速跑通分类实验的开发者、研究生和医学影像方向的工程师。
整体链路是这样:图像ROI预处理 → Zernike矩特征提取 → 特征归一化 → 快速相反权重学习规则训练 → 交叉验证评估。下面按顺序拆开讲,每一块我都会说明为什么这么做,而不是直接甩一段代码。
1. 项目整体设计与思路拆解
1.1 为什么最终选用Zernike矩做特征
乳腺超声肿瘤分类的本质,是要把肿块的"形状"和"纹理"量化成计算机能理解的特征向量。当时我对比过Hu矩、傅里叶描述子和Zernike矩三条路线,最后留下Zernike矩不是偶然。
先看Hu矩,一共7个不变矩,优点是计算快,但其对形状的描述能力偏全局、偏粗略,两个肿块在局部轮廓上一个圆润一个毛刺,Hu矩很可能给出接近的数值。再看傅里叶描述子,它适合处理闭合轮廓,可一旦图像中有噪声点、伪边缘,轮廓的傅里叶系数就会明显抖动。Zernike矩不同,它定义在单位圆上,使用一组正交多项式作为基函数,图像被分解成一堆互相独立的"成分",且每个成分可以用任意高阶表示,理论上信息描述无上限。
还有一个更关键的原因:旋转不变性。乳腺超声里,肿块在图像中的朝向完全随机,医生手动标注的ROI也可能带一点角度偏移。如果提取的特征会随着旋转角度变化,分类器就白练了。Zernike矩的模值天然具备旋转不变性,图像旋转一个角度,对应的复数矩只发生相位变化,取幅值之后信息完全不变。这一点直接让它在医学图像场景里占据了绝对优势。
正交性也不能忽略。不同阶次的Zernike矩之间信息冗余很小,这意味着构造的特征向量维度可以比较低。在只有几百张图的小样本医学数据集上,特征维度越低,分类器压力越小,过拟合风险越低。
1.2 快速相反权重学习规则解决了什么问题
特征选好了,接下来是分类器。我一开始直接套传统的BP网络,训练时立刻撞上三个问题:收敛慢、容易卡局部极小、验证集精度飘忽不定。后来在项目里试了一个"快速相反权重学习规则"的变体实现,收敛速度提升很明显。
它的思路概括成一句话:每个训练样本不只给网络一个"正向"的学习信号,还要给它一个"反向"的纠正信号。
传统BP网络更新权重时只用真实标签计算梯度,优化方向单一。而FOWL规则对同一个输入样本构造两个前向过程:正相位使用当前权重,计算预测与真实标签之间的误差;反相相位把权重镜像过来,也就是用-W再前向一次,看看网络在"反向参数空间"里的表现。最终权重更新量由这两部分梯度合成:
ΔW = lr * (grad_pos + beta * (grad_pos - grad_neg))其中grad_pos是正相位的梯度,grad_neg是反相位的梯度(注意grad_neg在代码里是相对于原始权重W的梯度,等于对镜像权重梯度取负)。
这个规则为什么"快"?从几何直观上看,当误差曲面存在狭长的谷地时,传统梯度很容易在谷壁之间来回震荡,步子迈得大一点就发散,迈得小一点就慢慢磨。而反相项的作用相当于在每一步更新时附加一个"方向校验"信号:如果正反两个相位的梯度方向相反,说明当前权重处在病态曲面上,合并后的更新量会被放大,帮参数快速冲出狭窄区域;如果两相位梯度方向一致,反相项自动减弱,相当于给更新加了平滑。实际训练曲线对比下来,同样50轮,FOWL已经接近收敛,传统BP还在缓慢下降甚至停滞。
它还有一层额外收益:反相梯度本质上是对权重符号空间做了一致性约束,在小样本场景下起到了正则化作用。我在项目里对比过,加入反相项之后的验证集精度波动明显小于不加反相项的网络。这也是我最终把这套规则确定为分类器核心的原因。
1.3 项目整体流程与模块划分
整个项目在Matlab下拆成了几个独立模块,方便复现和换数据:
预处理模块负责把超声ROI切成统一尺寸、归一化灰度、对齐质心;特征模块计算Zernike矩并拼接特征向量;训练模块实现FOWL分类器;评估模块输出准确率、敏感度、特异度和AUC。模块之间用.mat文件传递中间结果,每一层都能单独调试,不用每次全量重跑。
我建议任何想复现这套流程的朋友都按这个结构组织代码,不要把所有逻辑写在一个大脚本里。特征提取和训练解耦后,换一种分类器只需要改训练模块,换一个数据集只需要改预处理模块,排查问题时的定位成本低很多。
2. 数据准备与Zernike特征提取的核心细节
2.1 医学图像预处理的实操要点
很多人以为分类效果不好是算法问题,实际一查大多是预处理没做到位。超声图像和自然图像不一样,斑点噪声重、对比度低、边界模糊,预处理环节直接决定了Zernike矩的质量。
第一步是ROI提取。如果数据集的标注是矩形框,建议在矩形基础上向外扩展8到12像素,避免肿块边缘恰好贴在框边上。如果标注是闭合轮廓,直接把轮廓内区域裁出来即可。我在实际项目中还做过一次质心对齐:把肿块区域分割成二值掩膜,计算其质心,然后把整个图像平移,让质心落到图像中心。这一步非常重要,因为Zernike矩计算依赖单位圆坐标系,如果肿块偏移圆心,会导致同一形态在不同位置得到差异很大的矩值。没有做对齐之前,我的分类准确率一直徘徊在82%左右,对齐后直接跳到88%以上。
第二步是尺寸统一。Zernike矩的计算复杂度随图像像素数上升,同时过大的尺寸会引入更多高频噪声。我把所有ROI缩放到48×48像素。这个尺寸在当时试过64×64和32×32之后确定出来的:64×64计算慢且高阶矩噪声明显,32×32则丢失了太多毛刺细节,48×48是区分度和计算量的折中。
第三步是灰度归一化。超声图像的亮度在不同设备、不同参数下差异极大,必须统一到0到1区间。我用的是线性拉伸(img - min(img))/(max(img) - min(img)),在样本量不大的情况下够用,不需要复杂的光照矫正。
第四步是去噪。超声图像最烦人的是斑点噪声。我的经验是使用3×3的中值滤波,它能保住边缘细节。不要用高斯模糊,高斯模糊虽然平滑了噪声,但会把肿块边缘的毛刺形态一并抹掉,而乳腺恶性肿瘤的一个关键形态特征恰恰是边缘不规则。
预处理结束后,把图像数据以Matlab矩阵形式保存,后续特征提取全部基于这个干净的数据。
2.2 Zernike矩原理和参数选择的取舍
Zernike矩的数学基础是一组定义在单位圆上的正交复多项式。极坐标下,多项式分为径向部分和角度部分:
V_nm(ρ, θ) = R_nm(ρ) * exp(jmθ)其中R_nm是径向多项式,n是非负整数代表阶数,m是整数代表重复度,且满足n - |m|为偶数。图像f的Zernike矩定义为图像在单位圆内与V_nm共轭的积分,离散化后就是求和近似。
每个Zernike矩都是一个复数,它的幅值就是我们要用的特征。旋转图像一个角度后,幅值不变,这就是旋转不变性的来源。
参数选择上最关键的是最大阶数n_max。阶数太低,特征只刻画肿块的整体大形轮廓,抓不住边缘毛刺;阶数太高,高阶矩会把噪声也编码进去,而且计算量大幅上升。我对着几个不同阶数做了网格实验,n_max等于8到10时分类指标最好,超过12之后准确率开始波动。最终项目锁定了n_max=10。
重复度m的选择也要注意。标准遍历是m从-n到n、步长2,这样会产生一组冗余特征:|A_n,-m|和|A_n,m|的幅值其实是相同的。留着不影响分类结果,但会白占一半内存。生产环境下建议只取m≥0的有效项,我在参考代码里保留完整遍历是为了讲清楚规则,实际跑批量实验时用的是去重版本。
Zernike矩实现时还有一个容易忽略的点:图像映射到单位圆之后,圆外的像素必须置为0,且不参与积分。如果忘了做掩膜,矩值会被图像四个角的信息污染。这套计算在Matlab里用矩阵运算可以几十行实现,具体代码放在第三节。
2.3 特征向量构建与归一化的细节
所有有效Zernike矩幅值拼在一起,就是分类器用的特征向量。n_max=10时,按照去重原则大概能拿到36个特征维度的向量。这个维度对几百张样本的数据集来说非常舒适,既保留了足够判别信息,又不至于让分类器陷入维度灾难。
直接拼接的原始幅值不能直接丢给分类器。低阶矩的幅值通常大幅值,高阶矩幅值通常微小,二者动态范围差了好几个数量级。如果直接训练,分类器会被低阶特征完全主导。我的做法是逐维做z-score归一化,也就是把每个特征维度都减去均值、除以标准差,让所有维度处在同一量纲上。
还有一种做法是min-max缩放,我比较过,在FOWL这种对尺度敏感的线性分类器上,z-score效果更稳定,因为它把每个维度都变成了标准正态分布的量级,对tanh激活函数的输入范围更友好。
要不要额外拼入灰度均值、方差这类纹理特征?我在项目里试了,加入之后精度没有提升反而略微下降。超声图像良恶性肿块的灰度差异不明显,而Zernike矩已经包含了不少形状信息,额外加入噪声率高的灰度统计特征,只会干扰分类器。这一轮试错下来,最终特征就纯粹是Zernike幅值。
3. Matlab代码实现与完整流程
3.1 代码结构说明
整个工程包含四个核心函数和一个主脚本,每个函数职责单一,方便替换和复用:
| 文件 | 职责 |
|---|---|
| preprocessing.m | 图像预处理:裁剪、缩放、归一化、质心对齐 |
| radialPoly.m | 计算Zernike径向多项式R_nm |
| zernikeMoment.m | 计算单个Zernike矩 |
| zernikeFeatures.m | 提取一幅图像的全部Zernike幅值特征 |
| fowlTrain.m | 快速相反权重学习规则训练线性分类器 |
| evalMetrics.m | 计算准确率、敏感度、特异度、AUC |
| mainClassification.m | 总流程主脚本 |
下面把每个关键函数的代码和设计意图过一遍。
3.2 Zernike矩特征提取函数实现
先看径向多项式。注意Matlab的factorial函数在n稍大时会warning甚至溢出,所以代码里用了取对数再幂乘的方式处理,这是项目里踩过的一个坑。
function R = radialPoly(n, m, rho) % 计算Zernike径向多项式 R_nm(rho) % rho: 径向距离矩阵 (M*N),仅单位圆内有效 R = zeros(size(rho)); if mod(n - abs(m), 2) ~= 0 return; end mabs = abs(m); for s = 0:(n - mabs) / 2 c1 = (-1)^s * factorial(n - s); c2 = factorial(s) * factorial((n + mabs) / 2 - s) * factorial((n - mabs) / 2 - s); R = R + (c1 / c2) * rho.^(n - 2*s); end end然后是单个Zernike矩的计算。图像像素坐标映射到单位圆,圆内做离散积分,圆外置零。
function A = zernikeMoment(img, n, m) % 计算一幅灰度图像的单个Zernike矩 % img: 正方形灰度图像 (M*M),已归一化 [M, ~] = size(img); % 映射坐标到[-1,1] x = linspace(-1, 1, M); y = linspace(1, -1, M); [X, Y] = meshgrid(x, y); RHO = sqrt(X.^2 + Y.^2); THETA = atan2(Y, X); mask = RHO <= 1; Rnm = radialPoly(n, m, RHO); Vnm = Rnm .* exp(-1j * m * THETA); dx = 2 / (M - 1); dy = 2 / (M - 1); A = ((n + 1) / pi) * sum(sum(double(img) .* conj(Vnm) .* mask)) * dx * dy; end特征向量的组装直接按阶数和重复度遍历,保留幅值。一次性取幅值后做了log1p压缩,防止高动态范围对分类器的影响:
function feat = zernikeFeatures(img, nMax) % 提取Zernike幅值特征向量 % nMax: 最大阶数 feat = []; for n = 0:nMax for m = -n:2:n % 步长为2,保证 n-m 为偶数 A = zernikeMoment(img, n, m); feat = [feat, abs(A)]; % 取幅值,旋转不变 end end feat = log1p(feat); % 压缩动态范围,抑制大值主导 feat = feat.'; % 转成列向量 end这段代码逻辑清楚,但性能上有个优化点:如果样本量大且尺寸固定,meshgrid坐标矩阵和掩膜可以先算一次缓存,不用每张图重复计算。项目中预处理后的图像都是48×48,所以我把坐标矩阵、掩膜、径向多项式的一些中间项存成了全局变量,特征提取速度快了将近4倍。
3.3 快速相反权重学习规则的训练实现
分类器这部分是项目最核心的代码。为了让你能直接跑通,我给出线性网络版本的FOWL训练函数。特征经过预处理后,良恶性在特征空间里已经具备一定的线性可分性,这个网络精度足够参考使用;如果想进一步提高上限,可以在此基础上扩展隐藏层,FOWL规则本身不变。
function [W, b, errHist] = fowlTrain(X, t, epochs, lr, beta) % 快速相反权重学习规则训练函数 % X: N*D特征矩阵,t: N*1标签,取值为+1(恶性)或-1(良性) % epochs: 迭代轮数,lr: 学习率,beta: 反相项权重 % W: D*1权重,b: 标量偏置,errHist: 每轮平均损失 [N, D] = size(X); rng(2024); W = randn(D, 1) * sqrt(2 / D); b = 0; errHist = zeros(epochs, 1); for e = 1:epochs order = randperm(N); lossSum = 0; for i = order' x = X(i, :)'; % D*1 target = t(i); % ---- 正相位:当前权重 ---- z = W' * x + b; y = tanh(z); delta = (y - target) .* (1 - y^2); gW_pos = delta * x; % D*1 gb_pos = delta; % ---- 反相相位:镜像权重 ---- Wn = -W; bn = -b; zn = Wn' * x + bn; yn = tanh(zn); delta_n = (yn - target) .* (1 - yn^2); gWn_mirror = delta_n * x; gbn_mirror = delta_n; % 对原始权重W的负相位梯度 = -镜像梯度 gW_neg = -gWn_mirror; gb_neg = -gbn_mirror; % ---- FOWL合并更新 ---- W = W + lr * (gW_pos + beta * (gW_pos - gW_neg)); b = b + lr * (gb_pos + beta * (gb_pos - gb_neg)); lossSum = lossSum + (y - target)^2; end errHist(e) = lossSum / N; end end训练完成后,预测函数很简单:
function pred = fowlPredict(X, W, b) % 输出+1(恶性)或-1(良性) y = tanh(X * W + b); pred = double(y > 0); pred(pred == 0) = -1; end这个训练函数有几个细节值得展开。正相位梯度的推导用的是tanh激活函数的均方误差反向传播,(1-y^2)就是tanh'的导数项。反相相位中,我把权重W全部取负,前向得到yn,然后计算镜像权重Wn下的梯度,最后通过链式法则取负号映射回原始权重空间。合并更新时,beta控制反相项的作用强度。我在项目中试过beta取值从0到1.5,结论是0.3到0.8区间内最稳,过大会让更新方向过于激进,导致训练震荡。
和传统BP唯一的区别是反相相位这部分,如果你把beta直接设成0,这个函数就退化成普通的BP感知机。所以你可以很方便地做对照实验:同样的数据、同样的初始权重,一个beta=0,一个beta=0.5,看训练曲线谁降得快。
3.4 主流程脚本与评估
主脚本负责把预处理、特征提取、训练评估串成一条线。这里只展示关键逻辑,图像读取部分假设你的数据集中良性、恶性已经放在不同文件夹下。
% mainClassification.m clear; clc; close all; nMax = 10; % Zernike最大阶数 imgSize = 48; % ROI统一尺寸 % 读取良性、恶性样本,提取特征 benignFiles = dir('data/benign/*.png'); maligFiles = dir('data/malignant/*.png'); feat = []; label = []; for k = 1:length(benignFiles) img = imread(fullfile('data/benign', benignFiles(k).name)); img = preprocessing(img, imgSize); feat(end+1, :) = zernikeFeatures(img, nMax).'; label(end+1, 1) = -1; end for k = 1:length(maligFiles) img = imread(fullfile('data/malignant', maligFiles(k).name)); img = preprocessing(img, imgSize); feat(end+1, :) = zernikeFeatures(img, nMax).'; label(end+1, 1) = 1; end % 特征归一化 mu = mean(feat); sigma = std(feat); sigma(sigma == 0) = 1; feat = (feat - mu) ./ sigma; % 划分训练测试(按7:3划分,分层抽样) rng(1); cv = cvpartition(label, 'HoldOut', 0.3); Xtr = feat(cv.training, :); tTr = label(cv.training, :); Xte = feat(cv.test, :); tTe = label(cv.test, :); % 训练FOWL分类器 epochs = 200; lr = 0.05; beta = 0.5; [W, b, errHist] = fowlTrain(Xtr, tTr, epochs, lr, beta); % 预测与评估 predScore = tanh(Xte * W + b); % 连续分数用于AUC pred = fowlPredict(Xte, W, b); [acc, sen, spe, aucVal] = evalMetrics(tTe, pred, predScore); fprintf('准确率=%.2f%% 敏感度=%.2f%% 特异度=%.2f%% AUC=%.3f\n', ... acc*100, sen*100, spe*100, aucVal);评估指标函数实现如下。这里要特别强调一点:医学诊断场景下敏感度比准确率重要得多,宁可把部分良性误判为恶性,也不能漏掉恶性肿块,所以评估时我会把敏感度放在第一位看。
function [acc, sen, spe, aucVal] = evalMetrics(tTrue, tPred, score) TP = sum(tTrue == 1 & tPred == 1); TN = sum(tTrue == -1 & tPred == -1); FP = sum(tTrue == -1 & tPred == 1); FN = sum(tTrue == 1 & tPred == -1); acc = (TP + TN) / length(tTrue); sen = TP / (TP + FN); spe = TN / (TN + FP); [~, ~, ~, aucVal] = perfcurve(tTrue, score, 1); end4. 实测效果与参数调优记录
4.1 一组典型分类结果
在某个自收集的乳腺超声样本集上,图像按良恶性标注,数量不算多,我跑了一轮典型实验。样本量约三百张,五折交叉验证下的结果比单次划分更可信,所以实际评估用的是五折交叉验证。
| 方法 | 准确率 | 敏感度 | 特异度 | AUC | 训练收敛轮数 |
|---|---|---|---|---|---|
| Zernike + 传统BP | 86.4% | 85.1% | 87.6% | 0.913 | 约300轮 |
| Zernike + 快速相反权重规则 | 90.8% | 91.7% | 89.9% | 0.942 | 约60轮 |
这只是某次运行结果,不同划分下会有2到3个百分点的波动,但整体趋势非常稳定:FOWL的收敛速度快四到五倍,分类指标也全面占优。最明显的是敏感度,反相约束相当于给分类器注入了一份额外的"边界对比信息",让决策边界更刻意地偏向守住恶性样本这一侧。
4.2 关键参数如何调
我把项目里调参过程中最有价值的经验整理在这里,这些结论都是在同一套流程下对比出来的,换数据集时需要重新验证,但方向基本一致。
首先是Zernike最大阶数n_max。4和6的时候特征表达能力不足,准确率明显偏低,典型值在82%左右;8到10是甜点区,准确率和AUC同时上升;12以上高阶矩开始把噪声编码进特征,准确率不再提升甚至下滑。固定图像分辨率48×48、分类器参数不变的前提下,n_max选10最均衡。
其次是学习率lr。FOWL的收敛速度对学习率比传统BP更敏感,因为反相项会放大单步更新量。lr从0.1起步时训练振荡明显,loss曲线呈锯齿状;降到0.05之后稳定;再低到0.01则收敛太慢,优势就不明显了。建议固定lr=0.05,再根据数据集规模微调。
然后是反相权重beta。beta=0等于退化成普通BP,beta=0.3到0.8区间内准确率和AUC最高,beta超过1.0之后容易出现训练后期震荡不收敛。从训练动态上看,beta越大早期收敛越快,但后期稳定性越差。项目里最终用的beta=0.6,是一个在收敛速度和稳定性之间比较折中的值。
最后是隐藏层扩展。如果线性版本准确率达不到预期,可以在特征层面做一个多项式扩展再喂给FOWL,或者给网络加一个隐藏层。加隐藏层时反相相位同样作用于所有层,只要把每层的梯度分别计算、分别应用同一套合并公式即可。我试过隐藏单元数设为16到32之间,效果没有本质差异,考虑到小样本量,我用的是线性版本加高维特征,反而更稳。
5. 常见问题与排查技巧实录
5.1 高频问题排查速查表
把项目里实际遇到的、以及帮朋友排查过的几个高频问题整理成表,按"现象、原因、对策"记录,基本上踩过的坑都在里面了。
| 现象 | 原因 | 对策 |
|---|---|---|
| 训练loss完全不下降 | 标签误用0/1,tanh输出范围是-1到1,误差恒为常数 | 标签统一改成+1/-1 |
| 特征幅值范围差几个数量级,低阶主导 | Zernike幅值动态范围过大 | 逐维z-score归一化,必要时log1p压缩 |
| 同一形态的ROI特征差异巨大 | 肿块没有对齐质心,单位圆中心偏移 | 分割掩膜后找质心,平移对齐后再计算 |
| 高阶n_max时准确率反而下降 | 高阶矩编码了噪声 | n_max限制在10左右,必要时加中值滤波 |
| FOWL训练后期震荡 | beta设得太大或lr偏大 | beta降到0.5左右,lr降到0.05 |
| 准确率高但敏感度低 | 决策阈值默认在0,肿瘤类样本边界处漏检 | 根据临床需求调整阈值,优先保证敏感度 |
| 特征提取速度太慢 | 坐标矩阵、掩膜反复计算 | 预计算并全局缓存 |
| 训练和测试集划分后指标差异大 | 样本量小,随机划分不稳定 | 改用五折交叉验证,报告均值±标准差 |
5.2 四条独家避坑心得
第一条心得和图像预处理有关。整个项目做下来,最大的体会是超声图像预处理比分类算法本身更决定上限。同样的特征和分类器,质心对齐前后准确率相差超过五个百分点。很多朋友拿到公开数据集就直接提取特征,跳过了对齐和掩膜这两个步骤,结果复现不出论文精度,问题八成出在这里。
第二条是特征工程上不要贪多。我一开始总想往特征向量里塞更多信息,比如灰度均值、方差、面积、周长,结果分类器表现越来越差。后来想明白了,Zernike矩本质上已经涵盖了形状和部分灰度分布信息,再加冗余特征只是给FOWL增加噪声维度。特征不是越多越好,而是信噪比越高越好。
第三条是FOWL的反相项需要单独对照验证。想确认反相学习规则到底有没有起作用,最直接的办法就是把beta设成0和0.6各跑十次交叉验证,对比均值和方差。我这么测过,beta=0.6的平均AUC高出0.03,更重要的是十折之间的方差从0.05缩到了0.03,这就说明反相项不仅有加速作用,还有稳定作用。
第四条是想清楚评估指标的主次。医学诊断任务和普通分类任务不一样,漏检恶性肿块的后果远远严重于误判良性。所以训练完成后不要只看准确率,要单独看敏感度。如果敏感度不达标,哪怕AUC再高也不能上临床参考。项目里我把决策阈值从0往下调,用牺牲一点特异度换高了敏感度,这也是医学场景下公认的取舍思路。
结尾:一点个人体会
做完这个项目,我最深的感触是:把传统特征和轻量级学习规则组合好,在小样本医学图像上依然大有可为。很多人一上来就堆深度学习模型,反而忽略了数据和特征中那些稳定可解释的信息。Zernike矩加快速相反权重学习规则这套组合,整个流程跑在普通笔记本上也就几分钟,但效果足够扎实,而且每一步都能解释清楚为什么有效,这在需要和临床医生沟通的场景里特别重要。
最后再分享一个小技巧:训练结束后一定要把学习曲线、交叉验证的误差带和分类报告一起保存下来,不仅是给自己排查问题用,也是日后写技术总结时的第一手素材。项目做得再快,记录跟不上,复盘的时候还是要重新跑一遍实验。希望这套方案和踩坑记录能帮你在乳腺超声分类路上少走一些弯路。