ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深度学习Tensor完全指南:从张量基础到自动求导实战

深度学习Tensor完全指南:从张量基础到自动求导实战 1. 从“张量”开始认识深度学习的骨架我第一次翻开《动手学深度学习》的时候心里其实有点发怵。说实话早在接触这本书之前我已经在各种博客、课程里零零散散地见过“tensor”这个词无数次了但每次看到它脑子里都只有一个模糊的印象——好像是个多维数组好像跟矩阵差不多真正开始动手写代码、跑模型之后我才发现tensor远不是“多维数组”四个字能概括的它是整个深度学习框架的“通货”是数据在神经网络里流转时唯一携带的格式。如果你跟我一样是从机器学习那边转过来的可能已经习惯了numpy的ndarray——一个功能强大、生态成熟的数组对象。但一进入深度学习的代码你很快就会遇到一个尴尬的问题numpy的数组没法直接用GPU加速。而tensor正是为深度学习量身定制的数据载体它不仅能在CPU上工作还能无缝地流动到GPU、TPU这些加速设备上承担起矩阵乘法、自动求导、反向传播等一系列神经网络赖以生存的核心运算。这篇文章我想把关于tensor的学习心得做一个深入的整理。不是简单地罗列API文档而是从“为什么要设计成这样”“使用的时候有哪些坑”两个角度把《动手学深度学习》里关于tensor的部分掰开揉碎梳理成一套完整的笔记。适合正在入门深度学习的读者也适合已经跑通了一些模型、但觉得自己对底层数据流理解还不够透彻的朋友。2. 为什么要学tensor先搞懂它和普通数组的本质区别2.1 tensor不只是多维数组而是“会自动求导”的数组先做个类比。numpy的ndarray就像一把普通螺丝刀功能很明确你让它存什么它就存什么你让它算矩阵乘法它就老老实实算一个结果出来算完就完事它不关心你是拿这个结果去做什么的。而tensor更像是带了一把“刻度尺”的智能扳手——除了完成存储和计算之外它还在全程记录“数据是怎么来的”。这个区别是理解深度学习的关键。神经网络训练的核心步骤是反向传播而反向传播需要计算损失函数对每一个参数的梯度。如果用numpy手动实现你得自己按照链式法则一步步写出梯度公式然后手动更新每个参数。这个过程不仅繁琐、容易出错而且一改网络结构就要重写梯度计算逻辑。tensor的背后是自动求导autograd机制。每一个tensor都知道自己是从哪个操作计算出来的当你调用backward()方法时框架会根据这些记录自动完成梯度计算。你只需要把网络前向传播的代码写好剩下的事情框架全都包了。这也是深度学习框架为什么一定要有tensor而不是直接用numpy的原因。从《动手学深度学习》全书的角度看tensor是第一章就要掌握的地基。后面的模型实现、数据加载、训练循环所有代码几乎都是tensor在跑如果你对tensor的特性理解不透彻后面每一步都容易踩坑。2.2 tensor的数学本质标量、向量、矩阵和更高维度的统一我们回过头来从数学角度重新认识一下tensor。如果你翻过《动手学深度学习》的数学基础部分会看到这么一句话标量是0阶张量向量是1阶张量矩阵是2阶张量。再往上推多维数组就是更高阶的张量。刚开始看到这句话我还有点不理解——tensor不就是数组嘛为什么非要换一个名字后来才明白这里的“阶数”在深度学习里是有实际意义的一个标量0阶tensor表示一个单独的数值比如学习率、损失值。一个向量1阶tensor表示一组数值比如一个样本的特征向量。一个矩阵2阶tensor表示一组向量比如批量样本的特征矩阵形状是批量大小特征数量。三维tensor3阶在自然语言处理中非常常见比如一个批次里每个词都用向量表示一批文本就变成了批次大小序列长度词向量维度。四维tensor4阶则是计算机视觉中的常客比如一批图片的形状是批次大小通道数高宽。这个维度的概念贯穿了整个深度学习的代码实践。你用PyTorch写一个卷积神经网络看到的数据流动始终是四维tensor你写一个Transformer模型中间计算的注意力矩阵本质上其实是三维和二维tensor之间的相互变换。清楚了这一点之后你再去看框架的API文档、看别人的网络实现代码就不会再被那些复杂的reshape、transpose搞懵。因为你心里有谱了无论tensor的维度怎么变它的底层逻辑都是“数据组织成什么样的结构计算就以什么样的方式批量进行”。2.3 tensor和numpy的协作深度学习中的“双轨制”聊到这里你可能会问那我在深度学习里是不是就完全用不到numpy了其实不是。在实际工程中tensor和numpy往往是一种协作关系。数据预处理阶段你会用pandas、numpy做清洗、归一化、筛选进入模型之前把numpy数组转换为tensor模型输出的结果可能又要转成numpy再做可视化、分析、上报。这种“双轨制”在使用中有一个常见的坑tensor和numpy共享内存。如果你用的是PyTorch当你调用tensor.numpy()的时候返回的numpy数组和原tensor其实是共用同一块内存的修改其中一个另一个也会跟着变。这不是bug而是设计者为了效率故意为之的。但如果你没注意到这件事在调试代码时很可能莫名其妙地发现数据变了排查半天找不到原因。反过来也一样从numpy数组创建tensor时使用torch.from_numpy()创建的tensor也和原numpy数组共享内存。如果你希望两者互不影响就得用clone()方法或者torch.tensor()来显式复制一份数据。这个细节我放到后面的问题排查部分再详细讲但在这里先埋个伏笔提醒大家注意。3. tensor的创建与核心属性动手操作才是硬道理3.1 创建tensor的几种常用方式从《动手学深度学习》的代码实操来看tensor的创建方式至少要学会以下几种它们在实战中各有各的用途。从现有数据直接创建这是最直观的方式。你可以用一个Python列表或者numpy数组直接构建tensor。在PyTorch中写法是torch.tensor([1, 2, 3])注意这里是小写的tensor函数它会自动推断数据类型。同样是创建tensor还有一个torch.Tensor()构造函数但那个方式在类型推断上会比较“任性”——它会默认使用torch.float32而不是根据你传入的数据自动推断。我在初学阶段经常搞混这两个导致数据精度跟预期不一致后来才养成习惯日常都用torch.tensor()明确指定dtype参数。生成特定值的tensor。有时候你需要初始化一个全零或者全一的tensor来存放某种状态这时候用torch.zeros()和torch.ones()传入一个形状参数比如torch.zeros((3, 4))。如果你想生成一个单位矩阵可以用torch.eye()。还有一个比较常用的torch.full()可以把整个tensor填成同一个值在初始化bias这类参数时会用到。生成随机tensor。神经网络的参数初始化离不开随机数。最常用的是torch.randn()生成服从标准正态分布的随机数以及torch.rand()生成0到1之间的均匀分布随机数。为什么初始化这么重要因为如果所有参数初始值都一样在反向传播时这些参数就会得到相同的梯度更新之后依然一样神经网络就失去了“不同神经元学习不同特征”的能力等于白训练。这是深度学习中一个非常基础但又很容易被忽视的原理。从已有tensor创建新的tensor。如果你想把某个tensor的形状改了但数据不变可以用reshape或者view这俩后面会专门讲。如果你想对原tensor做某种变换得到一个新的tensor可以用torch.zeros_like()、torch.ones_like()这类带_like后缀的函数它们会仿照传入tensor的形状生成新tensor。用起来非常省事不用自己再手动写形状。3.2 tensor的三个核心属性形状、数据类型、设备我把这三个属性称为tensor的“身份证信息”每次调试代码第一件事就是确认这三个值。**形状shape**对应的是tensor每个维度的大小。比如一个形状为2, 3的tensor表示有2行、3列。形状的重要性在于深度学习中几乎所有操作都对形状敏感矩阵乘法要求前一个矩阵的列数等于后一个矩阵的行数卷积操作要求输入有通道维度两个tensor做逐元素运算要求形状匹配或者说满足广播机制。可以说深度学习调试的大部分时间都花在“形状不对”的报错上。数据类型dtype决定了tensor里每个元素的存储精度。常见的有torch.float3232位浮点数、torch.float6464位也叫double、torch.int6464位整数在深度学习中常用于标签数据。默认情况下如果没有特殊指定torch.tensor()会自动选择一个合适的数据类型但如果你从外部读入的数据涉及到精度转换就很容易出现“类型不匹配”的错误。设备device表示tensor存储在哪里——CPU还是GPU。在深度学习训练中GPU的内存带宽和并行计算能力远超CPU因此深度学习的核心计算都会放在GPU上。但一个非常常见的错误是你在CPU上创建了一个tensor然后想把它送到GPU上计算的模型里结果报错说“device mismatch”。这时候需要用.to(device)方法显式地转换设备其中device通常定义为torch.device(cuda:0)或torch.device(cpu)。我在刚开始写代码时几乎每跑一个模型都要跟这三个属性搏斗一番。建议新手在刚接触tensor时养成拿到数据先打印形状、数据类型、设备的习惯会省去后面大量的排查时间。3.3 给tensor“减肥”彻底搞懂reshape、view和transpose在tensor的实操中调整形状是最常见的操作之一。但很多人对于reshape、view、transpose三者的区别其实是模糊的往往都是报错了就换个函数试试。先说说view和reshape的区别。view的本质是“不改变数据存储顺序的情况下给tensor换一个观察视角”。它要求tensor在内存中的存储必须是连续的。什么叫连续呢简单理解就是tensor在内存里是按顺序排列的没有跳着存。如果你对tensor做了转置、切片这类的操作存储可能变得不连续这时候view会直接报错。reshape比view更“宽容”。如果你对不连续的tensor调用reshape它会自动创建一个数据副本并重新排列存储顺序。换句话说view是“零拷贝”的速度快reshape则可能暗地里复制数据多占一份内存。再来看transpose。reshape和view只是改变“怎么看数据”不改变数据的排列顺序。而transpose在PyTorch里是tensor.t()表示二维转置或tensor.transpose(dim0, dim1)交换两个维度是真正地改变数据的排列方式。转置之后数据的存储顺序就变了原来的连续存储被打破了。所以转置之后的tensor不能再直接调view必须先调用contiguous()方法让内存变得连续然后再view。实战中最常见的场景是把形状为批量大小通道数高宽的图像tensor转换成批量大小高宽通道数的格式方便可视化。这种情况下transpose和contiguous往往要配套使用。一句话总结只是想换个视角看数据用view不在乎是否复制数据、只希望形状正确用reshape要真正交换维度顺序用transpose如果需要接view先contiguous()。4. tensor的运算从逐元素操作到广播机制4.1 tensor的算术运算和“原地操作”陷阱tensor的算术运算看起来很简单比如、-、*、/这些在Python中看起来眼熟的运算符在tensor的世界里都有了新的含义它们都是“逐元素操作”也就是说对两个tensor中对应位置的元素分别做运算得到一个新的tensor。举个例子如果a和b都是形状为2, 2的tensor那么a * b并不是矩阵乘法而是把两个矩阵的对应位置元素相乘结果依然是2, 2。如果你想要真正的矩阵乘法得用torch.matmul(a, b)或者a b。这个区分是新手最容易犯错的地方——在数学课上学的矩阵乘法和深度学习代码里的写法完全不是一回事。除了普通的运算PyTorch里还有一个特殊的概念叫“原地操作”。比如a.add_(1)这种带下划线的方法表示直接在原tensor上修改而不产生新tensor。如果写成a.add(1)则会返回一个新的tensor原tensor不变。为什么要小心原地操作因为前面说过tensor带着自动求导的记录。如果你对某个需要计算梯度的tensor执行了原地操作就相当于篡改了它的历史记录导致自动求导时梯度计算不稳定甚至计算出错。PyTorch官方文档也多次警告对于requires_gradTrue的叶子tensor千万不要做原地操作。这是我踩过的很深的一个坑后面会单独分享。4.2 矩阵乘法和点积深度学习中最重要的运算如果要说深度学习中哪个运算出现频率最高矩阵乘法当之无愧。为什么我们稍微拆解一下一个神经网络层做的事情本质上就是输入向量或矩阵与权重矩阵相乘再加上偏置然后经过一个非线性激活函数。这个过程在代码里经常写成一个整体——线性层Linear Layer但其核心就是矩阵乘法。在《动手学深度学习》的tensor章节里矩阵乘法是一个重点。你需要至少掌握三种写法a.bmm()或torch.bmm()批量矩阵乘法用于形状为批量大小mn和批量大小np的tensor对每一批分别做矩阵乘法。torch.matmul() / 最通用的矩阵乘法支持任意维度的张量会根据输入的维度自动调整计算方式。torch.mm()严格的二维矩阵乘法只接受两个二维矩阵。实际写代码时我比较推荐用运算符因为它简洁且torch.matmul()的行为也是它。但要注意如果传入的数据维度不对操作有时会自动触发广播或者做一些隐式的维度变化导致结果跟你预期不符。所以写完矩阵乘法之后最好手动检查一下输出形状。点积是另一种常见的运算用torch.dot(a, b)表示要求两个一维tensor长度相同。点积的结果是一个标量表示两个向量对应位置元素相乘然后求和。它在很多算法里是基础组件比如注意力机制里的“query和key做点积”。4.3 广播机制为什么形状不同也能做运算如果你用numpy写过矩阵运算应该对广播broadcasting不陌生。tensor的广播机制和numpy本质上是同一套规则当两个tensor形状不同时PyTorch尝试把它们的形状“扩展”到相同然后再执行逐元素操作。广播的规则可以总结成两步。第一步从最后一位维度开始往前对比看两个tensor的每个维度是否满足以下三个条件之一相等、其中一个为1、或者其中一个不存在。第二步如果所有维度都满足条件就可以广播扩展后每个维度的长度取两者中的较大值如果某个维度两个数都不相等且都不为1就报错。举个例子你想给一个形状为2, 3的tensor每一行都加上同一个向量shape为3,这时候你就可以直接加不需要先把向量复制成2, 3的形状广播机制会帮你自动处理。这大大简化了代码。但广播机制也有它的隐蔽陷阱。我就遇到过一种情况两个shape分别为2, 3和3, 2的tensor做逐元素相加结果报错。本来我以为会广播但规则一检查发现最后一位维度分别是3和2不相等也不为1无法广播。这种报错虽然清晰但刚开始很容易让人一头雾水。多练习、多动手跑几次就会逐渐形成直觉。5. tensor的自动求导机制深度学习的魔法引擎5.1 计算图与requires_grad的来龙去脉在《动手学深度学习》中tensor章节一定会提到计算图computational graph。计算图是自动求导的基础。你可以把它想象成一张“记账本”每一次tensor运算框架都会记下这个操作并知道结果tensor是由哪些输入tensor计算得到的。具体到PyTorch里每个tensor有两个重要的属性requires_grad和grad_fn。requires_grad表示这个tensor是否需要计算梯度。通常你只对模型的参数weight、bias设置requires_gradTrue而输入数据的requires_grad一般是False除非你做某些需要对抗梯度的任务比如生成对抗网络里的生成器输入。grad_fn用来记录这个tensor是怎么来的。比如如果y x * w那么y.grad_fn指向一个乘法操作。框架可以通过grad_fn一层一层追溯得到整个前向传播的链条。当你调用backward()时PyTorch会沿着这条链条反向走一遍自动计算出每个需要梯度的tensor的梯度并且把梯度存放到tensor的.grad属性中。整个过程不需要你手动推导任何公式这就是自动求导被称为“深度学习魔法引擎”的原因。想理解得更深一点可以用一个生活化的类比你把一批数据输入模型前向传播就是按顺序完成了一系列操作autograd机制相当于在每一步都留了一张“计算凭据”反向传播则是沿着这些凭据逐一回退算出每个环节的“影响程度”。5.2 梯度累加、清零与detach的实操细节自动求导机制虽然自动但有几个实操细节你必须掌握否则模型训练结果会非常诡异。第一个是梯度累加机制。PyTorch默认不会在每次backward()后自动把梯度清零而是会把新计算的梯度累加到已有的.grad值上。为什么这么设计因为在某些场景下你确实需要累积梯度比如梯度累积gradient accumulation技巧——当显存不足时可以把一个大的batch拆成多个小batch分别计算梯度累积到一定步数后再做一次参数更新。但对常规训练来说这种累加行为是一个大坑。如果你忘了在每轮更新前清零梯度梯度会不断累加导致参数更新步长越来越大模型根本收敛不了。正确的写法是在每次反向传播之前调用optimizer.zero_grad()这也是《动手学深度学习》代码里每个训练循环几乎必写的一行。第二个是detach()方法的使用。当你希望某个tensor不参与梯度计算但又想用它来执行某些操作该怎么办答案是detach()。调用detach()会返回一个新的tensor这个新tensor和原来的tensor共享数据但requires_grad为False也不会有grad_fn。一个常见的场景是你想记录训练过程中每个epoch的loss变化并绘制曲线。如果你绘制时直接用了loss tensor由于loss还挂着计算图会导致绘图操作强制保留整个计算图造成内存泄漏。正确的做法是用loss.detach().cpu().numpy()转成numpy数组再存储。第三个是我反复吃亏的叶子节点器问题。只有叶子tensor即你直接创建的参数tensor的梯度会被保存到.grad属性中。非叶子tensor也就是由运算得到的中间tensor默认不保留梯度。这其实是一个内存优化策略因为中间变量的梯度在非需要时不必保存。如果某些情况下你确实需要中间tensor的梯度可以调用tensor.retain_grad()手动设置。刚开始学习时可能用不到但理解了这一点你看到“梯度为None”时就不会一脸懵了。5.3 一个完整的自动求导示例从前向到反向放一个非常经典的示例很多入门教材都会提到但大多只给代码不解释。我这里把关键逻辑拆开说。假设我们要计算 y x^2 2x 1 在 x 3 处的导数。手算我们知道导数是 2x 2在 x 3 时为 8。用PyTorch实现的话import torch x torch.tensor(3.0, requires_gradTrue) y x ** 2 2 * x 1 print(y) # tensor(16., grad_fnAddBackward0) y.backward() print(x.grad) # tensor(8.)这里x被设置为requires_gradTruey是由x经过一系列运算得到的所以y.grad_fn不为空。调用y.backward()之后PyTorch从y出发反向计算梯度最终把结果存到x.grad里。很多人不理解的是为什么backward()之后梯度存在x.grad里而不是返回一个值其实这正好体现了PyTorch的设计思路一个tensor可能被多个运算依赖因此梯度必须以属性的方式附着在tensor上方便后面优化器使用。优化器在更新参数时只需要读取param.grad然后用param.data - lr * param.grad得到更新。当然在实际使用中你不需要手动写更新逻辑直接调用optimizer.step()就行。通过这个例子我还想强调一点自动求导并不是只能计算标量的梯度。如果你的y是向量或矩阵调用backward()时需要传入一个与y形状相同的gradient参数它表示y的每个分量在反向传播中占据的权重。这个细节在实现损失函数、自定义模型时经常会用到。不过初学者在跑标准训练流程时通常y都是标量损失值所以直接调backward()即可。6. 基于tensor的实战手动实现一个线性回归6.1 从零构造数据和参数不靠框架高层API理论铺垫了这么多还是得动手写个例子。我选择线性回归作为tensor实战的练手项目因为它结构简单、计算过程直观非常适合用来观察tensor在训练循环里是怎么流动的。假设我们有一条直线 y 2x 1再加上一些噪声然后构造一批训练样本。我们用tensor来做参数估计目标是从数据中反推出权重w2和偏置b1。先构造数据和参数import torch # 设置随机种子保证实验可复现 torch.manual_seed(42) # 生成100个样本点 x torch.linspace(0, 2, 100) true_w 2.0 true_b 1.0 # y true_w * x true_b 噪声 y true_w * x true_b torch.randn_like(x) * 0.1这里torch.linspace生成从0到2均匀分布的100个点torch.randn_like(x)生成和x形状相同、服从标准正态分布的随机噪声。注意噪声的std设成了0.1让数据点在直线附近波动但不会太离谱。接下来初始化模型参数# 初始化参数随机给一个接近0的初始值 w torch.tensor(1.0, requires_gradTrue) b torch.tensor(0.5, requires_gradTrue)这里我特意把w和b声明为requires_gradTrue目的就是让PyTorch在反向传播时计算它们各自的梯度。梯度计算出来后优化器才能更新它们。6.2 训练循环的完整拆解训练循环的主体分四步前向传播、计算损失、清零梯度、反向传播、更新参数。先定义损失函数这里用均方误差MSEdef loss_fn(y_pred, y_true): # 均方误差所有样本误差平方后取平均 return ((y_pred - y_true) ** 2).mean()在等式y_pred w * x b里我们要注意一个广播细节。x的形状是100,w是标量0维tensor标量乘以向量是广播操作结果依然是100,这与数学表达一致。接着加上偏置b也是广播。训练循环长这样lr 0.05 num_epochs 200 for epoch in range(num_epochs): # 1. 前向传播 y_pred w * x b # 2. 计算损失 loss loss_fn(y_pred, y) # 3. 梯度清零关键 if w.grad is not None: w.grad.zero_() b.grad.zero_() # 4. 反向传播 loss.backward() # 5. 参数更新——手动计算梯度下降 with torch.no_grad(): w - lr * w.grad b - lr * b.grad if (epoch 1) % 20 0: print(fEpoch [{epoch1}/{num_epochs}], Loss: {loss.item():.4f}, w: {w.item():.4f}, b: {b.item():.4f})注意几个细节。第一步梯度清零。这里用了if w.grad is not None判断是因为第一次循环时w.grad还是None调用zero_()会报错。更常见的写法是直接调用optimizer.zero_grad()但既然我们没有用优化器就手动判断一下。第二步使用torch.no_grad()包裹参数更新代码。为什么要这么做因为w - lr * w.grad这个操作本身也是一个tensor运算如果不加no_grad()PyTorch会把这个减运算也加入计算图导致计算图越来越大、内存逐渐被撑爆。加上no_grad()之后这个更新操作就不会被记录为计算图的一部分实现“只更新参数不追踪更新过程”。第三步用loss.item()而不是直接打印loss。这是因为我之前用print打印loss时它会带着一整张计算图的信息输出格式很长很乱而且持有计算图会占用内存。item()方法会把tensor里的数值提取成Python标量干净利落。跑完这个循环你会发现w和b逐渐收敛到2附近和1附近loss也一路下降。这个训练循环虽然简单但它是所有更复杂的神经网络训练过程的最小骨架。你把w、b换成多层网络里的权重矩阵把y_pred的计算换成一组更复杂的矩阵运算本质上不变的依然是这五步。6.3 把上面的流程进阶用nn.Linear封装一层《动手学深度学习》后面章节里你会逐渐告别手写参数转而使用torch.nn模块。但别急着跳过上面的手动实现因为只有手写过一遍你才能真正理解nn.Linear内部在做什么。nn.Linear(in_features, out_features)本质上做的事情是内部维护一个形状为out_features, in_features的权重矩阵以及一个形状为out_features,的偏置向量输入数据经过y XW^T b得到输出。你在训练循环里调用optimizer.step()优化器会自动帮你完成之前我们手动做的“w - lr * w.grad”那一步。所以线性回归的手动实现其实是一个“降维版”的框架理解。理解了它再看PyTorch的官方英文教程、再看各种开源项目里的训练代码你会发现它们的骨架都长得差不多构建网络、定义损失函数和优化器、循环epoch、前向传播、计算loss、zero_grad、backward、step。唯一的区别在于网络结构和数据读取方式不同而已。我这里展示一个用nn.Linear的等价版本你感受一下对比import torch.nn as nn model nn.Linear(1, 1) # 输入1维输出1维 optimizer torch.optim.SGD(model.parameters(), lr0.05) x_2d x.view(-1, 1) # 需要变成100, 1的形状 y_2d y.view(-1, 1) for epoch in range(200): y_pred model(x_2d) loss ((y_pred - y_2d) ** 2).mean() optimizer.zero_grad() loss.backward() optimizer.step() if (epoch 1) % 20 0: print(fEpoch [{epoch1}/200], Loss: {loss.item():.4f})这里面的核心变化是原来手动管理的w和b现在被封装进了model.parameters()原来手动清零梯度变成了optimizer.zero_grad()原来手动做参数更新变成了optimizer.step()。但底层的东西一点都没变——tensor的自动求导、梯度累加机制、计算图追踪全部在按同一套原理工作。7. 常见问题与排查技巧实录7.1 RuntimeError: shape mismatch这个报错大概是tensor学习阶段最常遇到的。触发场景非常多样两个矩阵相乘但内维度不匹配、把形状不对的tensor传给某个要求固定输入维度的层、标签和预测值的形状对不上等等。我的排查方法很简单不管报错信息多长先看它给出的Expected shape和Actual shape分别是什么。比如RuntimeError: mat1 and mat2 shapes cannot be multiplied (16x3 and 2x4)意思是前一个矩阵是16行3列后一个矩阵是2行4列矩阵乘法要求第一个矩阵的列数等于第二个矩阵的行数3和2对不上所以报错。解决方案通常是在报错位置前一行打印tensor.shape看数据是不是你预期的形状。如果是数据加载的问题检查数据集构造函数或者预处理函数如果是网络层定义的问题检查in_features是否写对了。一个通用的排查技巧是把模型的forward代码里每个关键层的输入输出形状都打印一遍逐层定位。7.2 device mismatchCPU和GPU的数据打架另一个高频报错是RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!这个错我早期几乎天天遇见。原因很明确有些tensor在CPU上有些在GPU上而PyTorch不允许跨设备的张量直接运算。比如你的模型参数在GPU上但输入数据是从CPU加载的直接前向传播就会报错。解决办法也简单统一设备。通常的做法是在模型训练之前把所有需要的东西都调用.to(device)。我习惯写一个device变量device torch.device(cuda if torch.cuda.is_available() else cpu)然后把模型和数据都搬到这个设备model model.to(device) x x.to(device) y y.to(device)需要特别注意的是有些操作会自动生成新tensor比如torch.zeros()、torch.arange()等这些新生成的tensor默认是CPU上的需要手动.to(device)。如果你想减少遗漏可以给这些方法传入device参数比如torch.zeros(3, 4, devicedevice)。7.3 tensor转numpy时Shared Memory带来的“幽灵改值”还有一个我在实际项目中踩过的坑。我在处理一批数据时先在某一步把tensor转成了numpy后来又对numpy做了修改结果发现之前的tensor也变了。当时排查了很久后来才意识到是tensor和numpy共享内存导致的。具体来说以下操作会有共享内存行为tensor.numpy()把一个CPU上的tensor转成numpy数组。如果tensor的requires_gradFalse则结果与tensor共享内存。torch.from_numpy(array)从numpy数组创建tensor与原numpy数组共享内存。tensor.view()视图操作虽然它创建的是新的tensor对象但底层数据还是共享的。解决方法是使用clone()或者np.array()强制复制一份数据。比如# 强制复制避免共享内存 arr tensor.numpy().copy() # 或用 np.array(tensor) new_tensor torch.from_numpy(arr).clone()这里的关键是如果你只是读取数据共享内存没有关系但如果你修改数据就一定要复制。尤其在模型推断后对输出做后处理、分析时很容易不小心覆盖原始tensor的数据。7.4 backward()时梯度为None怎么回事我在实现自定义模型时遇到过一个很恶心的问题调用backward()不报错但某个参数的.grad是None参数一直没有更新。后来查资料才知道有几种常见原因该参数没有参与当前损失的计算。如果模型里有某个权重矩阵未被用到它自然不会有梯度。检查你的前向传播里是否漏掉了这个参数。该参数被detach()了。如果你在forward里对某个tensor调用了detach()计算图就在那里断掉了梯度传不回去。该参数被原地操作修改了。比如在forward里用修改了tensor可能导致autograd记录失效。该参数是叶子节点但requires_grad没有设置为True。排查的方法是在backward()之后逐个打印param.grad看看哪些是None然后沿着forward代码一步步检查看那个参数是怎么参与计算的。这个排查过程很枯燥但它能让你对计算图的理解更加深入。7.5 内存爆掉OOM以及计算图被意外保留训练深度学习模型时GPU显存溢出是最让人头疼的问题之一。OOMOut of Memory的触发原因有很多但有一个和tensor直接相关的原因计算图被意外保留。典型场景是在每个epoch的记录里你把loss直接保存到了一个列表里。由于loss是由计算图产生的tensor它持有整张计算图的引用。如果每个epoch都保存loss、但又不及时释放计算图显存就会越攒越多。最后在某个epoch爆掉报OOM。解决办法很简单保存loss时存它的标量值而不是tensor。train_losses.append(loss.item()) # 正确做法 # train_losses.append(loss) # 错误做法还有一个类似问题在绘图时直接使用loss tensor。如果要画loss曲线务必先调用loss.detach().cpu()再转numpy避免计算图被带到绘图函数里。8. 最后再分享几个我在实际使用中的小习惯跑了很多项目之后我逐渐养成了一些关于tensor的使用习惯分享出来希望能帮你少走一些弯路。第一个习惯是所有tensor相关代码一开始就明确设备和dtype。不要依赖环境的默认值。因为默认值在不同环境下可能不同跟别人协作时更容易引发问题。最好在数据集加载时就把dtype统一成torch.float32在加载到device时再统一搬设备。这样后续代码只需要关注逻辑不用反复处理类型、设备的不匹配。第二个习惯是打印tensor信息时用f-string配合shape、dtype、device一起打印。比如print(fx shape: {x.shape}, dtype: {x.dtype}, device: {x.device})。这个习惯看起来简单但在调试复杂模型时特别高效。很多时候一眼就能看出问题出在哪个tensor上。第三个习惯是在写训练循环时尽量把“清零梯度、前向传播、损失计算、反向传播、参数更新”这五步保持固定顺序不要随意调换。保持顺序可以减少很多奇怪的问题也方便代码审查和阅读。第四个习惯是多利用dir()或者tab补全来查看tensor有哪些方法。很多API我都是这么“翻”出来的。比如dir(torch.tensor([]))可以看到所有tensor方法当你不知道某个操作叫什么时翻一翻往往有惊喜。我在踩了无数坑之后最大的体会是tensor学得好不好不在于记住多少个API而在于你是否理解数据是怎么流动的以及自动求导在底层是怎么工作的。把《动手学深度学习》里的tensor章节配合上面的这些实操细节过一遍你会发现后面那些看似复杂的神经网络代码也不过是tensor的“排列组合”而已。
返回列表