说实话,我每次带新人入门深度学习,第一课讲的一定是张量。原因很简单:不管你是要做图像分类、自然语言处理还是强化学习,在PyTorch的世界里,一切数据的流动和计算都围绕张量展开。你初始化一个模型,模型参数是张量;喂入一批图片,输入数据是张量;前向传播算损失、反向传播算梯度,中间产物和梯度结果也都是张量。可以说,搞懂张量,就是在给后面所有内容打地基。
这篇笔记我会把张量(Tensor)和变量(Variable)一起拆开讲。很多刚接触PyTorch的朋友会有个困惑:为什么网上一搜教程,老文章里全是Variable,而新版代码里直接就是Tensor?这其实是PyTorch发展历程中的一次重要合并。我会把这件事的来龙去脉讲清楚,同时把张量创建、索引切片、形状变换、广播机制、自动求导这些核心用法过一遍,最后附上实操中最容易踩的坑和排查思路。适合已经开始读PyTorch代码但总感觉哪里“隔着一层”的朋友,也适合刚入门、被一堆API劝退的新手。
1. 张量与变量:为什么它们是PyTorch学习的起点
1.1 一切计算都围绕张量展开
先聊一个很多人忽略的事实:神经网络训练的本质,就是让一批数值在“正向传播-计算损失-反向传播-更新参数”的循环里不断流动。而这些数值的载体,就是张量。
你可以把张量理解成一个“集装箱”。普通的Python列表是散装货,看着直观但没法高效计算;而张量是一个规格统一的集装箱,里面的数据按照固定的形状排布,并且被底层C/C++引擎优化过,算起来飞快。更关键的是,它会记下自己在计算路径里的“足迹”——这为后面的自动求导打下了基础。
以一张常见的图像数据为例,我们通常用形状为[batch_size, channels, height, width]的四维张量来表示,比如[32, 3, 224, 224]。如果把张量当“集装箱”,那这里的形状就是集装箱的“规格标签”,里面的每个数值是像素点的亮度或颜色通道值。模型读取这批数据,经过一系列矩阵乘法、卷积操作,每一步操作都会产生新的张量。理解了这个概念,你就明白为什么张量是深度学习最基础、最核心的数据结构。
1.2 理解Variable的来龙去脉
很多人在翻旧教程时会被Variable整懵。在早期的PyTorch版本里,Variable和Tensor是分开的两个类:Tensor只负责存数据、做计算,而Variable在Tensor外面包了一层,额外记录“梯度”和“是否需要计算梯度”这些信息。
这个设计在当时的框架里是合理的,但也带来了不少麻烦。你写代码时得区分到底在用Tensor还是Variable,取数据要用.data,取梯度要用.grad,一套操作下来别扭得很。
到了0.4.0版本,PyTorch直接把Variable合并进了Tensor。从此以后,Tensor本身就能记录梯度了,你只要在创建时设置requires_grad=True,后面的一切都不需要额外包装。这也是为什么现在新教程里几乎看不到Variable的原因。
但问题来了:搜索引擎里沉淀了大量用Variable写的老代码,论坛问答里也还有一堆Vintage回答。如果你完全不懂Variable,看这些内容会一脸懵。所以这篇笔记特意把两者放在一起讲,一方面帮你理解当前Tensor的设计逻辑,另一方面给你一份“考古指南”,看到老代码时能迅速明白它在干什么。
2. 张量的创建:基础操作与核心概念
2.1 先搞清张量的三个核心属性
拿到一个张量,第一个动作一定是确认三件事:形状(shape)、数据类型(dtype)、所在设备(device)。这三个属性几乎决定了后续所有操作会不会报错。
用一段代码感受一下:
import torch x = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.float32) print(x.shape) # torch.Size([2, 3]) print(x.dtype) # torch.float32 print(x.device) # cpu形状就是维度大小。[2, 3]表示两行三列的矩阵。数据类型控制数值的精度和内存占用。设备决定这个张量是在CPU上还是在GPU上计算。新手最容易犯的错误就是:模型参数在GPU上,输入数据在CPU上,一运算直接报错。所以拿到任何张量先看一眼这三个属性,能省掉后面一大半的排查时间。
2.2 创建张量的多种方式与选择
PyTorch创建张量的API非常多,很多初学者会困惑到底该用哪个。我的建议是:先记住下面表格里的核心方法,其他的用到了再查文档。
| 创建方式 | 说明 | 典型使用场景 |
|---|---|---|
torch.tensor([...]) | 从Python列表/数组创建,会复制数据 | 手动构造小规模测试数据 |
torch.Tensor([...]) | 与torch.FloatTensor相同,默认float32 | 需要指定默认类型的快速创建 |
torch.as_tensor([...]) | 尽量共享内存,不复制则复用原数据 | 转换已有数据,减少拷贝开销 |
torch.from_numpy(np_array) | 从NumPy数组创建,共享内存 | 数据处理流水线中与NumPy互转 |
torch.zeros/ones(shape) | 生成全0/全1张量 | 初始化掩码、占位张量 |
torch.eye(n) | 生成单位矩阵 | 批处理中的单位变换 |
torch.arange(start, end, step) | 生成等差数列 | 生成序列索引 |
torch.linspace(start, end, steps) | 生成等间隔数值 | 可视化、采样坐标 |
torch.randn(shape) | 标准正态分布随机数 | 初始化权重,配合nn.Parameter |
torch.rand(shape) | 均匀分布[0,1)随机数 | 初始化偏置、Dropout掩码等 |
torch.randint(low, high, shape) | 整数随机数 | 生成随机标签、索引 |
这里有个容易踩的坑:torch.tensor和torch.Tensor看起来很像,行为却有差异。torch.Tensor([1, 2])等价于torch.FloatTensor([1, 2]),不管传入什么类型的数据,结果都是float32;而torch.tensor([1, 2])会保留整数int64类型。如果你后续要做浮点运算,用torch.tensor时最好显式指定dtype=torch.float32。
我自己的习惯是:构造测试数据一律用torch.tensor并写清dtype;从NumPy拿数据用torch.from_numpy或者torch.as_tensor;初始化模型参数则交给torch.randn或torch.zeros,配合nn.Parameter使用。
2.3 类型与设备:为什么深度学习默认用float32
先回答一个常见疑问:为什么PyTorch里默认浮点类型是float32而不是float64(双精度)?
核心原因是效率和内存的平衡。深度学习模型的参数量和中间激活值动辄几百万、几十亿,如果用float64,内存占用直接翻倍,GPU计算速度也会明显下降。而float32的精度(约7位有效数字)在实际训练场景下已经足够——梯度下降本身就带随机性,Loss的微小抖动根本不影响最终收敛效果。无脑用高精度,只会拖慢训练速度、增加显存开销。
只有极少场景需要额外精度,比如某些数值稳定性敏感的科学计算。普通模型训练,float32是默认最优解。
再说设备管理。CPU和GPU的张量可以直接通过.to()互转:
x = torch.randn(3, 3) x_gpu = x.to('cuda') # 转到GPU x_cpu = x_gpu.to('cpu') # 转回CPU老代码里常见的.cuda()方法现在也还能用,但.to()更通用——你可以把设备写成一个变量,方便做环境判断:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') x = torch.randn(3, 3).to(device)这样写的好处是,在没GPU的机器上跑也不会报错。
2.4 NumPy与PyTorch的互转
做深度学习的人几乎都绕不开NumPy。PyTorch和NumPy之间的互转没有想象中复杂,但有个细节必须记住:互转时数据可能共享底层内存。
import numpy as np arr = np.array([1, 2, 3]) t = torch.from_numpy(arr) # 共享内存 t[0] = 99 print(arr) # [99 2 3] 原数组也被改了用torch.from_numpy()转出来的张量与原始NumPy数组共享内存,修改其中一方会影响另一方。这在数据预处理时很高效,但如果不了解这一点,代码里会出现“数据莫名其妙被改动”的诡异问题。如果需要完全独立的副本,显式调用.clone()或.copy()。
反向转换更简单:t.numpy()。不过要提醒一点,带requires_grad=True的张量不能直接转NumPy,需要先.detach()再转。
3. 张量的常用操作:索引、变换与广播
3.1 索引与切片:和NumPy一致的体验
PyTorch的索引和切片语法基本照搬NumPy,只要你会用NumPy,上手几乎零成本。
x = torch.arange(12).reshape(3, 4) # tensor([[ 0, 1, 2, 3], # [ 4, 5, 6, 7], # [ 8, 9, 10, 11]]) print(x[1, 2]) # tensor(6) 取单个元素 print(x[:, 1]) # tensor([1, 5, 9]) 取所有行、第1列 print(x[1:, :2]) # tensor([[4, 5], [8, 9]]) 子区域 print(x[x > 5]) # tensor([ 6, 7, 8, 9, 10, 11]) 布尔索引常见的坑有两个。一是布尔索引返回的是一维张量,形状会“展平”,不是保留原始结构。二是切片产生的张量共享存储,如果你修改了切片,原张量也会跟着变。这个特性和Python列表完全不同,但和NumPy一致,写代码时必须心里有数。
3.2 形状变换:view、reshape、permute与contiguous的坑
形状变换是张量操作里最常用也最容易报错的部分。下面这些是实际项目里出现频率最高的操作:
| 方法 | 作用 | 典型场景 |
|---|---|---|
view(shape) | 按逻辑顺序重新排列元素 | 展平、重排批量数据 |
reshape(shape) | 类似view,但更包容 | 处理非连续张量时不报错 |
transpose(dim0, dim1) | 交换两个维度 | 矩阵转置、交换通道顺序 |
permute(*dims) | 按新顺序重排维度 | 图像NCHW转NHWC等 |
squeeze(dim) | 删除长度为1的维度 | 去掉批量维 |
unsqueeze(dim) | 插入长度为1的维度 | 增加通道维、批量维 |
view和reshape是新手最容易搞混的一对。view要求张量在内存中是“连续”的(contiguous),否则会报错:view size is not compatible with input tensor's size and stride。而reshape在当前实现里更灵活,它在数据不连续时会自动拷贝一份,保证结果正确。
举一个典型报错场景:
x = torch.arange(12).reshape(3, 4) y = x.t() # 转置后内存布局不再连续 print(y.is_contiguous()) # False z = y.view(2, 6) # 报错,因为 y 不连续 z = y.reshape(2, 6) # 正常,底层自动拷贝所以我的建议是:普通情况直接用reshape省心;但如果你对性能敏感、确定张量连续,用view可以避免一次拷贝。要查看是否连续,用is_contiguous()判断。
transpose和permute的区别也值得说清楚。transpose只能交换两个维度,permute可以任意重排所有维度。图像处理里常见的NCHW转NHWC,用permute(0, 2, 3, 1)一步搞定。这类操作返回的张量通常非连续,如果后面接view就得先.contiguous()。
3.3 广播机制
广播是PyTorch里一个极其好用的特性,一句话概括:不同形状的张量做运算时,小张量会自动“扩展”成大张量的形状。
规则只有两条:
- 从尾部维度开始对齐;
- 维度长度相等,或者其中一个为1时才能广播。
举个例子:
a = torch.ones(3, 1) # 形状 [3, 1] b = torch.ones(1, 4) # 形状 [1, 4] c = a + b # 结果形状 [3, 4]a的列数1和b的行数1都会被自动扩展到3和4,两个矩阵相加得到完整的3行4列。你可以理解成:广播就是让每个“长得比较小”的维度被拉伸到对方对应维度的大小。
实际使用中,给一批图片加均值、给所有样本减一个统计量,都是靠广播一行代码完成的。但广播也有隐患——如果两个张量的形状在某个维度上既不等长也不为1,就会报错:
a = torch.ones(3, 4) b = torch.ones(2, 4) c = a + b # 报错:维度3与2无法匹配排查这类问题的方法是打印两个张量的.shape,看尾部维度对不对得上。养成“相加之前先确认形状”的习惯,能帮你规避大量潜在bug。
4. Variable与自动微分:从手推梯度到自动求导
4.1 在Variable时代,写代码有多别扭
我把历史中的用法展示出来,大家体会一下。
# 早期版本的写法 from torch.autograd import Variable x = Variable(torch.Tensor([2]), requires_grad=True) y = x ** 2 y.backward() print(x.grad) # 梯度存在这里 print(x.data) # 数值存在这里整个训练过程里,你都必须清晰地意识到“哪个是数值、哪个是梯度、哪个是需要被优化器更新的参数”。尤其取数据时要写.data,否则一不小心就把整个Variable表达式带进了后续计算。写起来别扭,排查起来更累。
4.2 合并之后:Tensor全面接管自动求导
PyTorch 0.4.0之后,同样的代码简化成:
x = torch.tensor(2.0, requires_grad=True) y = x ** 2 y.backward() print(x.grad) # tensor(4.)不需要再包一层Variable,Tensor天生就有requires_grad属性和grad属性。这个合并的意义不只是少敲几个字,而是把“数据”和“梯度”的概念统一了,让用户和框架的交互更直观。
那自动求导的底层原理是什么?PyTorch采用动态计算图:每一次前向计算,框架会记录你是用什么操作、由哪些输入得到了哪些输出,生成一张有向无环图。调用backward()时,从输出节点出发,用链式法则一路反向计算每个叶子节点的梯度。
举一个具体的例子:
x = torch.tensor(3.0, requires_grad=True) w = torch.tensor(2.0, requires_grad=True) b = torch.tensor(1.0, requires_grad=True) y = w * x + b # 前向计算 y.backward() # 反向传播 print(x.grad) # 2.0 dy/dx = w print(w.grad) # 3.0 dy/dw = x print(b.grad) # 1.0 dy/db = 1你看,backward()之后,每个设置了requires_grad=True的张量都自动拿到了对应的偏导。如果当初我手推梯度,就是这个结果。这就是深度学习框架给开发者省下的最大力气——你只需要定义模型结构,梯度会自动算好。
4.3 梯度陷阱:累积、叶子节点、in-place操作
自动求导也不是“一键免操心”,下面三个坑是我见到的最常见的:
梯度累积问题。每次调用backward(),梯度会累加到.grad上,而不是重置。这是为了支持梯度累积的训练技巧,但对新手是个大坑。如果忘记在每个batch训练前清零梯度,梯度会越滚越大,Loss直接飞掉。所以标准的训练循环里一定有这一行:
optimizer.zero_grad()叶子节点概念。只有用户直接创建、且requires_grad=True的张量才是叶子节点。由叶子节点计算出来的中间张量不是叶子节点,它们的.grad默认不会被保存。如果你想拿到某个中间结果的梯度,需要用register_hook。新手遇到“为什么梯度是None”时,先查一下自己关心的张量是不是叶子节点。
in-place操作。像x.add_(1)这种原地修改数值的操作,会破坏计算图里的记录,导致反向传播时找不齐历史数据,轻则梯度算错,重则直接报错。PyTorch默认对这种情况非常严格。我的建议是:凡是requires_grad=True的张量,一律使用x = x + 1这种非原地操作,千万别图省事。
4.4 老代码“考古”指南:遇到Variable怎么办
翻老项目时看到Variable,不需要慌,做两个动作就行:
第一,把Variable(...)换成Tensor写法。比如Variable(torch.ones(3, 3), requires_grad=True)改成torch.ones(3, 3, requires_grad=True),功能基本等价。
第二,把.data的取数习惯去掉。老代码里的.data现在不推荐使用,直接用张量本身。比如老代码里的x.data.numpy(),新写法是x.detach().numpy()。.detach()表示从计算图中分离出来,不再追踪梯度,这在保存模型权重、可视化特征图时都很常用。
还有一类更隐蔽的老代码,会把Variable用在nn.Parameter的包装上,类似Variable(nn.Parameter(torch.randn(...)))。这种冗余包装直接删掉就行,nn.Parameter本身就是一个带requires_grad=True的Tensor。
5. 常见问题与排查技巧实录
5.1 报错速查表
把实操中高频出现的错误整理成一张速查表,遇到问题第一眼就能定位到方向。
| 报错信息 | 可能原因 | 解决方式 |
|---|---|---|
view size is not compatible with input tensor's size and stride | 在非连续张量上调用view | 先.contiguous()再view,或用reshape |
Expected all tensors to be on the same device | CPU张量与GPU张量混算 | 用.to(device)统一设备 |
grad can be implicitly created only for scalar outputs | backward()传入非标量输出 | 先对非标量求sum()或mean(),或给backward()传gradient参数 |
a leaf Variable that requires grad is being used in an in-place operation | 对叶子张量做原地修改 | 改用非原地操作,如x = x + 1 |
shape mismatch | 广播维度不匹配 | 打印两者.shape,检查尾部维度 |
梯度是None | 计算路径上没有梯度、requires_grad未开启或该张量不是叶子 | 确认requires_grad=True,检查从输入到输出路径是不是全被detach了 |
5.2 我踩过的三个真实坑
第一个坑是忘清零梯度。早年间我跑一个文本分类模型,Loss前几步明明在下降,突然爆到几百。排查半天,最后发现是每个batch没有zero_grad(),梯度跨batch累积,几步之后参数更新幅度越来越大。从那时起,我把训练循环的模板固定成“forward、loss、backward、step、zero_grad”的顺序,再没犯过这类错。
第二个坑是view和permute的组合。有一次做图像数据预处理,想把[N, C, H, W]转成[N, H, W, C]再展平。直接permute(...).view(...)报错,原因是permute后的张量不连续。加一个.contiguous()立刻解决。后来我总结了一条经验:只要看到view报错,第一反应检查前面有没有transpose、permute、.t()这类重排维度的操作。
第三个坑是in-place操作。我在给某个中间特征做归一化时图省事写了x /= x.max(),结果反向传播报错。原因是PyTorch需要保留原始值去计算梯度,原地修改等于把中间结果覆盖了。从那以后,所有需要梯度传播的张量我都写成x = x / x.max()。
5.3 新手排查三板斧
如果你刚接触PyTorch,遇到问题不知道从哪下手,记住下面三招,能解决大多数入门阶段的问题。
第一招:打印形状、类型和设备。任何报错第一反应都是加一行print(x.shape, x.dtype, x.device),看到底哪个环节和预期不符。
第二招:构造极小案例手动验证。不用先上真实数据,用torch.tensor([[1.0, 2.0], [3.0, 4.0]])这类小矩阵试一下,把中间的每一步结果打印出来。小案例跑通再换大数据,问题定位会快很多。
第三招:从叶子节点往回看计算图。梯度为None时,沿着输入到输出的路径检查:是不是某一步用了.detach()?是不是requires_grad没有传下去?是不是在某个非标量上直接调了backward()?绝大多数梯度问题逃不出这三种情况。
我个人带新人学PyTorch还有一个习惯:会专门让他们动手写一个小脚本,手动实现一个最简单的线性回归,用requires_grad=True+backward()+optimizer.step()把这个闭环跑通。别看这个例子小,它能帮你把张量、梯度、计算图这些概念真正焊死在脑子里。学完这篇笔记之后,建议你也去试一下,不需要用现成的nn.Linear,就纯手写一个y = w * x + b,然后自己训练几步。跑通的那一刻,你对张量和Variable的理解就真正落地了。
这个系列接下来还会接着写自动求导的进阶用法、模型构建的常用模块、数据装载还有一些实战中跑出来的小技巧。第一篇能把地基打牢,后面就顺理成章了。