资讯详情

资讯详情

PyTorch中Tensor与Variable的关系及自动求导实战

后台经常有刚入门的人同时拿着两套笔记来问我一套里满屏都是“张量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 / 全1torch.zeros(2, 3)torch.randn标准正态分布随机数torch.randn(2, 3)torch.rand0到1均匀分布torch.rand(2, 3)torch.arange等差序列torch.arange(0, 10, step2)torch.linspace指定步数等间距torch.linspace(0, 1, steps5)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_gradFalse或者用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_gradTrue) y Variable(torch.ones(2, 2), requires_gradTrue) 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_gradTrue) # 新版写法0.4.0之后 x torch.randn(3, 3, requires_gradTrue)两者在绝大多数情况下等价。如果看到网上老代码还有Variable(torch.Tensor(...))直接去掉Variable外壳即可。from torch.autograd import Variable这行在当前版本里已经找不到了。3.3 当看到旧代码里的Variable时怎么改旧代码里三个高频字段的迁移规则是旧Variable用法新版等价写法备注x.datax.detach()detach()返回不参与梯度计算的新张量且和计算图彻底断开x.gradx.grad保留不变x.grad_fnx.grad_fn保留不变但只在非叶子节点上有值Variable(x)x.clone().detach().requires_grad_(True)稳定实现“包装”含义Variable(torch.ones(...))torch.ones(..., requires_gradTrue)最常用特别强调一下.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]之后修改subt也会跟着变。如果只是读取这没问题但如果后续要维护原始数据不被污染请在一开始就明确用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_gradTrue的张量做运算时PyTorch会实时记录每一步操作形成一个有向无环图。每个节点保存了操作函数grad_fn每次调用backward()时就从这个图的末端开始按链式法则逐级往前计算梯度。这段“每跑一次程序就重新建一次图”的特性正是PyTorch最大的优势之一。和静态图框架不同Python的控制流if、for、while可以直接参与图的构建写模型就像写普通Python代码一样自然。x torch.tensor(3.0, requires_gradTrue) y x ** 2 z y * 2 z.backward() print(x.grad) # 结果是 12.0因为 dz/dx 4*x 12用数学验证一下z2*x^2dz/dx4xx3时是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_graphTrue。否则第一次backward后计算图就被释放了第二次直接报错“trying to backward through the graph a second time”。5.3 detach()、with torch.no_grad()和参数更新的实际区别requires_gradFalse的操作不参与图构建这是显式声明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_gradTrue) b torch.zeros(1, 1, requires_gradTrue)requires_gradTrue就是告诉框架这两个参数需要在反向传播时求解梯度。这里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(fepoch {epoch}, loss{loss.item():.6f}, w{w.item():.4f}, b{b.item():.4f})运行到最后w会收敛到接近2.0b接近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在真实工程里的更多细节展开。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →