cuSPARSE:稀疏矩阵运算。这里是不是代表英伟达自己撰写了很多优化函数算子?
NVIDIA 有专门的工程团队,针对自家每一代 GPU 架构(Volta、Turing、Ampere、Hopper),用底层 CUDA 汇编(PTX/SASS)手写这些算子的实现。这些实现会利用到很多普通开发者接触不到的硬件细节,比如 Tensor Core 的特定矩阵分块大小、共享内存的 bank conflict 规避策略、Warp 级别的 shuffle 指令、流水线预取等。
举个具体例子:同样是一个 256×256 的矩阵乘法,你自己写一个 naive 的三重循环 CUDA kernel,可能跑 2ms;用 cuBLAS 里 NVIDIA 手写的优化版本,可能只要 0.05ms。差距在于 cuBLAS 的实现做了分块(tiling)让数据驻留在共享内存中、用 Tensor Core 做加速、精心编排了数据预取和计算的重叠流水线,这些优化不是算法层面的创新,而是纯粹的硬件工程优化——只有最了解硬件的人(也就是造硬件的 NVIDIA 自己)才能写到极致。
所以 TensorRT 的性能优势有一部分就来自于此——它底层调用的是 NVIDIA 自家最优化的算子库,而 PyTorch 虽然也调用 cuDNN 和 cuBLAS,但在算子调度、内存管理、融合策略上没有 TensorRT 那么激进。
一、Kernel Launch 开销是什么
GPU 上每一个计算操作(比如一次矩阵乘法、一次卷积、一次 ReLU)都对应一个 kernel——本质上就是一段在 GPU 上并行执行的函数代码。
但 kernel 不是自己跑起来的,需要 CPU 发起调用,这个过程叫 kernel launch。每次 launch 大致要做这些事:CPU 把 kernel 的参数(输入数据指针、输出数据指针、网格/线程块配置等)打包,通过 PCIe 或 NVLink 发送给 GPU 的命令队列,GPU 的调度器从队列中取出指令并分配到 SM 上执行。
这个过程本身有固定延迟,大约 3-10 微秒。听起来很小,但如果你的模型有几百个算子,每个算子都要 launch 一次 kernel,这些延迟累积起来就很可观了。特别是当单个 kernel 的实际计算量很小时(比如一个简单的 ReLU 或 BatchNorm),launch 开销甚至可能超过计算本身的时间,GPU 大部分时间在等 CPU 发指令而不是在做计算。
这就是为什么 TensorRT 要做 层融合(Layer Fusion)——把多个小算子合并成一个大 kernel,一次 launch 完成原本需要多次 launch 的计算,减少 launch 次数。比如把 Conv → BatchNorm → ReLU 三个 kernel 融合成一个 kernel,launch 开销从 3 次变成 1 次。
二、中间内存读写 / 不必要的数据搬运
以 Conv → BatchNorm → ReLU 这个典型序列为例,在没有融合的 PyTorch 执行中:
-
Conv kernel 从显存读取输入,计算完毕,把输出写回显存
-
BatchNorm kernel 从显存读取 Conv 的输出,计算完毕,把输出写回显存
-
ReLU kernel 从显存读取 BatchNorm 的输出,计算完毕,把输出写回显存
每一步之间都有一次"写回显存 → 再从显存读取"的往返。显存(HBM/GDDR)的带宽虽然高(比如 H100 的 HBM3 有 3TB/s),但延迟比片上缓存(寄存器、Shared Memory、L1 Cache)高一到两个数量级。
这些中间结果其实是临时数据,只有下一个算子需要,不需要长期保存在显存中。如果三个算子融合成一个 kernel,Conv 的输出可以直接留在寄存器或共享内存中传给 BatchNorm 的逻辑,BatchNorm 的输出继续留在片上传给 ReLU,最终只有 ReLU 的输出才写回显存。中间两次显存读写完全消除了。
这就是"不必要的数据搬运"——数据在显存和计算单元之间做了本不需要的往返。
三、调度更优 kernel 是什么意思
这里有两层含义。
第一层:NVIDIA 提供了手写优化的高性能 kernel 库。
NVIDIA 确实编写了大量针对自家硬件深度优化的 kernel 实现,打包在以下库中:
-
cuDNN:深度学习基础算子(卷积、池化、归一化、激活等)
-
cuBLAS:矩阵乘法和线性代数运算
-
cuSPARSE:稀疏矩阵运算
这些 kernel 不是简单的教科书实现,而是针对每一代 GPU 架构的硬件特性(Tensor Core 的矩阵大小、共享内存容量、寄存器数量、Warp 调度策略)做了极致的底层优化。同样是矩阵乘法,cuBLAS 的实现可能比你自己写的 naive CUDA kernel 快 10-50 倍。
第二层:TensorRT 在构建阶段自动搜索最优 kernel。
对于同一个算子(比如一个特定尺寸的卷积),可能存在多种 kernel 实现策略,每种在不同的输入尺寸、精度、硬件上性能不同。TensorRT 在构建 engine 时会做 autotuning——对每个算子的所有候选 kernel 实际运行计时(这就是你用 trtexec 构建时等待较长时间的原因),选出在当前硬件 + 当前输入尺寸 + 当前精度下最快的那个。
所以"调度更优 kernel"的意思是:TensorRT 不仅用的是 NVIDIA 手写的高性能 kernel 库,还会根据你的具体场景自动选择最优的那个 kernel 实现。PyTorch 默认的 kernel 调度策略则粗糙得多,通常就是按规则选一个"通用较好"的实现,不会针对你的具体场景做搜索。
四、Parser 是什么
Parser 就是解析器,作用是读取 ONNX 文件并将其转换为 TensorRT 内部的计算图表示(叫 Network Definition)。
ONNX 文件本质上是一个 protobuf 序列化的数据结构,里面用一种标准化的格式描述了:有哪些算子节点、节点之间的数据流关系、每个节点的参数(权重、属性)、输入输出的形状和数据类型。
Parser 的工作就是逐个读取这些节点,把每个 ONNX 算子映射到 TensorRT 内部对应的层类型。比如读到一个 ONNX 的 Conv 节点,parser 就创建一个 TensorRT 的 IConvolutionLayer;读到 Relu,就创建一个 IActivationLayer。读完整个文件后,TensorRT 就拿到了一个完整的内部计算图,后续的优化(层融合、精度选择、kernel 搜索、内存规划)都基于这个图来做。
五、PyTorch 也是计算图,为什么不能直接识别
这个问题非常好。PyTorch 确实有计算图,但它和 TensorRT 需要的计算图有本质区别。
PyTorch 的动态图(Eager Mode):PyTorch 默认是边执行边构建图的。你写 y = conv(x),PyTorch 立刻执行这个卷积并返回结果,同时在后台记录这个操作到 autograd 图中(为了反向传播)。这个图是动态的——每次 forward 可能走不同的分支(if-else、for 循环、动态形状),图的结构可能每次都不一样。
TensorRT 需要的静态图:TensorRT 的所有优化(层融合、kernel 搜索、内存预分配)都需要在实际推理之前完成。它必须看到一个完整的、固定的、不会变化的计算图,才能分析哪些层可以融合、每个节点的输入输出形状是什么、总共需要多少显存。动态图无法做这些提前分析。
格式不兼容:即使用 torch.jit.trace 或 torch.compile 把 PyTorch 模型转成静态图,它的内部表示(TorchScript IR 或 FX Graph)和 TensorRT 的内部表示(Network Definition)是完全不同的数据结构,算子命名、算子粒度、属性格式都不一样。比如 PyTorch 的一个 nn.Linear 在 TorchScript 中可能表示为 aten::linear,而 TensorRT 没有这个算子名,它用的是 IMatrixMultiplyLayer + IElementWiseLayer。
ONNX 的角色就是通用中间语言。它定义了一套标准化的算子集合和图格式,PyTorch 通过 torch.onnx.export 把自己的算子翻译成 ONNX 标准算子,TensorRT 的 parser 再把 ONNX 标准算子翻译成自己的内部表示。ONNX 充当了 PyTorch 和 TensorRT 之间的"翻译桥梁"。
⚪为什么频域内部可以采用3*3卷积,频域不应该只有频谱信息吗,哪里有空间信息
如果只用1×1卷积(FD模块的做法): 网络只能看到 (u,v)这一个频率点的通道信息,无法知道它周围的频率分量是什么样的。单点判断很容易出错。
如果用3×3卷积(FDC模块的做法): 网络同时看到(u,v)及其周围8个相邻频率点的信息。如果(u,v)周围的频率分量也都有类似的高幅度且方向一致,那很可能是一条连续边缘的高频成分;如果(u,v)的高幅度是孤立的、周围频率分量的幅度很低或方向杂乱,那更可能是随机噪声。
⚪伪标签是怎么生成的
典型流程分为以下步骤:
第一步: 用少量有标注数据训练一个初始模型(称为Teacher模型)。
第二步: 用这个初始模型对大量未标注数据做预测,模型输出的预测结果就是伪标签。比如对一张未标注的细胞图像,模型预测"这些像素是细胞核、那些像素是背景",这个预测结果就被当作该图像的"标注"来使用。
第三步: 将真实标注数据和带伪标签的未标注数据混合在一起,重新训练模型(称为Student模型)。
第四步: 用更强的Student模型重新为未标注数据生成质量更高的伪标签,再训练下一轮模型。如此迭代,伪标签质量逐步提升,模型性能也逐步提高。
⚪情况A: 预测的条纹在中间断了一截。TopologyLoss会发现"断裂处缺少边缘"而产生大的惩罚,但DirectionalLoss在断裂处的二阶差分也会很大。这种情况下两个loss都会响应。
情况B: 预测的条纹没有断裂,位置也对,但边缘呈锯齿状(像楼梯一样左右摆动)。TopologyLoss可能很小——因为每个位置都有边缘存在,边缘强度和真实值差不多。但DirectionalLoss会很大——因为锯齿意味着竖直方向上梯度忽大忽小,二阶差分剧烈。
情况C: 预测的条纹整体偏移了2个像素。DirectionalLoss可能很小——条纹本身很平滑没有断裂。但TopologyLoss会很大——预测边缘位置和真实边缘位置不对齐。
所以两个loss的关系是:
| 边缘位置对不对 | 边缘平滑不平滑 | |
|---|---|---|
| TopologyLoss | 主要约束 | 部分约束 |
| DirectionalLoss | 不约束 | 主要约束 |
⚪TP、FP、FN 在分割可视化中的含义
以激光条纹分割为例,每个像素要么是"条纹"(正类),要么是"背景"(负类):
绿色 TP(True Positive,真阳性):模型预测是条纹,实际也是条纹。预测对了。 绿色区域越多说明模型越好。
红色 FP(False Positive,假阳性):模型预测是条纹,但实际是背景。多检测了,误报。 比如把噪声、飞溅亮点误认为条纹。
蓝色 FN(False Negative,假阴性):模型预测是背景,但实际是条纹。漏检测了,遗漏。 比如条纹断裂处没被识别出来。
⚪集成学习和深度监督:每个子 UNet 都先通过深度监督进行训练,并先在子网络内部完成多层输出融合;最后 ADS_UNet 再对四个子 UNet 的预测做加权集成。最终集成不只是学习 4 个 alpha,还包括每个子 UNet 内部可学习的监督权重 eta 以及阶段式更新的样本权重。



⚪所有层 stride=1:每一层增量都是 2,感受野线性增长:1 → 3 → 5 → 7。
同样三层 3×3 卷积,唯一区别是第1层步长改为 2。连乘 = 2×1=2,增量 = 2×2=4 。



⚪model.train() 不改变模型参数,只改变 BN 和 Dropout 等层的运行行为,确保它们在训练时执行正确的随机化和统计逻辑。
好的,下面把每一步都配上对应代码,形成一份完整的"记忆卡片":
完整训练流程 + 代码对照
准备阶段
# 1. 定义数据集、DataLoader
train_dataset = create_dataset(...)
train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
# 2. 定义模型
model = PhonePriceModel(input_dim=20, output_dim=4)
# 隐藏层1: Linear(20, 128) + ReLU
# 隐藏层2: Linear(128, 256) + ReLU
# 输出层: Linear(256, 4) ← 注意:不加softmax,因为CrossEntropyLoss内部会做
# 3. 定义损失函数
criterion = nn.CrossEntropyLoss()
# 4. 定义优化器
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
训练阶段
epochs = 50
for epoch in range(epochs):
# ---------- 本轮初始化 ----------
total_loss, batch_num = 0.0, 0
start = time.time()
# ---------- 内层循环:逐批训练 ----------
for x, y in train_loader:
# ① 切:切换训练模式
model.train()
# ② 预:前向传播,得到预测
y_pred = model(x)
# ③ 算:计算损失
loss = criterion(y_pred, y)
# ④ 清+反:梯度清零 → 反向传播
optimizer.zero_grad()
loss.backward()
# ⑤ 更:更新参数
optimizer.step()
# 记录:累加损失
total_loss += loss.item()
batch_num += 1
# ---------- 本轮结束,打印信息 ----------
print(f'epoch: {epoch+1}, loss: {total_loss/batch_num:.4f}, time: {time.time()-start:.2f}s')
收尾阶段
# 保存模型参数
torch.save(model.state_dict(), 'phone_price_model.pth')
记忆总结
准备四件套: 数据 → 模型 → 损失函数 → 优化器
每批六步走: 切 → 预 → 算 → 清 → 反 → 更
train model(x) criterion zero_grad backward step
收尾一句话: torch.save(model.state_dict(), 路径)
把"准备四件套"和"每批六步走"记住,任何 PyTorch 训练代码你都能从零写出来。
⚪
| 库 | 读取函数 | 返回类型 | 通道顺序 | 值域 | 特点 |
|---|---|---|---|---|---|
| matplotlib | plt.imread() | ndarray | RGB | PNG: float 0~1, JPG: uint8 0~255 | 轻量,但格式支持少,依赖 Pillow |
| OpenCV | cv2.imread() | ndarray | BGR | uint8 0~255 | 工业级,速度快,功能最全,但通道顺序是BGR |
| Pillow (PIL) | Image.open() | PIL.Image对象 | RGB | 需要 np.array() 转数组才是 uint8 | Python 图像处理标准库,格式支持最广 |
| 对比维度 | ndarray | PIL.Image |
|---|---|---|
| 本质 | 纯数值矩阵 | 图像专用对象 |
| 自我认知 | 不知道数据含义 | 知道自己是图像,知道模式、格式 |
⚪HanfengDataset和DataLoader的参数区别
train_dataset = HanfengDataset(cfg.data_root, cfg.train_mode, get_transforms('train', cfg.img_size))
val_dataset = HanfengDataset(cfg.data_root, cfg.val_mode, get_transforms('val', cfg.img_size))
print(f"train_dataset:{train_dataset[0][0].shape}")
train_loader = DataLoader(train_dataset, batch_size=cfg.batch_size, shuffle=True, num_workers=cfg.num_workers,worker_init_fn=worker_init_fn)
val_loader = DataLoader(val_dataset, batch_size=cfg.batch_size, shuffle=False, num_workers=cfg.num_workers,worker_init_fn=worker_init_fn)
⚪核心链条:bit → byte → 通道 → 像素
第一层:1个通道需要多少信息?
一个通道本质上描述的是"某种属性的强度",比如红色有多红。人眼对亮度的感知大约能区分200多个层级,所以用 8 bit = 2⁸ = 256 级(0~255)来量化一个通道的强度,恰好够用且不浪费。而 8 bit 正好等于 1 byte,这也是计算机最基本的存储单位,硬件读写非常高效。
第二层:为什么是3个通道?
这源于人眼的生理结构。人的视网膜有三种视锥细胞,分别对红(R)、绿(G)、蓝(B)三种波长的光敏感。颜色科学证明,通过这三种基色的不同比例混合(加色法),可以覆盖人眼能感知的绝大部分色彩空间。所以数字图像选择用 R、G、B 三个独立通道来编码颜色。
第三层:24 bit 的由来
把上面两层结合起来就很自然了:
- 每个通道 = 8 bit = 1 byte
- 3个通道(R, G, B)= 8 × 3 = 24 bit = 3 bytes
所以"24 bit 彩色图像"的含义就是:每个像素用 24 个 bit(3 个字节)存储,其中 R、G、B 各占 8 bit。
⚪经过编码器多层处理后,特征图各频率分量的相位配合可能偏离最优状态,导致边缘表示模糊。FDC模块通过交叉卷积在频域中同时优化幅度和相位,学习最有利于分割的频域表示,使边缘特征更加锐利,从而提升分割精度。

⚪输入序列 → QKV计算(qkv都是列向量,不过有dim深度) → 因果Mask注意力 (矩阵形式,每一行代表一个token与其他token的注意力分数)→ 新语义表示 → 只取最后一个token的表示 → 线性投影到词表维度 → softmax → 预测下一个词。
预测下一个 token = 当前 Q × 所有 K cache(含当前K)→ 注意力权重 → 加权 所有 V cache(含当前V)。再通过词汇表权重矩阵转到词汇表空间去映射

⚪FD Processing:整个模块的处理链路是:空域输入 → FFT → 实虚分解 → 交叉卷积增强 → 实虚重组 → IFFT → 增强后的空域特征。
为什么采用频域处理:在空域中难以区分的孔隙边界和背景(因为亮度差异很小),在频域中会体现为特定的高频分量,可以被更精准地操作和增强。这是空域卷积难以做到的,因为空域卷积的感受野有限,而频域中每一个频率分量本身就包含了全局信息。
为什么采用交叉卷积:
一、空域卷积只能在局部邻域内增强特征强度,而频域交叉卷积凭借全局视野和复数运算特性,能够在增强边缘锐利度的同时修正特征图中边缘位置的相位偏移,这是纯空域操作无法实现的。
二、同时调节幅度和相位
这是交叉卷积最核心的优势。空域中的普通卷积,或者频域中对实部和虚部分别做独立卷积,本质上只能改变频率分量的幅度(增强或抑制某些频率),但不改变相位。而交叉卷积通过让实部参与虚部的计算、虚部参与实部的计算,等价于用一个复数权重去乘频谱信号,这个复数权重既有模(改幅度)又有角度(改相位)。
幅度控制的是"有哪些频率、各自多强",决定了边缘的锐利程度;相位控制的是"这些频率分量在空间中怎么对齐",决定了边缘的精确位置。交叉卷积能同时做这两件事,而空域卷积或独立频域卷积只能做前一件。
三、修正编码-解码过程中的相位偏移
在网络内部,特征图经过多层卷积、下采样、上采样之后,边缘的精确位置在特征图中已经发生了偏移——下采样丢弃了高频分量导致边缘位置被量化模糊,上采样靠插值恢复的高频分量相位和原始不一致。这种偏差本质上就是相位误差。空域卷积受限于局部感受野,很难感知到这种全局性的位置偏移;而交叉卷积在频域中可以直接检测到相位的偏差并进行校正,把"偏了的边缘表示"拉回到更准确的位置。
四、信息交互的充分性
如果对实部和虚部分别独立做卷积,等价于假设卷积核的虚部为零,实部和虚部之间没有任何信息交互,浪费了频域中幅度与相位之间的耦合关系。交叉卷积打破了这个隔离,让两个分量充分交互,从而更完整地利用频域信息。
假设原始信号
假设有一行像素,代表一条从暗到亮的边缘:
位置: 0 1 2 3 4 5 6 7
像素值: 0 0 0 50 200 255 255 255
真实的边缘跳变发生在位置3到4之间,这是一个很明确的位置。
下采样发生了什么
做2倍下采样(每两个像素取一个,或者两两取平均),结果变成:
方式一:每隔一个取一个(stride=2)
位置: 0 1 2 3
像素值: 0 0 200 255
现在边缘跳变变成了位置1到2之间。当你之后上采样想恢复回8个像素时,你只知道"边缘大概在原始位置2到4之间的某个地方",但具体是3到4之间还是2到3之间,这个信息已经丢了。
⚪频域处理与相位信息 Q&A
Q1:下采样会不会丢失频域信息?
会,而且是必然的。这是奈奎斯特定理决定的,无论空间域还是频域,只要做了下采样,高于新奈奎斯特频率的信息就没了,任何网络结构都绕不开。
Q2:既然下采样必然丢信息,ADF-UNet是怎么应对的?
靠两个策略的组合。第一,每个尺度都有独立的频域分支,各层在自己当前分辨率上做频域处理,各管各的频段(第一层管高频边缘,第二层管中频结构,依此类推)。第二,跳跃连接让每一层频域处理的结果"绕过"后续下采样,直接传到解码器对应层,不再经历进一步的信息损失。
Q3:IFFT之后取实部、丢掉虚部,不是丢失了相位信息吗?
不是。相位信息在IFFT的求和过程中已经被"消化"进实部了。根据欧拉公式展开后,相位 $\phi_k$ 编码在 $\cos(\omega_k x + \phi_k)$ 里,不同的相位值会让余弦波在空间中左右平移,从而改变每个位置的像素值。对于实数信号,频谱满足共轭对称性,IFFT结果的虚部理论上严格为零。实际中出现的微小虚部只是频域操作打破对称性后产生的数值噪声,不携带有用信息。
⚪一阶差分损失对好边缘和平滑边缘的惩罚一样(都是1),无法引导模型让边缘更平滑。二阶差分损失对平滑边缘(0.5)的惩罚远小于锐利边缘(2),会引导模型主动让边缘过渡更平滑。
一句话总结:一阶差分告诉你"有没有边缘、边缘多强",二阶差分告诉你"这条边缘的过渡方式是否稳定一致"。 LDir 惩罚的不是边缘的存在,而是边缘过渡方式的不稳定性——它允许有边缘(速度可以大),但要求边缘的过渡必须平滑(加速度要小)。
一句话:一阶差分做损失会消灭边缘,二阶差分做损失会保留边缘但让它变平滑。(考虑y=x,二阶导为0,因为变化是一样的,如果同一步数变化率是一样的那代表二阶导为0)


⚪

直接比较像素值(比如用 BCE Loss)只关心每个像素的分类是否正确,不关心像素之间的空间关系。两个预测结果可能像素级准确率一样,但一个的边缘是连续的,另一个是断裂的。拓扑损失通过 Sobel 算子显式提取边缘信息,强制要求预测结果的边缘结构和标注一致,提高焊缝条纹这类细长结构的连续性和完整性。

⚪分布式训练中,模型参数的一致性不靠种子,而是靠进程0初始化后广播给所有进程来强制同步。种子真正影响的是数据采样——每个进程需要不同的种子来选取不同的训练图片,如果种子相同,所有进程会选到一模一样的图片,相当于4个GPU在重复处理同样的数据,浪费了3/4的算力。所以正确做法是:模型初始化靠广播保证一致,数据采样靠不同种子保证多样性。
⚪为什么只让 rank == 0 做?
因为如果 4 个进程同时保存同一个文件,就可能:
-
重复写文件
-
文件冲突
-
日志打印 4 遍
-
混乱
所以通常约定:在常见的 DDP 多卡训练里,通常就是“一张 GPU 对应一个进程”,每个进程各自有一份完整模型,处理自己那部分数据,然后同步梯度。
0 号进程是主进程,负责独占操作
比如:
-
打印日志
-
保存模型
-
写 checkpoint
-
做评估结果汇总
⚪
# 第一步:创建解析器(相当于开一个"登记窗口")
parser = argparse.ArgumentParser()
# 第二步:注册参数(告诉窗口"我接受哪些参数")
parser.add_argument('-opt', type=str, required=True)
parser.add_argument('--launcher', default='none', choices=['none', 'pytorch', 'slurm'])
parser.add_argument('--local_rank', type=int, default=0)
# 第三步:解析(从命令行读取实际传入的值)
args = parser.parse_args()
# 之后用 args.xxx 访问
print(args.opt) # 输出配置文件路径
print(args.launcher) # 输出 'none' 或 'pytorch'
⚪ ffted = torch.cat([torch.real(ffted.unsqueeze(-1)), torch.imag(ffted.unsqueeze(-1))], dim=-1)
# 合并实虚部到通道维度:(b, c, h, fft_w, 2) -> (b, 2c, h, fft_w)
用一个最小的例子对比两种做法。假设 C=2, H=1, W'=1,FFT后的复数值:
通道0:1+2j, 通道1:3+4j
张量形状 (1, 2, 1, 1),每个元素是复数。
做法A:unsqueeze(-1) + cat(dim=-1)
real = torch.real(ffted.unsqueeze(-1)) # (1,2,1,1,1)
imag = torch.imag(ffted.unsqueeze(-1)) # (1,2,1,1,1)
result = torch.cat([real, imag], dim=-1) # (1,2,1,1,2)
最后一个维度 d=2,存的是[{eal, imag]:
通道0:[1, 2] 通道1:[3, 4]

做法B:直接沿 dim=1 拼接
为什么排列顺序很重要
后面要恢复复数时,用的是:
rearrange(ffted, 'b (c d) h w -> b c h w d', d=2)
这一步把相邻的两个通道配对,第一个当实部,第二个当虚部。
做法A的排列 [1, 2, 3, 4]:配对结果是 (1,2) 和 (3,4),即通道0恢复为 1+2j,通道1恢复为 3+4j。完全正确。
做法B的排列 [1, 3, 2, 4]:配对结果是 (1,3)$和 (2,4),即"通道0"变成 1+3j,"通道1"变成 2+4j。实虚部完全配错了。
原因一:分组卷积需要实虚部在同一组内。每组卷积能同时看到同一个频率通道的实部和虚部,可以学到实虚部之间的关系,比如学到类似复数乘法的变换。
⚪ torch.einsum 是 Einstein Summation(爱因斯坦求和约定)的实现,用一个字符串公式描述张量运算。规律始终一样:看哪个字母在箭头右边消失了,那个维度就被求和了。einsum 的强大之处在于用一个字符串就能表达各种复杂的张量运算,不需要手动 reshape、permute、matmul 的组合。
⚪ 为什么加权求和可以使这个维度消失
一维的情况
假设有3个数:a=[2,5,3]a = [2, 5, 3] a=[2,5,3],对它们求和:
2+5+3=102 + 5 + 3 = 102+5+3=10
原来3个数(维度大小=3),求和后变成1个数(维度消失)。这就是最基本的"求和使维度消失"。
维度思考:遇到四个维度时,首先考虑后面两个维度展开为矩阵行列的形式,再通过沿着行排列添加一个新的维度,在单独添加一行代表第四个维度。
一个数字 → 一行数字(加W维)→ 一个矩阵(加H维)→ 一摞矩阵(加C维)→ 多摞矩阵(加B维)

⚪ 如何思考加权求和维度消失的问题?
用一个具体的张量来说明。假设形状 (C=3, H=2, W=4):

沿通道维度求和(dim=0)
思考方式:把三张矩阵重叠在一起,对应位置的值相加,三张变一张。

沿行维度求和(dim=1)
思考方式:在每张矩阵内部,把所有行压扁成一行,上下对应位置相加。

H维消失,每张矩阵从2行变成1行,但仍然是3个通道。
沿列维度求和(dim=2)
思考方式:在每张矩阵内部,把所有列压成一列,左右对应位置相加。

W维消失,每行4个值压成1个值。
统一的思考方法
不管多少维度,沿某个 dim 求和的思考步骤永远是:
第一步:找到要求和的那个维度,看它有几个元素。
第二步:想象沿着这个维度有一排"格子",把这些格子里的值加起来,合并成一个。
第三步:这个维度消失,其他所有维度保持不变。
用一句话记忆:沿哪个维度求和,哪个维度就消失。(3, 2, 4) 沿 dim=0 求和得 (2, 4),沿 dim=1 求和得 (3, 4),沿 dim=2 求和得 (3, 2)。
⚪ 为什么是“沿通道拼接”,而不是拼到别的维度?
因为卷积网络最习惯的特征组织方式就是:
(B, C, H, W)
其中:
-
H,W表示二维结构 -
C表示特征维度
实部和虚部其实都属于同一个频率位置上的两种描述量,所以最自然就是把它们视作两类特征,放到通道维里。
-
空间/频率坐标位置不变(每一个(u,v)代表一个a+bj,只保留实部会丢掉虚部,频谱不完整。)幅度更像是:在频率坐标 (u,v)这个“二维波模式”上,图像里这种波的强度有多大。相位更像是:这种波在空间里对齐到哪里、偏移了多少。
-
只是特征维度翻倍
这样很适合 Conv2d 继续做通道混合。
为什么不能只保留实部,或者只保留幅度?
因为那样会丢信息。
只保留实部:会丢掉虚部,频谱不完整。
只保留幅度:会丢掉相位,而相位对图像重建非常关键。
经典结论就是:
图像结构往往高度依赖相位信息,不只是幅度信息。
所以为了尽量无损地让网络处理频域特征,最稳妥的方法就是:
-
实部保留
-
虚部保留
-
一起交给网络学
-


完整FFT(torch.fft.fft2)和rfft2(torch.fft.rfft2)



⚪
self.fdc = nn.Conv2d(
in_channels=in_channels * 2,
out_channels=out_channels * 2 * self.groups, # 输出通道:groups * 2*out_channels
kernel_size=1,
groups=self.groups,
bias=True
),帮我思考1*1卷积核在该代码的作用是什么,能不能帮我举例一下,假设输入图像8个通道,分成了四组,每一组是两个通道,是不是相当于每一个卷积核在这两个通道中进行沿着图像从宽和高尺寸去提取融合新的特征,然后产生新的输出通道,如果输入是两个通道,那么输出怎么变成更多的通道呢?
```
你的理解大方向是对的,我用具体数值来走一遍。
设定参数
假设 in_channels=4,groups=4,那么 self.fdc 的参数是:
nn.Conv2d(
in_channels=4 * 2, # = 8(因为实虚部拼接,通道翻倍)
out_channels=4 * 2 * 4, # = 32
kernel_size=1,
groups=4
)
输入8个通道,输出32个通道,分4组。
分组情况
每组的输入通道数 = 8 / 4 = 2,每组的输出通道数 = 32 / 4 = 8。
| 组 | 输入通道 | 输出通道 |
|---|---|---|
| 组0 | 通道0, 1 | 通道0~7 |
| 组1 | 通道2, 3 | 通道8~15 |
| 组2 | 通道4, 5 | 通道16~23 |
| 组3 | 通道6, 7 | 通道24~31 |
2个输入通道怎么变成8个输出通道?
关键在于:每个输出通道都有自己独立的一组卷积核权重。
对于组0,输入是2个通道,要产生8个输出通道。就需要8个卷积核,每个卷积核的形状是 (2, 1, 1)——2表示要覆盖2个输入通道,1*1是空间尺寸。
帮我思考为什么卷积核通常都说是二维卷积核,这里却是卷积核的形状是 (2,1,1)三维的形状?请问怎么理解?
nn.Conv2d 中的 "2d" 意思是卷积核在空间上沿两个方向(高和宽)滑动。但卷积核本身的完整形状一定是三维的:(Cin,Kh,Kw),因为它必须覆盖所有输入通道。
规律就是:卷积核的维数 = 滑动方向数 + 1(多出来的那一维是通道维,不滑动,整体覆盖)。
参数量和计算复杂度都与卷积核的输入、输出通道、以及尺寸有关系:
⚪
# q: [B, num_heads, Hc, N] Hc=C//num_heads, N=H*W
# k: [B, num_heads, Hc, N]
# v: [B, num_heads, Hc, N]
attn = (q @ k.transpose(-2, -1)) * self.temperature
# attn: [B, num_heads, Hc, Hc] (沿最后一维 softmax,形状不变)
attn = attn.softmax(dim=-1)
space_out = (attn @ v)
# attn @ v: [B, num_heads, Hc, Hc] @ [B, num_heads, Hc, N]
# = [B, num_heads, Hc, N]
qkv的形状都是N个像素点对应,这里的 attention 是通道维度的注意力,每一行表示某个通道与其他通道之间的相似度;而 (attn @ v) 表示将所有通道的特征进行加权融合,生成新的通道表示。
能不能简洁一点告诉我,我感觉这很没有道理啊,假设原有的每个通道都代表特定的功能,现在经过注意力却被全部组合在一起了?
attention 不是把通道“混乱地搅在一起”,而是让每个通道有选择地参考其他通道,进行“微调增强”。

⚪传统自注意力/根号d 与 QK先进行L2归一化再点×的区别


q = F.normalize(q, dim=-1)
k = F.normalize(k, dim=-1)
q,k采用L2归一化,此时不需要再除以根号d。
⚪多头自注意力需要乘于一个温度系数,来控制考虑每一个头的缩放系数,多头可以看成将通道维度拆开,由一个维度(比如一行)拆开成了一行一列(两个维度)
self.temperature = nn.Parameter(torch.ones(num_heads, 1, 1))
attn = (q @ k.transpose(-2, -1)) * self.temperature
维度直接就对不上了,会报错。
即使你写成 (1,1,cper_head,1) 来正确对齐,意思就变成了:注意力矩阵的每一行(每个通道作为query去和其他通道比较时)用不同的温度缩放。这意味着通道0查询时用温度1.2,通道1查询时用温度0.8……这在语义上不太合理,因为同一个头内部的所有通道应该共享同一套注意力分布特性。
直觉理解
每个头的"职责"不同——比如Head 0可能负责捕捉低频纹理(需要平滑注意力,τ小),Head 1可能负责捕捉边缘细节(需要尖锐注意力,τ\tau τ大)。温度是控制整个头的注意力行为风格的,所以按头分配(一个头一个标量)是最自然的粒度。
用一个具体的数值例子:假设 num_heads=2,学到的温度是 τ0=0.5, τ1=2.0:
Head 0 的注意力矩阵所有元素乘以0.5,softmax后分布变平滑,关注多个通道。Head 1 的注意力矩阵所有元素乘以2.0,softmax后分布变尖锐,集中关注少数通道。
这就是温度与 num_heads 的关系:每个头学习自己的注意力锐度。
下图为对最后一个维度进行归一化的理解,深入理解,对每一行即是一个维度

⚪ 下采样的四种方法


关于连续性的问题
你说得对,PixelUnshuffle 确实破坏了空间连续性。拿刚才的例子看,通道1里 [1, 3; 9, 11] 这四个值在原图中并不相邻,它们之间隔了一个像素。所以单看任何一个通道,空间上是"隔行隔列采样"的结果。
但这不是大问题,原因在于:PixelUnshuffle 后面紧跟着卷积层,卷积会跨通道融合信息。4个通道合在一起仍然包含了完整的空间信息,只是需要后续卷积重新"理解"这种排列方式。实践中网络很快就能学会这一点。
主要应用场景
PixelUnshuffle 下采样主要用在对信息保留要求高的低级视觉任务,比如图像去噪、超分辨率、去模糊、去雨等图像恢复任务。这类任务的核心目标是像素级重建,每个像素的值都很重要,不能随意丢弃。Restormer、NAFNet、SwinIR 等经典恢复网络都采用了这种方式。
相比之下,高级视觉任务(分类、检测)更关心语义而非像素精度,丢几个像素无所谓,所以一般不用 PixelUnshuffle。
常见下采样方法对比
stride=2 卷积是最常用的方式,每隔一个像素取一次,同时用可学习的卷积核做特征变换。优点是简洁高效,缺点是 3*3stride=2 的卷积在 4*4 输入上感受野覆盖不均匀,会产生所谓的"棋盘效应",而且跳过的像素信息就直接丢了。分类、检测等任务广泛使用。
MaxPooling 在每个 2*2窗口取最大值,只保留最强响应,其余3个值全部丢弃,信息损失是最大的。早期CNN(VGG、AlexNet)中常见,现在用得越来越少。
AveragePooling 取 2*2窗口的均值,4个值压缩成1个。比 MaxPooling 更平滑,但同样会丢失细节。常用于网络末端的全局特征提取(Global Average Pooling)。
PixelUnshuffle 如前所述,零信息损失,但通道数膨胀 r平方倍,后续计算量会增大。适合图像恢复。
还有一种是 Patch Merging,这是 Swin Transformer 使用的方式。它和 PixelUnshuffle 其实很类似——把 2*2邻域的4个token沿通道拼接(通道×4),然后用一个 Linear 层把通道压回2倍。可以看作是 PixelUnshuffle + 线性降维的组合,既保留了信息,又控制了通道膨胀。
一句话总结选择逻辑:如果任务需要像素级精度(恢复类),优先选 PixelUnshuffle;如果任务只需要语义信息(分类/检测),stride=2 卷积就够了。
⚪try:
img_gt = imfrombytes(img_bytes, float32=True)
except:
raise Exception("gt path {} not working".format(gt_path))
# 没有 as e:只知道出错了,不知道错误原因
except Exception:
print("出错了")
# 有 as e:能看到具体错误原因
except Exception as e:
print(f"出错了,原因:{e}")
# 出错了,原因:division by zero
img_gt = cv2.cvtColor(img_gt, cv2.COLOR_BGR2RGB)
imfrombytes` 内部用 OpenCV 解码,默认是 **BGR** 顺序 - 转成 **RGB** 顺序,符合后续处理的习惯
img_lq = img_gt.copy()
- GT 图就是干净的原始图 - `lq` 是 GT 的**完整副本**,后续会在 `lq` 上**叠加噪声**,GT 保持干净不变
⚪if self.file_client is None:
self.file_client = FileClient(
self.io_backend_opt.pop('type'), **self.io_backend_opt)
img_bytes = self.file_client.get(gt_path, 'gt'),这部分代码什么意思,为什么可以读取二进制图像?
🔍 FileClient.get 做了什么?
对于 disk 类型,本质等价于:
with open(gt_path, 'rb') as f:
img_bytes = f.read()
👉 'rb' = read binary(读取二进制)
⚪1.字典读取
self.geometric_augs = self.opt['geometric_augs']
self.opt 就是从 yml 读进来的配置字典,所以:
# yml 里:
geometric_augs: true
# 等价于 Python 字典:
opt['geometric_augs'] = True
# 所以赋值后:
self.geometric_augs = True
2.scandir()函数用法,以及sorted()函数的用法
self.paths = sorted(list(scandir(self.gt_folder, full_path=True)))
| 代码 | 含义 |
|---|---|
scandir(full_path=True) | 扫描文件夹,返回所有文件的完整路径 |
list(...) | 生成器转列表 |
sorted(...) | 按文件名排序,保证顺序稳定 |
所以代码要先 list() 转换,再 sorted() 排序:
scandir(...) # 生成器,逐个产出路径,不能排序
↓
list(scandir(...)) # 转成列表,全部路径存入内存
↓
sorted(list(...)) # 对列表排序,保证顺序稳定
self.paths = [
'data/GT/0001.png',
'data/GT/0002.png',
'data/GT/0003.png',
...
]
⚪五种噪声分布

⚪训练数据加载的格式
| 对比项 | folder | meta_info_file | lmdb |
|---|---|---|---|
| 使用门槛 | 最低,开箱即用 | 需要提前生成 txt | 需要提前转换格式 |
| 读取速度 | 慢 | 慢 | 最快 |
| 磁盘占用 | 原始大小 | 原始大小+txt | 原始大小×1.2倍左右 |
| 适用场景 | 数据量小/调试 | 数据量中等 | 大规模训练 |
| 数据格式 | 原始图片文件 | 原始图片文件 | 二进制数据库 |
⚪img = F.pad(self.lq, (0, mod_pad_w, 0, mod_pad_h), 'reflect')

self.nonpad_test(img)
_, _, h, w = self.output.size()
self.output = self.output[:, :, 0:h - mod_pad_h * scale, 0:w - mod_pad_w * scale]
⚪前向传播,深度监督损失计算,梯度裁剪,模型权重副本指数移动平均更新
def optimize_parameters(self, current_iter):
self.optimizer_g.zero_grad()
preds = self.net_g(self.lq)
if not isinstance(preds, list):
preds = [preds]
self.output = preds[-1]
loss_dict = OrderedDict()
# pixel loss
l_pix = 0.
for pred in preds:
l_pix += self.cri_pix(pred, self.gt)
loss_dict['l_pix'] = l_pix
l_pix.backward()
if self.opt['train']['use_grad_clip']:
torch.nn.utils.clip_grad_norm_(self.net_g.parameters(), 0.01)
self.optimizer_g.step()
self.log_dict = self.reduce_loss_dict(loss_dict)
if self.ema_decay > 0:
self.model_ema(decay=self.ema_decay)
⚪torch.nn.utils.clip_grad_norm_(self.net_g.parameters(), 0.01),这句话什么意思,是不是梯度超过0.01就缩小到0.01,那岂不是梯度也太小了,网络更新的不是很慢吗?可选地更新 EMA 模型行代码的作用是不是在每次更新模型参数的时候都会去考虑历史模型参数的变化情况?
假设网络只有 3 个参数,梯度为:
g = [0.006, 0.006, 0.006]
整体 L2 范数 = √(0.006² + 0.006² + 0.006²) ≈ 0.0104
0.0104 > 0.01 → 触发裁剪
缩放比例 = 0.01 / 0.0104 ≈ 0.96
裁剪后:g = [0.00576, 0.00576, 0.00576]

⚪BasicSR的训练循环策略和传统的循环策略
教科书写法:
train_epoch = 数据循环 + 前向 + loss + 反向 + 更新
BasicSR 写法:
train.py = 数据循环 + 验证保存调度
model.optimize_parameters() = 前向 + loss + 反向 + 更新
也就是说,BasicSR 不是少了 train_epoch,而是把 train_epoch 拆开了。
train_epoch 是“把一整轮训练全包”的写法,适合简单固定模型;
optimize_parameters() 是“只封装一步更新”的写法,适合像 BasicSR 这种需要支持多模型、多训练策略的通用框架。
train.py 训练循环总结
双层循环结构
while current_iter <= total_iters: ← 外层:epoch 循环
prefetcher.reset() 重置数据读取器
while train_data is not None: ← 内层:batch 循环(核心)
current_iter += 1
├─ update_learning_rate() 调度器更新 lr
├─ feed_train_data() 数据送入模型
├─ optimize_parameters() 前向+反向+更新参数
├─ [每 print_freq 步] 打印日志
├─ [每 1000 步] 保存检查点
├─ [每 4000 步] 验证
└─ prefetcher.next() 取下一个 batch
epoch += 1
数据从哪里来
Dataset_GaussianDenoising(读图+在线加噪)
└─→ DataLoader
└─→ CPUPrefetcher(提前预取下一个 batch)
└─→ prefetcher.next()
└─→ {'lq': 含噪图, 'gt': 干净图}
一句话总结
外层循环控制 epoch,内层循环每步取一个 batch,按顺序执行:更新lr → 喂数据 → 训练一步 → 按频率做日志/保存/验证,数据由
CPUPrefetcher包装DataLoader提前预取。
⚪CPUPrefetcher和普通 DataLoader 的对比
| 普通 DataLoader | CPUPrefetcher | |
|---|---|---|
| 取数据时机 | 用的时候才读 | 提前读好等着 |
| GPU 等待 | 有等待 | 几乎无等待 |
| 实现复杂度 | 简单 | 多一层封装 |
| 适用场景 | 数据读取快 | 数据读取慢(大图、复杂增强) |
一句话总结
CPUPrefetcher的本质是**"永远比训练提前一步":当 GPU 训练第 N 个 batch 时,CPU 已经在读第 N+1 个 batch,prefetcher.next()返回的始终是上一步就已经准备好的数据**,返回None时代表数据集遍历完毕。
⚪深度学习中常见的 raise 用法
1. ValueError — 不支持的优化器类型
optim_type = "SGD"
if optim_type == "Adam":
optimizer = torch.optim.Adam(params)
elif optim_type == "AdamW":
optimizer = torch.optim.AdamW(params)
else:
raise ValueError(f"不支持的优化器:{optim_type}")
这个在 image_restoration_model.py 里你刚看过 ↑
2. ValueError — 损失函数未配置
if train_opt.get('pixel_opt'):
self.cri_pix = SEMDenoisingLoss()
else:
raise ValueError("pixel loss 未配置,请检查 yml 文件")
3. TypeError — 输入不是 Tensor
def forward(x):
if not isinstance(x, torch.Tensor):
raise TypeError(f"输入必须是 Tensor,当前是 {type(x)}")
return self.net(x)
forward(np.array([1, 2, 3]))
# TypeError: 输入必须是 Tensor,当前是 <class 'numpy.ndarray'>
4. RuntimeError — 输入尺寸不匹配
def forward(x):
if x.dim() != 4:
raise RuntimeError(f"输入必须是 4D tensor (B,C,H,W),当前是 {x.dim()}D")
return self.net(x)
forward(torch.randn(3, 256, 256)) # 少了 batch 维度
# RuntimeError: 输入必须是 4D tensor (B,C,H,W),当前是 3D
5. NotImplementedError — 子类必须重写的方法
class BaseModel:
def forward(self, x):
raise NotImplementedError("子类必须实现 forward() 方法")
这是深度学习框架里最常见的用法,BaseModel 定义接口,子类负责实现。
总结
| 场景 | 异常类型 |
|---|---|
| yml 配置了不支持的类型 | ValueError |
| 必填配置项缺失 | ValueError |
| 传入了 numpy 而不是 tensor | TypeError |
| 输入维度/尺寸不对 | RuntimeError |
| 子类没实现父类方法 | NotImplementedError |
⚪选择参数配置创建优化器
第一步:遍历网络所有参数
for k, v in self.net_g.named_parameters():
第二步:筛选出需要训练的参数
if v.requires_grad:
optim_params.append(v)
只有 requires_grad=True 的参数,才交给优化器。
第三步:如果某些参数被冻结,就报警告
else:
logger.warning(f'Params {k} will not be optimized.')
第四步:根据配置创建优化器
if optim_type == 'Adam':
self.optimizer_g = torch.optim.Adam(optim_params, **train_opt['optim_g'])
elif optim_type == 'AdamW':
self.optimizer_g = torch.optim.AdamW(optim_params, **train_opt['optim_g'])
**d 就是把字典展开。
torch.optim.AdamW(optim_params, **train_opt['optim_g'])
就等价于:
torch.optim.AdamW(
optim_params,
lr=0.0002,
weight_decay=0.0001,
betas=(0.9, 0.999)
)
所以这里的完整含义是
-
第一个参数
optim_params:要优化哪些参数,是一个列表,把所有需要训练的参数收集起来,统一交给优化器。 -
后面的
**train_opt['optim_g']:优化器的各种配置项,从字典里拆开传进去
也就是把前面筛出来的那些参数交给 Adam 或 AdamW。
本质上等价于:
for item in self.net_g.named_parameters():
k = item[0]
v = item[1]
for k, v in self.net_g.named_parameters():
-
字典的
.items()返回的是字典里的键值对 -
named_parameters()返回的是模型中所有可注册参数的名字和参数对象
⚪
| `__init__()` | 把网络、增强器、权重全部组装好,对象"造好了" |
| `init_training_settings()` | 配上损失/优化器/调度器,对象"会学习了" |
| `setup_schedulers()` | 创建学习率调度规则,让 lr 随训练进度自动变化 |
⚪#问题:为什么要在 image_restoration_model.py 中实例化 ImageCleanModel,而不是直接 model = Restormer()?
Restormer 是网络结构(大脑),ImageCleanModel 是训练逻辑容器(整个人)。两者职责完全不同。**
## 一、Restormer 只是"大脑",不会"学习"
Restormer (restormer_arch.py)
│ 只负责:
│ 输入张量 → 若干 Transformer 层计算 → 输出张量
│ 完全不知道:
│ 损失函数是什么、怎么反向传播、
│ 优化器怎么更新、验证怎么跑、模型怎么保存
│
└─→ 它只是一个 nn.Module,就像一块芯片
```
如果直接在 `train.py` 里写:
```python
model = Restormer(inp_channels=1, out_channels=1, ...)
# 那训练脚本就要自己写:
optimizer = AdamW(model.parameters(), ...)
scheduler = CosineAnnealingRestartCyclicLR(...)
loss_fn = SEMDenoisingLoss(...)
for iter in range(total_iter):
pred = model(lq)
loss = loss_fn(pred, gt)
loss.backward()
optimizer.step()
if iter % 4000 == 0:
# 验证逻辑...
if iter % 1000 == 0:
# 保存逻辑...
**问题:train.py 会变成几百行的大杂烩,且换一个任务(去雨/去模糊)就要重写整个脚本。*
## 二、ImageCleanModel 解决了什么问题?
它是一个**训练容器**,把所有训练相关逻辑封装进去:
ImageCleanModel
├── 持有 self.net_g = Restormer(...) ← 网络只是其中一个成员变量
├── 持有 self.optimizer_g ← 优化器
├── 持有 self.cri_pix = SEMDenoisingLoss ← 损失函数
├── 持有 self.schedulers ← 学习率调度
├── feed_train_data() ← 数据怎么进来
├── optimize_parameters() ← 一步训练怎么做
├── nondist_validation() ← 验证怎么跑
└── save() ← 模型怎么存
```
⚪dir 是 directory(目录/文件夹) 的缩写,self.img_dir 的意思就是"存放图像的文件夹路径"。
⚪数据处理流程
1.需要一个根目录 data_root
2.需要一个变量表示训练 / 测试 / 验证,比如 mode
3.需要定位到 images 和masks文件夹
4.需要用 os.listdir() 遍历图像文件
5.需要保存每张图像的完整路径
6.在 __getitem__(index) 里,需要根据图像文件名,去匹配对应的 mask
7.在 __getitem__(index) 里,通过索引取出第 index 个样本同时返回图像和对应的 mask。
⚪初始变量时需要需要用到的数据处理的变量数量:
__init__ 中需要定义的变量
第一组:目录路径(定位到文件夹)
self.img_dir:图像文件夹的完整路径,由 data_root + mode + "images" 拼接而成。
self.mask_dir:掩码文件夹的完整路径,由 data_root + mode + "1st_manual" 拼接而成。
第二组:文件索引(知道有哪些图像)
self.img_ids:图像文件夹下所有文件名的列表,通过 os.listdir 获取,比如 ["21_training.tif", "22_training.tif", ...]。
self.img_paths:每张图像的完整路径列表,由 img_dir + 每个文件名拼接而成。这个列表的索引位置就是后续 __getitem__(index) 中 index 的含义。
第三组:数据增强(可选)
self.transform:存储外部传入的数据增强管线(Albumentations的Compose对象)。如果传入None则不做增强。
为什么这样设计
__init__ 只做"登记"工作——把目录在哪、有哪些文件、用什么增强策略这些信息存下来,不读取任何图像数据。真正的图像读取发生在 __getitem__ 被调用时,每次只读一张,按需加载,避免一次性把整个数据集载入内存。
⚪数据处理中需要用到的os操作函数
1. os.path.join()作用:拼接路径
self.img_dir = os.path.join(data_root, mode, "images")
self.mask_dir = os.path.join(data_root, mode, "1st_manual")
mask_path = os.path.join(self.mask_dir, seq_id + "_manual1.gif")
2. os.listdir()作用:列出某个文件夹下的所有文件和子文件夹名称
self.img_ids = os.listdir(self.img_dir)
3. os.path.basename()作用:从完整路径中取出最后的文件名
seq_id = os.path.basename(self.img_paths[index]).split("_")[0]
img_id = os.path.basename(self.img_paths[index])
4. os.path.splitext()作用:把文件名拆成“主文件名 + 扩展名”
img_id = os.path.splitext(os.path.basename(self.img_paths[index]))[0]
⚪RuntimeError: result type Float can't be cast to the desired output type Byte 。这句话什么意思?
这个错误的意思是:PyTorch试图把Float类型的数据写入Byte类型的张量中,但这种隐式转换不被允许。
Float就是32位浮点数(torch.float32),Byte就是8位无符号整数(torch.uint8,范围0-255)。
这个错误在你的场景中最可能出现在计算Loss时,原因是mask的数据类型不对。cv2读取图像后,像素值是uint8(0-255),转成Tensor后就是Byte类型。而模型输出是Float类型,计算Loss时两者做运算就会触发这个错误。
解决方法是将mask转为Float并归一化到[0,1]:
mask = mask.float() / 255.0
这样mask就从Byte类型的0和255变成了Float类型的0.0和1.0,既匹配了模型输出的数据类型,又符合BCE等损失函数对标签值域[0,1]的要求。
⚪两种读取图像的方法,cv2和PIL.image.open
1.from PIL import Image
import numpy as np
data = np.array(Image.open(r"D:\github\DSNet-main\data\DRIVE\training\1st_manual\21_manual1.gif"))
print(data.shape)
👉 PIL Image 对象(PIL.Image.Image)
它的特点:
-
不是数值数组
-
不能直接用
.shape -
不能直接参与 PyTorch / NumPy 运算
三者的关系对比
| PIL Image 对象 | NumPy 数组 | Base64 字符串 | |
|---|---|---|---|
| 本质 | Python封装类 | 数值矩阵 | 文本字符串 |
| 存在目的 | 图像操作/处理 | 数值计算 | 传输/存储 |
| 像素可直接计算 | ❌ | ✅ | ❌ |
| 适合网络传输 | ❌ | ❌ | ✅ |
关键点:Image.open() 在打开文件时已经完成了解码/解压缩,PIL Image 对象里存的已经是还原后的原始像素,不是任何编码格式。Base64 是另一条路,主要用于 Web API 传图等场景。
2.data = cv2.imread(r"D:\github\DSNet-main\data\DRIVE\training\1st_manual\21_manual1.gif") if data is None: print("读取失败") else: print(data.shape)
这不是掩码本身的问题,而是 cv2.imread 的默认行为导致的。
cv2.imread 默认以彩色模式(cv2.IMREAD_COLOR)读取图像,无论原图是灰度还是彩色,都会强制转成3通道BGR。所以即使mask本身是单通道的二值图(只有0和255),读进来也变成了 (H, W, 3),三个通道的值完全相同。
解决方法是指定以灰度模式读取:
mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)
# 结果形状: (H, W),单通道
或者读取后手动取一个通道:
mask = cv2.imread(mask_path)[:, :, 0]
# 三个通道值一样,取哪个都行
对于分割任务,mask应该是单通道的 (H, W),这样才能和模型输出的形状匹配来计算Loss。
⚪ 帮我思考D:\github\DSNet-main\train_unext_stripe_continuity.py中的 get_transforms('train', cfg.img_size)参数为什么可以在hangfeng_dataset.py中输入两个参数?get_transforms不是输入一个参数image—size吗
Albumentations 数据增强的三个核心问题
一、为什么要同时传 image 和 mask
数据增强中有些操作会改变像素的空间位置,比如旋转、翻转、裁剪。图像旋转了30°,mask也必须旋转完全相同的30°,否则血管的标注位置就和图像对不上了,训练数据就废了。
Albumentations的做法是:把image和mask一起传入,内部用同一个随机种子控制所有空间变换,保证两者严格同步。而像亮度调整、加噪声这类只改变像素值不改变位置的操作,Albumentations会自动只作用于image,不动mask。你不需要手动区分,传进去就行。
二、为什么 self.transform 可以用括号调用
self.transform 存储的是一个 Compose 对象,不是普通函数。但Python有一个规则:任何实现了 __call__ 方法的对象都可以用括号调用。Compose类内部定义了 __call__,所以 self.transform(image=img, mask=mask) 实际上等价于 self.transform.__call__(image=img, mask=mask)。
本质上,Python中的函数本身也只是一个"有 __call__ 方法的对象",所以括号调用并不是函数的专利。
三、为什么不能写成 self.transform(img)
两个原因。第一,Albumentations的API设计要求用关键字参数传入,它内部通过 data['image'] 和 data['mask'] 这样的字典方式来区分输入,直接传位置参数会报错。第二,如果只写 self.transform(image=img) 不传mask,虽然语法上不报错,但mask就没有经过任何变换,图像和mask的空间对应关系就被破坏了。
所以完整的正确写法必须是 self.transform(image=img, mask=mask),既满足API要求,又保证图像与标注的空间一致性。
⚪ img_id = os.path.splitext(os.path.basename(self.img_paths[index]))[0]
最重要的一步是需要写image_paths这个列表,需要存储训练图像的每一张图像的路径,存放到列表中
所以 os.path.splitext() 最核心的作用
一句话说就是:把文件名和后缀名分开
-
"21_training.tif"→("21_training", ".tif") -
"cat.png"→("cat", ".png") -
"mask.jpg"→("mask", ".jpg")
⚪ def __getitem__(self, index):是不是返回一个字典?
不是字典,train_dataset[0] 返回的是一个元组(tuple)。
因为 __getitem__ 的最后一行是 return img, mask,Python中用逗号分隔的多个返回值会自动打包成元组,等价于 return (img, mask)。
所以 img, mask = train_dataset[0] 实际上是对这个元组做解包(unpacking):元组第一个元素赋给 img,第二个赋给 mask。
如果不解包,直接写 result = train_dataset[0],那 result 就是一个元组 (img, mask),需要用 result[0] 和 result[1] 分别访问。
⚪帮我思考如何读取到"D:\github\DSNet-main\data\DRIVE\training\images\21_training.tif"中的21这个序列?
seq_id = os.path.basename(self.img_paths[index]).split("_")[0]
os.path.basename 的作用是什么?
从完整路径中提取文件名,即取路径最后一个分隔符之后的部分。
以你的代码为例:
输入: "D:\github\DSNet-main\data\DRIVE\training\images\21_training.tif"
输出: "21_training.tif"
它自动丢弃了前面的目录部分,只保留最终的文件名。名字中的"base"就是"基础名"的意思,即去掉路径后剩下的最基本的文件名部分。
.split("_")是不是返回一个列表?
对,split("_") 的返回值就是一个列表。
以 "21_training.tif" 为例,按 _ 分割后得到:
"21_training.tif".split("_")
# 结果: ["21", "training.tif"]
# 索引: [0] [1]
所以 [0] 取的就是列表的第一个元素 "21",[1] 取到的是 "training.tif"。
⚪DRIVE数据集:mask 与 1st_manual 的区别
mask/(视野掩膜)
眼底相机拍摄的图像是一个圆形视野,圆形以外是黑色无意义背景。mask就是用来标记哪些区域是"有效的":白色(255)表示相机真实采集到的圆形区域(约占整张图68.4%的像素),黑色(0)表示圆形外的纯黑背景,不含任何生理信息。
1st_manual/(专家标注)
由第一位医学专家人工标注的视网膜血管二值图。白色(255)表示血管像素(仅占mask内面积约10.93%),黑色(0)表示非血管背景。名称中的"1st"表示第一位标注者,DRIVE还提供了2nd_manual作为第二位专家的标注,用于评估人类标注者之间的一致性。
为什么训练时必须用mask
关键问题在于:圆形视野外的黑色区域,输入图像本身就是全黑的,模型只要把全黑区域预测为"无血管"就能轻松获得极低的Loss。这部分像素占了整张图约31.6%,如果不加mask限制,会带来两个后果:
第一,大量"白送"的零损失像素稀释了真正有意义的梯度信号,模型在圆形视野内的学习效率下降,收敛变慢。
第二,模型会"偷懒"——它发现只要把黑色区域全预测为负样本就能显著降低Loss,而不需要真正学会在复杂的视网膜图像中区分血管和背景。
所以正确做法是:训练和评估时都只在mask=255的圆形区域内计算Loss和指标,强迫模型把全部学习能力集中在有效区域内的血管分割任务上。
一句话总结
mask告诉模型"在哪里算",1st_manual告诉模型"算什么"。mask是有效区域的边界,1st_manual是血管分割的真实标签。
⚪执行 input.view(4, -1) 时,PyTorch的计算过程是:第一个维度已经指定为4,总元素数是262144,所以第二个维度 = 262144 ÷ 4 = 65536。最终结果形状为 (4, 65536)
class BCEDiceLoss(nn.Module):
def __init__(self):
super().__init__()
def forward(self, input, target):
bce = F.binary_cross_entropy_with_logits(input, target)
smooth = 1e-5
input = torch.sigmoid(input)
num = target.size(0)
input = input.view(num, -1)
target = target.view(num, -1)
intersection = (input * target)
dice = (2. * intersection.sum(1) + smooth) / (input.sum(1) + target.sum(1) + smooth)
dice = 1 - dice.sum() / num
return 0.5 * bce + dice
⚪BatchNorm如何缓解
BatchNorm放在激活函数之前,让数值稳定在激活函数的输入范围内,比如sigmod最佳输入范围为(-3,3)。每一层的激活值上做两步操作:
第一步:标准化。 对当前mini-batch内的激活值,减去均值、除以标准差,强制将分布拉回到均值为0、方差为1的标准分布。不管前面的层参数怎么变,经过这一步后,本层的输入分布的"位置"和"尺度"都被锚定住了,不会剧烈漂移。
第二步:可学习的仿射变换。 引入两个可学习参数 γ(缩放)和 β(平移),让网络自己决定最终需要什么样的分布。这一步保证了标准化不会限制网络的表达能力——如果网络"觉得"某层就是需要非零均值的分布,它可以通过学习 γ 和 β 来恢复。
关键区别在于:没有BN时,每层输入分布的变化是前面所有层参数更新的复合结果,这种变化是不可控的、耦合的;有了BN后,分布的形状只由本层自己的 γ 和 β 决定,与前面层的参数变化解耦了。
假设第3层的输出,在第100次迭代时均值是5、方差是10,到了第200次迭代时均值漂移到了50、方差变成了200。BN的作用就是让这个分布跨迭代保持稳定——不管训练到第几步,第3层的输出经过BN后,都被拉回到一个可控的范围(先标准化到均值0方差1,再由γ和β微调)。
⚪图像能不能经过 Linear 层?
Linear 要求输入形状是 (batch, features),而图像是 (batch, channels, H, W),维度不匹配,所以需要先用 Flatten 把图像拉成一维向量。程
输入图像: (1, 3, 32, 32)
↓ Flatten
拉平向量: (1, 3×32×32) = (1, 3072)
↓ Linear(in_features=3072, out_features=256)
输出: (1, 256)
为什么 Linear 可以处理图像?
本质上图像就是一堆数值,Linear 只是做矩阵乘法,对数值本身没有任何限制,只要形状对齐就能计算。
但是代价很大——丢失空间信息
这正是早期全连接网络(MLP)处理图像的致命缺陷:
| 问题 | 说明 |
|---|---|
| 空间结构消失 | 拉平后像素的相邻关系完全丢失,左上角的像素和右下角的像素地位相同 |
| 参数量爆炸 | 一张 224×224×3 的图像 → 150528 个输入,第一层 Linear 就有海量参数 |
| 平移不变性消失 | 猫在左边和猫在右边,拉平后完全是不同的向量,模型需要重新学 |
Conv 和 Linear 的分工
正因为如此,现代网络通常这样设计:
图像输入
↓
Conv层(提取局部特征,保留空间结构)
↓
Conv层(继续提取更高级特征)
↓
Flatten(拉平)
↓
Linear层(做最终分类/回归决策)
Conv 负责"看懂图像",Linear 负责"做出判断"。
一句话总结
图像可以经过 Linear 层,但必须先 Flatten 拉平;代价是丢失空间结构,所以实际中 Conv 负责处理空间信息,Linear 只在最后几层做分类决策。
⚪Dropout

Dropout 放在激活后是为了真正清零神经元输出;缩放 1\ 1−p 是为了让训练期望与测试期望保持一致,避免数值尺度不匹配导致模型行为改变。这就是缩放的本质:让存活神经元补偿消失神经元的贡献,维持总信号期望不变。
⚪为什么第三方库mmcv、和pytorch有关系?
因为 mmcv 这类库不是纯 Python 代码,它里面包含大量用 C++/CUDA 写的底层算子(比如可变形卷积 deformable conv、RoI Align 等)。这些算子在编译的时候,必须和特定版本的 PyTorch 的底层接口(C++ ABI)绑定在一起。
你可以这样理解:PyTorch 就像一个插座,mmcv 就像一个插头。每个版本的 PyTorch 插座的孔位形状不一样,所以 mmcv 必须做出对应形状的插头才能插进去。torch 2.1 的插座和 torch 2.12 nightly 的插座,孔位完全不同。
计算图系统:PyTorch 在运行时会记录"哪个算子的输出传给了哪个算子",形成一张计算图,反向传播就是沿着这张图往回走。mmcv 的算子必须被正确记录到这张图里,训练才能正常进行。
自动求导:训练的核心是反向传播求梯度。mmcv 写了一个可变形卷积的前向计算,但 PyTorch 需要知道这个算子的梯度怎么算,才能把它纳入整个网络的反向传播链路里。所以 mmcv 必须按照 PyTorch 的 autograd 接口规范来注册自己的 backward 函数,否则梯度传到这一层就断了。"注册"的意思就是,mmcv 必须把自己的前向和反向实现按照 PyTorch 规定的这个格式写好——继承 torch.autograd.Function,定义 forward 和 backward 两个静态方法,用 ctx 保存中间变量。
纯 Python 的库(比如 numpy、pandas)就没有这个问题,装什么版本的 PyTorch 都能用。判断标准很简单:如果一个库安装时需要你指定 CUDA 版本和 PyTorch 版本的组合,或者安装过程中出现 building wheel 需要编译,那它就是这类库。反过来,像 timm、einops、albumentations 这些纯 Python 库,随便装都没问题。看安装过程:如果 pip install 的时候出现 Building wheel、running setup.py install、nvcc compiling 这类字样,说明它在编译 C++/CUDA 代码,就属于这类库。纯 Python 库的安装几乎是瞬间完成的,不会有编译过程。
⚪mamba流程







A 就是一个 模型参数,和卷积层的卷积核、Linear层的权重矩阵完全一样的性质。
用PyTorch类比:
class MambaBlock(nn.Module):
def __init__(self):
# 这些都是同一类东西——可学习参数
self.conv = nn.Conv2d(3, 64, 3) # 卷积核权重
self.linear = nn.Linear(64, 128) # 线性层权重
self.A_log = nn.Parameter(初始值) # A矩阵(存的是log(-A))
2.3 第二阶段:训练(反向传播更新)
训练过程中,A和其他所有参数一起通过梯度下降更新:
A←A−η⋅∂L∂A
其中L 是损失函数(BCE + Dice), η 是学习率。
但这里有一个技巧: 为了保证 A 始终为负值,实际存储的不是 A 本身,而是 log(−A):
# 存储时
self.A_log = nn.Parameter(torch.log(-A_init))
# 使用时
A = -torch.exp(self.A_log) # 保证A始终为负
训练几个epoch后,A可能变成:
Atrained=[−0.7, −2.3]
不再是整齐的 −1,−2,而是被优化为 最适合裂缝分割任务的值。
2.4 第三阶段:推理(固定不变)
训练完成后, A 的值就固定了。无论处理哪张图像、哪个token,都用同一个 A。
A=[−0.7, −2.3](永远不变)

是不是Δ,B,C都是由线性层linear组成的,都是由输入特征x影响?

⚪Transformer 的学习率通常要小 5~20 倍左右。2 为什么 Transformer 学习率要更小
原因1:Transformer 参数更新更敏感
Transformer 里面有:
-
Self-Attention
-
LayerNorm
-
多头注意力
这些结构对参数变化 非常敏感。
如果学习率太大,很容易:
-
loss 爆炸
-
不收敛
-
attention 崩掉
所以必须用 更小学习率。
原因2:Transformer 参数更多
比如:
UNet
参数量大概~30M
Swin Transformer
可能达到:~80M甚至 100M+
参数越多:
每一步更新越要谨慎
否则容易震荡。
┌──────────────────────┬───────────────┐
│ 模型类型 │ 推荐 LR 范围 │
├──────────────────────┼───────────────┤
│ CNN(UNet/DABNet等) │ 1e-3 ~ 5e-3 ✓ │
├──────────────────────┼───────────────┤
│ SwinTransformer │ 5e-5 ~ 5e-4 │
├──────────────────────┼───────────────┤
│ SegFormer │ 1e-4 ~ 6e-4 │
└──────────────────────┴───────────────┘
⚪

⚪浮点数统计:这段代码用 thop 库的 profile 函数来统计模型的计算量,流程如下:
第一步:构造虚拟输入
dummy = torch.randn(1, cfg.input_channels, cfg.img_size, cfg.img_size) 创建一个形状为 [1, C, H, W] 的随机张量,模拟一张图片输入。模型并不真正"学习",只是让数据走一遍前向传播,统计每层的运算次数。
第二步:profile 统计 MACs
profile(model, inputs=(dummy,)) 会在模型的每个算子(Conv2d、Linear、BatchNorm 等)上注册钩子(hook),前向传播时自动累加每层的乘加操作数(MACs,Multiply-Accumulate Operations)。例如一个卷积层的 MACs = $K^2 \times C_{in} \times C_{out} \times H_{out} \times W_{out}$。返回值 macs 就是整个网络的总 MACs。
第三步:MACs → FLOPs
gflops = (macs * 2) / 1e9,因为一次乘加操作(MAC)包含一次乘法和一次加法,所以 1 MAC ≈ 2 FLOPs。除以 $10^9$ 转换为 GFLOPs 单位。
你的报错原因是 cfg.input_channels 被设成了 1,而 DeepLabV3+ 的 EfficientNet 编码器要求输入 3 通道,把 input_channels 改为 3 就行。

⚪❯ 为什么有continuve进度条依然显示有30个数据?Testing: 100%|█████████████████████████████████████████████████████████████████████████████████████| 30/30 [00:06<00:00, 4.96it/s, IoU=0.7753, Dice=0.8734]

⚪❯ dummy = torch.randn(1, cfg.input_channels, cfg.img_size, cfg.img_size).to(cfg.device) macs, params_thop = profile(model, inputs=(dummy,), verbose=False),这里3通道计算会不会比1通道计算多3倍
● 3通道 vs 1通道的 FLOPs 差异
结论:不是3倍,而是几乎没有差别
原因在于卷积的 FLOPs 计算公式:
卷积 FLOPs 公式
FLOPs = 2 × Cout × Hout × Wout × (Cin × Kh × Kw)
┌─────────────┬────────────────┐
│ 参数 │ 含义 │
├─────────────┼────────────────┤
│ Cout │ 输出通道数 │
├─────────────┼────────────────┤
│ Hout × Wout │ 输出特征图尺寸 │
├─────────────┼────────────────┤
│ Cin │ 输入通道数 │
├─────────────┼────────────────┤
│ Kh × Kw │ 卷积核大小 │
└─────────────┴────────────────┘
第一层卷积才受影响,后续层完全不变
以典型的第一层卷积为例(3×3 卷积,64个输出通道,输入 224×224):
3通道输入: 2 × 64 × 224 × 224 × (3 × 3 × 3) = 2 × 64 × 224 × 224 × 27
1通道输入: 2 × 64 × 224 × 224 × (1 × 3 × 3) = 2 × 64 × 224 × 224 × 9
差值: 3倍 × 第一层FLOPs
但第一层之后,所有中间层的输入通道都是 64、128、256...,与原始输入通道数完全无关。
---
实际占比有多小?
以 EfficientNet-B0 为例:
总 GFLOPs ≈ 0.39G
第一层卷积 (3→32, 3×3, stride=2):
3通道: 2 × 32 × 112 × 112 × (3×3×3) ≈ 0.0021G
1通道: 2 × 32 × 112 × 112 × (1×3×3) ≈ 0.0007G
差值: 0.0014G ← 占总量的 0.36%
3通道比1通道多的 FLOPs,仅占总计算量的不到 0.4%,可以忽略不计。
---
所以你的代码是正确的
dummy = torch.randn(1, cfg.input_channels, cfg.img_size, cfg.img_size).to(cfg.device)
# ↑ 3通道
macs, params_thop = profile(model, inputs=(dummy,), verbose=False)
用3通道输入计算 FLOPs 是正确做法,因为:
1. 模型本来就是接收3通道输入训练的,用3通道才符合实际推理场景
2. 即使改成1通道,误差也不到 0.5%,对 GFLOPs 的报告结果几乎无影响
⚪余弦退火和余弦热重启策略

⚪Adam和余弦退火中的学习率变化的区别:
对,这个理解非常准确。再帮你梳理得更清晰一点:
Adam 是"微观视角"——它只看每个参数自己的梯度历史。w_1 的一阶矩和二阶矩只和 w_1 过去的梯度有关,w_2 的只和 w_2的有关。Adam 完全不知道"现在是第几轮"、"训练进行到什么阶段了"、"loss 有没有收敛"。它是一个无状态的局部决策机制——每个参数各管各的,根据自己的梯度信号做自适应调整。
余弦退火是"宏观视角"——它只看训练进度。"当前是第 t 轮,总共 T 轮,所以学习率应该是多少"。它不关心任何一个具体参数的梯度是大是小,也不区分不同参数,而是给出一个全局统一的训练节奏:前期大步探索,后期小步精修。

余弦退火是教练,负责制定整体训练计划——前期高强度、后期精细化。Adam 是每个运动员自己的身体反馈机制,根据自身状态微调发力方式。教练不会替运动员决定具体怎么发力,运动员也不会自己决定今天该练多狠。两个层面的决策互不干涉,组合起来效果最好。

其中:
-
wt:第 t 次更新时的参数
-
nt:第 t 次更新时的学习率
-
m^t:梯度一阶矩估计
-
v^t:梯度二阶矩估计
-
ε:防止分母为 0 的小常数
2. Adam 在调什么
Adam 主要通过
来调整每个参数的更新幅度。
也就是说,Adam 做的是:根据每个参数自己的梯度历史,决定这个参数该更新得大一些还是小一些。所以它解决的是:不同参数之间,步子大小不同
例如:某个参数梯度一直很大,Adam 会用梯度二阶矩估计压小它的更新,某个参数梯度一直很小,Adam 会用梯度二阶矩估计相对放大它的更新。这叫参数级自适应调整。
Adam 通过梯度一阶矩和二阶矩,对不同参数 w 的更新幅度进行自适应调整,根据某个参数历史的梯度的变化来得到这次更新的梯度
,每个iteration都会进行对参数的更新,此时学习率n没变,但是参数的更新幅度变了;余弦退火则通过改变全局学习率 nt,控制模型在不同训练阶段的整体更新速度。因此,Adam 解决的是“不同参数怎么更新”,余弦退火解决的是“训练前期和后期整体更新多快”,两者属于不同层面的调节,不是重复,而是互补。
⚪



梯度下降优化方法-Adam方法 (指数梯度变化对应着梯度变化,除以梯度平方变化对应着学习率的变化)
optimizer = optim.Adam(params=[w], lr=0.01, betas=(0.9, 0.999)) # betas=(梯度用的,学习率用的)
这里 betas 的两个值分别对应:0.9 是一阶矩(Momentum)的衰减系数 beta_1,用于平滑梯度;0.999 是二阶矩(RMSProp)的衰减系数 beta_2,用于自适应调整学习率。这两个值也是 Adam 论文中推荐的默认值。

⚪梯度下降优化方法-RmsProp,控制从99%历史梯度来取

对应的代码:torch.optim.RmsProp(param=w,lr=0.1,Alpha=0.99)
Adam 就是同时用一阶矩(Momentum 思路)估计梯度方向,用二阶矩(RMSProp 思路)估计自适应学习率,再加上偏差修正,成为目前最常用的优化器。
⚪梯度下降优化方法—AdaGrad 优化方法,学习率除以累积平方梯度
AdaGrad 的关键创新是:同一参数的不同历史值使用不同的学习率。更新频繁的参数(累积梯度大)学习率自动变小,更新稀少的参数(累积梯度小)学习率保持较大。总体趋势是学习率逐渐减小。
对比之前的框架:Momentum 是优化"梯度"那一项(用指数加权平均平滑梯度),而 AdaGrad/RMSProp 是优化"学习率"那一项(自适应调整学习率)。

缺点: 可能会导致学习率过早、过量的降低,导致模型后期学习率太小,较难找到最优解。
对应的代码:torch.optim.AdaGard(param=w,lr=0.1)
⚪动量法Momentum(torch.optim.SGD,如果带动量法就是SGD,不带则是随机梯度下降法):本质是对每个参数的梯度,历史梯度也考虑了90%情况
梯度下降是结合 本次损失函数的导数(作为梯度) 基于学习率 来更新权重的。
公式:
W新 = W旧 - 学习率 ×(本次的)梯度
存在的问题:
- 遇到平缓区域,梯度下降(权重更新)可能会慢。
- 可能会遇到 鞍点(梯度为0)
- 可能会遇到 局部最小值。
解决思路:
从上述的 学习率 或者 梯度入手,进行优化,于是有了:动量法Momentum,自适应学习率AdaGrad,RMSProp,综合衡量:Adam
动量法Momentum:
动量法公式:
St = β × St-1 + (1 - β) × Gt
解释:
- St:本次的指数移动加权平均结果。
- β:调节权重系数,越大,数据越平缓,历史指数移动加权平均 比重越大,本次梯度权重越小。
- St-1:历史的指数移动加权平均结果。
- Gt:本次计算出的梯度(不考虑历史梯度)。
加入动量法后的 梯度更新公式:
W新 = W旧 - 学习率 × St
# 3. 创建优化器(函数对象) → 基于SGD(随机梯度下降), 加入参数 momentum, 就是 动量法.
# 参1: (待优化的)参数列表, 参2: 学习率, 参3: 动量参数.
optimizer = optim.SGD(params=[w], lr=0.01, momentum=0.9) # 细节: momentum=0(默认), 只考虑: 本次梯度.
这里的关键点是:当 momentum=0 时,就退化为普通的 SGD,S_t = G_t,只用本次梯度更新;当 momentum=0.9 时,历史梯度占 90% 的权重,当前梯度占 10%,起到平滑和加速的效果。
⚪梯度下降法

图中标注这是梯度下降的优化方法——指数加权平均(参数一般设置为0.9,90%依赖历史,10%依赖现在),而不是梯度下降本身。右上角提到它是后续学习Momentum(动量法)、AdaGrad、RMSProp、Adam这四种优化器的基础,这四种方法的公式都用到了指数移动加权平均。这里的梯度指的是每一个参数对历史的梯度指数加权

这样做的好处是:梯度不再剧烈抖动,而是变得平滑。如果连续几步梯度方向一致,加权平均后的梯度就会加速;如果梯度方向来回震荡,正负相消后就会减速。这正是 Momentum(动量法)的核心思想——给梯度加一个"惯性",使优化过程更稳定、更快收敛。

⚪反向传播(Back Propagation)详解
一、一句话核心
反向传播就是链式求导法则在神经网络中的应用。它解决的问题是:损失函数 E对网络中每一个参数 w 的偏导数 ∂E/∂w怎么高效算出来,算出来之后就能用梯度下降去更新参数了。

为什么高效? 因为计算的每一层梯度都可以去为前一层的所有参数去算它们的梯度

⚪梯度下降算法回顾
在进行模型训练时,有三个基础的概念:
- Epoch:使用全部数据对模型进行一次完整训练,训练轮次
- Batch_size:使用训练集中的小部分样本对模型权重进行以此反向传播的参数更新,每次训练每批次样本数量
- Iteration:使用一个 Batch 数据对模型进行一次参数更新的过程

⚪损失最小化的两种方法
1.梯度下降法:核心思想:梯度下降法是一种寻找使损失函数最小化的方法。梯度方向是函数增长最快的方向,所以取反方向就是函数下降最快的方向。

2.正规方程法:要求矩阵可逆,类似于二元一次方程通过令导数为0,求出最优的w解,近而求出最小化loss的w值,loss本身就是凹曲线,因为loss需要变小,所以可以用梯度为零的方法。

⚪L1损失函数。|x|这个函数在x=0处有一个尖角,导数在这里不存在(从左边趋近是-1,从右边趋近是+1,突然跳变)。这意味着当预测值非常接近真实值时,梯度不会逐渐变小引导模型"缓缓停下",而是始终保持恒定大小的梯度,可能导致模型在最优点附近来回震荡,难以精确收敛。

这也正是 Smooth L1(Huber Loss)被提出的原因——在误差小的时候用L2(平滑的抛物线,梯度随误差减小而减小),在误差大的时候用L1(避免L2对大误差的过度惩罚),兼顾了两者的优点。
L1的梯度:
- 预测值 > 真实值时,梯度 = +1
- 预测值 < 真实值时,梯度 = -1
方向是对的,始终指向正确的方向。但不管误差是100还是0.001,梯度的绝对值永远是1。
对比L2的梯度:
- 梯度 = 2(ŷ - y),大小和误差成正比
假设学习率 lr = 0.01,预测值已经非常接近真实值,误差只有0.001:
- L2的参数更新量:0.01 × 2 × 0.001 = 0.00002,非常小的一步,轻轻靠近
- L1的参数更新量:0.01 × 1 = 0.01,步子依然很大
0.01的步子去修正0.001的误差,直接跨过去了,到了另一边。然后梯度变号,又迈0.01跨回来,再跨过去……就这样反复震荡,永远停不到最优点上。
所以问题不是方向错了,而是快到终点了还在全速冲刺,刹不住车。L2的梯度会随误差减小而自动减速,L1不会。
⚪L2损失函数.L2损失更容易关注误差大,但是容易梯度爆炸,因为会将误差平方,导致误差loss很大,求导后梯度很大


⚪正则化:在原始Loss后加惩罚项,防止过拟合。
Loss = Loss_data + λ × 正则项
L1正则化:绝对值之和
正则项 = Σ|wᵢ|,梯度恒为±1,不管权重多小都固定步长地往0砍。结果是部分权重被精确压到0,类似于卷积核中某些参数为0,或者某些神经元为0,模型变稀疏,自动完成特征选择。PyTorch中需要手动实现。
L2正则化:平方之和
正则项 = Σwᵢ²,梯度 = 2w,和权重成正比,权重越小推力越弱。结果是所有权重都缩小但不会归零,模型更平滑稳定。PyTorch中通过优化器的weight_decay参数实现。
核心区别用一个例子说清:
A = [3, 0, 0],B = [1, 1, 1]
L1(A) = L1(B) = 3,L1对两者无差别,但优化过程天然倾向产生A这种稀疏解。
L2(A) = 9,L2(B) = 3,L2严重惩罚大权重,偏好B这种分散均匀的解。
选择原则: 无关特征多、需要特征选择 → L1;需要整体平滑防过拟合 → L2;两者结合 → Elastic Net。深度学习中L2(weight_decay)更常用,因为通常不希望直接丢弃特征,而是让所有权重保持在合理范围内。
⚪一、损失函数的概念
损失函数也叫成本函数、目标函数、代价函数、误差函数,核心作用是衡量模型好坏(模型拟合情况)。
二、损失函数的分类
按任务类型分为两大类:
分类问题:一般需要对损失函数做.detach().numpy().copy()
- 多分类 → CrossEntropyLoss(交叉熵损失) (softmax损失函数,不需要手动写,公式内置)
- 二分类 → BCELoss(二元交叉熵损失) (sigmod损失函数,需要手动写,公式不内置
-
损失函数 激活函数 是否内置 CrossEntropyLoss Softmax 内置,不要手动加 BCELoss Sigmoid 不内置,需要手动加 BCEWithLogitsLoss Sigmoid 内置,不要手动加 - 损失函数图像

回归问题:
- MAE(Mean Absolute Error):平均绝对误差,对异常值不敏感
- MSE(Mean Squared Error):均方误差,对异常值敏感(因为平方放大了大误差)
- Smooth L1:结合了MAE和MSE的优点,小误差时类似MSE(平滑),大误差时类似MAE(不爆炸)
三、多分类交叉熵损失的详解
公式:Loss = -Σy·log(S(f(x)))
各符号的含义:
- x:输入样本
- f(x):网络输出的加权求和(即logits,未归一化的分数)
- S(f(x)):经过Softmax处理后的概率分布(将logits转为0~1之间且和为1的概率)
- y:样本属于某一类别的真实概率(one-hot编码时,正确类为1,其余为0)
多分类中有语义分类和图像分类:
图像分类:目标是给整张图一个标签——"这是猫/狗/鸟"。所以模型最后把所有像素信息汇聚成一个向量(比如通过全连接层或全局平均池化),输出形状是 [batch_size, num_classes],每张图对应一组logits,算一次交叉熵。
语义分割(你做的视网膜血管分割):目标是给每个像素一个标签——"这个像素是血管/背景"。这种情况下,确实是对每个像素单独算交叉熵,然后取平均。模型输出形状是 [batch_size, num_classes, H, W],每个像素位置都有一组logits。
大白话理解: 损失函数的结果 = 最小化"正确类别概率的负对数"。也就是说,模型把正确类别的预测概率越高,loss越小;预测概率越低,loss越大。这就迫使模型学会给正确答案分配更高的概率。
⚪标签的两种表达方式:one-hot和索引,用一个 4×4图像、3类语义分割 的例子来展示。
假设这是一张遥感图像的语义分割,3个类别:
- 0 = 水域(水)
- 1 = 植被(树)
- 2 = 建筑(房)
真实场景长这样:
水 水 树 树
水 水 树 房
水 树 房 房
树 树 房 房
左上角是水,右下角是建筑,中间过渡区是植被。
索引标签形式
形状:[4, 4],每个像素直接存类别编号
0 0 1 1
0 0 1 2
0 1 2 2
1 1 2 2
就这么简单,一个二维矩阵,每个位置一个整数。
One-Hot形式
形状:[3, 4, 4],3个通道,每个通道是一张"二值掩码",表示"该像素是否属于这个类别"。
通道0(水域掩码):
1 1 0 0
1 1 0 0
1 0 0 0
0 0 0 0
只有水域像素为1,其余为0。
通道1(植被掩码):
0 0 1 1
0 0 1 0
0 1 0 0
1 1 0 0
只有植被像素为1。
通道2(建筑掩码):
0 0 0 0
0 0 0 1
0 0 1 1
0 0 1 1
只有建筑像素为1。
两者的对应关系
拿像素 (1, 3) 举例:
- 索引标签:值为 2,表示"建筑"
- One-Hot标签:通道0=0,通道1=0,通道2=1,即 [0, 0, 1]
⚪查看模型的总参数量,以及模型每层参数的具体数值是多少
1.from torchinfo import summary
summary(model, input_size=(batch_size, channels, H, W))
查看每层的输出形状和参数数量。
2.查看每层模型的参数数值和名称
for name, param in my_model.named_parameters():
遍历模型里所有的参数,每次循环拿到两个东西:
name→ 参数的名字,比如"layer1.weight"、"layer1.bias"param→ 参数的具体数值(是一个张量)
⚪self 就是"我自己这个对象"。self.xxx = yyy 就是把 yyy 永久贴在这个对象身上,之后随时可以用 self.xxx 取出来。
self.name = name 这行在做什么?
def __init__(self, name, age):
self.name = name # 把传进来的 name,永久存到这只狗身上
self.age = age # 把传进来的 age,永久存到这只狗身上
如果没有 self.name = name,只写:
def __init__(self, name, age):
name = name # ❌ 只是临时变量,函数结束就消失
那么 dog1.bark() 里访问 self.name 就会报错,因为这只狗身上根本没有存 name。
⚪参数初始化与反向传播核心逻辑总结
第一层:为什么需要参数初始化?
神经网络训练的本质是通过反向传播不断更新权重 W。但如果初始权重设置不当,训练会从一开始就出问题:
- 权重过小 → 信号前向逐层缩小(y消失),同时梯度反向连乘缩小(梯度消失),浅层 W 几乎得不到更新
- 权重过大 → 信号和梯度逐层放大失控(梯度爆炸)
- 权重全相同 → 所有神经元梯度一致,同步更新,网络等于白训练(对称性问题)
第二层:反向传播是怎么工作的?
前向传播:数据从前往后流,每层做两步:
![]()
反向传播:梯度从后往前传,目的只有一个——求出每个 W 的梯度,用来更新 W。
这里涉及两种求导,角色完全不同:

第三层:为什么必须对 a 求导来传递梯度?

更加清晰的解释:要计算某层权重 Wi的梯度,必须先得到损失对该层输出的梯度;而该梯度是通过链式法则从后续层逐层反向传播得到的。

第四层:为什么小权重会让梯度消失?

第五层:如何选择初始化方法?
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| ReLU 系激活函数 | Kaiming(std = √(2/fan_in)) | 专为 ReLU 设计,补偿其单侧抑制 |
| Tanh/Sigmoid | Xavier(std = √(2/(fan_in+fan_out))) | 考虑了输入输出双向方差 |
| 浅层网络 | 随机初始化 | 层数少,连乘效应弱,够用 |
| 全0/全1/固定值 | ❌ 永远不用 | 无法打破对称性 |
一条贯穿全文的核心逻辑
参数初始化的本质,是在反向传播开始之前,给每个 W 一个合理的起点,使得梯度在层间传递时既不消失也不爆炸,让每一层的 W 都能收到有效的更新信号,训练才能真正发生。
⚪参数初始化方法解析
图像讲的是神经网络中两种常见的权重初始化策略:Kaiming 初始化和 Xavier 初始化。
一、Kaiming 初始化(也叫 HE 初始化)
专为 ReLU 激活函数设计(图中红字标注"kaiming + ReLU")。
| 变体 | 分布 | 参数公式 |
|---|---|---|
kaiming_normal_() | 正态分布 | std = √(2 / fan_in) |
kaiming_uniform_() | 均匀分布 | limit = √(6 / fan_in) |
- fan_in:输入层神经元的个数,即特征的个数(图中红字强调)
二、Xavier 初始化(也叫 Glorot 初始化)
适用于 Tanh、Sigmoid 等对称激活函数。
| 变体 | 分布 | 参数公式 |
|---|---|---|
| 正态化Xavier | 正态分布 | std = √(2 / (fan_in + fan_out)) |
| 均匀分布Xavier | 均匀分布 | limit = √(6 / (fan_in + fan_out)) |
- fan_out:输出层神经元的个数
关于 [0, std] 的含义
|<--- 68.3% --->|
|<------ 95.4% ------>|
|<--------- 99.7% --- -- -- ->|
─────┼─────┼─────┼─────┼─────┼─────┼─────
-3σ -2σ -1σ 0 +1σ +2σ +3σ
这里实际上是数学上正态分布的参数表示,完整写法是:
- 0 代表均值(mean = 0),即分布以 0 为中心
- std 代表标准差(standard deviation)
意思是:从均值为 0、标准差为 std 的正态分布中随机采样,作为初始权重值。
用均值为 0 是为了保证初始权重不偏向正负任何一侧;用合适的 std 是为了控制每层输出的方差不会爆炸或消失,这正是 Kaiming/Xavier 初始化设计 std 公式的核心目的。
⚪ReLU 截断负值,为什么不怕丢失信息?
1.网络在训练过程中,W 和 b 会不断调整。如果某个方向的信息真的重要,权重会自动调整,让这个信息以正值的形式出现在某个神经元上,而不是依赖于负值被保留。
换句话说,重要的信息训练结束后一定会以某种正值形式存在于网络中。
2.ReLU 让部分神经元输出为 0,这叫稀疏激活,反而带来了好处:
- 减少了神经元之间的干扰,每次只有一部分神经元参与计算
- 让网络对不同输入有选择性地响应,不同样本激活不同的神经元子集
- 类似于人脑中神经元的稀疏放电机制
⚪激活函数在前向传播和反向传播中的意义
一、先理解:没有激活函数会怎样?
假设一个三层网络,没有激活函数:
三层网络退化成一层线性变换,无论堆多少层,表达能力和单层完全一样。激活函数的根本作用就是打破这种线性叠加。
二、前向传播中的意义:引入非线性,提升表达能力
前向传播是数据从输入流向输出的过程,激活函数在每一层做的事是:
把线性变换的结果,映射成非线性的输出
输入x → 线性变换z=Wx+b → 激活函数f(z) → 下一层输入
具体意义体现在三个层面:
1. 让网络能拟合非线性问题
现实中绝大多数问题都是非线性的,比如图像分类、医学图像分割。没有激活函数,网络只能划一条直线来分类,加了激活函数,网络可以学出任意复杂的决策边界。
2. 控制信息的传递方式
不同激活函数对信息有不同的"过滤"方式:ReLU 把负值截断为 0,相当于让神经元选择性激活,只传递有意义的信号;Sigmoid 把输出压缩到 (0,1),适合表达概率。
3. 增加网络的深度价值
正是因为每层都有非线性变换,深层网络才能逐层抽取更高级的特征——浅层学边缘,中层学形状,深层学语义。这是深度学习"深度"的真正价值所在。
| 导数特性 | 结果 | 代表激活函数 |
|---|---|---|
| 导数 < 1,连乘后趋近 0 | 梯度消失,浅层学不到 | Sigmoid、Tanh |
| 导数恒为 1(正区间) | 梯度完整传回 | ReLU |
| 导数 > 1,连乘后爆炸 | 梯度爆炸,训练不稳定 | 设计不良的激活函数 |
激活函数在前向传播中赋予网络拟合非线性的能力,在反向传播中决定梯度能否有效传递。这两个方向缺一不可——前向传播决定网络能学什么,反向传播决定网络能不能学会。
⚪一、激活函数与梯度饱和
Q1:Sigmoid 梯度最大值是多少?5层后梯度是多少?
Sigmoid 导数公式为 σ'(x) = σ(x)(1 - σ(x)),在 x=0 时取最大值 0.25。5层网络反向传播时梯度连乘:0.25⁵ = 1/1024 ≈ 0.001,接近于 0,这就是梯度消失的根本来源。
Q2:梯度为 0 时,输入输出分别是多少?
当输入 x 非常大时,输出趋近于 1,梯度趋近于 0;当输入 x 非常小时,输出趋近于 0,梯度同样趋近于 0。只有 x=0 附近梯度最大为 0.25。输入绝对值超过 5 时,Sigmoid 基本完全饱和。
二、梯度为 0,为什么参数不更新?
Q3:梯度为 0 → 参数不更新的原因
权重更新公式为 W_new = W_old - η × (∂L/∂W)。当梯度等于 0 时,更新量为 η×0=0,W_new = W_old,权重完全没有变化。
Q4:梯度为 0,为什么 Loss 不变?
梯度的本质是 Loss 随 W 变化的速率。梯度为 0 说明 W 无论怎么变,Loss 都不变。根本原因是 Sigmoid 的饱和区把输入差异压死了——输入 x=100 和 x=101,输出都是 1.000000,W 变了但预测值没变,Loss 自然不变。
三、参数更新机制
Q5:深度学习中所有 W 都需要更新吗?
默认情况下所有 W 都参与反向传播并被更新。但存在例外:迁移学习中前几层会被冻结(requires_grad=False);预训练词向量有时固定不训练;TensorRT 部署后参数完全固定,不存在更新。
Q6:更新所有 W,是不是用优化器?
是的。反向传播(loss.backward())只负责计算梯度,优化器(optimizer.step())负责用梯度真正更新 W,两件事是分开的。常见优化器从简单到复杂依次是 SGD、Momentum、Adam、AdamW,目前实践中 Adam 最常用。
四、训练异常:Loss 一直不变
Q7:训练中 Loss 不变的常见原因
本质只有一种:W 没有被有效更新。具体可能是学习率设置不合理(太小、太大、或等于 0);梯度消失导致 W 几乎不动;代码层面忘记写 loss.backward() 或 optimizer.step() 或 zero_grad();数据问题如标签全部相同、输入未归一化;结构问题如权重初始化全为 0、ReLU 大量死亡、网络太深配合 Sigmoid 使用。
五、Loss 关于 W 的完整数学推导
Q8/Q9:二分类交叉熵 + ReLU 的推导
前向传播逐层展开:
- z₁ = W₁x + b₁(第一层线性变换)
- h = max(0, z₁)(ReLU,负值截断为 0)
- z₂ = W₂h + b₂(第二层线性变换)
- ŷ = 1 / (1 + e^{-z₂})(Sigmoid,输出概率)
- L = -[y·log(ŷ) + (1-y)·log(1-ŷ)](二分类交叉熵)
反向传播链式法则:BCE 对 ŷ 求导,再乘 Sigmoid 对 z₂ 的导数,两项合并后化简得到 ∂L/∂z₂ = ŷ - y,非常简洁。继续往前传:
- 对 W₂ 的梯度:(ŷ - y) · h
- 对 W₁ 的梯度:(ŷ - y) × W₂ × 𝟙[z₁>0] × x
其中 𝟙[z₁>0] 是 ReLU 的导数,正区间为 1,负区间为 0。正区间梯度连乘不衰减(1⁵=1),这正是 ReLU 替代 Sigmoid 的核心优势。
六、整体逻辑链
整个对话的知识点形成一条完整因果链:
Sigmoid 饱和 → 梯度为 0 → Loss 不变 → W 不更新 → 网络无法学习(梯度消失)
解决方案:用 ReLU 替代 Sigmoid,正区间导数恒为 1,梯度不衰减。
⚪self.edge_conv_1x7 = nn.Conv2d(in_channels, edge_ch1, (1, 7), padding=(0, 3), bias=False)

⚪"""初始化边缘检测卷积核(固定权重,类似Sobel算子)"""
kernel_1x7 = torch.tensor([[-1, -2, -3, 0, 3, 2, 1]], dtype=torch.float32).view(1, 1, 1, 7)
kernel_1x5 = torch.tensor([[-1, -2, 0, 2, 1]], dtype=torch.float32).view(1, 1, 1, 5)
kernel_1x9 = torch.tensor([[-1, -2, -3, -4, 0, 4, 3, 2, 1]], dtype=torch.float32).view(1, 1, 1, 9),请问这里为什么要用.view(1, 1, 1, 7)?
把普通的长度为 7 的一维核,变成 Conv2d 所要求的四维权重格式 (输出通道数, 输入通道数, 核高, 核宽),也就是 (1,1,1,7)
⚪

神经元四个值的作用详解
一、前向传播的两个值
1. 内部状态值 z₁ = W₁·x + b₁
作用:
- ✅ 存储加权求和的结果(激活函数的输入)
- ✅ 反向传播时计算激活函数导数(需要用到z的值)
- ✅ 调试和可视化(观察神经元在激活前的状态)
为什么要保存?
# 前向传播
z1 = W1 @ x + b1 # 保存这个值!
a1 = sigmoid(z1)
# 反向传播需要用它
∂L/∂z1 = ∂L/∂a1 * sigmoid'(z1) # ← 这里需要z1!
# sigmoid'(z1) = sigmoid(z1) * (1-sigmoid(z1))
如果不保存会怎样?
- ❌ 无法计算激活函数导数
- ❌ 需要重新计算(浪费计算资源)
2. 激活值 a₁ = σ(z₁)
作用:
- ✅ 传递给下一层作为输入(前向传播)
- ✅ 反向传播时计算权重梯度(需要用到前一层的激活值)
- ✅ 最终输出的预测结果(输出层的激活值)
为什么要保存?
# 前向传播
a1 = sigmoid(z1) # 保存这个值!
z2 = W2 @ a1 + b2 # 下一层需要a1
# 反向传播需要用它
∂L/∂W1 = ∂L/∂z1 @ x.T # ← 这里需要前一层的激活值(这里是x)
∂L/∂W2 = ∂L/∂z2 @ a1.T # ← 这里需要a1!
如果不保存会怎样?
- ❌ 无法计算权重梯度
- ❌ 需要重新进行整个前向传播
二、反向传播的两个梯度
3. 内部状态值梯度 ∂L/∂z₁
作用:
- ✅ 计算权重梯度 ∂L/∂W₁(用于更新权重)
- ✅ 计算偏置梯度 ∂L/∂b₁(用于更新偏置)
- ✅ 传递到前一层(转换为前一层的激活值梯度)
计算公式:
∂L/∂z1 = ∂L/∂a1 * σ'(z1)
↑ ↑
后层传来 激活函数导数
用途:
# 1. 更新权重和偏置
∂L/∂W1 = ∂L/∂z1 @ x.T
∂L/∂b1 = ∂L/∂z1
W1 -= lr * ∂L/∂W1
b1 -= lr * ∂L/∂b1
# 2. 传递到前一层
∂L/∂x = W1.T @ ∂L/∂z1 # 前一层的激活值梯度
4. 激活值梯度 ∂L/∂a₁
作用:
- ✅ 接收后一层传来的误差信号
- ✅ 结合激活函数导数计算内部状态值梯度
- ✅ 桥梁作用(连接后层和当前层)
计算公式:
∂L/∂a1 = W2.T @ ∂L/∂z2
↑ ↑
后层权重 后层内部状态值梯度
用途:
# 接收后层误差
∂L/∂a1 = W2.T @ ∂L/∂z2
# 计算本层内部状态值梯度
∂L/∂z1 = ∂L/∂a1 * σ'(z1)
三、四个值的关系图
前向传播(计算并保存):
x → z1 = W1·x + b1 → a1 = σ(z1) → z2 = W2·a1 + b2 → ...
↑ 保存 ↑ 保存
反向传播(使用保存的值计算梯度):
... → ∂L/∂a1 ← W2.T·∂L/∂z2
↓
∂L/∂z1 = ∂L/∂a1 · σ'(z1) ← 需要z1
↓
∂L/∂W1 = ∂L/∂z1 · x.T ← 需要x(前一层的a)
四、为什么需要保存这些值?
内存 vs 计算的权衡
| 策略 | 优点 | 缺点 |
|---|---|---|
| 保存z和a | 反向传播快速 | 占用内存 |
| 不保存,重新计算 | 节省内存 | 计算慢2倍+ |
六、总结表格
| 值 | 类型 | 计算阶段 | 作用 | 依赖 |
|---|---|---|---|---|
| z₁ | 前向值 | 前向传播 | 激活函数输入,计算σ'(z) | W₁, x, b₁ |
| a₁ | 前向值 | 前向传播 | 传给下一层,计算∂L/∂W | z₁ |
| ∂L/∂a₁ | 梯度 | 反向传播 | 接收后层误差,计算∂L/∂z | 后层W和∂L/∂z |
| ∂L/∂z₁ | 梯度 | 反向传播 | 更新W和b,传给前层 | ∂L/∂a₁, z₁ |
七、记忆口诀
前向传播存两值:
- z存下来算导数(激活函数导数需要z)
- a存下来算权重(权重梯度需要前一层的a)
反向传播算两梯度:
- ∂L/∂a接收误差(从后层来)
- ∂L/∂z更新参数(乘激活导数)
核心链条:
∂L/∂a → ×σ'(z) → ∂L/∂z → ×a_prev → ∂L/∂W
本质:这四个值构成了神经网络训练的完整闭环——前向传播计算输出(z→a),反向传播计算梯度(∂L/∂a→∂L/∂z→∂L/∂W)!🎯
神经网络与激活函数核心总结
一、网络结构(图片内容)
输入层(3特征) → 隐藏层1(加权求和) → 隐藏层2(激活函数) → 输出层(2输出)
5个神经元 5个神经元
核心原理:
- 全连接:相邻层神经元全部互连,同层神经元相互隔离
- 权重更新:W1 = W0 - 学习率 × 梯度
- 前向传播:前层输出 = 后层输入
二、四大激活函数速查表
Sigmoid:自己乘自己的补 σ'(x) = σ(x) · (1-σ(x))
Tanh: 1减自己平方 tanh'(x) = 1 - tanh²(x)
ReLU: 大于0为1否则0 ReLU'(x) = {1 if x>0, 0 if x≤0}
Softmax:输出减标签(配交叉熵)∂L/∂x = s - y
三、关键特性对比
| 特性 | Sigmoid | Tanh | ReLU | Softmax |
|---|---|---|---|---|
| 梯度消失 | 严重✗ | 中等△ | 无✓ | 无✓ |
| 计算速度 | 慢(exp) | 慢(exp) | 快✓ | 中(exp) |
| 零中心化 | 否✗ | 是✓ | 否✗ | - |
| 最大梯度 | 0.25 | 1.0 | 1.0 | - |
常见问题
Q:为什么ReLU最常用?
- 计算简单(比大小)
- 梯度为1(正区域)不会消失
- 稀疏激活(约50%神经元被抑制)
Q:Sigmoid问题在哪?
- 梯度最大0.25,深层网络梯度消失
- 输出非零中心,影响收敛速度
Q:Softmax一定要配交叉熵?
- 不是必须,但配合后梯度简化为 s-y
- 数值稳定性更好
核心记忆:梯度 = 激活函数导数 × 前一层误差,导数决定了梯度传播效率!🎯





1903

被折叠的 条评论
为什么被折叠?



