看视频版《动手学深度学习》第二课的时候,我一度以为是自己漏看了一节:没有模型,没有损失函数,整节都在讲怎么对张量做切片、reshape、拼接、广播。但这些看似基础的“数据基本操作”,后来几乎构成了我写所有训练代码的地基。尤其是当你在真实项目里处理图像、文本、表格数据,而不是教材里现成的 mnist 时,才会发现数据如果不整理成模型能吃的形状,后面再花哨的网络结构都跑不起来。
这门课的定位很清楚,适合刚入门深度学习、想系统搭起知识框架的人。数据基本操作就是那个最先要过的坎。不管是 PyTorch、TensorFlow 还是 Paddle,核心的数据结构都是张量(Tensor),围绕张量的创建、访问、变换、组合、批量装载,就是第二课真正想让你掌握的“肌肉记忆”。这篇笔记我会以 PyTorch 为例,把第二课的要点串成一条完整的数据处理链路,把我自己踩过的坑、查阅过的底层逻辑一并放进去,尽量把每一步“为什么要这么做”也讲明白。
1. 数据操作:深度学习里最先要过的坎
1.1 为什么第二课就把数据操作摆在模型前面
很多初学者会觉得,深度学习入门不都应该从“神经网络长什么样”开始吗?我一开始也有这个错觉。但真按课程顺序走下来就会明白,所有深度学习框架的底层计算,都不是对着原始图片、文本字符串做的,而是统一交给一种叫“张量”的多维数组。你输入一张 224x224 的彩色图片,对框架来说就是一个形状为 [3, 224, 224] 的张量;你输入一句长度为 20 的话,token 化之后就变成 [1, 20] 甚至 [batch, 20, hidden] 的张量。
如果连张量都操作不熟练,后面写数据管道会非常痛苦。课程把这个内容排在第二课,相当于先修一条路,后面模型训练、反向传播都是在这条路上跑车。我个人还会额外把它看作“从数学符号过渡到编程实现”的桥梁:数学课上我们说矩阵相乘,代码里就要做张量乘法;数学上说“把这个矩阵转置”,代码里就要做 .T 或 .permute。你越早把这两套语言对应起来,后面读论文复现代码就越顺。
1.2 一次数据操作到底在做什么
用大白话描述,数据进入模型前要经历五个环节:读取、清洗、格式化、张量化、按批供给。前两件事取决于具体业务,图像要解码、文本要去噪、表格要补缺失值;格式化与张量化则相对通用,就是把数据变成统一尺寸、统一数值类型、统一排布规则的多维数组;按批供给则是训练时用 DataLoader 不停地把数据一小块一小块喂给模型。
很多人会忽略格式化这个环节。比如图像数据有的项目是 [高,宽,通道],有的框架期望 [通道,高,宽];同一个数据,排布顺序不同,模型看到的“语义”完全不同。第二课里练的 reshape、permute、transpose,本质上都在解决这类维度约定问题。学的时候不是背几个 API,而是要建立起“数据形状就是我对框架说的话”这种意识。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 张量基础操作,先把手感和维度感练出来
2.1 从创建张量到看懂 shape:先学会“查形状”
我不建议一上来就死磕 torch 的全部初始化方式,先把最常用的几个记熟就够了。torch.arange(12) 会生成从 0 到 11 的一维张量,torch.zeros((2,3)) 和 torch.ones((2,3)) 初始化全 0、全 1 张量,torch.randn((3,4)) 生成标准正态分布的随机张量。实际写代码时,随机初始化常用来模拟输入、验证网络前向能不能通;全 0、全 1 张量则常用于初始化掩码或某些中间变量。
python复制import torch
x = torch.arange(12)
print(x.shape) # torch.Size([12])
print(x.numel()) # 12,元素个数
print(x.reshape(3, 4)) # 变成 3 行 4 列
y = torch.randn(3, 4)
print(y.shape) # torch.Size([3, 4])
这里最容易被忽略的是 reshape 的底层逻辑。它把原张量按“行优先”顺序拉平,再重新切成目标形状,所以元素总量必须一致。换句话说,reshape(3,4) 不是简单地对半裁,而是把 0 到 11 这 12 个数按顺序填成三行四列。很多新手会忽略的一件事是:reshape 可能返回原数据的视图,也可能触发拷贝,取决于内存是否连续。如果你之后对 reshape 后的结果做原地修改,又把它当成原数据的副本,容易出隐蔽 bug。我的习惯是:不确定会不会影响原数据时,优先用 .clone() .reshape(),或者直接用 .view() 前先确认内存连续。课程里只讲了 API,但这一条经验是我在实际项目里吃过亏后才记住的。
2.2 索引、切片与拼接:像操作列表一样操作张量
张量支持类似 Python 列表的索引方式,但维度更多,规则也更强。要练出手感,核心是记住:X[-1] 取最后一行,X[1:3] 取第 2 行到第 3 行,左闭右开,和 Python 原生切片一致。多维索引时每个维度依次写,比如取一张图片张量 img 的 R 通道就是 img[0],如果图片张量形状是 [3, H, W],还可以用 img[0, :, :] 来强调维度语义。
拼接在数据准备中非常常用。torch.cat 沿已有维度拼接,不新增维度;torch.stack 则新增一个维度。举一个我常用的例子:如果你想凑一个 batch,可以把多张相同尺寸的图片张量 [3, 224, 224] 用 torch.stack 堆成 [N, 3, 224, 224],而 torch.cat 更适合把同一维度上的数据接长,比如把两个 batch 合并成更大的 batch。
python复制a = torch.randn(2, 3)
b = torch.randn(2, 3)
c = torch.cat((a, b), dim=0) # 形状 4x3,沿着行拼
d = torch.stack((a, b), dim=0) # 形状 2x2x3,新加第 0 维
这里有一个辨别技巧:如果你希望“多出的那一层”表示数据条数,用 stack;如果你只是想把同一维度上的数据累加得更长,用 cat。刚开始练数据操作时可以刻意把一张图的 H,W,C 变成 C,H,W,你会发现所有框架都要求你心里有清晰的维度地图,不然拼接时经常会得到意想不到的维度。
2.3 广播机制:小张量自动扩展的“偷懒”智慧
广播机制是第二课里最容易被忽略、但训练中最常遇到的内容之一。它解决的是两个形状不完全相同的张量能不能直接做运算的问题。比如你有一个形状为 [3, 1] 的张量 A,和一个形状为 [1, 4] 的张量 B,A + B 在数学上不成立,但在深度学习框架里可以算,结果会变成 [3, 4]。
规则听起来简单:从尾部维度对齐,两个维度要么相等,要么其中一个是 1。满足这个条件就能广播。比如 [3,1] 与 [1,4] 尾部对齐后可以扩展为 [3,4];而 [3,2] 与 [4,1] 尾部对齐后 2 和 1 可以扩展,但 3 和 4 不相等,直接报错。
广播机制实际用在哪里?最常见的场景是数据归一化。假设一批图片张量形状是 [N, C, H, W],你想对每个通道分别减去均值、除以标准差,均值形状是 [C],就可以直接让 [N, C, H, W] 和 [C] 广播。这样省去了复制均值的额外操作。另一个场景是给一批样本统一加上偏置向量。理解广播之后,你会发现很多代码里能少写几层循环,性能也会更好。但要注意,广播是逻辑层面扩展,并不代表真的把所有数据复制一遍,内存上不会爆炸,初学者不必太担心。
2.4 类型转换与设备迁移:float32 和 GPU 的“隐形约定”
张量不仅有形状,还有数据类型(dtype)和所在设备(device)。深度学习中绝大多数模型参数是 float32,图像像素转成张量后也默认变成 float32;整数类型常用作标签、索引,比如分类任务的类别编号一般用 torch.long 或 torch.int64。如果你不小心把标签用成 float32,很多损失函数会直接报类型错误。
| 需求场景 | 常用 dtype | 说明 |
|---|---|---|
| 网络输入、权重 | torch.float32 |
默认精度,显存/内存消耗均衡 |
| 图像像素归一化前的整数值 | torch.uint8 |
PIL 读入常见格式 |
| 标签索引、embedding 查找 | torch.long / torch.int64 |
损失函数和索引都要求整型 |
| 布尔掩码 | torch.bool |
用于条件筛选 |
设备迁移也是数据操作的一部分。模型放到 GPU 上跑,数据一般也要同步放上去,否则每步计算都会在 CPU 与 GPU 之间来回复制,性能惨不忍睹。最简单的方式是 tensor = tensor.to(device),其中 device 可以写成 torch.device("cuda:0") 或 torch.device("cpu")。我的经验是:写训练脚本时,把数据类型处理和 .to(device) 放在同一个数据准备函数里,尽量让整个 batch 一步到位,而不是在训练循环里临时到处迁移。这样代码更可控,也更容易排查设备不匹配的报错。
3. 真实数据预处理:从零散的图片文本到标准张量
3.1 图像读入、缩放到张量化的全流程拆解
课程里的张量大多从数字直接生成,但真实项目里数据往往是一张张图片、一份份文本。图片最常见的处理链路是:用 PIL 或 cv2 读入,做必要的缩放与增强,最后转成框架张量。这个过程中隐藏着一个很多新手会忽略的点——图像读进来时通常是 HWC 格式,即高、宽、通道,而 PyTorch 模型通常接受 CHW 格式。如果你不做转换,直接拿原始数组往网络里塞,经常会出现维度对不上的报错。
python复制from PIL import Image
from torchvision import transforms
transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(), # HWC -> CHW,像素值从 0~255 缩放到 0~1
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
img = Image.open("demo.jpg").convert("RGB")
img_tensor = transform(img) # 形状 [3, 224, 224],float32
其中 ToTensor 这一行最容易踩坑。它不只是把 PIL 对象换成张量,还会自动把 HWC 重排成 CHW,并把 0~255 的像素值缩放到 0~1。如果你直接用 torch.tensor(np.array(img)),得到的仍然是一张 HWC、dtype 为 uint8 的张量,后续很多操作都会出问题。因此我的建议是:图像转张量尽量走 ToTensor(),不要自己手写转换,除非你很清楚自己在处理什么格式。
归一化的均值、标准差通常取 ImageNet 的统计值 [0.485, 0.456, 0.406],这是很多预训练模型的默认值。如果你用的是自己训练的模型,最好从训练集上重新统计,或者干脆先不归一化,等前向流程跑通后再加也不迟。我之前有段时间一直疑惑为什么训练 loss 浮动不正常,排查了半天才发现是数据的归一化参数填错了,模型输入分布和预训练权重的预期分布完全不一致。
3.2 离散标签、文本内容的通用张量化思路
图像数据要转张量,文本和离散标签也要转张量。分类问题里的字符串类别,比如 cat、dog,框架并不认识,一般做法是先建立一个类别到索引的映射,再把索引存成 torch.long 张量。如果你要做多标签或某些特殊结构,可能还要继续转成 one-hot 编码,但大多数损失函数更希望接收索引而不是 one-hot,所以不要盲目转换。
python复制labels = ["cat", "dog", "bird"]
label_to_idx = {label: i for i, label in enumerate(labels)}
samples = ["cat", "bird"]
idx_tensor = torch.tensor([label_to_idx[s] for s in samples], dtype=torch.long)
文本建模还会涉及 token 化、构建词表、padding 到相同长度等步骤。从数据操作角度讲,核心仍然是把变长文本变成定长张量。如果你的样本有长有短,就要用 pad_sequence 或手动补齐到 batch 内最大长度,模型才能按固定形状计算。这个过程稍微复杂,但本质和第二课的思想一样:不管原始数据长什么样,最终都要在保持语义的情况下,变成一个形状规整、类型正确的张量集合。
3.3 Dataset 封装:把零散数据整理成框架认识的“数据源”
张量只是单个数据块,真正训练时你会希望有一个对象能“按需返回第 i 个样本”,这就是 Dataset。PyTorch 的 Dataset 需要实现两个方法:__len__ 表示样本总数,__getitem__ 根据索引返回一个样本。返回的内容通常是一个元组,比如 (image_tensor, label_tensor)。我之前在第一版代码里把数据处理逻辑全都写在训练循环里,结果每次循环都要重新读图、做变换,慢到怀疑人生。后来改成 Dataset 之后,逻辑清晰了,速度也上来了,因为 DataLoader 可以配合多进程预取数据。
python复制from torch.utils.data import Dataset
class ImageDataset(Dataset):
def __init__(self, image_paths, labels, transform=None):
self.image_paths = image_paths
self.labels = labels
self.transform = transform
def __len__(self):
return len(self.image_paths)
def __getitem__(self, idx):
img = Image.open(self.image_paths[idx]).convert("RGB")
label = self.labels[idx]
if self.transform:
img = self.transform(img)
return img, torch.tensor(label, dtype=torch.long)
初学者最容易忽略的是 __getitem__ 的性能问题。每个 epoch 都会多次调用它,如果你在 __getitem__ 里做特别重的计算,比如每次都从磁盘加载一张超大原图再缩小,训练速度就会被数据读取卡住。工程上常见的优化是提前把图片缩放到较小尺寸或缓存到内存中;再不行就增加 DataLoader 的 num_workers。但这些都是后话,先把 Dataset 的正确结构写出来,才能让后续训练代码干净很多。
4. 批处理与数据加载:训练效果的关键操作与参数影响
4.1 Batch_size 与 epoch:每一个训练轮次里数据怎么流动
数据和模型都准备好后,训练时并不会一次性把所有数据喂进去,而是分成一个小批次一个小批次地送。batch_size 决定每个批次有多少个样本,epoch 决定整个数据集被完整遍历多少遍。假设有 1000 张图,batch_size = 32,那么一个 epoch 大约会产生 31 个批次,最后一个批次可能不足 32。
从经验上讲,batch_size 过小会让梯度估计噪声变大,loss 容易震荡;batch_size 过大虽然每步更稳定,但会占用大量显存,还可能收敛到比较尖锐的局部最优点。实际操作中,我会先根据显存选择能放下的最大 batch,再在这个基础上调学习率。不要盲目追求显存刚好装满,训练速度不一定更快,反而可能因为数据增强、中间激活值波动导致 OOM。
| 参数 | 常见取值 | 影响 |
|---|---|---|
| batch_size | 8、16、32、64 | 越大越稳定,但显存占用越高 |
| num_workers | 0、2、4、8 | 越大预取越快,但会增加内存与 CPU 开销 |
| shuffle | True / False | 训练一般 True,验证一般 False |
| drop_last | True / False | 是否丢弃最后不足一个 batch 的样本 |
epoch 也不是越大越好。训练轮数与精度的关系不是线性上升,在某个点之后,验证精度可能不再提升,甚至开始下降。正常的做法是每一个 epoch 后在验证集上算一次指标,保存验证指标最好的权重。关于训练轮数的选择,不要只是拍脑袋设个 100 轮然后不看训练曲线,早停或动态调整学习率都比单纯堆轮数更有效。
4.2 shuffle 为什么默认要打开
不少人在一开始写训练循环时,为了省事直接把整个数据集按顺序喂给模型,然后发现 loss 曲线特别奇怪,甚至训练不收敛。最常见的原因是没有 shuffle。数据集的存储顺序往往不是随机的,比如前 1000 张全是猫,后 1000 张全是狗,不 shuffle 的话,每个 batch 都只包含同一个类别,模型会疯狂学习“当前 batch 的类别先验”,梯度更新方向来回横跳,收敛速度自然慢。
理论上,随机梯度下降希望在每轮迭代中近似采样独立同分布的小批量数据。shuffle 之后,每个 batch 更像整体数据分布的一个随机缩影,损失函数对梯度的估计就更无偏。实际操作上,训练集加载时应该把 shuffle=True;验证集和测试集通常用 shuffle=False,因为验证不需要更新梯度,保持顺序还能让日志结果可复现。做时间序列预测时尤其要谨慎,不能随便全局 shuffle,否则会破坏时间依赖,一般会按时间窗口构造样本,再考虑要不要对窗口乱序。
4.3 DataLoader 的 worker 与内存、速度平衡
DataLoader 不是简单地把 Dataset 包一层就完事。它对数据处理速度的影响非常大。以 PyTorch 为例,num_workers=0 表示数据加载在主进程中进行,代码简单,但每次要从磁盘读图、做变换时都会阻塞训练。num_workers=2 或更大时,数据加载会放到多个子进程中,训练当前 batch 的同时后台已经在准备下一个 batch,GPU 利用率能明显提高。
不过 num_workers 不是越大越好。每个 worker 都会拷贝一部分数据,内存占用随之上升;worker 太多还会造成 CPU 调度开销,甚至可能因为频繁切换导致速度下降。我的经验是:如果只是几千张小图,num_workers=2 就够;如果是大规模图像数据集,可以逐步往上加到 4、8,同时观察 CPU 和内存占用情况。Windows 环境下建议把 DataLoader 的创建放在 if __name__ == "__main__": 内,否则多进程会反复启动,容易报错。
pin_memory=True 也是一个常用优化项。它让 DataLoader 把数据放到锁页内存中,向 GPU 拷贝时可以更快。缺点是会增加内存占用,如果你的机器内存比较紧张,可以不开。drop_last=True 则会丢掉最后不足一个 batch 的数据,这在分布式训练中特别有用,因为要保证每个进程的 batch 数一致;单卡训练时丢不丢影响不大,但如果你发现某个 epoch 的 batch 数量比预期少,可能就是 drop_last 在起作用。
5. 数据操作踩坑与排查技巧:shape、增强、显存与轮数
5.1 shape 对不上:不看维度就写代码的代价
我见过最多、自己也踩过最多的坑就是 shape mismatch。初学者拿着模型结构,以为是网络写错了,但实际往往是数据输入阶段维度不对。比如图片处理成 [H, W, C] 后直接塞给期望 [C, H, W] 的模型,报错信息会提示某个维度的 size 不匹配。这种问题排查起来特别费时间,因为报错栈往往指向模型内部,而不是数据处理函数。
我的习惯是:在把数据交给模型前先打印一条日志,看一下 batch 张量的形状和 dtype。
python复制for images, labels in train_loader:
print(images.shape, images.dtype, labels.shape, labels.dtype)
# 预期看到 torch.Size([batch, 3, 224, 224]) torch.float32
break
只要这一步确认了,后面模型报 shape 错时就能更快定位到是模型结构定义问题,还是数据流中间环节出了问题。还有个小技巧:shape 错误经常来源于拼接和堆叠用错。比如想把两个 batch 合成一个 batch,用 torch.stack 而不是 torch.cat,结果多出维度,导致后续全连接层 mat1 和 mat2 形状无法相乘。遇到这种报错,先看是不是维度数量多了一维或少了一维,再决定是 squeeze、unsqueeze、reshape 还是重新检查拼接方式。
5.2 预处理顺序和不一致性带来的隐蔽问题
图像预处理顺序错了不一样会直接报错,但会影响训练效果。拿分类任务来说,常见顺序是:读图、缩放/裁剪、转张量、归一化。RandomResizedCrop、RandomHorizontalFlip 这些增强操作一般放在转张量之前,因为 PIL 层面做几何变换比较自然。归一化必须放在转张量之后,因为此时才能拿到 float 类型的像素值。如果你把归一化写进自定义函数,又同时在 transform 里用了 Normalize,相当于减了两次均值,数据分布完全偏掉。
更隐蔽的问题是训练集和验证集预处理不一致。增强类操作比如随机裁剪、随机翻转,只能用在做训练数据增强时;验证集应该只做确定性变换,比如 Resize、CenterCrop。我的建议是分开定义两套 transform:
python复制train_transform = transforms.Compose([
transforms.RandomResizedCrop(224),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize(mean=MEAN, std=STD)
])
val_transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=MEAN, std=STD)
])
如果不小心在验证集里也做随机增强,你会发现验证精度上下跳得很厉害,不是模型问题,而是验证数据本身每次都不一样,指标失去了可比性。类似的问题也会出现在所有对数据做增强的环节里,特别提醒:训练完成后再做推理时,也要走和验证一致的 transform,不要为了省事把训练增强原样套上去。
5.3 显存不够、loss 震荡与训练轮数的关系
训练时最尴尬的报错之一是 CUDA out of memory。有时并不是你的模型太大,而是 DataLoader 在每次迭代时产生的 batch 张量占用了太多显存,尤其是把整图放大到极大分辨率后。处理方法有几个方向:调低 batch_size、缩小输入图片分辨率、开启混精训练,或者使用梯度累积。其中缩小 batch_size 是最直接的办法,但要注意学习率也要相应调整,否则收敛速度会变慢。
loss 震荡也是新手常遇到的问题。数据层面来看,可能是 batch_size 太小导致梯度噪声太大,也可能是训练数据标签错误或样本分布极度不均。先用一个固定的小数据集做 overfit 测试是排除问题的最好方式:如果模型在小数据集上 loss 能降下去,说明数据管道和网络结构基本正常,接下来再去调 batch_size、学习率和训练轮数。反之,如果十几张图都跑不顺,大概率是数据基本操作里埋了雷。
关于训练轮数与精度的关系,我个人经验是这样的:刚开始训练时 loss 下降明显,验证精度上升很快;到了中后期,loss 仍在缓慢下降,但验证精度可能停滞甚至下降,这时候继续加轮数不一定有用。与其盲目加轮数,不如考虑用学习率衰减、数据增强、调整 batch_size 等手段。数据基本操作练得越熟,越容易快速做这类实验,因为你已经把“换数据形态”变成了一件低成本的事。
python复制# 常见的梯度累积写法,小显存也能模拟大 batch
accumulation_steps = 4
optimizer.zero_grad()
for i, (images, labels) in enumerate(train_loader):
outputs = model(images)
loss = criterion(outputs, labels)
loss = loss / accumulation_steps
loss.backward()
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
上面这段代码等价于把 batch_size 扩大了 4 倍,但显存占用没变,代价是训练步数变多、速度变慢。它是显存受限时的通用解法,也是理解 batch 与梯度更新之间关系的一个好例子。
回头看第二课,确实有种“基础决定上层”的感觉。那时候我在终端里反复打印 x.shape,觉得挺枯燥,可后来写数据管道时,遇到 transpose、permute、unsqueeze 这类操作能顺手拈来,才想起多亏当初没跳过这些看似简单的练习。如果你现在也卡在张量操作这里,别急着往后翻,找一个自己手头的数据集,哪怕只有几十张图,从读取、整理、封装到 DataLoader 全部自己写一遍,你会比看十遍 PPT 都有收获。
