
学深度学习绕不开的第一个基础概念就是 Tensor。我当初翻《动手学深度学习》D2L的时候前面数据操作和自动微分这几章看着简单实际动手敲却踩了不少坑。后来把 Tensor 这部分彻底吃透再回头看模型训练、反向传播这些内容明显顺了很多。这篇笔记就把我在 Tensor 学习过程中整理出来的核心要点、常用代码和踩坑经验一次性写出来给正在入门深度学习、尤其是刚开始用 PyTorch 的朋友做个参考。如果你是零基础也不用担心我会尽量用生活化的方式把维度、形状、广播这些听起来高深的概念讲清楚只要本地能跑起 Python 环境照着代码敲一遍Tensor 的基本功就算打下来了。1. Tensor 是什么先搞懂这个深度学习里的万能容器1.1 从一维到N维用三个例子理解 Tensor 的 shapeTensor 最朴素的理解就一句话它是一个可以装任意维度数据的容器。一维的 Tensor 就像一张成绩单只能装一串数字例如 40 个学生的语文分数二维的 Tensor 就是一张表格40 个学生各考 6 门课于是有了 40 行乘以 6 列三维的 Tensor 可以理解成一本表格合集40 个学生、6 门课、4 个学期三个维度就分别是学生、科目、学期。在深度学习里最常见的四维 Tensor 是图像数据shape 通常是(batch_size, channel, height, width)。比如一批 32 张 224x224 的彩色图片对应的 Tensor 就是(32, 3, 224, 224)中间的 3 是 RGB 三个通道。这个维度顺序初学者可能一时记不住但多看几次就条件反射了。我用代码随手创建一个三维 Tensor方便你直接观察import torch x torch.arange(24).reshape(2, 3, 4) print(x.shape) # torch.Size([2, 3, 4]) print(x)输出结果是一组 2 个 3 行 4 列的数字块。理解 shape 的关键是从“最外层到最内层”逐层数括号最外层有 2 个块每个块里有 3 行每行有 4 个元素。以后你看到任何高维 Tensor只要用这个“逐层数”的方法就能快速搞清楚它的结构。1.2 为什么深度学习框架偏爱 Tensor 而不是 NumPy有 NumPy 基础的同学可能会问NumPy 数组也能做多维运算它和 Tensor 到底有什么区别答案是功能上两者确实高度重合但 Tensor 是为深度学习量身定制的。第一Tensor 原生支持 GPU 加速。把数据放到 GPU 上之后大规模矩阵运算可以并行执行速度比 CPU 快几个数量级。NumPy 本身不支持这种运算虽然可以通过第三方库间接调用 GPU但体验远不如 Tensor 来得自然。第二Tensor 有自动求导机制。训练神经网络的核心是反向传播求梯度PyTorch 的 Tensor 会记录每一步运算之后通过backward()自动算出梯度这是 NumPy 做不到的。第三Tensor 和 PyTorch 的神经网络模块深度集成模型的输入输出、参数、损失函数全部是用 Tensor 来流转的。所以总结下来就是NumPy 适合做通用科学计算Tensor 是深度学习生态里的“通用货币”。也不是说学了 Tensor 就不用 NumPy实际项目中两者经常混用后面我会讲到它们之间怎么互转。1.3 三大关键属性shape、dtype、device拿到一个 Tensor第一反应应该看三样东西形状shape、数据类型dtype、所在设备device。这三个属性决定了你能不能做某次运算也决定了你能不能拿到想要的结果。shape形状即每个维度的大小。它决定参与运算的矩阵能不能对齐。dtype数据类型。PyTorch 里常用的是torch.float32、torch.float64、torch.int64等。神经网络里的权重和激活值一般都用torch.float32整数类型通常用来做索引或者标签。device数据在 CPU 还是 GPU 上。CPU 上的 Tensor 和 GPU 上的 Tensor 不能直接做运算这是一个非常常见的报错来源。来看一段实际代码x torch.tensor([1, 2, 3], dtypetorch.float32) print(x.shape) # torch.Size([3]) print(x.dtype) # torch.float32 print(x.device) # cpu值得注意的是PyTorch 的默认浮点类型是torch.float32默认整数类型是torch.int64。如果你用 Python 的float列表创建 Tensor得到的就是torch.float32。后面训练模型时如果模型参数是float32而输入数据是float64就可能触发类型不匹配的报错这类问题我放到第 4 章踩坑部分细讲。2. Tensor 创建与常用操作照着抄就能跑通的代码清单2.1 七种创建 Tensor 的方式与适用场景我把自己平时用得最多的创建方式整理成了一张清单初学阶段建议全部敲一遍形成肌肉记忆。import torch import numpy as np # 1. 从列表或数组创建 a torch.tensor([[1, 2], [3, 4]]) b torch.tensor(np.array([1.0, 2.0, 3.0])) # 2. 全0、全1、对角线为1的矩阵 zeros torch.zeros((2, 3)) ones torch.ones((3, 2)) eye torch.eye(3) # 3. 随机数 rand torch.randn(2, 3) # 标准正态分布 uniform torch.rand(2, 3) # [0, 1) 均匀分布 randint torch.randint(0, 10, (2, 3)) # 整数均匀分布 # 4. 等差数列 ar torch.arange(0, 10, 2) # 从0到10步长为2 lin torch.linspace(0, 1, 5) # 从0到1等分5个点 # 5. 指定数值填充 full torch.full((2, 3), 7.0) # 6. 形状复制 x torch.randn(2, 3) like torch.zeros_like(x) # 保持形状和dtype填充0 # 7. 指定设备和是否需要梯度 gpu_tensor torch.randn(2, 3, devicecuda if torch.cuda.is_available() else cpu) param torch.randn(2, 3, requires_gradTrue)每个都有它的使用场景。torch.randn常用于初始化模型权重因为标准正态分布是神经网络参数初始化的常见选择torch.arange适合生成序列数据或者位置编码torch.zeros_like和torch.ones_like在实现自定义层时特别方便不需要手动写死 shape。2.2 reshape 与 view这两个到底差在哪reshape和view是改变 Tensor 形状最常用的两个方法也是一对容易踩坑的兄弟。view是视图操作不复制底层数据新旧 Tensor 共享同一块内存。但是view有个限制要求原 Tensor 在内存中是连续的。如果对 Tensor 做了转置、切片等操作内存可能就不再连续此时调用view会直接报错。reshape则更宽容只要形状合法它能重新排列数据如果内存不连续它会自动复制一份再调整形状。代价就是可能多一次内存拷贝性能略低于view。来看一个真实的报错场景x torch.arange(12).reshape(3, 4) y x.t() # 转置此时内存不连续 # y.view(12) # 报错 y.reshape(12) # 正常运行初学阶段我的建议很简单统一用reshape先把需求搞定等以后优化性能时再考虑view。另外还有一个操作contiguous()可以把不连续的 Tensor 在内存中变成连续之后就能继续用view了。它们之间的关系可以理解为view是最省内存的视图操作reshape是更安全的选择contiguous()则是修复内存布局的工具。2.3 索引、切片和算术运算的注意点Tensor 的索引和切片跟 NumPy 几乎一模一样上手很快但要记住一个重要机制切片返回的是视图不是拷贝。这意味着你修改切片的结果原 Tensor 也会跟着变。x torch.arange(12).reshape(3, 4) sub x[1:3, :] sub[0, 0] 999 print(x) # tensor([[ 0, 1, 2, 3], # [999, 5, 6, 7], # [ 8, 9, 10, 11]])如果你的需求是“取出一部分数据独立修改不影响原 Tensor”记得先调用.clone()比如x[1:3].clone()。算术运算里初学阶段最容易忽略的是矩阵乘法和逐元素乘法的区别。PyTorch 里*表示逐元素相乘形状必须能对齐或者广播矩阵乘法用的是或torch.mm、torch.matmul。三个矩阵相乘的例子A torch.randn(3, 4) B torch.randn(4, 5) C A B # 矩阵乘法结果是 (3, 5) D A * A # 逐元素平方同形状在《动手学深度学习》后续的层归一化、注意力机制里到处都用到矩阵乘法如果一开始就分清楚*和后面看代码会轻松很多。2.4 广播机制不同形状的张量怎么相加广播机制是 Tensor 操作里最“魔法”也最容易出 bug 的地方。简单说当两个 Tensor 形状不完全一致时PyTorch 会尝试把它们的维度自动扩展到一致再进行运算。具体规则是从最后一个维度开始往前对齐每个维度上的尺寸要么相等要么其中一个为 1。比如(3, 1)和(1, 4)相加时第一对是 3 和 1扩展成 3第二对是 1 和 4扩展成 4最终得到(3, 4)。a torch.tensor([[1.0], [2.0], [3.0]]) # shape (3, 1) b torch.tensor([[10.0, 20.0, 30.0]]) # shape (1, 3) c a b print(c.shape) # torch.Size([3, 3])我理解广播的方式是“复制后计算”把(3, 1)的每一行复制 3 份把(1, 3)的每一列复制 3 份两个都变成了(3, 3)然后相加。不过真实实现并不会真的复制数据只是逻辑上这么理解。最容易出错的情况是维度不满足规则比如(3, 2)和(2, 3)相加最后一位一个是 2 一个是 3既不相等也没有 1就会直接报错。解决广播报错的方法就是打印两个 Tensor 的shape一个个维度去检查。2.5 Tensor 与 NumPy 互转共享内存的坑Tensor 和 NumPy 的互转在数据处理阶段非常常见。torch.from_numpy()可以把 NumPy 数组变成 Tensor.numpy()可以把 Tensor 变成 NumPy 数组。但有一个重要机制这种转换是共享底层内存的修改任意一方都会影响另一方。arr np.array([1.0, 2.0, 3.0]) t torch.from_numpy(arr) t[0] 100.0 print(arr) # [100. 2. 3.]如果不想要这种耦合就显式调用.clone()或np.array(...)复制一份。另外PyTorch 的to()方法可以实现 Tensor 在 dtype、device 之间的转换比如t.float()、t.to(cuda)这些方法在训练循环里会频繁用到。3. 自动求导机制Tensor 凭什么是深度学习的基石3.1 计算图与 requires_grad 的基础逻辑深度学习训练的核心是梯度下降而梯度来自反向传播。PyTorch 的自动求导机制就是让所有可训练参数在计算过程中自动记录“历史”然后在需要时一次性算出每个参数的梯度。为了让 Tensor 参与自动求导需要在创建时设置requires_gradTrue。这个标志告诉 PyTorch我需要在反向传播时计算关于这个张量的梯度。x torch.tensor([2.0], requires_gradTrue) y x ** 2此时y就带有一个grad_fn属性记录它是通过pow操作得到的。你可以把计算图理解成一条流水线每个操作是一个节点数据从最开始的输入流到最终的输出。反向传播时误差从最后一个节点逆着流水线传回来每个节点计算出局部梯度并传给前面一层。我最初学这个机制时走了弯路总想手动去推每个参数的梯度公式。后来发现理解自动求导的关键是信任框架你只要定义好前向计算和损失函数调用backward()梯度就会自动挂在每个requires_gradTrue的张量的.grad属性上。3.2 一次 backward 后到底发生了什么来看一个最经典的最小例子理解backward()的完整链路x torch.tensor([2.0], requires_gradTrue) y x ** 2 y.backward() print(x.grad) # tensor([4.])y x^2在x2处的导数是4所以x.grad的结果是4。这个例子简单但已经说明了两件事第一backward()是从y这个标量开始的第二梯度存到了叶子节点x.grad里。有几个细节需要特别说明默认情况下backward()只能对标量生效。如果y是一个向量或者矩阵你需要传入一个和y形状相同的gradient参数表示每个输出位置接收到的梯度权重。初学训练模型时 loss 一般都是标量比如取平均或求和所以直接loss.backward()就够了。只有叶子节点即由用户直接创建、不经过其它操作的requires_gradTrue张量会默认保存.grad。中间节点的梯度默认不保留如果想保留需要调用retain_grad()。梯度是累加的。每次backward()之后.grad里的值是加上这一次的梯度而不是替换。这就是为什么训练循环里必须调用optimizer.zero_grad()或者w.grad.zero_()。第 3 点是新手踩坑重灾区。有一次我写训练循环忘了清零梯度loss 在几个 epoch 后直接变成nan排查半天才发现是梯度累加导致参数更新步长越来越大。3.3 用 autograd 手写一个线性回归训练循环为了真正理解自动求导在训练中是怎么工作的我建议你实现一个最简单的线性回归不用任何封装好的模型训练模块只用 Tensor 和backward()。这也是《动手学深度学习》前几章结构最清晰的一个实验。先生成模拟数据import torch # 真实参数 w_true torch.tensor([2.0, -3.4]) b_true torch.tensor([4.2]) # 1000个样本2个特征 X torch.randn(1000, 2) y X w_true b_true 0.01 * torch.randn(1000)然后初始化参数并写训练循环w torch.zeros(2, requires_gradTrue) b torch.zeros(1, requires_gradTrue) lr 0.03 num_epochs 100 for epoch in range(num_epochs): # 前向计算 y_hat X w b # shape (1000,) 广播后加b loss ((y_hat - 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 % 10 0: print(fepoch {epoch}, loss {loss.item():.6f}) print(fw {w}, b {b})这个例子虽然只有几十行但几乎包含了之后所有深度学习训练循环的关键要素前向计算、计算损失、反向传播、更新参数、清零梯度。我把每一步都拆开讲一下。y_hat X w bX是(1000, 2)w是(2,)矩阵乘法结果是(1000,)b的形状是(1,)通过广播机制加到每个输出上。这里关注的正是形状是否对齐。loss ((y_hat - y) ** 2).mean()均方误差损失必须是一个标量。如果这里用了.sum()而不是.mean()loss 数值会大很多学习率就需要相应调小很多初学者在这里才意识到mean和sum对训练的影响。更新参数时为什么要加torch.no_grad()因为如果不加w - lr * w.grad这个操作会被记录进计算图导致内存和计算量翻倍甚至造成梯度计算错误。更新参数这个动作不应该参与自动求导。w.grad.zero_()必须放在参数更新之后、下一轮反向传播之前。漏掉这一步梯度就会累积loss 会异常波动甚至变成nan。训练结束后得到的w和b会非常接近真实的w_true和b_true。如果你跑出来的结果偏差比较大可以把学习率调小一些或者增加迭代轮数。3.4 梯度累积、no_grad 与 detach训练循环里的三个细节除了上面提到的zero_()学习自动求导时还会频繁遇到三个概念梯度累积、no_grad和detach。梯度累积指的是“不清零梯度多累积几步再做一次更新”的技巧。因为显卡显存有限有时一次装不了太大 batch就用小 batch 跑几步把梯度累加到一起再统一更新参数。PyTorch 默认梯度累加的行为反而成了实现这个技巧的便利条件只要不在每个 batch 后调zero_()就能自然累积。但更多时候梯度累加是 bug不是功能。常规训练里每个 batch 结束后一定要清零。torch.no_grad()和detach()都是“切断梯度传播”的手段。no_grad表示在其作用域内的所有运算都不构建计算图常用于模型推理阶段在验证集上计算精度时使用可以显著降低内存占用。detach()则是返回一个不参与计算图的新 Tensor但底层数据仍然共享。两者的区别在于作用范围no_grad是一个上下文环境影响所有在它内部创建和运算的 Tensordetach是单独作用在某个 Tensor 上。实际使用中训练阶段需要“冻结”某一部分参数时或者计算某个指标时不想让梯度影响主模型都常用到detach()。4. Tensor 学习踩坑实录这几个报错我赌你迟早会遇到4.1 device 不匹配CPU 和 GPU 张量不能直接运算新手第一次用 GPU 训练时最常见的报错是RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!这个报错翻译成人话就是你在一个算式里同时用了 GPU 上的数据和 CPU 上的数据PyTorch 不知道按哪个设备计算。我自己的习惯是把所有 Tensor 和模型都统一放到同一个 device。有一行代码可以用来统一管理device torch.device(cuda if torch.cuda.is_available() else cpu)然后数据在进入模型前都调用.to(device)模型本身也调用model.to(device)。要注意的是手动创建的常量 Tensor 也记得要to(device)比如torch.ones_like(x)这种利用已有 Tensor 创建的还好但你要写torch.zeros(2)再和一个 GPU Tensor 相加就会报错。4.2 in-place 操作改了个值梯度算不出来了in-place 操作指的是直接修改原 Tensor 内容的操作比如x 1、x[0] 3、x.zero_()。这类操作本身没问题问题出在自动求导机制上。当 Tensor 参与过前向传播之后计算图已经记录了它的旧值。如果你此时在原 Tensor 上做 in-place 修改等反传时计算图发现“历史数据被改了”就会直接报错RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.解决方法通常有两个一是只对不参与求导的张量做 in-place 操作比如训练循环里的w.grad.zero_()这种是允许的二是更新可训练参数时不要用 in-place而是用w w - lr * w.grad或者把原来的 in-place 写法改成w - lr * w.grad之前先确认w不是叶子节点且在no_grad作用域中。我在前面的线性回归例子里用的就是with torch.no_grad(): w - lr * w.grad在上下文内部操作避开了这个坑。4.3 shape 对不上矩阵乘法到底怎么对齐矩阵乘法报错也是高频问题尤其是第一次接触卷积和全连接层时RuntimeError: mat1 and mat2 shapes cannot be multiplied (3x4 and 5x2)这个报错很直白(3, 4)和(5, 2)无法相乘因为左矩阵的列数 4 必须等于右矩阵的行数。排查时最有效的方法是打印每一步的shape一行行追踪数据的维度变化。有个细节要注意一维张量和矩阵相乘时行为会跟直觉不一样。比如torch.randn(4) torch.randn(4, 5)得到(5,)而torch.randn(1, 4) torch.randn(4, 5)得到(1, 5)。前者一维向量参与了乘法结果会被“降维”。如果代码里某个环节多了一个维度或者少了一个维度结果就完全对不上。遇到这种情况可以手动用unsqueeze(0)或者squeeze(0)调整维度等形状稳定后再继续。4.4 几个容易被忽视的小细节还有几个我见过很多初学者反复踩的细节放在最后提醒一下。第一打印 loss 时忘了.item()。如果直接print(loss)输出的是一个 Tensor日志里会出现tensor(0.0123, grad_fn...)。用loss.item()会变成纯 Python 数值更干净同时还能分离计算图。第二忘了zero_()清零梯度。症状是 loss 在训练初期下降正常过几个 epoch 后突然剧烈波动甚至变成 nan。排查梯度累积最快的方法是检查训练循环里有没有zero_()。第三对切片做原地修改时误改了原 Tensor。前面 2.3 节说过切片是视图不是拷贝想要独立数据一定要clone()。第四把 dropout 或 BatchNorm 的“训练模式/评估模式”概念跟 Tensor 操作混淆。虽然这不完全是 Tensor 的问题但很多人在验证集上忘了关闭requires_grad导致显存占用异常。建议验证阶段用with torch.no_grad():包住前向计算。写到这里Tensor 相关的核心知识基本覆盖了。我觉得 Tensor 这部分是《动手学深度学习》里最基础也最该反复练的一块后续看卷积、循环神经网络、Transformer 时会发现所有结构本质上都在做 Tensor 的搬运、变形和数学运算。我个人的体会是练 Tensor 一定要动手敲不要觉得“代码看着简单就跳过”很多时候你以为自己会了实际一跑代码shape、device、梯度清零的问题全冒出来了。希望这份笔记能帮你少踩我踩过的那些坑把基本功打得更扎实。