Shape 是深度学习最重要的数据契约

深度学习代码 80% 的报错都是 Shape 不匹配。但 Shape 不只是维度对齐,它是模型每一层之间的数据契约。这篇把 batch、特征、类别维度和常见的 Shape 流水线一次讲透。

学习路线:PyTorch 深度学习基础(43~58) · 第 46 课
第 46 课第 46 课 Shape 数据契约视频封面,展示 B、F、C、H、W 等维度符号和模型流水线。
Learning Path

PyTorch 深度学习基础(43~58)

在前 20 篇 PyTorch 入门上继续深化,补齐张量操作、自动微分、优化器家族和训练排错能力。

学完本阶段你能做到:独立搭建一个 PyTorch 训练脚本,正确处理 Shape、device、梯度清空、优化器选择和学习率策略,并能根据 loss/accuracy 曲线诊断训练问题。

推荐读法:当前更新主线,建议跟更;每篇都要配合代码自检。

查看完整阶段 · 16 篇
Lesson Guide

这一课怎么学

先看目标,再带着问题读正文。读完后用练习确认自己真的理解了。

本节你会学到

  • 说清楚「Shape 是深度学习最重要的数据契约」解决的核心问题
  • 知道它在「PyTorch 深度学习基础(43~58)」中的位置
  • 把 深度学习、激活函数、神经网络 这些关键词联系起来

前置知识

  • 建议先读完上一篇:计算图与梯度累积:为什么训练前必须清空梯度
  • 能区分输入、输出、数据和模型目标。

概念回顾

  • 【深度学习】深度学习可以理解为更复杂的机器学习方法,它让模型通过多层变换逐步提取特征。 前面或后面会反复用到它。
  • 【激活函数】没有激活函数,多层网络叠起来仍然像一条直线;有了它,模型才能表达弯曲复杂的关系。 前面或后面会反复用到它。
  • 【神经网络】神经网络不是神秘大脑,它更像一组可以不断调整的函数组合。 前面或后面会反复用到它。
  • 【损失函数】损失函数就是模型的扣分规则,分数越低,说明预测越接近真实答案。 前面或后面会反复用到它。

核心概念

常见误区

  • 不要只记定义,要追问它解决了什么问题。
  • 不要只看 API 名字,要同时关注输入输出 shape 和训练流程。

课后练习

  • 用 3 句话向一个零基础朋友解释「Shape 是深度学习最重要的数据契约」。
  • 打开概念库里的「深度学习」,补一遍它和本文的关系。
  • 读下一课「reshape、view、transpose 和广播机制怎么选:张量形状转换实战」前,先写下你认为它会解决的问题。

自检练习与参考答案

1. 如果一段 PyTorch 代码报 device 不一致,第一步应该检查什么?

先检查模型参数、输入 Tensor、标签 Tensor 是否在同一个设备上。常见修正是把它们统一 `.to(device)`。

2. 为什么学习 PyTorch 时不能只看 API 名字?

因为训练是否正确经常取决于 shape、dtype、device 和梯度流,API 名字只能告诉你工具,不会保证数据契约正确。

3. 读完本文后,至少应该能画出哪条训练主线?

`输入数据 → forward → loss → backward → optimizer.step()`,并知道本文主题位于这条链路的哪一环。

下一步建议读「reshape、view、transpose 和广播机制怎么选:张量形状转换实战」。

你有没有遇到过这种报错:

RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x784 and 10x256)

然后你盯着数字看半天,不知道 784 和 10 哪个对哪个错。

或者更阴险的情况:代码不报错,loss 也在降,但准确率死活上不去——最后发现是标签维度搞错了,模型学了个寂寞。

Shape 不是“凑对就行”的细节,它是模型每一层之间的数据契约。这一篇我们把 batch、特征、类别这几个维度彻底理清楚,让你看到任何 Shape 都能一眼判断对不对。

第 46 课视频 - Shape 是深度学习最重要的数据契约

概念回顾

第 15 篇我们学了 Tensor 的基本概念,第 43 篇补齐了 dtype、device、索引切片。今天我们聚焦 Tensor 最容易出问题的维度——Shape。第 45 篇刚讲完计算图,你记住:计算图里的每个节点都是一个 Tensor,每个 Tensor 都有 Shape,Shape 错了图就断了。


一句话解释

Shape 是 Tensor 每个维度的大小。深度学习里每个维度都有约定俗成的含义——第 0 维是 batch,最后一维通常是特征或类别,搞反了模型就废了。


五个你必须背下来的维度符号

后面所有文章都会用到这些符号,先把它们刻在脑子里:

符号 含义 典型场景
B batch size,一批多少个样本 所有任务
F feature,特征数量 表格数据
C channel,通道数 图像(RGB=3)
H / W 高 / 宽 图像
T sequence length,序列长度 文本、时间序列
D embedding dimension,向量维度 词向量
V vocabulary size,词表大小 NLP
num_classes 类别数 分类任务

Shape 变化的四类本质操作

不管什么层、什么模型,Shape 的变化逃不出这四类:

类型 操作 例子
增加维度 unsqueezestack、DataLoader 加 batch [F] → [B, F]
删除维度 squeezeflattenpooling [B,C,H,W] → [B,C*H*W]
改变维度大小 LinearConv2dEmbedding [B,10] → [B,32]
调换维度顺序 transposepermute [B,H,W,C] → [B,C,H,W]

记住这四类,看到任何 Shape 变化你都能归类。


三条核心 Shape 流水线

这是全文最重要的部分。把这三条流水线记牢,你就能看懂 80% 的训练代码。

流水线 1:表格分类

原始数据 [B, F]
   ↓ Linear(in=F, out=hidden)
[B, hidden]
   ↓ Linear(in=hidden, out=num_classes)
[B, num_classes]
   ↓ CrossEntropyLoss
target: [B]

每个样本有 F 个特征,经过两层全连接,最后输出 num_classes 个分数。

import torch.nn as nn

class TableClassifier(nn.Module):
    def __init__(self, in_features, hidden, num_classes):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_features, hidden),
            nn.ReLU(),
            nn.Linear(hidden, num_classes)
        )
    def forward(self, x):
        return self.net(x)   # [B, F] → [B, num_classes]

model = TableClassifier(20, 64, 4)
criterion = nn.CrossEntropyLoss()

# 验证 Shape
x = torch.randn(32, 20)          # [B=32, F=20]
logits = model(x)
print(logits.shape)               # torch.Size([32, 4])
y = torch.randint(0, 4, (32,))    # [B=32],注意是 1 维,不是 [32, 4]
loss = criterion(logits, y)       # ✅ logits [32,4] 对 y [32]

最容易错的地方CrossEntropyLoss 的标签是 [B],不是 [B, num_classes](不是 one-hot)。

流水线 2:图像分类(CNN)

图片输入 [B, 3, 32, 32]
   ↓ Conv2d(3→16)
[B, 16, 32, 32]
   ↓ MaxPool2d(2)
[B, 16, 16, 16]
   ↓ Conv2d(16→32)
[B, 32, 16, 16]
   ↓ MaxPool2d(2)
[B, 32, 8, 8]
   ↓ Flatten
[B, 32*8*8]   = [B, 2048]
   ↓ Linear(2048, num_classes)
[B, num_classes]
class SimpleCNN(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 16, 3, padding=1),   # [B,3,32,32]→[B,16,32,32]
            nn.ReLU(),
            nn.MaxPool2d(2),                   # →[B,16,16,16]
            nn.Conv2d(16, 32, 3, padding=1),  # →[B,32,16,16]
            nn.ReLU(),
            nn.MaxPool2d(2),                   # →[B,32,8,8]
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),                      # →[B, 2048]
            nn.Linear(32 * 8 * 8, num_classes) # →[B, 10]
        )
    def forward(self, x):
        x = self.features(x)
        x = self.classifier(x)
        return x

关键检查点Flatten 之后接 LinearLinearin_features 必须精确等于 C*H*W。算错了就报 mat1 and mat2 shapes cannot be multiplied

流水线 3:二分类

输入 [B, F]
   ↓ Linear
[B, hidden]
   ↓ Linear(out=1)
[B, 1]
   ↓ BCEWithLogitsLoss
target: [B, 1]   ← 注意是 2 维,标签是 float
model = nn.Sequential(
    nn.Linear(20, 64),
    nn.ReLU(),
    nn.Linear(64, 1)         # 输出 1 个 logit
)
criterion = nn.BCEWithLogitsLoss()

x = torch.randn(32, 20)
logits = model(x)             # [32, 1]
y = torch.randint(0, 2, (32, 1)).float()   # [32, 1],注意 .float()!
loss = criterion(logits, y)   # ✅

二分类 vs 多分类的 Shape 差异——这张表请背下来:

任务 模型输出 标签 Shape 标签类型 损失函数
多分类 [B, num_classes] [B] long CrossEntropyLoss
二分类 [B, 1] [B, 1] float BCEWithLogitsLoss

DataLoader:最前面永远多一个 B

这是新手最常忽略的事:单个样本没有 batch 维度,经过 DataLoader 后自动在最前面加一个 B

# 单个样本
x_single = torch.randn(10)        # [10]
print(x_single.shape)             # torch.Size([10])

# 经过 DataLoader
from torch.utils.data import TensorDataset, DataLoader
dataset = TensorDataset(torch.randn(100, 10), torch.randint(0, 3, (100,)))
loader = DataLoader(dataset, batch_size=32)

for x, y in loader:
    print(x.shape)   # torch.Size([32, 10])  ← 多了个 32
    print(y.shape)   # torch.Size([32])
    break

口诀:DataLoader 后,最前面一定多一个 B。


三个高频报错与排查

报错 1:mat1 and mat2 shapes cannot be multiplied

RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x784 and 10x256)

原因:Linear 的 in_features 和输入最后一维对不上。

x = torch.randn(32, 784)
layer = nn.Linear(10, 256)    # in_features=10,但输入是 784
layer(x)   # ❌ 报错

修复:把 in_features 改成 784。

报错 2:Expected target size

RuntimeError: Expected target size [32, 10], got [32]

原因:多分类标签写成了 one-hot [B, num_classes],但 CrossEntropyLoss 要的是 [B]

# ❌ 错误:标签是 one-hot
y_onehot = torch.zeros(32, 10)
y_onehot[range(32), torch.randint(0, 10, (32,))] = 1
loss = criterion(logits, y_onehot)

# ✅ 正确:标签是类别索引
y = torch.randint(0, 10, (32,))
loss = criterion(logits, y)

报错 3:Expected object of scalar type Long

RuntimeError: Expected object of scalar type Long but got Float

原因CrossEntropyLoss 的标签必须是 torch.long,但传了 float。

y = torch.randint(0, 10, (32,)).float()   # ❌ float
loss = criterion(logits, y)

y = torch.randint(0, 10, (32,)).long()    # ✅ long
loss = criterion(logits, y)
# 或者
y = y.long()

排查 Shape 的万能方法

在每个层后面 print(x.shape),是排查 Shape 最快的方法。

def forward(self, x):
    print("输入:", x.shape)
    x = self.conv1(x)
    print("conv1 后:", x.shape)
    x = self.pool(x)
    print("pool 后:", x.shape)
    x = self.flatten(x)
    print("flatten 后:", x.shape)
    x = self.fc(x)
    print("fc 后:", x.shape)
    return x

课后练习

练习 1:一个 CNN 输入是 [64, 3, 28, 28],经过 Conv2d(3, 32, 3, padding=1) 后 Shape 是多少?再经过 MaxPool2d(2) 呢?最后 Flatten 后接 Linear,in_features 应该是多少?

练习 2:下面代码为什么报错?怎么改?

model = nn.Linear(100, 5)
x = torch.randn(32, 10, 100)
y = model(x)

练习 3:多分类任务,num_classes=7batch_size=16。写出模型输出、标签、loss 的 Shape 和类型。

参考答案 / 自检思路

练习 1

  • Conv2d(3, 32, 3, padding=1):padding=1 + kernel=3 + stride=1 保持尺寸不变,Shape [64, 32, 28, 28]
  • MaxPool2d(2):尺寸减半,Shape [64, 32, 14, 14]
  • Flatten:[64, 32*14*14] = [64, 6272]
  • Linear 的 in_features = 32 * 14 * 14 = 6272

练习 2:不报错。Linear 只看最后一维,[32, 10, 100] 经过 Linear(100, 5) 变成 [32, 10, 5]。这是 Linear 的广播特性——它只作用在最后一维。如果本意是让每个样本只输出一个 5 维向量,那输入应该是 [32, 100],需要先 reshape。

练习 3

  • 模型输出 logits:Shape [16, 7],类型 float
  • 标签 y:Shape [16],类型 long
  • loss:标量,Shape [](0 维)

核心要点小结

  • Shape 是每层之间的数据契约,维度含义约定俗成:第 0 维是 batch
  • 三条核心流水线:表格分类、图像分类、二分类——背下来
  • 多分类标签是 [B] 且类型 long;二分类标签是 [B, 1] 且类型 float
  • DataLoader 自动在最前面加 batch 维
  • 排查 Shape 最快的方法:每层后面 print(x.shape)

下一篇我们解决实战中最头疼的问题:reshape、view、transpose 和广播机制到底什么时候用哪个。

Discussion

留言讨论