首页 / 资讯中心 / 文章详情

PyTorch Tensor全面解析:从基础操作到自动求导实践

PyTorch Tensor全面解析:从基础操作到自动求导实践 ★ FEATURED ARTICLE
先从一个小问题开始你在学机器学习或者深度学习的时候是不是总觉得“数据”这个词太抽象读论文的时候看到tensor、张量这些词脑袋里大概有个模糊概念知道它是核心但真正要自己写代码的时候又不知道从哪儿下手。尤其是第一次跑PyTorch的官方MNIST例子看到torch.tensor(...)、.view(-1, 28*28)这些操作整个人是蒙的。这篇博文就是想把这层窗户纸捅破。咱们就围绕深度学习框架PyTorch里的Tensor张量展开把这个最基础、也最容易糊弄过去的概念彻底讲清楚。我不打算念PPT式的定义而是直接用一堆可以复制的代码配合真正踩过的坑告诉你Tensor是什么、怎么用、为什么必须这么用。适合正在入门机器学习、有一定Python基础但对着PyTorch代码发怵的同学。看完之后你至少能无压力看懂数据集加载部分也知道自己写模型时哪些地方容易出幺蛾子。1. Tensor到底是什么先把它和NumPy放在一起看1.1 不要被“张量”这个高冷名字吓住“张量”这个翻译确实容易劝退人听起来像某种深奥的物理量。但如果不用数学课本的定义用工程视角去看Tensor就是一个多维数组。如果说负数是一维数组向量让你用一个数字列表表示一条线上不同位置的值二维数组矩阵是表格有行有列那Tensor就是把这个思路继续扩展下去三维数组是多个矩阵叠在一起四维数组是多个三维数组再堆起来。就这么简单。PyTorch的Tensor本质上就是这套多维数组逻辑的升级版。它不单纯是个容器还搭载了很多深度学习中频繁用到的功能能在CPU或GPU上存储数据支持自动求导这是神经网络训练能跑起来的命根子支持一套广播机制让不同形状的数据也能做运算可以和NumPy无缝互转只要你弄明白数据是在CPU还是GPU上。所以你现在可以把Tensor理解成“一个跑在Python环境里自带数学计算和深度学习优化能力的多维数组容器”。1.2 Tensor和NumPy的关系专业分工但不重复造轮子很多人刚接触会困惑NumPy也可以做矩阵运算为什么非要Tensor这俩确实有血缘关系——早期的PyTorch设计理念很大程度上参考了NumPy的API风格。但有几个核心差异决定了你在深度学习里基本离不开Tensor。差异一硬件加速。NumPy的运算几乎都跑在CPU上你想用GPU加速得自己想办法Tensor原生支持CUDA.to(cuda)一行代码就能把整个数据搬到显卡上。当数据量从几千个变成几百万个矩阵乘法的速度差距可以达到几十倍甚至上百倍。差异二自动梯度。这是最关键的差异。训练神经网络需要对损失函数求梯度传统做法是手动推导公式再写代码。Tensor只要设置了requires_gradTrue框架会自动记录你做的每一步运算反向传播到终点时直接给你梯度答案。NumPy没有这个能力。差异三底层实现。PyTorch的Tensor底层是用C实现的还做过专门的访存优化。你在深度学习里要反复做大批量矩阵乘法如果硬用NumPy计算图、自动微分这些事全部自己手搓基本等于重新发明轮子。顺带说一句Tensor和NumPy在绝大多数情况下是无缝转换的torch.from_numpy()可以把NumPy数组变成Tensor注意这通常会共享内存后面细说.numpy()可以把张量变回NumPy数组。所以流程上完全可以是“先用NumPy做数据预处理再转成Tensor喂给模型”。2. 创建Tensor的几种实用姿势每种的坑都给你标出来2.1 从现有数据直接转换最简单但不一定最合适有了Python基础创建Tensor第一步想到的往往是从列表转换import torch a torch.tensor([1, 2, 3]) # 整数列表 b torch.tensor([[1.0, 2.0], [3.0, 4.0]]) # 浮点矩阵 c torch.tensor(5) # 标量0维张量 print(a.dtype) # torch.int64 print(b.dtype) # torch.float32 print(c.shape) # torch.Size([])注意几个细节torch.tensor()会立刻复制数据所以它是拷贝而非引用。如果你用torch.tensor(ndarray_data)后续改动原数组Tensor不会变。整数列表默认的dtype是torch.int64浮点列表默认是torch.float32。这两个类型在深度学习里很常用但int64在后续跟浮点张量运算时容易报类型不匹配的错所以我个人建议在创建时就明确指定dtypea torch.tensor([1, 2, 3], dtypetorch.float32)如果你的数据本身已经是Tensor想做一个同样形状的新副本别再用tensor()用tensor.clone()更高效而且它会在计算图里保留自己的梯度链条这在一个循环里复用变量时极其重要。2.2 批量创建全零、全一、随机是数据初始化的重头戏构建神经网络权重、生成掩码、准备特征图这些场景下我们几乎从不手写列表而是直接用批量创建函数。zeros torch.zeros(3, 4) # 全零矩阵 ones torch.ones(2, 3) # 全一矩阵 randn torch.randn(3, 3) # 标准正态分布随机数 rand torch.rand(3, 3) # [0,1)均匀分布 full torch.full((2, 2), 7.5) # 填充常量 eye torch.eye(3) # 单位矩阵 arange_input torch.arange(0, 10, 2) # [0,2,4,6,8]类似range linspace_input torch.linspace(0, 1, 5) # [0, 0.25, 0.5, 0.75, 1.0]这其中的函数值得多说一句别看randn和rand只差一个字母语义差别巨大。torch.randn采样自标准正态分布数值可以是负的范围不设限torch.rand采样自均匀分布数值永远在0到1之间。初始化神经网络的线性层权重通常用前者做随机dropout掩码或概率值通常用后者。用错地方轻则收敛变慢重则直接发散。还有一批和NumPy固化到肌肉记忆里的对应关系torch.zeros_like(x)生成和x形状相同、值全0的Tensortorch.ones_like(x)同理。做模型的mask操作、避免显存重复分配时非常常用。2.3 指定设备和数据类型是性能优化第一课上面的创建方式默认都跑在CPU上。如果环境里有NVIDIA显卡可以这样创建gpu_tensor torch.randn(3, 4, devicecuda)或者先定义一个小工具统一管理设备device torch.device(cuda if torch.cuda.is_available() else cpu) t torch.randn(3, 4, devicedevice)这个device参数在模型训练的任何一个环节都绕不开。最痛苦的情况是模型在GPU上但你在CPU上作预测输入然后突然在某个中间层报错“Expected all tensors to be on the same device”。这类问题后文会专门列一个排查清单。这里只提醒一句尽量在创建Tensor的那一刻就把device块配好别指望后面统一搬。3. 索引、切片与形状变换张量操作的底层法则3.1 索引切片和Python列表不一样的地方深度学习里经常要切batch、取某些通道、提取特征子区域。Tensor的索引切片写法上接近NumPy但有几个新手特别容易踩的坑。t torch.arange(12).reshape(3, 4) # [[0, 1, 2, 3], # [4, 5, 6, 7], # [8, 9, 10, 11]] t[0] # 第0行 t[:, 1] # 所有行的第1列 t[1:, :2] # 从第1行开始取每行前两列 t[t 5] # 布尔索引返回一维Tensor [6,7,8,9,10,11]关键差异在于两个点。第一切片默认共享底层内存。t[0]返回的新Tensor和原始t共享同一块数据存储。你改了t[0]里的某个值t本身也会变反过来也一样。这个设计是为了避免频繁拷贝对性能是好事但对不熟悉的人来说就是灾难。如果你需要独立副本务必加.clone()。**第二布尔索引返回的是“一维展开”结果。**很多人用t[t 5]之后以为得到的是保持原来二维形状的Tensor结果shape变成(6,)一脸蒙。原因在于布尔索引本质上是“按条件挑选满足条件的元素”不管你原始维度怎么排列结果都是把这些元素拍平成一维。3.2 view、reshape、permute与transpose四兄弟千万别搞混形状操作是打通“数据维度”和“模型输入输出维度”的桥梁。每天写模型都会遇到view或者reshape但这两个词其实有细微差别。t torch.randn(2, 6) # 重新解释数据形状不改写底层数据 t.view(3, 4) # 能成功因为2*6 3*4 t.view(4, 3) # 也能成功 t.view(-1) # 整体拍平成12个元素 t.view(2, -1) # 第0维固定为2剩下的自动推导为6view要求Tensor在内存中是连续的。如果中途做过transpose()或permute()内存布局就不再连续直接view会报错。t2 t.transpose(0, 1) # 形状变成(6, 2)但不是连续内存 try: t2.view(3, 4) except RuntimeError as e: print(e) # view size is not compatible with input tensors size and stride此时有两招可以救场用.contiguous()先把内存重新排列得连续再view直接使用reshape它在非连续情况下会自动做拷贝不需要你手动先调contiguous()。那transpose和permute又做了什么呢它俩只交换维度的顺序t3 torch.randn(2, 3, 4) # (批次?, 高度, 宽度) t4 t3.permute(2, 0, 1) # (宽度, 批次?, 高度) t5 t3.transpose(0, 2) # 交换第0维和第2维(4, 3, 2)permute可以任意调整所有维度顺序transpose一次只能交换两个维度。在图像处理里最常见的就是把(B, H, W, C)转成(B, C, H, W)恰好就是permute(0, 3, 1, 2)。如果你忘了做这一步图片进到卷积网络里会得到一堆莫名其妙的Shape错误且报错信息非常不直观。3.3 维度操作最容易忽略的细节保持维度 vs 减少维度sum、mean、max这类聚合操作默认会把对应维度“吞掉”。比如一个(3, 4)矩阵执行t.sum(dim1)结果是(3,)维度减少了。这个行为在很多场景下符合直觉但在做矩阵运算对齐时却常常出问题。t torch.randn(3, 4) print(t.sum(dim1).shape) # torch.Size([3]) print(t.sum(dim1, keepdimTrue).shape) # torch.Size([3, 1])keepdimTrue保留了长度为1的那个维度好处在于后续可以借助广播机制直接进行行运算完全不需要手动加维度对齐。博主实操经验是在正式写代码时聚合操作一律加上keepdimTrue除非你在那一刻真的明确想缩小维度。这个习惯能省下大量排查维度bug的时间。4. 数值计算与广播机制矩阵运算里的“隐形规则”4.1 基础运算和矩阵乘法要分开记Tensor的加减乘除是逐元素操作也就是对应位置直接算。比如a b要求a和b的尺寸完全相同或满足后面要讲的广播条件a * b不是矩阵乘法而是对应位置相乘。a torch.arange(6).reshape(2, 3) b torch.arange(6).reshape(2, 3) c a b # 逐元素相加 d a * b # 逐元素相乘不是矩阵乘 e a b.T # 矩阵乘法等价于torch.matmul f torch.matmul(a, b.T)矩阵乘法一定要记三种写法a b、torch.matmul(a, b)以及更底层的torch.mm(a, b)。三者对二维矩阵来说语义一样但是纯Python语法糖torch.matmul还额外支持高维Tensor批量矩阵乘torch.mm只支持二维。写模型时推荐torch.matmul它更通用。4.2 广播机制不用手动对齐形状但规则必须清楚广播是Tensor运算中最容易出罗乱的地方。我举个例子你有一个形状为(batch, features)的二维数据要减去每一列的均值data torch.randn(10, 4) # 10条样本4个特征 mean_col data.mean(dim0) # shape (4,) centered data - mean_col # 能直接减mean_col被广播这里能成功的原因就是广播PyTorch从最后一个维度开始比较如果两个张量某个维度长度一致或者其中一个长度为1就会自动扩展到另一个的长度。(10, 4)和(4,)比较(4,)被理解为(1, 4)然后扩展成(10, 4)。不是所有形状都能广播。规则是从最右边维度往左对齐两个维度相等或者其中一个是1如果某个维度其中一个张量没有视为1。如果都不满足会报错x torch.randn(5, 3) y torch.randn(4, 3) # ValueError: shape mismatch因为倒数第2维5和4不相等多维度广播还有一个实操痛点你明明只想对某一列加个常数结果因为列向量和行向量的形状差异广播出来的结果把整行都改了。所以遇到广播相关bug我习惯先打印两个张量的shape在草稿纸上手动对齐一遍再检查代码。这个老方法比任何调试工具都好使。4.3 in-place操作与常规操作一个下划线引发的血案PyTorch里面大量操作存在两个版本普通版返回新Tensor不修改原Tensor带下划线版例如add_、mul_、relu_则会在原Tensor的内存上直接修改。x torch.arange(6).reshape(2, 3) y x.add(1) # x不变y是结果 z x.add_(1) # x原地加1z也指向x看着挺方便但在自动求导开启时不准确使用in-place操作轻则梯度计算混乱重则直接报错“a leaf Variable that requires grad is being used in an in-place operation”。原因也很好理解PyTorch的反向传播需要依赖运算历史如果你把参与过前向传播的一个张量原地改了旧的计算图就失效了。我的实际建议是模型主体逻辑里一律用普通版本只有在对缓存张量做初始化、或者在明确不跟踪梯度的场景下才允许用in-place版本。宁可多写一行也不要在训练中途为“看起来省事”埋雷。5. 自动求导Tensor能成为深度学习基石的关键5.1 requires_grad与grad_fn两个必须理解的核心概念前面说过Tensor和NumPy的核心区别就是自动求导。这部分的直接体现就是两个属性requires_grad和grad_fn。x torch.tensor([2.0, 3.0], requires_gradTrue) y x ** 2 # y也是Tensorrequires_gradTrue z y.sum() z.backward() print(x.grad) # tensor([4., 6.])就是dz/dxx.grad里存的就是梯度z.backward()触发反向传播算出梯度。x.requires_gradTrue简单告诉PyTorch“请为这个变量记录梯度”而grad_fn则记录的是“这个Tensor是通过什么运算得到的”——y.grad_fn会显示类似PowBackward0说明它是乘方运算的结果。如果你打印x.grad_fn得到的是None因为x是叶子节点是手动创建的不是某个运算的结果。这个机制就构成了PyTorch动态计算图的基础。每次前向传播时框架会把运算过程记下来反向传播时沿着记录的路径逐步求导。这也是为什么PyTorch被称为动态图框架——你的每一步操作都实时构建计算图中途改变分支、动态控制循环都没问题。5.2 梯度累加一个几乎所有人都踩过的坑在PyTorch默认行为里梯度不会自动清零。每次调用backward()梯度会累加在.grad属性上。x torch.tensor([2.0], requires_gradTrue) y x ** 2 y.backward() print(x.grad) # tensor([4.]) y x ** 2 y.backward() print(x.grad) # tensor([8.])累加而非覆盖这在默认batch训练里其实没问题因为通常在每次反向传播前都会手动执行optimizer.zero_grad()。但一旦你忘了这一行梯度就是上一次和这一次的和参数更新就会“跳步”loss曲线会呈现出一种“这边刚降下去那边马上反弹”的奇特抖动。进阶场景里梯度累加也可以是特性而非坑。当显存不够用一个大batch时你可以把batch拆成小的子batch分别做前向和反向但先不清零梯度等梯度累计到相当于完整batch时再执行一次优化器参数更新。这个技巧很多踩过显存门槛的同学应该都用过类似的思路。5.3 从Tensor到模型参数它们的关系比想象中紧密理解了Tensor带梯度再看神经网络模型参数就通透多了。PyTorch里一个模型的nn.Linear层它的权重本质上就是Parameter对象而Parameter又是Tensor的子类import torch.nn as nn layer nn.Linear(4, 3) print(layer.weight) # Parameter包含tensorrequires_gradTrue print(layer.weight.shape) # (3, 4)所以模型训练的整个链路其实是输入数据Tensor→ 模型内部计算Tensor运算→ 损失Scalar Tensor→ 反向传播计算梯度→ 优化器更新修改Parameter的.data。理解了Tensor等于拿到了进阶模型结构的上一半门票。有两点需要额外注意只有requires_gradTrue的Tensor参与计算时生成的新Tensor才是可求导的。冻结某些层如迁移学习场景时把参数的requires_grad设为False即可省显存省计算。.detach()可以从计算图中脱离出来。做特征提取、评估模型中间层输出、计算对应的“不可梯度”版本时很常用生成的Tensor和原值相同但不参与梯度传播也不再需要保存运算历史。6. 常见报错与排查技巧实录6.1 维度不匹配Distributions的Shape问题最快排查法新手遇到最多的恐怕就是“shape不匹配”。这类报错在PyTorch中很直白但直白并不代表好解决。比如a torch.randn(4, 3) b torch.randn(4, 2) c a b # RuntimeError: The size of tensor a (3) must match the size of tensor b (2) at non-singleton dimension 1报错信息已经把维度标出来了但真实场景常常发生在叠了很多层网络之后。我的排查顺序是打印出操作数的shapeprint(a.shape, b.shape)在草稿纸上从右往左比较维度看哪个位置既不是1也不相等确认是否丢了keepdim是否忘记transpose或permute。几乎所有维度问题都能通过这三步定位到源头。别用眼睛盯着代码硬找打印shape是最朴素的Debug方式。6.2 设备不一致GPU训练里最磨人的错误这是跑GPU训练时的高频报错。典型对话是代码在CPU上跑得好好的一上GPU就报RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cpu and cuda:0!通常的原因是模型参数在GPU输入数据在CPU。排查办法为数据、模型、标签统一指定device torch.device(cuda if torch.cuda.is_available() else cpu)在for batch in dataloader之后立刻把data, labels data.to(device), labels.to(device)如果是自己构建一些辅助张量比如mask、索引也要统一device。还有一个容易忽略的点某些计算结果是从NumPy转换来的转出来时还在CPU上几层运算之后才被发现。建议在自定义数据集或预处理函数里把一切张量创建操作都带上device参数这是从根上杜绝此类问题的最小成本方案。6.3 数据类型dtype不匹配的低级错误深度学习常用类型是torch.float32。当你从NumPy读入的数据是float64时直接转Tensor会保持float64在某些模型里就会触发类型不一致报错或者虽然能跑但是慢得离谱。import numpy as np arr np.random.randn(3, 4) # float64 t1 torch.from_numpy(arr) # dtype就是float64 t2 torch.randn(3, 4) # float32 t3 t1 t2 # RuntimeError: expected scalar type Double but found Float解决方案很粗暴在数据进入模型之前显式统一dtypet1 t1.float()至于整数张量用于索引时是torch.longtorch.int64用于分类标签时torch.long是标配。转常量的一个简单口诀就是要小数用.float()要索引用.long()。6.4 实战经验从一次图片归一化事故说起有一次做图像分类我把numpy读进来的图片像素从0到255不做处理直接转Tensor结果训练出来的acc一直上不去。一开始还以为是网络结构问题后来才发现是自己没做归一化——像素值范围太大梯度经过几层后直接爆掉。正确做法应该是类似这样from PIL import Image import torchvision.transforms as transforms transform transforms.Compose([ transforms.ToTensor(), # 自动把HWC转CHW且像素缩放到[0,1] transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ])这里ToTensor之后图片像素会被自动缩放到0到1之间再按ImageNet的统计值做标准化得到均值约为0、方差约为1的输入。这个细节直接决定模型能不能稳定收敛。所以每当你发现训练的模型loss一直都在一个高位震荡而代码逻辑看起来完全没问题第一步先检查输入数据的量级。我见过太多“模型不收敛”的怪问题最后都只是输入张量数值范围爆了。最后我的一点实际体会PyTorch的Tensor看起来简单却是整个框架运行逻辑的核心枢纽。哪天你随手写代码时发现报错不用慌按部就班打印shape、确认device、查看dtype大部分问题都可以在五分钟内定位。我自己早期踩过最深的一个坑是误以为tensor()和clone()一样结果在数据增强循环里改了原始数据却不自知整个训练集被逐渐污染acc忽高忽低。后来养成了一个习惯凡是需要继承原Tensor数值但又不希望改动影响原对象的场景统一写.clone()而不是依赖Python基础的引用传递逻辑。还有一个小技巧愿意分享调试Tensor操作的时候尽量把中间结果打印成.tolist()或者print(x[0, :5])只看前几个元素而不是直接打印完整大张量。数据量一大终端刷屏刷到怀疑人生而且影响判断。希望这篇关于PyTorch Tensor的梳理能让你少走几步弯路。Tensor理解到位之后模型构建、数据处理、调试报错都只是顺理成章的事。
阅读完成 · 觉得有帮助?
咨询建站