2. 从Tensor到Variable:PyTorch自动求导的基石
2.1 开篇:一个绕不开的起点
不论你是因为参加竞赛、做毕业设计,还是想转行搞深度学习才点开这篇文章,只要你打算用PyTorch,张量(Tensor)就是第一道门槛。而“张量与变量”这个标题,看起来像是两个概念并列,但在PyTorch的版本演进里,它们其实是两个时代的东西。
我最早接触PyTorch的时候,网上的教程还处于“张量是张量,变量是变量”的阶段。当时每次定义一个需要求梯度的参数,都得写Variable(torch.tensor(...), requires_grad=True),代码里到处套着Variable壳子。后来PyTorch 0.4.0版本发布,Tensor和Variable合并了,官方直接说“以后统一用Tensor,想要梯度就设置requires_grad=True”,大家一阵欢呼。所以你手里的PyTorch 2.x里,其实已经没有独立的Variable类了,你看到的只有一个「支持跟踪梯度的Tensor」。
但这不代表“变量”这个概念消失了。恰恰相反,它是理解梯度计算、反向传播、计算图这些核心机制的关键钥匙。“张量与变量(一)”这个系列,我打算给你讲透这层关系,这一篇先把地基打好——张量的创建、属性和基本操作,以及变量和自动求导的来龙去脉。看完了你就能明白,为什么说PyTorch的自动求导精妙,以及你写代码时那些requires_grad、no_grad、backward到底在背后干了什么。
2.2 从最日常的创建开始
很多人一上来就喜欢抄那些包含randn、zeros之类的代码,但不理解每个函数背后的数据形态,后面整个模型都会出问题。我建议你先把“创建张量”这件事想透,它只有几类场景:从已有数据创建、从特定分布生成、从NumPy数组转换,以及创建特殊形状的空张量。
import torch import numpy as np # 从Python列表创建 t1 = torch.tensor([1, 2, 3], dtype=torch.float32) # 从NumPy数组创建 arr = np.array([[1, 2], [3, 4]]) t2 = torch.from_numpy(arr) # 按形状创建全零/全一张量 t3 = torch.zeros(2, 3) t4 = torch.ones(2, 3) # 用随机值填充,模拟初始化 t5 = torch.randn(2, 3) # 标准正态分布 t6 = torch.rand(2, 3) # 0~1均匀分布这里有个老生常谈但必须拎出来说的坑:torch.tensor和torch.Tensor是两种东西。torch.tensor()是个工厂函数,它会根据你传入的数据推断或指定dtype;torch.Tensor()则直接调用默认构造器,在没传数据的情况下就生成一个未初始化的张量,里面可能是内存里的垃圾数字。我见过不少人用torch.Tensor(2, 3)想生成零矩阵,结果打印出一堆莫名其妙的值,然后在群里问“是不是PyTorch有bug”。没有bug,是你用错了API。
再说torch.from_numpy()和NumPy数组的共享关系。torch.from_numpy()创建的张量与原来的NumPy数组共享底层内存,意味着你改NumPy里的值,Tensor那边也会变。这在数据处理时很省内存,但也容易出事。如果你做了一个原地操作,比如t2.add_(1),NumPy数组也会被改掉。真实项目里我处理数据时稍不注意就得重新加载,后来养成习惯:凡是需要复制又不想互相干扰的情况,就用torch.tensor(arr.copy())或t2.clone()。
从随机生成角度说,randn和rand的选择不是随便来的。神经网络权重初始化常用的小随机数,如果只是均匀分布rand(n, m)是0到1之间,而参数初始化通常还希望正负对称,所以randn更常见,它生成的是均值0、方差1的标准正态分布。实际项目中,配合a * randn(...) + b就能控制缩放和偏移,这比先均匀分布再变换要直观。
2.3 张量到底“长”什么样:三条命脉
要真正用好张量,光会创建不行,你得随时能回答出三个问题:它的形状(shape)是什么、里面存的数据类型(dtype)是什么、它待在哪个设备(device)上。我管这三样叫“数据三件套”,几乎所有报错和奇怪行为的根源都能落到这三样上。
t = torch.randn(4, 5) print(t.shape) # torch.Size([4, 5]) print(t.dtype) # torch.float32 print(t.device) # cpu- shape:表示各维度大小。维度数叫“秩”,一个标量是0维,一维向量、二维矩阵、三维批量图像分别是1、2、3维。搞深度学习最常见的错误就是维度对不上,比如模型输入是
[batch, 3, 224, 224],你喂给它的是[3, 224, 224],一跑就是一个维度错误。这个真的急不得,拿到数据第一步永远是print(data.shape)。 - dtype:PyTorch默认浮点数类型是
float32,在GPU上这就是核心算力主打的精度。整数类型默认是int64。你如果不小心用整型数据去跑模型,很多层会直接罢工。像是字符串、bool这些类型,也只能在不参与梯度计算的场景里用用。 - device:CPU上创建的张量没法直接喂给GPU上的模型,强行运行会直接报错,提示“device mismatch”。所以真正的工程代码里,经常能看到
model.to(device)、data.to(device)这种操作,把参数和数据搬到同一个设备上去。
这三个东西就像炖菜的调味料——少了一样,菜可能还能吃,但味道一定不对。调试模型时,先用一段小代码把输入张量、模型参数、目标张量的这三样都打印出来,能省掉大量排查时间。
2.4 索引、切片和“假装变了个形状”
张量操作里最常用到的是索引和形状变换。索引规则和Python的列表、NumPy数组基本一致,但很多人一上来会栽在“共享存储”这个点上。
t = torch.arange(10).reshape(2, 5) row = t[0] # 第一行 cols = t[:, 1:3] # 所有行的第1~2列 sub = t[:1] sub[0, 0] = 999 # 原t也会变t[0]拿到的不是复制品,而是原始张量的一块视图。视图和原数据是共享内存的,改视图里的值,原张量跟着变。这个特性在操作大矩阵、降低内存占用时很有用,但对没意识到它的人来说,莫名其妙改了原数据,排查半天都找不到原因。需要独立副本时,老老实实加个.clone()。
形状变换里最常用的是view和reshape。区别有意思:view要求内存连续,所以有时得先.contiguous();reshape更灵活,它会自己在背后处理连续性问题。还有一点,不要试图用view去改一个非连续张量的形状,会直接报错。从实用角度说,你直接在代码里用reshape基本能覆盖九成需求,但面试或者读老项目时很多地方还是用view,所以两个都要懂。
permute和transpose则负责调换维度顺序,经常处理图像数据时用。图像前是[批次, 高度, 宽度, 通道],绝大多数模型期望[批次, 通道, 高度, 宽度],于是img.permute(0, 3, 1, 2)就是家常便饭,这行代码你要是理解不深,从数据集处理到模型前向一路都会满头问号。
我觉得学张量操作最有效的方式,是拿一个小张量打印出来,然后做一步,打印一步。张量的每一步操作都会产生一个新张量,看它的shape和值的变化,看多了自然就有手感。
2.5 重新认识“变量”:自动求导的起点
如果只看张量本身,它只是一个装着数字的容器。可一旦打开了requires_grad开关,这个容器立刻就能记住“它是怎么被算出来的”,这就是变量(Variable)要做的事。
在早期版本里,PyTorch把“能追踪梯度的张量”单独封装成了Variable,要求所有需要梯度的操作都在Variable上执行。后来版本合并,Tensor自己在requires_grad=True时就能挂上一系列回溯信息。可以这样理解:张量本身是菜,变量是给菜贴的标签。
# 老写法,PyTorch 0.4.0之前 # from torch.autograd import Variable # x = Variable(torch.tensor(2.0), requires_grad=True) # 新写法,0.4.0之后至今 x = torch.tensor(2.0, requires_grad=True)在这里,requires_grad就是那个决定“我叫不叫变量”的开关。打开它意味着,只要这个张量参与了某个运算,之后你调backward(),它就能沿着这次运算的记录去计算和累积梯度。这就要求我们:只有真正需要求梯度的叶子张量才打开这个开关。如果你给一个不需要梯度的中间结果也开了,计算图就会造得又大又费内存,前向和反向都会慢一大截。
围绕“如何开、何时关”,PyTorch给了三个不同的手段,我经常收到这类提问,整理出来给大家:
| 手段 | 作用场景 |
|---|---|
requires_grad=True | 创建张量时直接开启追踪,用于模型参数或需要更新值的输入 |
requires_grad_() | 对已有张量原地修改,用于把某个本来不追踪的张量临时改成追踪 |
torch.no_grad() | 关闭梯度追踪的上下文管理器,用于推理阶段、模型评估或避免多余计算图 |
实际写模型时,评估集和验证集上我们通常会包一层with torch.no_grad():。这样做除了省显存,更重要的是避免因为反向传播把梯度栈越叠越深导致内存爆掉。有人只是测试个分类准确率,忘写了,结果显存越占越多,最后程序崩了,入职第一周就在这上面栽跟头。
2.6 计算图:一次回放式记账
自动求导的底层,是计算图(Computational Graph)。你不需要一开始就看得特别深,但至少要明白它的思路:把一次前向运算拆成许多个小步骤,记录每个步骤的输入和输出、以及每个步骤对输入的导数关系,然后反向传播时按照链式法则一步步算出梯度。用生活里的话说,就是“记账本”——每一步怎么算来的,全都有记录。
x = torch.tensor(2.0, requires_grad=True) y = x * 3 z = y * y上面这段代码里,PyTorch会建立一张这样的关系:x是叶子节点,y是中间结果,z是最终输出。当你调用z.backward(),它会从z出发,回推:z对y的导数是2*y,y对x的导数是3,所以z对x的导数是2*y*3 = 36。这个36就是自动求导帮你算出来的结果。
z.backward() print(x.grad) # tensor(36.)这里的grad属性,就是该张量“作为叶子节点”累积到的梯度。可以对比手算验证一下:y = 6,z = 36,z对x的确导数为2×6×3=36。只要你会链式求导,就能自行验证代码算得对不对。我在刚学PyTorch的第二个月,就干过这种事:写个自定义公式,先手算一遍导数,再用backward()去对答案。这个习惯帮我避开了无数后来真正跑模型时的“梯度为None”和“渐变爆炸”问题。
还有一点,如果你的最终结果不是一个标量,而是向量或矩阵,backward()需要传一个与输出形状相同的“梯度种子”参数。这个参数就是你赋给输出“最外层梯度”的初始值。比如输出是[2, 2]的矩阵,你希望它一开始的梯度是全是1,就传torch.ones(2, 2)。这个细节很多人一开始完全不知道,只知道报错说“grad can be implicitly created only for scalar outputs”,弄明白之后就会恍然大悟。
2.7 数据处理的最后一公里:和NumPy与Matplotlib配合
如果你是为了做实验或跑模型用的张量,那还有两件事基本跑不掉:一是张量和NumPy互相转换,二是把结果可视化。
张量转NumPy的坑在于共享内存确实太隐蔽了。
t = torch.tensor([1.0, 2.0]) n = t.numpy() n[0] = 100.0 print(t) # tensor([100., 2.]) 原张量跟着变了!我当年做数据可视化时,把一个tensor导成numpy准备画曲线,画完了想把曲线再处理一下,回头发现原始张量的值被动过,追溯了好一阵才意识到是这行t.numpy()的问题。现在我的处理习惯是:如果只是为了转一份继续折腾,就用t.cpu().numpy(),让它落回CPU;如果要保留两者各自独立,就.clone().detach().cpu().numpy()。detach()用来从计算图上摘掉梯度信息,cpu()搬到CPU,clone()负责复制,三个一起才是安全的“导出给其他库用”的姿势。
t.cpu().detach().numpy()关于可视化,很多人在深度学习入门时会拿MNIST那种手写数字数据集来练手,用matplotlib画出来就能直观看到模型输入长什么样。这里要注意tensor图像通常是以[通道, 高, 宽]存储的,而matplotlib认的是[高, 宽, 通道]。你需要img.permute(1, 2, 0)调换一下维度。这个小细节,做图像的人天天用,但初学者几乎都会踩一次。
2.8 常见问题与排查实录
这里把入门阶段最容易踩的几个坑和对应的解决办法直接整理成一张表,方便你遇到事时快速查阅:
| 报错/现象 | 原因 | 处理建议 |
|---|---|---|
RuntimeError: mat1 and mat2 shapes cannot be multiplied | 矩阵乘法时后一个矩阵的行数不等于前一个矩阵的列数,维度对上错了 | 打印两个张量的shape,手动算一下相乘后的形状 |
RuntimeError: expected scalar type Float but found Long | dtype不匹配,模型或算子要求float,喂进去的是int64 | 检查输入是否忘了.float() |
RuntimeError: Input type and weight type should be the same | 设备和dtype不一致,比如输入是CPU张量,模型参数在GPU上 | 统一用to(device),模型和数据都放同一个设备 |
UserWarning: grad can be implicitly created only for scalar outputs | backward()时输出不是标量 | 先.sum()变标量,或者传入同形状的gradient参数 |
RuntimeError: Trying to backward through the graph a second time | 反复调用同一个图的backward(),而默认retain_graph=False | 第一次反向前先传retain_graph=True(但更推荐重新前向一次构建新图) |
ValueError: cannot reshape array of size ... | 用view或reshape时元素总数对不上 | 检查总元素个数是否等于各维度乘积 |
所有的梯度都是None | 叶子张量没设requires_grad=True,或者中途用了detach() | 检查叶子节点是否开了追踪,检查是否在中途把梯度链条断掉了 |
我最想强调的是最后一条。新手兴奋地搭完一个小模型,发现参数梯度全是None,接着就在群里问“是不是PyTorch坏了”。绝大多数时候,不是框架坏,是你把图链断了。比如用了detach()之后,又对detach出来的张量调用backward(),那么原来的那个分支根本不会得到梯度。你只需要简单想想:它的“求导账本”是不是还连着原来的路径,就能自己判断出来。
排查时我有个固定流程:打印模型参数是否requires_grad=True;打印输入张量是否requires_grad=True;检查前向过程有没有调用detach();检查loss是不是标量。这套下来基本能定位九成问题。
2.9 这个帖子还能往后怎么用
张量和变量这个话题,一次是不可能讲到底的。后面如果继续往下写,我会接着讲张量的广播机制、合并与拆分、范数计算、以及torch.nn.Parameter如何和优化器配合。这些内容才是真正能把模型搭起来不踩坑的关键。
就我自己带人入门的小经验来说,最建议你的学习路径是:先把张量的创建和基础操作过一遍,然后去做一个极小的线性回归,亲手实现前向传播、计算loss、调用backward、再用优化器更新参数。只要这一整个闭环亲手跑通,你对自动求导的理解就会从“听说过”变成“会用”,后面学什么网络结构都顺了。
那就先这样,下一篇继续。