后台经常有刚入门的人同时拿着两套笔记来问我:一套里满屏都是“张量(Tensor)”,另一套里到处是“变量(Variable)”,写法还不一样,仿佛在学两个框架。其实这两个名字背后正好是PyTorch的一段版本演进史,想明白它们的关系,比急着敲代码更重要。这篇笔记默认你已经装好PyTorch并了解基本Python语法,我会把张量的本质、创建方式、变量概念的来龙去脉、维度操作和自动求导机制一并讲清楚,也会穿插一些我实际跑代码时踩过的坑。
无论你之后是去做CV、NLP还是推荐系统,第一步都是跟张量打交道。Tensor就是整个PyTorch世界里的基本货币,后面所有网络层、损失函数、优化器,本质上都在消费和产出Tensor。而Variable在2018年之后的版本里已经被并入Tensor,很多旧教程里还在用它,导致新手照着敲会直接报错。所以这篇笔记我特意把这两个词放在一起,先把概念理顺,后面的事情才顺。
1. 从“维度”说起:张量到底是什么
1.1 标量、向量、矩阵和张量的关系
很多人第一次看到“张量”这个词,会下意识觉得它是个特别高深的东西。其实在PyTorch的语境里,你可以简单把它理解成一个“多维数组”。只不过这个数组不是孤立的,它还要承担自动求导、GPU加速这些额外的责任。
拿数字来举例:一个单独的数字,比如3.14,叫标量,对应0维张量;一排数字[1, 2, 3],叫向量,对应1维张量;一张表格,有行有列,叫矩阵,对应2维张量;再往上叠,三本书摞在一起,每本都是表格,那就成了3维张量。在PyTorch里,图像通常被表示为4维张量,形状是(batch, channels, height, width),意思是“一批图片,每张图片有多个颜色通道,每个通道是二维像素矩阵”。语言模型里的文本则常被表示成(batch, sequence_length, hidden_size)这样的3维或更高维张量。
理解到这一层就够了:张量就是更高维度的数组,PyTorch里面所有模块都围绕它展开。这个理解越早建立,后面看别人的模型代码就越不容易晕。
1.2 Tensor的内部结构:是一块连续内存
张量不只是逻辑上分维度,物理上它在内存里通常是一段连续的区域。PyTorch的Tensor由两部分构成:头信息(元数据)和存储区(Storage)。头信息记录了形状(shape)、数据类型(dtype)、步长(stride)、设备(device)等;存储区才是真正存放数据的连续内存块。
为什么要有头信息?因为很多所谓的“变换”其实没有复制数据,而是改了一下视图。比如对矩阵做转置,底层数据没动,只是把步长信息换了一下,新张量还是指向同一块内存。这个设计带来了极快的操作效率,但也埋了一个坑:你改一个“视图”张量的数据,原张量的数据也会变。很多新手在这里翻车,比如用transpose之后直接做in-place修改,结果意外改了原始数据。
检查shape的常用方式:张量.shape、张量.size()、张量.ndim,三者分别告诉你形状、形状、维度数。len(tensor)只返回第一维大小,千万别当成元素总数用。
1.3 为什么深度学习框架都选择张量作为基本数据结构
你可以把计算过程想成一条流水线:数据进来是一个形状统一的“托盘”,每一层运算从托盘里取出数据、加工、再放到新的托盘上。如果每个样本形状都不一样,流水线根本无法批量化。张量的作用,就是把不同数据统一成规范的多维数组,让矩阵运算库(如BLAS、cuBLAS)能用高度优化的方式一次算完一大批数据。
这也是GPU发挥优势的关键。GPU擅长并行计算,而张量天然是“大批规则数据”的载体,所以GPU上的矩阵乘法能比CPU快几十上百倍。换句话说,张量是连接人类思维(高维数据怎么组织)和硬件能力(大规模并行计算)的桥梁。
提示:刚开始不需要精通所有底层内存布局知识,但至少要知道“视图与拷贝”这件事存在,否则后面查数据异常时会很痛苦。
2. 创建张量的常见姿势与数据类型的坑
2.1 最常用的创建函数
我在实际项目里常用的就那几种,这里直接整理成表格,方便你贴在旁边随时查:
| 函数 | 作用 | 示例 |
|---|---|---|
| torch.tensor([1, 2, 3]) | 从已有Python列表创建 | torch.tensor([[1, 2], [3, 4]]) |
| torch.Tensor(3, 4) | 构造指定形状,不承诺初值 | torch.Tensor(2, 3) |
| torch.zeros / torch.ones | 全0 / 全1 | torch.zeros(2, 3) |
| torch.randn | 标准正态分布随机数 | torch.randn(2, 3) |
| torch.rand | 0到1均匀分布 | torch.rand(2, 3) |
| torch.arange | 等差序列 | torch.arange(0, 10, step=2) |
| torch.linspace | 指定步数等间距 | torch.linspace(0, 1, steps=5) |
| torch.as_tensor | 高效转换现有数组 | torch.as_tensor(numpy_array) |
注意区分torch.tensor和torch.Tensor:前者是工厂函数,会尽可能保留你传入数据的内容和类型;后者是类型构造器,只负责按shape分配一块内存,里面的值可能是残留的垃圾数据,直接拿来用大概率会出错。所以除非明确知道要做什么,否则优先用torch.tensor、torch.zeros、torch.randn这类确定性构造。
还有一个日常很容易遇到的需求:把NumPy数组转成Tensor。用torch.as_tensor会比torch.tensor更高效,因为它会尽量和NumPy共享内存,而不是复制一份。当然共享内存意味着修改一边会影响另一边,需要留意。
2.2 dtype、device与requires_grad三个属性的优先级
创建Tensor后,有三件事要时刻盯住:数据类型(dtype)、所在设备(device)、是否需要梯度(requires_grad)。
默认dtype是torch.float32,也就是32位浮点数。图像、标签、模型权重绝大多数情况下都该用float32;如果显存紧张,可以用float16,但要配合缩放策略,否则精度损失明显。整数类型(torch.int64等)一般只用于标签索引。
device就更直观了。没有GPU就用cpu,有GPU就尽量把张量和模型放到cuda上。最典型的问题是“device mismatch”:模型在GPU上,输入在CPU上,一forward就给你抛RuntimeError。我自己的习惯是写一个工具函数:
def to_device(t, device): return t.to(device)然后在主程序里统一指定device,所有数据、模型、损失都走同一个入口,这样能少踩很多设备不一致的坑。
requires_grad则决定是否参与自动求导链,后面第5章会专门讲。这里只想强调一点:如果你创建的是临时张量,不需要梯度,就明确设requires_grad=False,或者用torch.no_grad()包住,既能省内存又能避免误挂计算图。
2.3 随机数种子的必要性
做实验要可复现,这一点怎么强调都不过分。PyTorch的随机数跟NumPy、Python自带random是相互独立的,所以你要分别设置:
import torch import random import numpy as np torch.manual_seed(42) np.random.seed(42) random.seed(42)只要这三句在脚本开头执行,同一份代码在多台机器上的初始权重、数据打乱顺序都能对上。我最早没重视这个,结果调参的时候每次结果都不一样,根本分不清是代码改好了还是随机数在帮忙。后来每次实验前都固定种子,才真正开始“可控地调参”。
3. 变量(Variable)的前世今生:旧教程给新手的最大困惑
3.1 Variable原本解决什么问题
回到PyTorch 0.3.0及更早时期,Tensor就只是一个单纯的数组,不会记录运算过程,也没有梯度信息。那时候想让模型自动求导,就必须把Tensor包装成Variable,由Variable去构建计算图、保存梯度。所以在老教程里你会看到这样的代码:
from torch.autograd import Variable x = Variable(torch.ones(2, 2), requires_grad=True) y = Variable(torch.ones(2, 2), requires_grad=True) z = x + y print(z.data) print(z.grad_fn)当时要拿到数据就访问.data,要拿到梯度就访问.grad,要追溯运算函数就看.grad_fn。这套设计在当时是合理的,但它带来了一个明显问题:代码里全是Variable包裹,写起来冗长,而且Tensor和Variable两套概念让新手头大。
3.2 新版PyTorch为什么合并成Tensor
从0.4.0开始,PyTorch把Variable直接并入了Tensor。Tensor本身就拥有requires_grad、grad、grad_fn、is_leaf这几个属性,功能上完全覆盖了Variable。也就是说,你现在只需要写Tensor,不再需要额外包一层Variable。
新旧写法对比:
# 旧版写法(0.3.0及之前) from torch.autograd import Variable x = Variable(torch.randn(3, 3), requires_grad=True) # 新版写法(0.4.0之后) x = torch.randn(3, 3, requires_grad=True)两者在绝大多数情况下等价。如果看到网上老代码还有Variable(torch.Tensor(...)),直接去掉Variable外壳即可。from torch.autograd import Variable这行在当前版本里已经找不到了。
3.3 当看到旧代码里的Variable时怎么改
旧代码里三个高频字段的迁移规则是:
| 旧Variable用法 | 新版等价写法 | 备注 |
|---|---|---|
| x.data | x.detach() | detach()返回不参与梯度计算的新张量,且和计算图彻底断开 |
| x.grad | x.grad | 保留不变 |
| x.grad_fn | x.grad_fn | 保留不变,但只在非叶子节点上有值 |
| Variable(x) | x.clone().detach().requires_grad_(True) | 稳定实现“包装”含义 |
| Variable(torch.ones(...)) | torch.ones(..., requires_grad=True) | 最常用 |
特别强调一下.data和.detach()的区别。.data虽然也能取出数据,但它不会参与自动求导追踪,导致你在后续操作里对.data做修改时,计算图完全不知道,可能会在反向传播时算错梯度。.detach()同样返回不追导的张量,但它明确地跟计算图断开,并且从语义上告诉你“这里我主动不要梯度了”。我现在的代码规范里一律用.detach(),不用.data。
还有一个容易混的点:t.detach()返回的张量和原张量共享同一块内存,改一个另一个跟着变。想彻底脱离关系,需要再接一个.clone()。所以在导出保存模型输出、写日志时,我常用的是output.detach().cpu().numpy():先脱离计算图,再转到CPU,再变NumPy数组,三件套少一个都可能踩坑。
4. 张量维度变换和索引切片:操作中的高频点
4.1 view/reshape:到底该用哪一个
view和reshape都能改形状,但行为有差别。view要求原张量的内存是连续的;reshape在内存不连续时会自动先复制一份数据。
x = torch.randn(2, 3) y = x.view(3, 2) # 可以,因为 x 本身连续 z = x.t().view(3, 2) # 报错,t() 后内存不连续 w = x.t().reshape(3, 2) # 可以,内部会先复制在能保证连续的情况下,view不会复制数据,效率高;但如果拿不准原张量经过什么操作,或者程序里既有转置又有切片,我更建议无脑用reshape,它最坏情况只是多一次拷贝,但不会莫名其妙报错。
其他常用变换:squeeze()去掉长度为1的维度,unsqueeze(dim)增加指定位置的长度为1维度,permute(...)做任意维度的重排,transpose(dim0, dim1)交换两个维度。初学者最容易把transpose和permute弄混,记法很简单:transpose只能交换两个轴,permute可以任意重排多个轴。
4.2 索引与切片的踩坑提醒
Tensor的索引语法和NumPy几乎一样:t[1]取第二行,t[:, 0]取所有行的第一列,t[0:2]取前两行。但有一个细节经常被忽略——当只给一个索引时,得到的是“行子张量”,它的维度会减少1。如果你希望保留维度,要用t[0:1]或t[None, 0],这在某些模型的前处理里很关键。
另外,切片返回的是视图,不是拷贝。也就是说,sub = t[:, 0]之后修改sub,t也会跟着变。如果只是读取,这没问题;但如果后续要维护原始数据不被污染,请在一开始就明确用clone()拷贝一份。对我来说,凡是会被后续修改的切片数据,一律先clone。
4.3 广播机制(broadcasting)
这是PyTorch里最方便也最容易被误解的机制。简单说,当两个张量形状不同时,PyTorch会尝试自动扩展较小的形状,让它们能进行元素级运算。
a = torch.randn(3, 1) # 形状 (3, 1) b = torch.randn(1, 4) # 形状 (1, 4) c = a + b # 结果形状 (3, 4)规则是从右往左逐维对齐:1对1、不同但一方为1时扩展、否则直接报错。这套机制让写代码省事很多,但也容易产生“静默扩展”导致的意外内存消耗。比如一个形状(1000, 1000)张量跟一个形状(1000, 1)张量相加,结果会生成(1000, 1000)张量,内存翻倍。在训练超大模型时要留意,纯靠广播写出来的代码不一定省内存。
5. 自动求导:requires_grad和grad_fn是怎么工作的
5.1 动态计算图
PyTorch的自动求导建立在“动态计算图”之上。当你对requires_grad=True的张量做运算时,PyTorch会实时记录每一步操作,形成一个有向无环图。每个节点保存了操作函数(grad_fn),每次调用backward()时,就从这个图的末端开始,按链式法则逐级往前计算梯度。
这段“每跑一次程序就重新建一次图”的特性,正是PyTorch最大的优势之一。和静态图框架不同,Python的控制流(if、for、while)可以直接参与图的构建,写模型就像写普通Python代码一样自然。
x = torch.tensor(3.0, requires_grad=True) y = x ** 2 z = y * 2 z.backward() print(x.grad) # 结果是 12.0,因为 dz/dx = 4*x = 12用数学验证一下:z=2*x^2,dz/dx=4x,x=3时是12。这个过程完全由框架自动完成。
5.2 反向传播时最容易踩的梯度累积坑
调用一次backward()之后,梯度会默认累积在叶子张量的.grad属性上,而不是清零。换句话说,第二次backward()时,梯度是两个batch的总和。因此,经典的训练循环里必须加:
optimizer.zero_grad() loss.backward() optimizer.step()如果忘了zero_grad(),loss可能小幅度下降之后就开始震荡,因为梯度一直在叠加。我见过很多新手把网络训练崩了,排查半天发现就是少了一行清零。很多人只用optimizer.zero_grad(),我在自定义梯度场景时也会直接写param.grad.zero_(),效果一样。
还有一个场景是计算二阶导数,或者在一个循环里对同一张量多次求梯度,这时需要给backward()传retain_graph=True。否则第一次backward后计算图就被释放了,第二次直接报错“trying to backward through the graph a second time”。
5.3 detach()、with torch.no_grad()和参数更新的实际区别
requires_grad=False的操作不参与图构建,这是显式声明;with torch.no_grad():是临时关闭梯度追踪的上下文管理器;.detach()是在某个具体张量上切掉与计算图的连接。
它们三者的使用场景不同。我通常在推理阶段用with torch.no_grad()把整个前向过程包起来,训练阶段则正常计算;至于把某个中间结果拿出来可视化、写入日志时,就用.detach().cpu().numpy()。
参数更新这一步很关键:optimizer.step()内部本质是“用梯度去修改参数”。如果直接写w = w - lr * w.grad,这个操作会新建一个变量,耗费内存且不正规。正确做法是:
with torch.no_grad(): w -= lr * w.grad它利用原地减法直接修改参数的Tensor数据,既省内存又不破坏计算图。这里必须关闭梯度追踪,否则在原地上做运算时,PyTorch会继续往计算图里挂载节点,导致内存疯狂增长。
6. 第一次实战:用张量和自动求导训练一个简单回归模型
6.1 数据准备与模型
光说概念容易飘,我拿一个最简单的线性回归模型完整走一遍。假设真实函数是y = 2x + 1,我们往里面加一点高斯噪声,然后用Tensor训练出一个模型去逼近。
import torch x = torch.linspace(0, 10, 100).reshape(100, 1) true_w = 2.0 true_b = 1.0 y = true_w * x + true_b + torch.randn_like(x) * 0.2 w = torch.randn(1, 1, requires_grad=True) b = torch.zeros(1, 1, requires_grad=True)requires_grad=True就是告诉框架:这两个参数需要在反向传播时求解梯度。这里x和y不需要梯度,它们只是输入和标签。
6.2 训练循环
lr = 0.02 for epoch in range(1000): y_pred = x @ w + b loss = ((y_pred - y) ** 2).mean() loss.backward() with torch.no_grad(): w -= lr * w.grad b -= lr * b.grad w.grad.zero_() b.grad.zero_() if epoch % 100 == 0: print(f"epoch {epoch}, loss={loss.item():.6f}, w={w.item():.4f}, b={b.item():.4f}")运行到最后,w会收敛到接近2.0,b接近1.0。这就是一个最小的“训练闭环”:预测 → 算损失 → 反向传播 → 更新参数 → 清空梯度。每一步都离不开Tensor的自动求导能力。
6.3 训练过程中的几个细节
loss输出用loss.item()而不是loss,是为了把梯度追踪的张量转成普通Python浮点,既方便打印又不会积累计算图。
with torch.no_grad()更新参数时不会影响w和b本身的requires_grad属性,它们仍然保留在计算图体系里,下一轮还能正常求导。我曾经担心过“关闭梯度后参数会不会从此脱离训练”,测试后确认不会,这个上下文管理器只是临时关闭追踪。
还有一个小技巧:如果你在写一个新的网络结构,想检查某个中间层的shape,可以直接打印layer(x).shape。Tensor在这里看起来就是个能跑多维数组的“活计算单元”,跑对了shape再放进整个模型里,能省去很多调试时间。
7. 顺带说几句学习建议
我个人的体会是,学PyTorch不要一上来就啃完整文档。先把这篇笔记里的Tensor概念、Variable背景、维度变换、自动求导这四件事练熟,然后用一个最小的线性回归或简单的分类任务走完训练闭环,再到具体任务里查对应的模块文档,是最稳妥的路径。
如果在实操中遇到“x和y不在同一设备上”“梯度累加导致loss爆炸”“view连续性问题”这些报错,大概率都和我上面写的章节相关。先把这些基础概念理清,再去碰网络层和损失函数,会发现后面的事情顺畅很多。
Tensor和Variable这个名字本身,就是一次很好的框架设计反思:简化API、统一概念,让新手少学一套概念。咱们入门时顺着这个演进理解,就不会被网上十几年前的教程带偏。下一篇笔记我打算写数据加载和模型搭建的基础,到时候再把Tensor在真实工程里的更多细节展开。