
深度学习入门绕不过PyTorchPyTorch入门绕不过张量。这句话我带过好几个新人之后体会特别深——很多人一上来就冲模型架构、冲训练代码结果一跑就报错报错要么是dimension mismatch要么是device mismatch归根结底都是张量基础没打牢。更麻烦的是这些问题排查起来特别费时间因为报错信息经常出现在几百行代码之外单纯看错误提示很难定位到根因。所以把张量操作这一块认真搞懂收益非常大。这篇内容从张量的基本概念讲起覆盖创建、索引、形状变换、数学运算、广播机制到归约操作最后用一个能直接跑通的线性回归例子把知识点串起来。不管你是刚装好PyTorch的新手还是已经在用框架但总被各种shape问题折磨的同学这篇应该都能帮上忙。环境怎么装这里不多说直接去PyTorch官网首页选好系统、包管理器和CUDA版本复制生成的那条命令装就行。1. 张量到底是什么先搞懂PyTorch的“数据原子”1.1 从标量到张量一张图看懂维度关系张量Tensor这个名字听起来高深其实理解起来没那么复杂。从维度上看它是一个递进关系0维张量是标量就一个数1维张量是向量一排数2维张量是矩阵一个表格3维及以上没有统一的数学叫法就都叫张量。拿图像来举例会更直观。一张灰度图就是一个二维矩阵nei存的是每个像素的亮度值一张彩色图会变成三个二维矩阵叠在一起分别对应R、G、B三个通道这就是一个三维张量shape是(C, H, W)如果把一批彩色图叠起来就成了四维张量shape是(N, C, H, W)。N是batch sizeC是通道数H是高度W是宽度。PyTorch里处理图像数据的默认就是这种NCHW顺序跟某些框架不一样这个千万别记反。NLP的场景也类似一段文本经过分词和编码之后通常是(batch, seq_len, hidden_dim)的格式batch是一批句子seq_len是句子的长度hidden_dim是每个词的向量表示维度。你看张量本身不神秘真正需要花心思的是搞清楚每个维度代表什么含义这才是后面所有操作不出错的前提。1.2 三个必懂属性shape、dtype、device任何一个张量你想用好它至少要同时关注三样东西shape、dtype、device。如果说张量是装数据的箱子shape就是这个箱子的长宽高。它告诉你数据在每一个维度上有多少个元素比如torch.Size([2, 3])表示一个2行3列的矩阵。所有张量操作报错一半以上都跟shape对不上有关。dtype是数据类型。PyTorch里的常见类型有torch.float32对应模型默认的权重类型、torch.float64、torch.int64一般叫LongTensor做索引时经常用到、torch.bool布尔类型。新手最容易忽略的一点是PyTorch不会像Python那样自动帮你做类型转换一个float32的张量和一个float64的张量做运算虽然有时会自动提升但在涉及GPU和模型参数时很容易出问题。我个人的建议是训练模型时统一用float32不要轻易改成float64因为GPU上float64的计算效率比float32低很多显存占用也直接翻倍。device是张量所在的设备CPU还是GPUCUDA。GPU上的计算和显存是独立的CPU上的张量不能直接和GPU上的张量做运算必须先把它们放到同一个设备上。这个报错频率极高RuntimeError: Expected all tensors to be on the same device。解决办法也简单用张量的.to(device)方法比如x x.to(cuda:0)。1.3 张量和NumPy数组的关键区别如果你有过用NumPy的经验会发现张量的很多操作跟数组很像。但两者有几个关键区别直接影响后续的使用习惯。第一张量可以放到GPU上运算。这是PyTorch能真正跑起来深度学习的最底层支撑NumPy做不到这一点。第二张量可以记录梯度。这是神经网络训练的基础——你在张量上设置requires_gradTrue之后PyTorch会自动记录所有和它相关的计算形成一个计算图之后调用.backward()就能自动算出梯度。这也是torch.Tensor被称为带自动求导的数组的原因。第三内存布局和操作语义上有差异比如某些索引操作在NumPy里返回视图在PyTorch里却返回拷贝这个后面专门讲。2. 创建张量比你想的要讲究2.1 按值创建torch.tensor、torch.Tensor和as_tensor的区别最直接的创建方式是torch.tensor()传入一个Python列表或NumPy数组import torch a torch.tensor([1, 2, 3]) b torch.tensor([[1.0, 2.0], [3.0, 4.0]], dtypetorch.float32) print(a.dtype) # torch.int64整数默认是int64 print(b.shape) # torch.Size([2, 2])这里有个坑torch.tensor()和torch.Tensor()只差一个下划线用法却有本质区别。torch.tensor()是从数据创建张量而torch.Tensor()本质上就是torch.FloatTensor它是PyTorch预定义的类。如果你执行torch.Tensor(2, 3)它创建的是一个2行3列的未初始化张量里面的值是内存里的随机垃圾数据。我见过有人拿它当初始化工具结果跑出来的模型一会一个样还找不到原因。还有一个函数是torch.as_tensor()。它和torch.tensor()最大的区别是as_tensor在数据已经是张量时不会复制内存在数据是NumPy数组时只要类型匹配也会尽可能共享内存。torch.from_numpy()也是同样的道理后面会专门讲这个共享内存的特性。2.2 按规律创建zeros、ones、arange、linspace很多场景下你不需要手动写数据直接用现成的函数生成z torch.zeros(2, 3) # 全0矩阵初始化bias常用 o torch.ones(2, 3) # 全1矩阵 e torch.eye(3) # 3x3单位矩阵做one-hot时常用 full torch.full((2, 3), 7) # 全部填充为7 r torch.arange(0, 10, 2) # 步长为2的序列 [0, 2, 4, 6, 8] l torch.linspace(0, 1, 5) # 0到1之间等间隔取5个数 [0, 0.25, 0.5, 0.75, 1]arange和linspace的区别要分清arange是给定起始值、结束值和步长linspace是给定起始值、结束值和个数。前者适合整数序列后者适合需要精确控制数量的浮点序列。比如在绘制曲线时生成一系列x坐标我通常用linspace因为要的是等间隔分成N份而不是从0加到100步长0.1这种可能因为浮点误差导致末尾差一点的情况。2.3 随机张量randn、rand、normal怎么选构建模型初始参数、生成模拟数据、做随机采样都离不开随机张量。新手很容易把torch.randn和torch.rand搞混。torch.randn生成的是标准正态分布也就是均值为0、方差为1的随机数取值范围理论上没有上限大部分值落在[-3, 3]之间。torch.rand生成的是[0, 1)之间的均匀分布随机数。torch.randint生成指定范围内的整数。torch.normal可以指定均值向量和标准差向量生成更复杂的正态分布数据。torch.manual_seed(42) # 固定随机种子保证实验结果可复现 rn torch.randn(2, 3) # 标准正态 rd torch.rand(2, 3) # [0,1)均匀分布 ri torch.randint(0, 10, (2, 3)) # [0,10)整数 rn_custom torch.normal(mean0.0, std1.0, size(2, 3))这里强烈建议所有用到随机数的实验代码开头都加上torch.manual_seed(42)不然每次运行结果都不一样遇到问题很难复现。做深度学习实验可复现性是从第一天就该养成的习惯。2.4 和NumPy互转from_numpy的“共享内存”陷阱PyTorch和NumPy的互转非常频繁很多数据预处理流程是用NumPy完成的然后转成张量喂给模型。import numpy as np arr np.array([1, 2, 3]) t torch.from_numpy(arr) # 共享内存 t[0] 100 print(arr) # [100 2 3]arr也被改了torch.from_numpy和torch.as_tensor在类型匹配的情况下会共享内存也就是说改动张量会连带改动原来的NumPy数组反之亦然。这在某些场景下是好事比如数据预处理时你想省一次拷贝但如果你没意识到这一点就可能在调试时莫名其妙发现我的原始数据怎么变了。PyTorch官方提供的方法是tolist()和numpy()用于转回Python或NumPy格式t torch.tensor([1.0, 2.0]) lst t.tolist() # [1.0, 2.0] arr t.numpy() # 和t共享内存但只支持CPU上的float等类型3. 张量操作核心细节这些函数不懂等于白学3.1 形状操作view、reshape、permute、transpose怎么选形状变换是张量操作里最常用、也最容易出问题的一类。view是PyTorch里最基础的形状变换它的工作方式是把张量在内存中的元素按顺序重新划分成新形状。注意按顺序三个字这意味着它只适用于内存连续的张量。如果张量经过了转置、permute等操作内存布局发生变化直接view会报错RuntimeError: view size is not compatible with input tensors size and stride。reshape跟view很像但它的处理方式更聪明如果内存连续就直接复用底层数据如果不连续就复制一份到连续内存再进行view。所以严格来说reshape更保险但偶尔会多一次内存拷贝。如果你特别在意性能先用view报错再考虑contiguous()和view的组合。permute和transpose用来交换维度transpose一次只交换两个维度permute可以按任意顺序重新排列所有维度。x torch.randn(2, 3, 4) y x.view(2, -1) # (2, 12)-1表示自动推断 z x.permute(2, 0, 1) # (4, 2, 3)把所有维度重新排列 t x.transpose(0, 1) # (3, 2, 4)只交换第0和第1维还有一个常用操作是squeeze和unsqueeze专门处理大小为1的维度。squeeze去掉大小为1的维度unsqueeze增加一个大小为1的维度。它们的价值在于很多时候某个运算要求张量必须是3维而你的数据是2维就需要用unsqueeze(0)在前面加一个维度把二维数据变成batch size为1的三维数据。同理在特征图上加通道维度也会用到unsqueeze。需要特别注意的是squeeze()不传参数时会把所有大小为1的维度全干掉这有时不是你想要的结果所以最好显式指定维度比如x.squeeze(1)。3.2 索引与切片三种玩法一次讲清PyTorch的索引和切片整体上沿用了NumPy的语法但有一个关键区别必须牢记基础切片冒号构成的区间切片返回的是视图而高级索引整数数组、布尔掩码返回的是拷贝。这意味着你改切片得到的结果原张量也会跟着变改布尔索引得到的结果原张量不会变。x torch.arange(12).reshape(3, 4) # tensor([[ 0, 1, 2, 3], # [ 4, 5, 6, 7], # [ 8, 9, 10, 11]]) row0 x[0] # 第0行 col1 x[:, 1] # 第1列 sub x[1:3, 1:3] # 第1到2行、第1到2列的子块 mask x 5 # 布尔掩码 print(x[mask]) # 一维张量只包含大于5的元素 result torch.where(x 5, x, torch.zeros_like(x)) # 满足条件保留原值否则替换为0布尔掩码在数据筛选、loss计算时非常常用。比如在分类任务里我只想计算特定类别的loss就可以用掩码把其他样本过滤掉再计算。torch.where相当于一个向量化的三元表达式torch.where(condition, x, y)condition为True的位置取x的值否则取y的值三个参数都是张量而且x和y的shape要能跟condition广播。取单个元素要注意一点如果张量是0维的x.item()能拿到Python标量如果是1维但只有一个元素x.item()也能用。在打印loss、acc这些指标时一定要加.item()否则打印出来的是tensor(0.1234)而且这里的张量还会跟计算图挂钩稍微不注意就容易导致显存不释放。3.3 拼接与拆分cat和stack别搞混torch.cat和torch.stack是两个经常被新手弄混的函数因为中文翻译都叫拼接。torch.cat是在某一个已有维度上把张量接起来。两个shape为(2, 3)的张量沿着第0维拼接结果是(4, 3)沿着第1维拼接结果是(2, 6)。它要求除了拼接的维度以外其他维度的尺寸必须完全一致。torch.stack则是把一组张量叠加成一个新的维度。两个shape为(2, 3)的张量stack之后结果是(2, 2, 3)不管你指定的dim是什么最终都会引入一个新的维度隐式的意思就是在这里把几个张量组成一个batch。a torch.randn(2, 3) b torch.randn(2, 3) c torch.cat([a, b], dim0) # (4, 3) d torch.stack([a, b], dim0) # (2, 2, 3)实践中cat用于特征图在通道维度上的合并或者把多个数据源的输出拼接成一个大张量stack常用于把多个batch拼成一个更大的batch比如数据加载器每次从不同文件读取一批数据最后把它们stack起来。拆分操作对应的是torch.split和torch.chunk。split可以指定每个块的大小chunk是平均分成N块。用得相对少一些但在做模型并行、多头注意力时也能见到。3.4 数学运算逐元素、矩阵乘法、inplace操作张量层面的数学运算分两类一类是逐元素运算一类是矩阵运算。逐元素运算就是两个shape相同的张量对应元素做加减乘除也可以用标量直接运算。乘方、开方、指数、对数、三角运算也都属于这一类x torch.randn(3, 3) y torch.randn(3, 3) add x y # 对应元素相加 mul x * y # 对应元素相乘注意这不是矩阵乘法 pow2 x ** 2 exp_x torch.exp(x) sin_x torch.sin(x)矩阵乘法要使用符号或者torch.matmul。它执行的是真正的矩阵乘法对维度要求是(x1, x2)和(x2, x3)最终结果是(x1, x3)。在深度学习里全连接层的前向计算就是输入张量乘以权重矩阵x torch.randn(5, 10) # 5个样本每个10维特征 w torch.randn(10, 2) # 权重矩阵从10维映射到2维 out x w # (5, 2)还有一类容易忽略的操作是inplace操作就是在原张量上直接修改不需要返回新张量。它们在PyTorch里通常带下划线后缀比如x.add_(1)等价于x x 1x.zero_()把张量清零x.unsqueeze_(0)在原位加维度。inplace操作的好处是省内存在处理超大张量时能明显减少内存峰值。但坏处也很明显在进行自动求导的张量上做inplace操作PyTorch会禁止或者在计算梯度时出错。我在实际代码里参数更新阶段经常用inplace而中间计算结果一律不用安全第一。3.5 broadcasting广播机制PyTorch中最容易踩坑的设计广播机制broadcasting是NumPy和PyTorch都有的特性它的作用是让两个shape不完全一致、但在某些维度上兼容的张量能够直接运算。可以说没有广播机制你写batch运算会痛苦十倍。广播的规则只有一句话从右往左逐个维度对齐每个维度要么大小相等要么其中一方为1要么这个维度不存在。对齐时某些维度不存在就相当于补了一个大小为1的维度然后把这个维度扩展到另一方的维度大小。举几个例子a torch.randn(3, 1) b torch.randn(1, 4) c a b # (3, 1) 和 (1, 4) 广播成 (3, 4) scale torch.randn(1) x torch.randn(2, 3) y x * scale # 最后维度1扩展成3结果还是(2, 3)相当于对每一行做相同的缩放 bias torch.randn(3) z x bias # (2, 3) 和 (3,)从右对齐33不足的维度补1得到(2,3)第三个例子在深度学习里特别常见一个(batch, feature_dim)的线性层输出加上一个(feature_dim,)的bias向量直接相加就能给每个样本的每个特征都加上对应的偏置而不用显式地把bias写成一个(batch, feature_dim)的大矩阵。广播机制虽然方便但也是静默出错的高发区。因为有些张量可以广播结果并没有报错但你拿到的shape可能不是你以为的那个。比如两个(3, 4)和(4, 3)的张量相加最后维度是4对3不相等也不存在一方为1才会报错。如果碰巧两个维度中有一个是1广播就会生效但你的数据语义可能就完全不对了。所以每次做运算之前最好先在大脑里过一遍我的shape要变成什么再用调试信息验证。3.6 归约操作sum、mean、max这些函数的dim参数归约操作是把一个张量的某一维折叠成一个值最典型的有sum、mean、max、min、argmax、norm。归约操作必须搞清楚dim参数的含义。dim指的是你要归约掉的那个维度。举个例子一个(3, 4)的矩阵x.mean(dim0)是沿着第0维取平均也就是每一列的所有行取平均结果是(4,)x.mean(dim1)是沿着第1维取平均也就是每一行的所有列取平均结果是(3,)。简单记dim0就是消灭第0维输出的shape从(3,4)变成(4,)。还有一个很实用但容易被忽略的参数是keepdim。如果不设置归约后维度会直接消失如果设置keepdimTrue结果会保留大小为1的那个维度。x torch.randn(3, 4) s0 x.sum(dim0) # (4,) s1 x.sum(dim0, keepdimTrue) # (1, 4) max_vals, max_idx x.max(dim1) # 返回最大值和对应下标 sorted_vals, sorted_idx x.topk(2, dim1) # 每行取前2大的值keepdimTrue在构建可广播的归一化操作时非常实用。比如做标准化先算均值再除以标准差如果不keepdim均值是一个(4,)的一维张量直接除以(3,4)的数据广播规则是够用的但有些更复杂的情况比如在三维特征图上做通道维归一化维度对不齐就容易出问题。我的习惯是拿不准时宁可加上keepdimTrue最多多一个大小为1的维度广播运算时完全不受影响。4. 实操现场用张量操作跑通一个线性回归讲了一堆操作是时候把它们串起来用一次。线性回归是最好的练手例子麻雀虽小五脏俱全前向传播、损失计算、反向传播、参数更新全都涉及而且代码量很小适合完整跑一遍。4.1 造数据综合运用linspace、unsqueeze和randn我们先生成一组带噪声的线性数据假设真实关系是y 2x 1再加一点高斯噪声。import torch torch.manual_seed(42) w_true torch.tensor(2.0) b_true torch.tensor(1.0) x torch.linspace(-1, 1, 200).unsqueeze(1) # (200, 1) y w_true * x b_true 0.05 * torch.randn_like(x) # (200, 1)这里用到了几个操作linspace生成从-1到1均匀分布的200个x值unsqueeze(1)把(200,)变成(200, 1)因为后面要做矩阵乘法randn_like生成和x相同shape的噪声乘0.05控制噪声幅度。4.2 前向计算和损失体会矩阵乘法和广播接下来初始化参数计算预测值。参数w和b需要设置requires_gradTrue这样PyTorch才会自动记录梯度。w torch.randn(1, requires_gradTrue) b torch.zeros(1, requires_gradTrue) y_pred x w b # x是(200,1)w是(1,)结果为(200,1)然后加b广播 loss ((y_pred - y) ** 2).mean() # MSE这里发生的操作其实很多x w是矩阵乘法(200,1)乘以(1,)实际上变成(200,1)加b时b是(1,)广播到(200,1)计算loss时差值的平方然后取均值。4.3 反向传播和参数更新小心处理no_grad和梯度清零反向传播只需一行loss.backward()调用之后w.grad和b.grad会被自动填充为loss对它们的梯度值。参数更新时需要手动做注意必须放在torch.no_grad()环境里因为参数更新这个操作本身不应该被记录进计算图否则下一步计算时计算图里又多了节点内存和逻辑都会乱。learning_rate 0.1 for epoch in range(200): y_pred x w b loss ((y_pred - y) ** 2).mean() loss.backward() with torch.no_grad(): w - learning_rate * w.grad b - learning_rate * b.grad w.grad.zero_() b.grad.zero_() if epoch % 40 0: print(fepoch {epoch}, loss {loss.item():.4f}) print(fw {w.item():.4f}, b {b.item():.4f})这里还有两个关键点。第一为什么每次更新完要调用grad.zero_()因为PyTorch的梯度是累积的backward()会把梯度加到已有的grad上如果不手动清零下一轮的梯度会把上一轮的梯度也加进去导致loss不下降甚至越训越差。deep learning框架这么设计是有原因的在一些复杂的循环网络场景里累积梯度反而有用但在普通训练循环里不清零是经典的新手错误。第二为什么用loss.item()打印如果直接print(loss)打印出来的是tensor(0.1234)而且这里的tensor还留在计算图里循环几百次不释放显存会被占满。运行这段代码最终的w和b会非常接近2和1。loss从最初的十几逐渐降到0.002左右整个训练过程就是张量运算在驱动。5. 新手最容易踩的5个坑5.1 device不匹配报错报错信息是RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!。原因就是两个张量一个在GPU一个在CPU没法直接运算。解决办法统一用to(device)最好在代码开头把device定义为一个变量device torch.device(cuda if torch.cuda.is_available() else cpu) x x.to(device)这里有个经验模型参数和输入数据都要放到同一个设备上。模型用model.to(device)输入数据用batch batch.to(device)。如果数据从DataLoader拿到也是一个道理千万别只放一个忘了另一个。5.2 view在非连续内存上报错转置之后直接view是最常见的触发器。转置会改变张量的内存步长导致内存不连续这时view就报错。可以用transpose后的tensor先调用contiguous()它会重新申请一块连续内存并拷贝数据然后再view。也可以直接改用reshape它会自动处理连续性问题但要注意它可能产生额外拷贝。对性能敏感的场景尽量用contiguous().view()组合并把这一步尽量前置避免在热循环里反复拷贝。5.3 广播维度不符合预期报错一般是RuntimeError: The size of tensor a (3) must match the size of tensor b (4) at non-singleton dimension 3。这种问题排查起来反而简单因为报错会明确告诉你维度不匹配。难点在于没有报错但是结果不对的广播。举个例子你要把(4,3)的数据每行减一个(4,)的向量结果你写成了x - v如果x是(4,3)而v是(4,)右边不够长补1变成(1,4)然后3和4不相等也不存在1会报错但如果你运气差v刚好是(3,)它会被广播成(4,3)代码不报错但语义全错了——你是想每行减一个4维的向量结果减成了一个3维的向量数据全乱了。所以广播不出问题的最好方法就是运算前print每个中间量的shape人眼确认一遍。5.4 在需要梯度的张量上做inplace操作报错信息是RuntimeError: a leaf Variable that requires grad is being used in an in-place operation。原因前面说过PyTorch需要保存你修改前的值来计算梯度inplace操作把它们覆盖了梯度没法算。这个坑在写训练循环时特别容易踩因为代码里到处都是、-、zero_()。我的判断标准是如果是模型参数自己在更新用inplace没问题如果是参与前向传播的中间张量一律不inplace。这个习惯能帮你避开绝大多数这类报错。5.5 NumPy和PyTorch共享内存导致“灵异事件”前面讲过from_numpy和as_tensor共享内存最容易踩的场景是用NumPy对数据做预处理然后from_numpy转成张量再往GPU上搬运过程中某个环节把原NumPy数组改了结果模型每次迭代读到的输入都在变loss曲线诡异无比。解决办法是用torch.tensor(arr)显式拷贝或者干脆用torch.as_tensor(arr.copy())明确告诉PyTorch我不想共享内存。如果数据量不大我建议一律用torch.tensor()省心最重要。最后再分享一个我实际带人时的小习惯遇到任何奇怪的shape报错先在报错的前一步print出所有涉及的张量的shape一行一行盯着看。大多数玄学问题最后都能发现是某个维度对不上或者哪里少了一个unsqueeze。张量操作说到底就是哪个维度、什么含义、往哪里拼这三件事把这个想清楚了PyTorch的墙基本就翻过去一大半了。