Skip to content

第 10 章 2019—2020:规模化——EfficientNet 与缩放定律 ​

学习目标 ​

  • 理解 EfficientNet(2019)的 MBConv 模块与复合缩放
  • 理解「缩放定律」:数据、参数、计算量如何共同决定模型性能
  • 用 PyTorch 实现 MBConv 风格网络
  • 用实验观察「数据越多 loss 越低;数据不足时大模型过拟合」的规律

10.1 时代背景:大模型与大数据的正反馈 ​

2018 年后,计算机视觉和 NLP 同时进入「规模化」时代。视觉这边,2019 年 Google 的 EfficientNet 用复合缩放(同时放大深度、宽度、分辨率)在 ImageNet 上以 1/10 的计算量达到 SOTA。语言这边,OpenAI 的 GPT-2(2019)、GPT-3(2020)以及 Scaling Laws 论文(2020)揭示了一个更深的规律:模型性能随参数量、数据量、计算量的幂律增长。

这两件事指向同一句话:规模本身是力量。本章用两个可运行实验,把「规模化」讲透。

10.2 MBConv:高效卷积的基本单元 ​

EfficientNet 的核心模块是 MBConv(Mobile Inverted Bottleneck Convolution),由 MobileNetV2 的倒置残差 + 挤压激励(SE)注意力组成:

  1. 1×1 升维:先把通道数扩大(如 4 倍),增加表达能力;
  2. 深度可分离卷积:每个通道单独做 3×3 卷积(分组数 = 通道数),参数从 C×C×9 降到 C×9;
  3. 挤压激励(SE):全局平均池化 → 两个全连接 → sigmoid,给每个通道一个「重要性权重」,按通道加权;
  4. 1×1 降维:把通道数压回目标值,再接残差捷径。

examples/ch10_mbconv.py 实现:

python
class MBConv(nn.Module):
    def __init__(self, in_c, out_c, expand=4, stride=1):
        super().__init__()
        hidden = in_c * expand
        self.expand_conv = nn.Sequential(
            nn.Conv2d(in_c, hidden, 1), nn.BatchNorm2d(hidden), nn.SiLU(),
        ) if expand > 1 else nn.Identity()
        self.dw = nn.Sequential(
            nn.Conv2d(hidden, hidden, 3, stride, padding=1, groups=hidden),
            nn.BatchNorm2d(hidden), nn.SiLU(),
        )
        self.se = nn.Sequential(
            nn.AdaptiveAvgPool2d(1), nn.Flatten(),
            nn.Linear(hidden, max(8, hidden // 4)), nn.SiLU(),
            nn.Linear(max(8, hidden // 4), hidden), nn.Sigmoid(),
        )
        self.project = nn.Sequential(nn.Conv2d(hidden, out_c, 1), nn.BatchNorm2d(out_c))
        self.shortcut = nn.Sequential()
        if stride != 1 or in_c != out_c:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_c, out_c, 1, stride), nn.BatchNorm2d(out_c),
            )

    def forward(self, x):
        h = self.expand_conv(x)
        h = self.dw(h)
        h = h * self.se(h).view(-1, h.shape[1], 1, 1)   # SE:按通道加权
        return self.project(h) + self.shortcut(x)

运行结果:

EfficientNet 风格参数量:120,386
epoch 1:训练准确率 = 86.72%
epoch 2:训练准确率 = 89.76%
epoch 3:训练准确率 = 89.67%
测试准确率 = 87.50%

12 万参数达到 87.5%——比第 5 章 292 万参数的 TinyVGG(91.8%)低一些,但效率高一个数量级。EfficientNet 的复合缩放是在这个骨干上同时放大三件事:层数(深度)、每层通道数(宽度)、输入分辨率。论文给出一个观察:三者的最优比例大致是固定的——这就是「缩放」的由来。

10.3 缩放定律:三个变量的幂律 ​

2020 年,OpenAI 的 Kaplan 等人发表《Scaling Laws for Neural Language Models》,发现神经语言模型的测试 loss 与三个量近似成幂律关系:

loss ≈ a·N^(-α) + b·D^(-β) + c0

其中 N 是参数量,D 是数据量。含义:每翻一倍参数或数据,loss 以稳定比例下降;想同时压榨两者,要按比例同步放大(Chinchilla 定律,2022 年进一步精确了「参数:数据 ≈ 1:20」的最优配比)。

GPT-2(15 亿参数,2019)、GPT-3(1750 亿参数,2020)就是这个规律的工程兑现:模型更大、数据更多、计算更多 → 语言能力更强。ChatGPT 之所以震撼世界,不是因为发明了新架构,而是把已有的 Transformer 放大到足够大。

10.4 实验:在迷你世界里观察缩放 ​

examples/ch10_scaling.py 在一个人造语言上做微型缩放实验:20 个符号,按固定 bigram 概率表生成文本(保证「真实分布」已知且可无限采样)。然后:

  • 用不同大小的训练数据(10% / 30% / 100%)训练迷你 GPT;
  • 用固定测试段评估;对比小模型(d=32)与大模型(d=64)。

运行结果:

随机猜测基线 loss ≈ 2.996
数据  10%:小模型(d=32) loss = 3.278,大模型(d=64) loss = 5.631
数据  30%:小模型(d=32) loss = 2.891,大模型(d=64) loss = 3.044
数据 100%:小模型(d=32) loss = 2.834,大模型(d=64) loss = 2.846

读这张表,两个规律清晰可见:

  1. 数据越多,测试 loss 越低:对两种规模都成立(小模型 3.28→2.89→2.83;大模型 5.63→3.04→2.85);
  2. 数据不足时,大模型反而更差:10% 数据下,d=64 的 loss(5.63)远高于 d=32(3.28)——大模型参数多,小数据下严重过拟合;数据充足后两者趋同。

这就是 Chinchilla 定律的直觉版本:模型规模要和数据量匹配。真实的 GPT-3 训练用了约 3000 亿 token,正是因为 1750 亿参数需要这么多数据才能「吃饱」。

完整代码 ​

本章用到的完整示例代码:

examples/ch10_mbconv.py ​

py
"""第 10 章示例(2019):EfficientNet 的 MBConv 模块。

MBConv = 1×1 升维 + 深度可分离卷积 + 挤压激励(SE)注意力 + 1×1 降维,
用更少的计算量取得更好的精度。

运行方式:
    uv run python examples/ch10_mbconv.py
"""
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"

transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.2860,), (0.3530,)),
])
train = datasets.FashionMNIST("data", train=True, download=True, transform=transform)
test = datasets.FashionMNIST("data", train=False, download=True, transform=transform)
train_loader = DataLoader(train, batch_size=256, shuffle=True)
test_loader = DataLoader(test, batch_size=1024)


class MBConv(nn.Module):
    def __init__(self, in_c, out_c, expand=4, stride=1):
        super().__init__()
        hidden = in_c * expand
        self.expand_conv = nn.Sequential(
            nn.Conv2d(in_c, hidden, 1), nn.BatchNorm2d(hidden), nn.SiLU(),
        ) if expand > 1 else nn.Identity()
        self.dw = nn.Sequential(
            nn.Conv2d(hidden, hidden, 3, stride, padding=1, groups=hidden),
            nn.BatchNorm2d(hidden), nn.SiLU(),
        )
        self.se = nn.Sequential(
            nn.AdaptiveAvgPool2d(1), nn.Flatten(),
            nn.Linear(hidden, max(8, hidden // 4)), nn.SiLU(),
            nn.Linear(max(8, hidden // 4), hidden), nn.Sigmoid(),
        )
        self.project = nn.Sequential(nn.Conv2d(hidden, out_c, 1), nn.BatchNorm2d(out_c))
        self.shortcut = nn.Sequential()
        if stride != 1 or in_c != out_c:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_c, out_c, 1, stride), nn.BatchNorm2d(out_c),
            )

    def forward(self, x):
        h = self.expand_conv(x)
        h = self.dw(h)
        h = h * self.se(h).view(-1, h.shape[1], 1, 1)   # SE:按通道加权
        return self.project(h) + self.shortcut(x)


class TinyEfficientNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.stem = nn.Sequential(nn.Conv2d(1, 16, 3, 1, 1), nn.BatchNorm2d(16), nn.SiLU())
        self.stages = nn.Sequential(
            MBConv(16, 24, expand=2, stride=1),
            MBConv(24, 32, expand=4, stride=2),     # 28→14
            MBConv(32, 48, expand=4, stride=2),     # 14→7
            MBConv(48, 64, expand=6, stride=1),
        )
        self.head = nn.Sequential(
            nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(64, 10),
        )

    def forward(self, x):
        return self.head(self.stages(self.stem(x)))


model = TinyEfficientNet().to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
print(f"EfficientNet 风格参数量:{sum(p.numel() for p in model.parameters()):,}")


def evaluate(loader):
    model.eval()
    correct = total = 0
    with torch.no_grad():
        for xb, yb in loader:
            pred = model(xb.to(device)).argmax(1)
            correct += (pred == yb.to(device)).sum().item()
            total += len(yb)
    return correct / total


for epoch in range(3):
    model.train()
    for xb, yb in train_loader:
        xb, yb = xb.to(device), yb.to(device)
        opt.zero_grad()
        loss = criterion(model(xb), yb)
        loss.backward()
        opt.step()
    print(f"epoch {epoch + 1}:训练准确率 = {evaluate(train_loader) * 100:.2f}%")

print(f"测试准确率 = {evaluate(test_loader) * 100:.2f}%")

examples/ch10_scaling.py ​

py
"""第 10 章示例(2019—2020):缩放定律——数据量与模型规模。

在一个人造"语言"(固定 bigram 分布生成)上训练迷你 GPT:
1. 数据越多,测试 loss 越低;
2. 数据不足时,更大的模型过拟合更严重(模型规模要和数据量匹配)。

运行方式:
    uv run python examples/ch10_scaling.py
"""
import torch
from torch import nn

torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"

V, BLOCK = 20, 32
g = torch.Generator().manual_seed(0)

# 人造语言的"语法":20 个符号,每个符号后接什么由固定的 bigram 概率表决定
bigram = torch.rand(V, V, generator=g)
bigram = bigram / bigram.sum(1, keepdim=True)


def gen_text(n, seed=0):
    gg = torch.Generator().manual_seed(seed)
    out = torch.randint(0, V, (1,), generator=gg)
    for _ in range(n - 1):
        out = torch.cat([out, torch.multinomial(bigram[out[-1]], 1, generator=gg)])
    return out


full = gen_text(20000)
train_full = full[:16000].to(device)
test = full[16000:16000 + 512].to(device)


class Block(nn.Module):
    def __init__(self, d):
        super().__init__()
        self.ln1 = nn.LayerNorm(d)
        self.attn = nn.MultiheadAttention(d, 2, batch_first=True)
        self.ln2 = nn.LayerNorm(d)
        self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))

    def forward(self, x, mask):
        x = x + self.attn(self.ln1(x), self.ln1(x), self.ln1(x), attn_mask=mask)[0]
        return x + self.mlp(self.ln2(x))


class MiniGPT(nn.Module):
    def __init__(self, d=32):
        super().__init__()
        self.tok = nn.Embedding(V, d)
        self.pos = nn.Embedding(BLOCK, d)
        self.blocks = nn.ModuleList([Block(d) for _ in range(2)])
        self.head = nn.Linear(d, V)

    def forward(self, x):
        B, T = x.shape
        h = self.tok(x) + self.pos(torch.arange(T, device=x.device))
        mask = torch.triu(torch.full((T, T), float("-inf"), device=x.device), diagonal=1)
        for block in self.blocks:
            h = block(h, mask)
        return self.head(h)


def train(subset, d=32, steps=400):
    model = MiniGPT(d).to(device)
    opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
    for _ in range(steps):
        start = torch.randint(0, len(subset) - BLOCK, (64,), device=device)
        xb = torch.stack([subset[s:s + BLOCK] for s in start])
        yb = torch.stack([subset[s + 1:s + BLOCK + 1] for s in start])
        opt.zero_grad()
        loss = nn.functional.cross_entropy(model(xb).transpose(1, 2), yb)
        loss.backward()
        opt.step()
    model.eval()
    eval_losses = []
    with torch.no_grad():
        for i in range(0, len(test) - BLOCK, BLOCK):
            xb = test[i:i + BLOCK].unsqueeze(0)
            yb = test[i + 1:i + BLOCK + 1].unsqueeze(0)
            eval_losses.append(nn.functional.cross_entropy(model(xb).transpose(1, 2), yb).item())
    return sum(eval_losses) / len(eval_losses)


print(f"随机猜测基线 loss ≈ {torch.log(torch.tensor(V)).item():.3f}")
for frac, label in [(0.1, "10%"), (0.3, "30%"), (1.0, "100%")]:
    subset = train_full[: int(len(train_full) * frac)]
    loss_s = train(subset, d=32)
    loss_m = train(subset, d=64)
    print(f"数据 {label:>4}:小模型(d=32) loss = {loss_s:.3f},大模型(d=64) loss = {loss_m:.3f}")

动手实践 ​

  1. 运行 examples/ch10_mbconv.py,把 MBConv 的 expand 从 4 改成 1 和 8,对比参数量与准确率。
  2. 运行 examples/ch10_scaling.py,把测试数据比例从 20% 改成 50%,观察三个数据档位的 loss 变化。
  3. 在缩放实验中增加 d=128 档位,验证「数据越少,大模型越吃亏」是否更明显。
  4. 修改 MBConv 的 SE 模块:把两个全连接改成一个(直接 sigmoid),观察效果差异。

常见错误 ​

错误写法现象原因
深度可分离卷积忘了 groups=hidden参数量暴涨分组数必须等于通道数
SE 的 view(-1, C, 1, 1) 形状不对reshape 报错SE 输出是 (B, C),要扩展成 (B, C, 1, 1)
用最后一个 batch 的 loss 当评估指标结论噪声大训练 loss 有波动,要用固定测试集
小数据 + 大模型直接对比得出「大模型没用」的片面结论要同时报告数据量维度
复合缩放只放大一个维度收益递减EfficientNet 是同时缩放深度/宽度/分辨率

章末练习 ​

基础

  1. MBConv 的四个步骤是什么?
  2. 深度可分离卷积为什么省参数?写出参数量公式。
  3. 缩放定律说的三个变量是什么?

提高

  1. 计算:输入 64 通道,expand=4,普通 3×3 卷积 vs 深度可分离 + 1×1 升维降维,参数量各是多少。
  2. 在 examples/ch10_scaling.py 中把训练步数从 300 加到 1000,重新观察 10% 数据下大模型是否追上小模型。
  3. 用 log-log 坐标画出数据量 vs loss,观察是否接近直线(幂律)。

挑战

  1. 实现一个 5 个 MBConv 阶段的完整 EfficientNet-B0 简化版,在 Fashion-MNIST 上训练并报告参数与准确率。
  2. 复现 Chinchilla 思想:固定总计算量,比较「大模型少数据」与「小模型多数据」两种配置,哪个测试 loss 更低。

章末自测 ​

  1. MBConv 中深度可分离卷积的 groups 参数应等于?
    • A. 1
    • B. 输入通道数
    • C. 输出通道数
    • D. 批大小
  2. SE 模块的作用是?
    • A. 空间注意力
    • B. 按通道加权
    • C. 下采样
    • D. 正则化
  3. EfficientNet 的复合缩放同时缩放?
    • A. 深度、宽度、分辨率
    • B. 学习率、批大小
    • C. 数据、标签
    • D. 卷积核、池化
  4. 缩放定律中,测试 loss 与参数量近似成?
    • A. 线性关系
    • B. 幂律关系
    • C. 指数关系
    • D. 无关
  5. 数据不足时,大模型通常?
    • A. 表现更好
    • B. 过拟合更严重
    • C. 没有区别
    • D. 无法训练
  6. Chinchilla 定律的直觉是?
    • A. 模型越大越好
    • B. 数据越多越好
    • C. 模型规模与数据量要匹配
    • D. 计算量无关
  7. GPT-3 的参数量约为?
    • A. 1.5 亿
    • B. 1750 亿
    • C. 100 万
    • D. 7000 亿
  8. 缩放实验中 10% 数据下大模型 loss 高于小模型的原因是?
    • A. 大模型参数少
    • B. 过拟合
    • C. 学习率太小
    • D. 数据错误
  9. MBConv 的倒置体现在?
    • A. 先降维再升维
    • B. 先升维再降维
    • C. 无升维
    • D. 只降维
  10. 2020 年《Scaling Laws for Neural Language Models》的作者机构是?
    • A. Google
    • B. OpenAI
    • C. Meta
    • D. DeepMind