⚪训练函数的核心流程是:清零梯度 → 前向传播 → 计算loss → 反向传播 → 更新参数,每个 epoch 分训练和测试两个阶段,训练阶段更新参数,测试阶段只评估效果不更新参数,用 torch.no_grad() 节省内存。

optimizer.zero_grad() → 清空上一个batch的梯度,否则梯度会累加

pred = model(img) → 前向传播,得到预测结果 (B, NUM_CLASSES, H, W)

loss = criterion(...) → 预测和真实mask逐像素对比,算出损失

loss.backward() → 反向传播,计算每个参数的梯度

optimizer.step() → 按梯度方向更新所有参数

def train(model, train_loader, test_loader, epochs):

    # 1. 定义损失函数(逐像素交叉熵)
    criterion = nn.CrossEntropyLoss()

    # 2. 定义优化器
    optimizer = optim.Adam(model.parameters(), lr=LR)

    # 3. 记录每轮损失
    train_loss_list = []
    test_loss_list  = []

    for epoch in range(epochs):

        # ===== 训练阶段 =====
        model.train()                        # 开启训练模式
        total_train_loss = 0.0

        for img, mask in train_loader:
            # 4. 数据送入GPU
            img  = img.to(DEVICE)            # (B, 3, H, W)
            mask = mask.to(DEVICE)           # (B, H, W)

            # 5. 梯度清零(每个batch必须清零)
            optimizer.zero_grad()

            # 6. 前向传播
            pred = model(img)                # (B, NUM_CLASSES, H, W)

            # 7. 计算损失
            loss = criterion(pred, mask)

            # 8. 反向传播
            loss.backward()

            # 9. 更新参数
            optimizer.step()

            total_train_loss += loss.item()

        # 计算本轮平均训练损失,会计算每一轮的loss,方便后面画出loss曲线,并且无论每一轮中的batch多少,单独计算每一轮的batch都是计算其平均batch的损失,添加到loss的列表中
        avg_train_loss = total_train_loss / len(train_loader)
        train_loss_list.append(avg_train_loss)


        # ===== 测试阶段 =====
        model.eval()                         # 开启测试模式
        total_test_loss = 0.0

        with torch.no_grad():                # 不计算梯度
            for img, mask in test_loader:
                img  = img.to(DEVICE)
                mask = mask.to(DEVICE)

                pred = model(img)
                loss = criterion(pred, mask)
                total_test_loss += loss.item()

        avg_test_loss = total_test_loss / len(test_loader)
        test_loss_list.append(avg_test_loss)


        # 10. 打印每轮结果
        print(f'Epoch [{epoch+1}/{epochs}]  '
              f'train_loss: {avg_train_loss:.4f}  '
              f'test_loss:  {avg_test_loss:.4f}')

    return train_loss_list, test_loss_list

train_loss_list.append(avg_train_loss)
```
```
epoch1 结束 → avg=0.85 → train_loss_list=[0.85]
epoch2 结束 → avg=0.72 → train_loss_list=[0.85, 0.72]
epoch3 结束 → avg=0.61 → train_loss_list=[0.85, 0.72, 0.61]
...
epoch100结束→ avg=0.12 → train_loss_list=[0.85, 0.72, ..., 0.12]

⚪# 训练完模型后,一次性调用:

# 第一张图:看loss是否在下降
plt.plot(range(epochs), loss_list)  横坐标和纵坐标
plt.title('损失值曲线变化图')
plt.grid()  网格线
plt.show()

# 第二张图:看预测值和真实值是否接近
plt.scatter(x, y)                                    # 散点
plt.plot(x, y_pred, color='red',   label='预测值')   # 红线
plt.plot(x, y_true, color='green', label='真实值')   # 绿线
plt.legend()  有label则需要展现# 显示图例(红色=预测,绿色=真实)
plt.grid()
plt.show()

⚪问题1:为什么可以调用 t1.data?

t1 是对象,不是一个数

就像之前讲的,张量是一个装数的容器对象torch.tensor() 这个函数把普通数字变成了对象。它背后调用了 torch.Tensor 这个类,按照类的模板创建了一个实例,这个实例就是对象。对象里面有很多属性:

t1 = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)

# t1 对象内部长这样:
t1.data           # 存放的实际数据  → tensor([1., 2., 3.])
t1.grad           # 梯度           → 反向传播后才有值
t1.requires_grad  # 是否需要求导   → True
t1.dtype          # 数据类型       → torch.float32

.data 就是对象里专门存放数值的那个属性,可以像列表一样用下标访问:

t1.data[0]        # 取第一个数 → 1.0
t1.data[0] = 100  # 修改第一个数

t1 和 t1.data 的区别

t1          →  完整张量对象(带计算图、带梯度追踪)
t1.data     →  只是里面的数据部分(脱离了计算图)
t1.data[0] = 100
# 直接修改数据,绕过了计算图
# 所以 PyTorch 不会追踪这个修改操作

问题2:t1.detach().numpy() 是什么意思

这是链式调用,一步一步来看:

t1                    # 带计算图的张量(requires_grad=True)
  ↓
t1.detach()           # 从计算图上摘下来,变成普通张量
  ↓                   # requires_grad 变成 False
t1.detach().numpy()   # 再转成 numpy 的 ndarray
t1 = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)

t2 = t1.detach()
# t2.requires_grad → False  ← 已经脱离计算图

n1 = t2.numpy()
# n1 → array([1., 2., 3.])  ← 成功转为numpy

⚪PyTorch 的反向传播 backward() 只支持标量张量对向量张量求导,不支持向量对向量求导。原因是深度学习中求导的目的是计算每个参数的梯度,loss 必须是一个数才能明确优化方向,如果 loss 是多个数,程序不知道该朝哪个方向优化。

举例来说:

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x * 2   # y = [2.0, 4.0, 6.0],是向量

# ❌ y 是向量,直接求导报错
y.backward()

# ✅ 先用 sum() 压缩成一个标量,再求导
y.sum().backward()   # y.sum() = 12.0,是一个数,可以求导

就像实际训练中:

# loss 是一个标量(一个数)
loss = criterion(output, label)   # 例如 loss = tensor(0.356)

# ✅ 直接反向传播
loss.backward()

# 如果 loss 不小心是向量,先压缩
loss.sum().backward()

标量张量就是里面只有一个数的张量,例如 torch.tensor(100) 输出是 tensor(100),没有中括号;而向量张量 torch.tensor([1.0, 2.0, 3.0]) 输出是 tensor([1., 2., 3.]),有中括号,这是两者最直观的区别。

⚪梯度为0时,则代表loss损失最小,此时预测值接近真实值,权重也不会在更新

⚪张量的形状操作八种方法

图1 文字

源数据: [6, 9, 9, 2, 8, 7]

tensor([[6, 9, 9],
        [2, 8, 7]]), shape=(2,3)

不改变内容:          改变内容:
[6, 9]              [6, 2]
[9, 2]              [9, 8]   → [6, 2, 9, 8, 9, 7]
[8, 7]              [9, 7]

[6, 9, 9, 2, 8, 7]

图2 文字

涉及到的API:
    reshape()       在不改变张量内容的前提下,对其形状做改变.
    unsqueeze()     在指定的轴上增加一个(1)维度,等价于:升维.
    squeeze()       删除所有为1的维度,等价于:降维.
    transpose()     一次只能交换2个维度.
    permute()       一次可以同时交换多个维度.
    view()          只能修改连续的张量的形状,连续张量 = 内存中存储顺序 和 在张量中显示的顺序相同.
  ★ contiguous()   把不连续的张量 → 连续的张量,即:基于张量中显示的顺序,修改内存中的存储顺序.
    is_contiguous() 判断张量是否是连续的.

⚠️ 补充注意点

reshape vs view

reshapeview
连续张量✅ 可用✅ 可用
不连续张量✅ 自动处理❌ 直接报错
推荐程度更安全,优先用需先确认连续性
# 安全写法:先contiguous再view
x = x.contiguous().view(2, 3)

# 或者直接用reshape,更省心
x = x.reshape(2, 3)

unsqueeze vs squeeze

x = torch.tensor([1, 2, 3])   # shape=(3,)

x.unsqueeze(0)   # shape=(1,3)  ← 在0轴加一维
x.unsqueeze(1)   # shape=(3,1)  ← 在1轴加一维

# 典型场景:给单张图片加batch维度
img.unsqueeze(0)  # (C,H,W) → (1,C,H,W)  送入模型必须有batch维!

# squeeze 注意:只删除size=1的维度,其他维度不受影响
y = torch.rand(1, 3, 1, 4)
y.squeeze()   # shape=(3,4)  两个为1的维度都被删掉

transpose vs permute

x = torch.rand(2, 3, 4)

# transpose 一次只能换两个轴
x.transpose(0, 1)          # shape=(3,2,4)

# permute 一次指定所有轴的新顺序,更灵活
x.permute(1, 2, 0)         # shape=(3,4,2)

# ⚠️ 注意:transpose/permute 之后张量变不连续
# 如果后续要用view,必须先加 .contiguous()
x.transpose(0,1).contiguous().view(-1)

连续性问题总结

原始张量          → 连续
reshape/view      → 连续
transpose/permute → ❌ 不连续!

所以常见固定搭配:
x.transpose(1,2).contiguous().view(...)
x.permute(0,2,1).contiguous().reshape(...)

⚪这是一个 shape=(2, 3, 4) 的三维张量

对应数据还原

x = torch.tensor([
    [             # 0轴 第1个元素
        [3, 4, 6, 5],      # 1轴第1行,2轴4个元素
        [8, 8, 8, 3],      # 1轴第2行,2轴4个元素
        [4, 9, 6, 7],      # 1轴第3行,2轴4个元素
    ],
    [             # 0轴 第2个元素
        [2, 8, 8, 5],      # 1轴第1行,2轴4个元素
        [6, 4, 2, 2],      # 1轴第2行,2轴4个元素
        [2, 7, 9, 4],      # 1轴第3行,2轴4个元素
    ]
])

print(x.shape)  # torch.Size([2, 3, 4])

用现实场景理解

0轴=2  →  2张图片(batch)
1轴=3  →  每张图片3行像素
2轴=4  →  每行4个像素值

图中红框标注的就是每个轴的第一个元素,帮助你快速定位各轴的起始位置。

shape=(2,3,4) 意思就是:2个 [3×4的矩阵] 叠在一起,从外到内依次对应 0轴→1轴→2轴。

import torch

x = torch.tensor([[
    [[1, 2, 3, 4],      # 通道0(边缘检测)
     [1, 2, 3, 4],
     [1, 2, 3, 4],
     [1, 2, 3, 4]],

    [[8, 9, 8, 9],      # 通道1(目标区域)
     [8, 9, 8, 9],
     [8, 9, 8, 9],
     [8, 9, 8, 9]],

    [[0, 0, 0, 0],      # 通道2(背景,没用)
     [0, 0, 0, 0],
     [0, 0, 0, 0],
     [0, 0, 0, 0]]
]])   # shape = (1, 3, 4, 4)

# 压缩 H,W → 每个通道得到一个均值
attention = x.mean(dim=(2,3), keepdim=True)
print(attention)
# shape = (1, 3, 1, 1)
# 通道0: 2.5   ← 一般
# 通道1: 8.5   ← 很强,说明这个通道很重要!
# 通道2: 0.0   ← 没激活,不重要

# 用这个分数乘回原特征图
x = x * attention
# 通道1被放大了,通道2被抑制了

点乘:
    要求: 两个张量的维度保持一致,对应元素直接做 相应的操作.
    API:
        t1 * t2
        t1.mul(t2)              # multiply: 乘法


矩阵乘法:
    要求: 两个张量,第一个张量 的列数,等于 第二个张量 的行数(A列 = B行)
    结果: A行B列
    API:
        t1 @ t2
        t1.matmul(t2)
        t1.dot(t2)              扩展: 只针对于一维张量有效.

案例:
        演示张量 和 numpy之间如何相互转换,以及 如何从标量张量中 提取其内容.


涉及到的API:
        场景1: 张量 → numpy  nd数组对象
            张量对象.numpy()                共享内存
            张量对象.numpy().copy()         不共享内存,  链式编程写法.

        场景2: numpy nd数组 → 张量.
            from_numpy()                    共享内存
            torch.tensor(nd数组)            不共享内存

        场景3: 从标量张量中 提取其内容.
            标量张量.item() (反向传播会用)


掌握:
        张量 → numpy:       张量对象.numpy()
        numpy → 张量:       torch.tensor(nd数组)
        从标量张量中 提取其内容:   标量张量.item()

共享内存,栈中的变量指向同一个地址,堆中的内存只有一份,右边.copy(),动态的从堆中去拷贝一份内存

⚪核心区别:数据来源不同

torch.tensor(data)torch.rand(2, 3)
数据来源你自己指定自动随机生成
参数含义传入具体数据传入形状大小
数据类型根据data自动推断默认 float32
使用场景已知数据转张量需要随机初始化

直接看代码

# torch.tensor() → 你给数据,它帮你包装成张量
data = [[1, 2, 3], 
        [4, 5, 6]]
x = torch.tensor(data)
# tensor([[1, 2, 3],
#         [4, 5, 6]])   ← 内容完全由你决定


# torch.rand() → 你给形状,它帮你填随机数
y = torch.rand(2, 3)
# tensor([[0.1234, 0.5678, 0.9012],
#         [0.3456, 0.7890, 0.2345]])  ← 内容随机,每次不同

创建四维张量

# 均匀分布 U(0,1)
a = torch.rand(2, 3, 4, 5)

# 标准正态 N(0,1)
b = torch.randn(2, 3, 4, 5)

三者对比

type(x)x.type()x.dtype
归属Python内置函数PyTorch方法PyTorch属性
返回对象的张量的完整类型字符串张量的数据类型
示例<class 'torch.Tensor'>'torch.FloatTensor'torch.float32
用途判断是什么对象判断/转换张量类型查询数据精度

if __name__ == '__main__' 的作用

先理解 __name__ 是什么

每个 Python 文件运行时,都有一个内置变量 __name__

  • 如果这个文件是直接运行的:__name__ == '__main__'
  • 如果这个文件是被别人 import 的__name__ == '文件名'

你写了一个文件 a.py,这个文件有两种"被使用"的方式:

方式1:你直接运行它

python a.py

方式2:别人在自己的文件里 import 它

import a

这两种方式有本质区别

直接运行,说明你就是想执行这个文件里的代码。

import,说明别人只是想借用你文件里的某个函数,并不想让你文件里的其他代码跑起来。


问题出在哪

# a.py
def add(x, y):
    return x + y

print("我是测试代码,跑了!")   # 你写来测试用的

当别人写 import a 时,那行 print 也会执行,这不是他想要的,他只是想用 add 函数。


这行代码就是为了区分这两种情况

if __name__ == '__main__':
    print("只有直接运行我,这里才执行")

Python 规定:

  • 直接运行这个文件,__name__ 自动等于 '__main__' → 条件成立 → 执行
  • 被 import,__name__ 等于文件名 'a' → 条件不成立 → 跳过

最简单的理解

这行代码就是一个门卫,意思是:

"只有你是直接来找我的,我才开门。你是通过别人转介绍来的,这扇门不给你开。"

  • NumPy:只能跑在 CPU
  • PyTorch:可以把数据搬到 GPU 上运算,训练速度快几十倍

alt+shift是纵向选择,ctrl+x是剪切

静态图必须把整个计算流程定义完,才能"开机运行"。tensorflow2.x版本已经优化了调用api简洁(keras),并且采用了动态图

定义阶段(只是画图,没有任何计算发生):
  x ──→ ×2 ──→ +1 ──→ relu ──→ 输出

运行阶段(喂数据进去,一次性跑完整条路):
  3  ──→  6  ──→  7  ──→  7  ──→  7

动态图:每一行执行完,就立刻有结果

x = torch.tensor(3.0)
h = x * 2          # 执行完这行,h = 6.0,现在就能打印
h2 = h + 1         # 执行完,h2 = 7.0
out = relu(h2)     # 执行完,out = 7.0

# 你可以在任意一行后面插入 print,随时查看
print(h)    # 6.0,完全没问题

静态图不能改的是图的结构(哪些节点存在、怎么连接),而不是数据本身。每次 sess.run() 可以喂不同的数据,但运算逻辑是固定的。

动态图每次 forward 都是重新"现场搭图",所以结构可以每次都不一样,这对 RNN、树形网络等动态结构极其重要。

"调用 API 简单"这里是不是指的是直接调用pytorch的库比较简单?

具体说就是:PyTorch 提供的那些现成函数、类(nn.Linearnn.Conv2dloss.backward() 等),调用起来写法简单、符合直觉,不需要很多额外的"仪式代码"。

TF 1.x 里你想做同一件事,需要先声明变量、初始化、开 Session……这些都是调用它的库函数时必须配套写的"样板",跟你真正想做的事情无关,纯粹是框架要求你做的。

PyTorch 把这些样板都封装进去了,你只需要调用核心的那几个 API,其他的它帮你处理。

所以"API 简单"= 调用 PyTorch 的库时,代码量少、语义清晰、不需要额外的配套步骤。

⚪(数据类型)可变 vs 不可变

数据类型可变/不可变原因
Integer 整型❌ 不可变数字改了就是新对象
Float 浮点型❌ 不可变同上
Boolean 布尔型❌ 不可变True/False 不能改
String 字符串❌ 不可变改了就是新字符串
Tuple 元组❌ 不可变创建后内部元素不能改
List 列表✅ 可变可以增删改元素
Set 集合✅ 可变可以添加删除元素
Dictionary 字典✅ 可变可以增删改键值对

简单验证

# 不可变——字符串改了是新对象
s = "hello"
s[0] = "H"  # 报错!字符串不能改

# 不可变——元组
t = (1, 2, 3)
t[0] = 99   # 报错!元组不能改

# 可变——列表
lst = [1, 2, 3]
lst[0] = 99  # 没问题,直接改了

一句话记住

列表、字典、集合可变;数字、字符串、元组不可变。

无序的意思

无序 = 元素没有位置,不能用下标访问:

s = {1, 2, 3}
print(s[0])   # 报错!集合没有下标

除了集合 Set 是无序的,其余都是有序的。

break = 直接退出整个循环

for i in range(10):
    if i == 5:
        break      # 到5就不跑了
# 输出:0 1 2 3 4

continue = 跳过这次,继续下一次

for i in range(5):
    if i == 3:
        continue   # 跳过3,继续跑4
# 输出:0 1 2 4

一句话记住:

break 是"不玩了",continue 是"这局跳过,下局继续"

⚪for 循环核心总结

第一步:理解 for 循环本身

for 循环自动递增,不需要手动 += 1,每次迭代开始会强制从 range 取下一个值,手动改了也没用。

for i in range(1, 4):  # i 自动变成 1, 2, 3
    print(i)

第二步:判断需要几个 for 循环

问自己:有几个东西在独立变化?

独立变化的维度需要的 for 数量
1个(打印列表)1个 for
2个(乘法表:行和列)2个 for
3个(三维矩阵:长宽高)3个 for

第三步:双重 for 循环怎么写

结构:

for 外层变量 in range(外层范围):
    for 内层变量 in range(内层范围):
        执行逻辑

执行顺序:外层走一步,内层走完全程

外层 j=1 → 内层 i=1 结束
外层 j=2 → 内层 i=1,2 结束
外层 j=3 → 内层 i=1,2,3 结束

第四步:内层范围两种情况

固定范围(内外互不影响):

for i in range(3):
    for j in range(3):  # 每次都是 0,1,2
        print(i, j)

动态范围(内层跟着外层变,如乘法表):

for j in range(1, 10):
    for i in range(1, j+1):  # 最多只到j,形成三角形
        print(f"{j}*{i}={j*i}")

一句话总结

有几个独立变化的维度就用几个 for,外层控制大循环,内层控制小循环,内层范围可以固定也可以依赖外层变量动态变化。

⚪帮我思考batchsize=4,是如何取到4个image和label对的,for循环不是一次只能取到一对吗

from torch.utils.data import DataLoader

dataloader = DataLoader(dataset, batch_size=4, shuffle=True)
```

---

## 为什么可以打包

DataLoader 内部实现了一个特殊方法 `__iter__`,让它支持被 for 循环遍历,每次循环时自动执行打包逻辑:
```
for 循环请求下一个  →  DataLoader内部执行:
                       1. 从Dataset取第1条
                       2. 从Dataset取第2条
                       3. 从Dataset取第3条
                       4. 从Dataset取第4条
                       5. 把4条堆叠成一个张量
                       6. 返回给for循环

分工关系

Dataset        →    DataLoader       →    for循环
负责存每一条数据      负责按batch_size打包     负责一批一批取出来
[img0, label0]
[img1, label1]  →  打包成4个一组  →  for batch in dataloader
[img2, label2]
[img3, label3]

实际结构

dataloader = DataLoader(dataset, batch_size=4)

for batch in dataloader:
    images, labels = batch
    # images 的形状是 (4, 3, 224, 224)  → 4张图片
    # labels 的形状是 (4,)              → 4个标签

images 不是一张图,而是4张图叠在一起的张量,DataLoader 已经把4条数据堆叠好了。


类比理解

  • Dataset = 一叠散装的卡片,每张卡片是一对图片+标签
  • DataLoader = 自动把卡片每4张装一个袋子
  • for 循环 = 每次取一个袋子,袋子里已经有4张了

⚪for 循环在深度学习中的使用

1. 训练固定轮次:

for epoch in range(100):  # 明确训练100轮
    loss = train()
    print(f"第{epoch}轮 loss={loss}")

2. 遍历数据集批次:

for batch in dataloader:  # 把数据一批一批喂给模型
    images, labels = batch
    output = model(images)

3. 遍历模型每一层:

for layer in self.layers:  # 数据依次经过每一层
    x = layer(x)

4. 遍历多个模型评估:

for model in [resnet, vgg, mobilenet]:
    acc = evaluate(model)
    print(acc)

while 循环在深度学习中的使用

1. 训练直到loss收敛:

while loss > 0.001:   # 不知道要几轮,达到精度才停
    loss = train()

2. 早停机制:

while not early_stop:
    loss = train()
    if 连续10轮loss没下降:
        early_stop = True

for 循环:知道要循环多少次,或者有一个明确的集合要遍历完。

while 循环:不知道要循环多少次,只知道满足某个条件就一直循环


对比表

对比forwhile
循环次数确定不确定
控制方式遍历集合/范围条件为True就继续
适合场景有限集合、固定次数条件驱动、不知道几次
忘写终止条件不会死循环会死循环

for循环三种使用方法的本质区别

第一种:直接遍历元素

for name in names:

每次循环,name 直接拿到的是元素本身,不知道自己在第几个位置。

第二种:遍历下标

for i in range(len(names)):

每次循环,i 拿到的是位置编号(0,1,2,3),再通过 names[i] 间接拿到元素。


核心差异对比

对比for x in listfor i in range(len(list))
循环变量元素本身位置下标
知道第几个?❌ 不知道✅ 知道
能修改列表?❌ 不能✅ 能
代码简洁度更简洁稍繁琐

还有第三种:两者结合 enumerate

既想要元素,又想要下标,用 enumerate

for i, name in enumerate(names):
    print(f"第{i}个是{name}")

总结一句话

只读数据用第一种,需要位置或修改数据用第二种,两者都要用 enumerate

是模板/蓝图,定义了有哪些属性和方法。 实例 是根据模板创建出来的具体东西。 对象 是实例的另一种叫法,几乎等价。

if 本质上就干一件事:满足条件才执行

常见的三个场景:

1. 判断是否合法,不合法就拦截

if b == 0:
    raise ValueError("不能除以0")

2. 走不同的路,根据情况选择处理方式

if 是GPU:
    用GPU跑
else:
    用CPU跑

3. 控制某个功能开不开

if use_attention:
    执行注意力模块

简单说就是:"如果...就...",跟日常说话逻辑完全一样。

# 1. 如果数据是空的,就跳过不处理
for item in dataset:
    if item is None:
        continue
    process(item)

# 2. 如果是训练模式,就开启dropout
if self.training:
    x = self.dropout(x)

# 3. 如果缓存已经算过了,就直接用缓存不重新算
if self.cache is None:
    self.cache = self.compute()
return self.cache

# 4. 如果输入是列表,就先转成tensor
if isinstance(x, list):
    x = torch.tensor(x)

# 5. 如果loss够小了,就停止训练
for epoch in range(100):
    loss = train()
    if loss < 0.01:
        break

⚪类为什么没有 __init__ 也能当参数用?

因为 self.属性 的读取不依赖 __init__,只依赖查找链。只要类字典里有,self.xxx 就能找到。

所以 __init__ 对属性来说不是必须的,它只是在实例创建时往实例字典里写值的一个时机而已。


核心对比表

对比维度__init__ 实例属性类属性
存储位置每个实例自己的 __dict__类的 __dict__
各实例独立?✅ 互不影响❌ 共享,改一个全变
创建时机实例化时才存在类定义时就存在
修改方式instance.x = valClass.x = val
传参方式实例化时传入 Model(hidden=256)实例化前修改 Model.hidden = 256

直觉类比

你可以把它理解为:

  • 类属性 = 宿舍楼的公共公告栏(所有人共享,改了所有人都看到新的)
  • 实例属性 = 每个人房间里自己的便签(各自独立,互不干扰)

当你在自己房间找便签(self.x):先看自己房间(实例字典),没有就去看公告栏(类字典)。

当你调用 torch.matmul(a, b) 时,Python 只是一个"薄壳",真正的矩阵乘法是由底层的 CUDA Kernel(一段跑在 GPU 上的二进制程序)执行的。

CUDA Kernel 在编译时,nvcc(NVIDIA 的编译器)会把 .cu 源码编译成针对特定 sm_xx机器码(PTX 或 SASS)。

就像你不能把 ARM 的二进制文件直接跑在 x86 CPU 上一样,为 sm_75 编译的 Kernel 无法在 sm_61 的 GPU 上执行

先搞清楚 CUDA 是什么

CUDA 是 NVIDIA 开发的一套软件工具包,它的作用是:

让你的程序能够"说话"给 GPU 听。

没有 CUDA,你的程序根本不知道怎么和 GPU 通信,更别说让 GPU 干活了。

CUDA 里面包含:

  • CUDA Driver:和 GPU 硬件直接对话的最底层驱动
  • CUDA Runtime:提供给上层软件(比如 PyTorch)调用的接口
  • nvcc 编译器:把 CUDA 代码编译成 GPU 机器码的工具

GPU、CUDA、PyTorch 三者的关系

用一个比喻来理解:

GPU          →  一台机器(硬件)
CUDA         →  这台机器的操作系统 + 驱动
PyTorch      →  跑在操作系统上的应用程序

就像 Windows 应用程序需要 Windows 系统才能跑,PyTorch 需要 CUDA 才能和 GPU 通信

为什么 CUDA 版本和 PyTorch 版本要匹配?

PyTorch 在编译时,会调用当时版本的 CUDA Runtime 的接口

不同版本的 CUDA Runtime,接口可能发生变化(新增、修改、废弃某些函数)。

PyTorch 2.1  →  编译时用的 CUDA 12.1 的接口
你系统装的    →  CUDA 11.8(接口不一样)
→ PyTorch 找不到对应的函数 → 报错

这和普通软件一样——一个应用程序依赖某个版本的动态库,换了版本就可能跑不起来。

你的 GPU 架构
    ↓ 决定支持哪些 CUDA 特性
CUDA 版本
    ↓ 提供编译环境和运行时接口
FlashAttention(现场编译 CUDA Kernel)
    ↓ 编译时链接 PyTorch 的 C++ 接口
PyTorch 版本
    ↓
你的 Python 代码调用 flash_attn

PyTorch 常用 Tensor 操作总表

🧩 一、形状变换类(最常用🔥)

操作作用示例典型用途
reshape()改变形状x.reshape(B,-1)最通用改形状
view()改形状(需连续内存)x.view(B,-1)老写法
flatten()压平维度x.flatten(2)CNN→Transformer
transpose()交换两个维度x.transpose(1,2)attention
permute()任意重排维度x.permute(0,2,3,1)BCHW→BHWC
unsqueeze()增加维度x.unsqueeze(1)增加通道/批次
squeeze()去掉1维x.squeeze()去除单通道
contiguous()内存连续x.contiguous()配合 view

🧠 二、统计与归约类

操作作用示例
sum()求和x.sum(dim=1)
mean()均值x.mean(dim=2)
max()最大值x.max(dim=1)
min()最小值x.min(dim=1)
argmax()最大值索引x.argmax(dim=1)
argmin()最小值索引x.argmin(dim=1)
std()标准差x.std(dim=1)

🔥 三、数学计算类

操作作用示例
matmul() / @矩阵乘法x @ W
mm()2D矩阵乘torch.mm(a,b)
bmm()批量矩阵乘torch.bmm(a,b)
softmax()归一化x.softmax(dim=-1)
sigmoid()Sigmoidx.sigmoid()
relu()ReLUx.relu()
exp()指数x.exp()
log()对数x.log()

🔄 四、广播与复制类

操作作用示例
repeat()复制数据x.repeat(1,3,1,1)
expand()广播扩展x.expand(B,C,H,W)
clone()深拷贝x.clone()
detach()脱离计算图x.detach()

🎯 五、类型与设备转换

操作作用示例
float()转 floatx.float()
long()转 longx.long()
half()转 float16x.half()
to()转设备或类型x.to(device)
cuda()上GPUx.cuda()
cpu()转CPUx.cpu()

📐 六、维度信息查询

操作作用示例
shape查看形状x.shape
size()查看形状x.size()
dim()维度数x.dim()
numel()元素个数x.numel()

⚪为什么 BN 放在激活函数之前(Pre-Activation)

1. BN 的本质目的:控制分布

BatchNorm 的核心操作是将每层输入归一化到均值为0、方差为1,然后通过可学习参数 γ、β 重新缩放:

它希望在数据进入非线性变换之前,让输入分布处于一个受控的范围内。

2. 如果 BN 放在激活函数之后会怎样?

以 ReLU 为例,ReLU 会将所有负值截断为0,输出分布天然是非负的、不对称的。此时再做归一化,操作的是一个已经被破坏掉对称性的分布,归一化的统计意义会打折扣。

顺序归一化的输入分布效果
BN → ReLU接近高斯分布,均值0方差1归一化效果最充分
ReLU → BN非负截断分布,均值 > 0归一化统计估计不准确

3. 激活函数的"饱和区"问题

对于 Sigmoid、Tanh 等饱和激活函数,如果输入值过大或过小,梯度会趋近于0(梯度消失)。BN 将输入约束在0附近,恰好落在激活函数的线性区/高梯度区,有助于梯度顺畅回传。

Sigmoid 梯度: 输入在 [-2, 2] 区间梯度最大
BN 输出集中在 [-2, 2] → 充分利用激活函数的有效区域

⚪变量 vs 对象

对象是实际存在的"东西",它占据一块内存,拥有具体的类型和值。比如整数 3、字符串 '我爱你',它们在内存中是真实存在的数据实体。

变量只是一个"名字标签",它本身不存储数据,只存储一个地址,告诉你"我指向的那个对象在哪里"。

打个比方:对象是一栋房子(有面积、有结构、有内容),变量是贴在你手机通讯录里的一条记录(只存了这栋房子的地址)。房子是真实占空间的实体,通讯录条目只是一个指向它的标签。

所以 a = 3 这行代码做了两件事:在内存中创建对象 3,然后让变量名 a 记住这个对象的地址。

栈 vs 堆

栈和堆都是内存中的区域,但管理方式完全不同。

栈内存的特点是"自动、快速、但空间小"。你可以把它想象成一摞盘子——只能从顶部放入和取出,严格按顺序管理。函数调用时自动分配,函数结束时自动释放,不需要你操心。它适合存放小且生命周期明确的东西,比如变量名和它持有的引用地址。

堆内存的特点是"灵活、空间大、但需要管理"。你可以把它想象成一个大仓库——东西可以随意存放在任何位置,但你需要一套管理系统(垃圾回收机制)来决定什么时候清理不再使用的东西。它适合存放大小不确定、生命周期不确定的东西,比如对象实体。

用图中的例子 a = 3

存在哪里存了什么比喻
变量 a栈内存对象的地址(1531372336)通讯录里的一条记录
对象 3堆内存类型=int,值=3真实的那栋房子

为什么要分开放?因为对象可能很大(比如一个包含百万元素的列表),不适合放在空间有限的栈里。而变量只是一个地址(一个整数),非常轻量,放在栈里高效管理即可。

再举一个例子帮你巩固

a = 3
b = a

执行完之后,栈里有两个变量 ab,但它们存的地址相同,都指向堆里同一个对象 3堆里自始至终只有一个对象,不会因为赋值就复制一份。这就是"变量是标签,对象是实体"的直接体现。

str:严格一维

字符串是字符的线性序列,不支持嵌套,永远是一维的:

s = "hello"
len(s)      # 5
s[0]        # "h"(取出来的还是str,但不能再往下嵌套维度)

list / tuple:本身是一维的,但可以嵌套

这是最容易混淆的地方。

list 和 tuple 本身永远是一维的——它们就是一排"格子",每个格子里放一个元素:

a = [10, 20, 30]       # 一维,3个元素
len(a)                  # 3

但格子里可以再放 list,形成嵌套

b = [[1, 2, 3],
     [4, 5, 6]]

这看起来像二维矩阵,但Python不认为它是二维的。对Python来说,b 只是一个一维 list,里面恰好装了2个元素,每个元素碰巧也是 list:

len(b)       # 2(只看第一层,有2个元素)
b[0]         # [1, 2, 3](第一个元素是个list)
b[0][1]      # 2(两次索引,不是二维索引)

而 NumPy 是真正的多维:

arr = np.array([[1, 2, 3],
                [4, 5, 6]])
arr.shape    # (2, 3) ← 真正的二维结构
arr[0, 1]    # 2 ← 一次索引,用逗号分隔维度

关键区别

list 嵌套NumPy 数组
结构一维容器套一维容器真正的多维连续内存
索引b[0][1](两次访问)arr[0, 1](一次访问)
规则性内层长度可以不同每个维度大小必须一致
维度信息没有,需要自己数.shape.ndim 直接获取

普通的 listtupleintstr 等内置类型没有定义 .shape 属性.shape 是 NumPy/PyTorch/Pandas 等库在自己的类中专门定义的,用来描述数据的维度结构

.shape vs .size() 的区分

这是你做深度学习经常会遇到的:

PyTorch中,两种写法等价:

  • tensor.shape → 属性,不加括号
  • tensor.size() → 方法,加括号

NumPy中,两者含义不同:

  • arr.shape → 返回维度元组,如 (3, 224, 224)
  • arr.size → 返回元素总数,如 3×224×224 = 150528,也是属性不加括号

二、属性 vs 方法

这是你上一个问题的延伸,也是最容易混淆的地方:

属性(property):描述对象"是什么"、"有什么",存储的是一个值,不加括号

方法(method):描述对象"能做什么",是一个可执行的动作,加括号

用一个直觉来判断:名词性质的 → 不加括号,动词性质的 → 加括号。

以 PyTorch Tensor 为例:

用法性质含义
tensor.shape属性(名词:"形状是什么")返回维度信息
tensor.dtype属性(名词:"数据类型是什么")返回数据类型
tensor.device属性(名词:"在哪个设备上")返回设备信息
tensor.reshape(3, 4)方法(动词:"去变形")执行变形操作
tensor.cuda()方法(动词:"去转移到GPU")执行设备转移
tensor.mean()方法(动词:"去计算均值")执行计算
x = torch.randn(3, 224, 224)

x.shape       # torch.Size([3, 224, 224])
x.size()      # torch.Size([3, 224, 224])  ← 不传参时和shape一样
x.size(0)     # 3    ← 传参:获取第0维的大小
x.size(1)     # 224  ← 传参:获取第1维的大小

属性无法接收参数,方法可以。 这就是 .size() 必须设计成方法的原因——它需要支持 size(dim) 这种按维度查询的功能。

"一切皆对象"的准确理解

这句话的准确含义是:Python中所有可以被变量引用的"东西"(值/数据)都是对象,但并不是所有代码/语法元素都是对象。

是对象的:所有"值"

判断标准很简单——能被赋值给变量、能用 id()type() 查看的,就是对象。

a = 100          # 数字是对象
a = len          # 函数是对象
a = int          # 类本身是对象
a = import_module("math")  # 模块是对象

这些东西都能放在等号右边,被变量"指向",所以它们都是对象。

核心思想:一切皆对象

Python中所有数据都是对象,每个对象由三个要素组成:

要素说明获取方式
标识(identity)对象的唯一身份标识,本质上是内存地址id(obj)
类型(type)决定对象能存什么值、能做什么操作type(obj)
值(value)对象实际存储的数据内容print(obj)

为什么需要环境变量

本质是一个"快捷寻址表"。没有它,运行程序必须输完整路径(如 D:\ruanjian\miniconda\python.exe)。把路径加入 Path 后,系统会自动去里面找,直接输 python 就能运行。


Windows 用户变量 vs 系统变量

用户变量只对当前账号生效,系统变量对所有账号生效。两者的 Path 会合并使用,系统先找系统 Path,找不到再找用户 Path。


Windows vs Linux 环境变量的区别

主要是三点:路径分隔符不同(\ vs /; vs :)、设置方式不同(图形界面 vs 编辑配置文件)、大小写敏感性不同(Linux 严格区分大小写)。


Linux 的 .bashrc

全称 Bash Run Commands,是终端的初始化配置文件,每次打开终端自动执行一遍里面的内容。主要存两类东西:环境变量(export PATH=...)和命令别名(alias ll="ls -la"。修改后需要执行 source ~/.bashrc 立刻生效,否则要重开终端才能生效。

分步骤来:


第一步:打开 .bashrc 文件

nano ~/.bashrc

nano 是 Linux 里一个简单的文本编辑器,这行命令就是用它打开 .bashrc 文件。


第二步:在文件末尾添加一行

比如你想把 miniconda 加入 Path:

export PATH="/home/user/miniconda/bin:$PATH"

注意后面的 $PATH 是什么意思?它代表"原来已有的 Path 内容"。这样写是把新路径追加到原来的 Path 前面,而不是覆盖原来的 Path

如果你写成:

export PATH="/home/user/miniconda/bin"

原来的所有路径就全丢了,系统命令都找不到了,会出大问题。


第三步:保存文件

在 nano 编辑器里:

  • Ctrl + O 保存
  • Enter 确认
  • Ctrl + X 退出

第四步:让改动生效

source ~/.bashrc

验证是否成功

echo $PATH

这行命令会打印出当前完整的 Path,看看你新加的路径在不在里面就知道成功了。

文件夹存的是什么谁来用
bin可直接运行的命令用户/系统
lib程序运行时依赖的库程序运行时自动调用
include库的接口说明文件开发者写代码时参考

for key in {"name": "张三", "age": 18}:
    print(key)
# 输出:name  age

for..in 遍历字典时,默认遍历的是(标签那一列),所以输出 nameage,一共循环2次。

32位系统中,内存地址用32位二进制表示,最多寻址 2³² = 4GB 内存。64位系统用64位地址,理论上可寻址 2⁶⁴ ≈ 1800万TB,实际当前硬件支持到几百TB,但对普通用户来说"无限够用"。

好,用一个完整的具体例子从头到尾走一遍。


场景:程序要计算 a + b,其中 a = 10,b = 20


第一步:数据怎么存进内存的

程序运行时,操作系统把变量存入内存,假设分配如下:

地址存的内容含义
1000号00001010a = 10(二进制)
1001号00010100b = 20(二进制)
1002号00000000准备存结果

每个格子存1字节,地址就是格子的编号。


第二步:CPU 如何读取数据

CPU 内部有几个关键部件:

  • MAR(内存地址寄存器):CPU 告诉内存"我要访问哪个地址",先把地址写到这里
  • MDR(内存数据寄存器):内存把数据返回给 CPU 后,先放在这里
  • ALU(运算单元):真正做加法的地方

读取 a = 10 的过程:

1. CPU 把地址 1000 写入 MAR
2. CPU 发出"读"信号给内存
3. 内存根据 MAR 里的地址 1000,找到对应格子
4. 把格子里的内容 00001010 放入 MDR
5. CPU 从 MDR 拿到数据 00001010,即 10

读取 b = 20 同理,地址换成 1001。


第三步:CPU 完成计算并写回内存

1. ALU 拿到 10 和 20,做加法,得到 30
2. CPU 把地址 1002 写入 MAR
3. 把结果 30(00011110)写入 MDR
4. CPU 发出"写"信号
5. 内存把 MDR 的内容写入 1002 号格子

整个流程总结:

读:CPU → 把地址给MAR → 内存找格子 → 数据放入MDR → CPU拿走
写:CPU → 把地址给MAR,数据给MDR → 发写信号 → 内存把数据存入格子

现在你能理解为什么32位地址只能到4GB了吗?MAR 寄存器只有32位宽,写进去的地址最大就是42亿,超过这个数字的格子地址根本放不进 MAR,CPU 就永远找不到那些格子。

内存条(RAM) 是 CPU 的"工作台",程序运行时把需要的数据临时放在这里,CPU 可以极快地读写。但它是断电清空的,关机后数据全消失。

硬盘 是"仓库",用来永久保存数据,断电不丢失。但速度比内存慢得多。

监督学习和非监督学习区别:

监督学习的"目标"是指:每一个训练样本都有一个对应的真实答案 yi​,模型要学习逼近这个答案。

如果稍微改变一下场景,就确实变成了监督学习:

场景: 你有100个样本,每个样本有多个特征(比如身高、体重、年龄等),然后你人工标注了每个样本属于A类还是B类。接着让模型从这些带标注的数据中学习分类规则。

这就是标准的监督分类任务了。模型学到的边界可能恰好是"某个特征小于0.1归A",但也可能是一个复杂的非线性边界——关键是这个规则是模型从标注数据中自己学出来的,而不是你预先指定的。

三种情况对比

情况本质
你直接说"<0.1归A,≥0.1归B"人工规则,不是机器学习
你给每个样本标注了A/B,让模型自己学规则监督学习(分类)
你只给数据不给任何标注,让算法自己发现分组无监督学习(聚类)

核心判断标准始终是:训练时模型能不能看到每个样本的正确答案。能看到就是监督,看不到就是无监督。至于规则是你手写的还是模型学的,那是"是不是机器学习"的问题,而非"是不是监督"的问题。

聚类:找数据中隐藏的分组。核心问题是"谁和谁是一伙的",依据是样本间的相似性(距离)。

降维:用更少的维度表达数据。核心问题是"哪些维度是冗余的",依据是保留数据最重要的信息/结构。(多个x可能会影响y,减少x的个数)

简单比喻:一群人站在空间中,聚类是问"他们分成了几个小团体",降维是问"能不能用更少的坐标就描述清楚他们的位置"。

一、torch.roll 的本质

  • 零计算量操作:不做任何乘加运算,只是把数据从一个内存位置搬到另一个位置
  • 可以 GPU 加速:每个 CUDA thread 独立计算自己负责的元素应该从哪里读取,完全并行
  • 主要开销:不在计算,而在内存拷贝(每次 roll 创建新 tensor)和多次 kernel launch 的调度开销

二、Kernel Launch 的概念

  • 定义:CPU 向 GPU 下达一次"执行任务"的指令
  • 每次 launch 都有固定开销:调度准备(~3μs)→ 执行 → 收尾(~2μs),launch 之间 GPU 空闲等待
  • 核心原则:少量大任务 > 大量小任务。1 次卷积 launch(内部大量计算)比 9 次 roll launch(每次只搬数据)更高效

三、为什么 9 次 roll 不能合并为 1 次 launch

  • 根本原因:每次 roll 的通道范围、移动方向、移动距离都不同,PyTorch 逐条执行 Python 语句,不会自动跨语句合并
  • 解决方案:写自定义 CUDA kernel(统一索引映射),或用 torch.gather 预计算索引表一次完成

四、CUDA 体系在深度学习中的角色

Python代码 → PyTorch框架 → cuDNN库 → CUDA kernel → GPU硬件
  • CUDA:NVIDIA 的 GPU 编程工具包,让 GPU 能做通用并行计算
  • CUDA kernel:运行在 GPU 上的函数,被几千个线程同时执行,每个线程用自己的编号处理对应的数据
  • 日常使用时无需手写:PyTorch 已封装好,底层自动调用 CUDA kernel

五、需要自定义 CUDA Kernel 的三种场景

场景典型例子
PyTorch 没有现成操作9 组通道不同方向移位
现有实现性能不够Flash Attention 融合 QKV 计算,速度提升 2-4 倍
算子融合减少 launch 次数add + relu 融合为 1 次 launch,TensorRT 自动做这件事

六、实际结论

torch.roll 在深层小特征图上绝对耗时极低(不到前向传播的 1%),不是性能瓶颈。真正值得关注的是语义正确性问题(边界循环绕回),可用 F.pad + slice 同时解决绕回和效率两个问题。

MobileNet 和 ShuffleNet 都是为移动端和嵌入式设备设计的轻量化网络,核心目标是在大幅降低计算量的同时尽量保持精度。

MobileNet(google17年发表) 的策略是用深度可分离卷积替代标准卷积,将原本一步完成的"空间特征提取 + 通道特征融合"拆成两步独立操作:先用深度卷积对每个通道单独做3×3空间卷积,再用1×1逐点卷积做通道间融合,参数量降低约88%。但代价是空间信息和通道信息被分开处理,无法像标准卷积那样联合建模"某个空间位置上多个通道的组合模式",导致特征交互能力下降。(采用1*1卷积在通道间交互的时候,只能考虑1*1领域自身的像素值,却不能考虑其周围的像素值。

MobileNet 的策略:优化 3×3 卷积

MobileNet 把 3×3 标准卷积替换成深度可分离卷积(3×3 深度卷积 + 1×1 逐点卷积),大幅降低了 3×3 卷积的计算量。但它的 1×1 逐点卷积仍然是标准的全通道卷积,所有输入通道都参与计算。

在实际的 MobileNet 中,1×1 逐点卷积占了总计算量的约 75%。也就是说,3×3 的部分已经很轻了,真正的计算瓶颈反而转移到了 1×1 卷积上。

ShuffleNet 的策略:优化 1×1 卷积

ShuffleNet 看到了这个问题:既然 1×1 卷积才是瓶颈,那就对 1×1 卷积做分组

把 1×1 卷积的输入通道分成 g 组,每组独立计算,计算量降为 1\g​。然后用 Channel Shuffle 解决分组造成的信息隔绝问题。至于 3×3 的部分,ShuffleNet 同样用深度卷积处理(和 MobileNet 一样)。

两者的共同局限在于:为了追求轻量化,都在不同程度上牺牲了特征交互的充分性。深度卷积让每个通道只看自己、不看其他通道;分组卷积让每组只在组内交流、不跨组交流。虽然逐点卷积和Channel Shuffle分别做了补偿,但都无法完全恢复标准卷积那种在3×3空间邻域内同时融合所有通道的联合建模能力。这就是为什么这类轻量化网络在激光条纹等需要精细特征交互的小目标分割任务上,召回率会下降。

对1*1卷积的输入图像的特征通道采用通道分组的办法来卷积,本身就是压缩通道,分组和不分组都可以进行压缩

输入12通道
    │
    ▼
分组1×1卷积(3组,每组4通道独立计算)
    │
    │  参数量: 48(而非144)
    │  问题: 组间信息隔绝
    ▼
Channel Shuffle(reshape → 转置 → flatten)
    │
    │  参数量: 0(纯索引重排,无需学习)
    │  效果: 每个新组包含所有旧组的通道
    ▼
3×3 深度卷积(每通道独立空间卷积)(传递到下一层)
    │
    ▼
分组1×1卷积(3组,每组已混合全局信息)
    │
    ▼
输出12通道(融合了全部输入通道的信息)

深度可分离卷积为什么削弱特征交互能力

举个具体例子:假设输入有3个通道,分别编码了亮度、红色分量、蓝色分量。标准卷积可以学到这样一个模式——"亮度高 且 红色强 且 蓝色弱"的像素区域,因为它的卷积核能同时访问这三个通道并计算它们的联合响应。这就是通道间交互:不同通道的信息在同一个卷积操作中被联合编码。

深度卷积的卷积核形状是 3×3×1,每个通道有自己独立的卷积核, 只看自己的通道,完全不知道其他通道在发生什么。

Dice Loss 与 Focal Loss 总结

核心问题

语义分割中存在严重的类别不平衡:激光条纹仅占图像面积不到3%,背景占97%以上。如果用普通BCE Loss,模型把所有像素都预测为背景也能获得很低的损失值,因为BCE对每个像素独立计算后取平均,大量正确的背景像素会稀释少量错误的前景像素的损失。


Dice Loss:从全局重叠率入手

Dice Loss 计算的是预测与真实标签的重叠比率,而非逐像素求和再平均。它的分子是预测正确的前景像素数量,分母只涉及"预测为前景"和"实际为前景"的像素。这意味着无论背景有多少、预测得多好,都不会降低Dice Loss。模型必须把前景预测好,损失才会下降。

Focal Loss:从样本难度入手

Focal Loss 在 BCE 的基础上乘了一个调制因子 $(1-p_t)^\gamma$,其中 $p_t$ 表示模型预测正确的概率。对于已经学好的像素(无论前景还是背景),$p_t$ 接近1,调制因子接近0,损失被大幅压缩;对于还没学好的像素,$p_t$ 偏低,损失被保留。效果上,占绝大多数的易分类背景像素几乎不贡献梯度,模型的参数更新被少数难分类像素(主要是前景边缘、反光噪点等)主导。

两者的本质区别

Dice Loss 不区分难易,只区分前景和背景——前景整体预测好就行。Focal Loss 不区分前景和背景,只区分难和易——难的像素多学,易的像素少学。

三个补充要点

$p_t$ 的含义: 不是前景概率,而是"预测正确的概率"。前景像素时 $p_t = p_i$,背景像素时 $p_t = 1-p_i$,是一种统一两类情况的简写记号。

背景也计算损失: Focal Loss 对前景和背景都计算损失,否则模型会把所有像素都预测为前景来骗取零损失。只不过易分类的背景像素损失被调制因子压到接近零。

$\gamma=2$ 的来源: 平方不是数学必然,而是经验最优。$\gamma$ 控制对易分类样本的抑制强度,$\gamma=0$ 退化为BCE,$\gamma$ 越大抑制越强。Lin等人在COCO数据集上实验发现 $\gamma=2$ 效果最好。

DETR(detection transformer)

DETR Object Queries 核心总结

1. Object Queries是什么?

本质:100个可学习的embedding向量(随机初始化的参数)

作用:代表100个"对象槽位",每个query负责在图像中寻找一个对象

输出:每个query预测 → 类别(包括"无对象"类∅)+ 边界框


2. 为什么是100个?

统计依据

  • COCO数据集平均每张图:7.7个对象
  • 99%的图像:对象数 < 30个
  • 100个是工程上的最优选择(覆盖率vs计算效率)

工作机制

假设图像有11个对象:
Query1-11  → 预测具体对象类别和位置 ✓
Query12-100 → 预测"无对象(∅)" ✓

局限性

  • 如果图像真有150个对象 → 只能检测前100个,会漏掉50个
  • 但这种极端情况<0.01%

3. Decoder中的两种Attention

Self-Attention(queries之间)

Q = queries
K = queries  
V = queries

作用:queries互相协商,避免重复检测同一对象

Cross-Attention(queries看图像)★核心★

Q = queries
K = 图像特征(来自Encoder)
V = 图像特征(来自Encoder)

作用:queries从图像中提取对象信息

为什么必须有Cross-Attention?

问题:Object queries初始是随机向量,不含图像信息
解决:通过Cross-Attention,queries才能"看到"图像内容

没有Cross-Attention → queries永远不知道图像里有什么 ❌

4. Attention机制的本质理解

核心公式

Attention(Q, K, V) = softmax(Q·K^T / √d) · V

你的理解 ✓ 完全正确!

Q(Query)去查询 → 代表"我想关注什么"(q代表横坐标,代表每一行一个token)

  • Q与每个K做点积 → 计算相似度/关注度
  • 关注度高的K对应的V会被更多地提取

具体例子

Cross-Attention中:
Query1 = [想找汽车的向量]

Image Features:
K1, V1 = [汽车区域特征]
K2, V2 = [道路区域特征]
K3, V3 = [行人区域特征]

计算:
相似度1 = Query1 · K1 = 0.9  ← 高关注度!
相似度2 = Query1 · K2 = 0.1
相似度3 = Query1 · K3 = 0.2

softmax后权重:[0.7, 0.1, 0.2]

输出 = 0.7×V1 + 0.1×V2 + 0.2×V3
     ≈ 主要是汽车特征 ✓

5. 信息流总结

图像 
  ↓ Encoder
图像特征(K, V)
  ↓ Cross-Attention (Q来自queries)
Queries融合图像信息
  ↓ Self-Attention (queries互相看)
Queries避免重复
  ↓ FFN
最终预测:类别 + 边界框

6. 核心要点

  1. Object queries = 100个侦探,每个负责找一个对象
  2. 100个够用,因为99%+图像对象数<30
  3. Cross-Attention是关键,让queries能"看见"图像
  4. Q查询K/V的关注度 ✓ 你的理解正确
    • Q决定"想关注什么"
    • K决定"与Q的相似度"
    • V提供"实际内容"
    • 高相似度的V被更多提取

Focal Loss 公式

好的,我逐步展示2×2上采样2倍变成4×4的完整过程。

输入图像X(2×2)

位置:  (0,0) (0,1)
       (1,0) (1,1)
       
值:      1     2
         3     4

上采样s=2,输出Y(4×4)

关键:计算每个输出位置

对于Y的每个位置(i,j),需要:

  1. 找到在原图X中的对应位置:(i/s, j/s)
  2. 找周围4个像素
  3. 根据距离加权平均

最终结果Y(4×4)

1.0   1.5   2.0   2.0
2.0   2.5   3.0   3.0
3.0   3.5   4.0   4.0
3.0   3.5   4.0   4.0

转置卷积详细计算

1. 原理推导

标准卷积: 4×4 →(kernel=3, stride=2)→ 2×2
转置卷积: 2×2 →(kernel=3, stride=2)→ 4×4(反向过程)

核心操作步骤:

  1. 输入间插零(zero-padding insertion)
  2. 四周补零(外边界padding)
  3. 标准卷积

2. 完整数值例

输入X(2×2):

1  2
3  4

卷积核(3×3):

0.1  0.2  0.1
0.2  0.4  0.2
0.1  0.2  0.1
Step 1:插入零(stride=2,插入s-1=1个零)
水平插零:  1  0  2
            3  0  4

垂直插零:  1  0  2
            0  0  0
            3  0  4
Step 2:边界补零(padding)

对于kernel=3,需要padding=1:

0  0  0  0  0
0  1  0  2  0
0  0  0  0  0
0  3  0  4  0
0  0  0  0  0
Step 3:标准卷积(stride=1)

计算Y(0,0):

卷积窗口:  0  0  0
            0  1  0
            0  0  0

Y(0,0) = 0×0.1 + 0×0.2 + ... + 1×0.4 + ... = 0.4

计算Y(0,1):

卷积窗口:  0  0  0
            1  0  2
            0  0  0

Y(0,1) = 1×0.2 + 2×0.2 = 0.6

计算Y(1,1)(核心位置):

卷积窗口:  1  0  2
            0  0  0
            3  0  4

Y(1,1) = 1×0.1 + 2×0.1 + 3×0.1 + 4×0.1 = 1.0

最终输出Y(4×4):

0.4   0.6   0.8   0.2
0.6   1.0   1.4   0.4
0.8   1.4   2.0   0.6
0.3   0.6   0.9   0.4
特性转置卷积双线性插值
输出特点0.4, 0.6, 1.0, 1.4...1.0, 1.5, 2.5, 3.0...
值域变化可能超出输入范围严格在输入范围内
边界效应棋盘伪影(checkerboard)平滑过渡
训练代价需要反向传播更新kernel无需训练

应用场景

转置卷积:

  • 语义分割(FCN、UNet的decoder)
  • GAN生成器
  • 需要学习上采样模式的任务

双线性插值:

  • 简单图像放大
  • 与卷积结合(conv + upsample)
  • 推理速度要求高的场景

更多推荐