☰
PyTorch张量与Variable详解:从创建操作到自动求导实战
2026/10/14 15:02:18 网站建设 项目流程

说实话,我每次带新人入门深度学习,第一课讲的一定是张量。原因很简单:不管你是要做图像分类、自然语言处理还是强化学习,在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. 从尾部维度开始对齐;
  2. 维度长度相等,或者其中一个为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 deviceCPU张量与GPU张量混算用.to(device)统一设备
grad can be implicitly created only for scalar outputsbackward()传入非标量输出先对非标量求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的理解就真正落地了。

这个系列接下来还会接着写自动求导的进阶用法、模型构建的常用模块、数据装载还有一些实战中跑出来的小技巧。第一篇能把地基打牢,后面就顺理成章了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询