先从一个小问题开始:你在学机器学习或者深度学习的时候,是不是总觉得“数据”这个词太抽象?读论文的时候看到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_grad=True,框架会自动记录你做的每一步运算,反向传播到终点时直接给你梯度答案。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在后续跟浮点张量运算时容易报类型不匹配的错,所以我个人建议在创建时就明确指定dtype:
a = torch.tensor([1, 2, 3], dtype=torch.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的Tensor;torch.ones_like(x)同理。做模型的mask操作、避免显存重复分配时非常常用。
2.3 指定设备和数据类型,是性能优化第一课
上面的创建方式默认都跑在CPU上。如果环境里有NVIDIA显卡,可以这样创建:
gpu_tensor = torch.randn(3, 4, device="cuda")或者先定义一个小工具统一管理设备:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") t = torch.randn(3, 4, device=device)这个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 tensor's 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(dim=1),结果是(3,),维度减少了。这个行为在很多场景下符合直觉,但在做矩阵运算对齐时却常常出问题。
t = torch.randn(3, 4) print(t.sum(dim=1).shape) # torch.Size([3]) print(t.sum(dim=1, keepdim=True).shape) # torch.Size([3, 1])keepdim=True保留了长度为1的那个维度,好处在于后续可以借助广播机制直接进行行运算,完全不需要手动加维度对齐。博主实操经验是:在正式写代码时,聚合操作一律加上keepdim=True,除非你在那一刻真的明确想缩小维度。这个习惯能省下大量排查维度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(dim=0) # 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原地加1,z也指向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_grad=True) y = x ** 2 # y也是Tensor,requires_grad=True z = y.sum() z.backward() print(x.grad) # tensor([4., 6.]),就是dz/dxx.grad里存的就是梯度,z.backward()触发反向传播算出梯度。x.requires_grad=True简单告诉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_grad=True) 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包含tensor,requires_grad=True print(layer.weight.shape) # (3, 4)所以模型训练的整个链路其实是:输入数据(Tensor)→ 模型内部计算(Tensor运算)→ 损失(Scalar Tensor)→ 反向传播(计算梯度)→ 优化器更新(修改Parameter的.data)。理解了Tensor,等于拿到了进阶模型结构的上一半门票。
有两点需要额外注意:
- 只有
requires_grad=True的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报错信息已经把维度标出来了,但真实场景常常发生在叠了很多层网络之后。我的排查顺序是:
- 打印出操作数的shape:
print(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解决方案很粗暴:在数据进入模型之前显式统一dtype:
t1 = t1.float()至于整数张量,用于索引时是torch.long(torch.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理解到位之后,模型构建、数据处理、调试报错都只是顺理成章的事。