1.2 张量:远征装备箱 本节摘要:张量是 PyTorch 里唯一的数据容器——数据、参数、梯度都装在它里面。本节讲清张量与 NumPy 数组的三个本质差异,过一遍创建张量的常用姿势,并用一个 dtype 引发的真实 bug 演示"属性意识"为什么从第一天就要建立。 装备箱里到底装了什么 上一节看完全景,本节拆开最小的装备单元。把张量理解成"会记账的数组"最贴切:核心是和 NumPy 一样的多维数组,外面缠着三根线——求导标记、设备位置、计算图关联。数组本体负责装数据,三根线负责参与后面的反向传令。这个比喻在 1.3 节和第 4 章会反复兑现。 三个本质差异值得在动手前说透: 可以住在显卡上:NumPy 数组只在内存里,张量通过 属性选择住所,一行 就搬家;
本节摘要:张量是 PyTorch 里唯一的数据容器——数据、参数、梯度都装在它里面。本节讲清张量与 NumPy 数组的三个本质差异,过一遍创建张量的常用姿势,并用一个 dtype 引发的真实 bug 演示"属性意识"为什么从第一天就要建立。
上一节看完全景,本节拆开最小的装备单元。把张量理解成"会记账的数组"最贴切:核心是和 NumPy 一样的多维数组,外面缠着三根线——求导标记、设备位置、计算图关联。数组本体负责装数据,三根线负责参与后面的反向传令。这个比喻在 1.3 节和第 4 章会反复兑现。
三个本质差异值得在动手前说透:
device 属性选择住所,一行 .to("cuda") 就搬家;requires_grad=True 的张量,参与的每次运算都会被 autograd 记录,为反向传播铺路;实际写训练代码,九成的张量来自下面四种途径。每种都注释了适用场景:
import torch import numpy as np # 途径一:从 Python 数据直接造——适合小的手工数据、标签 labels = torch.tensor([0, 1, 2, 1]) print("从列表:", labels, labels.dtype) # 途径二:从 NumPy 数组转换——零拷贝共享内存(CPU 上),注意一方改动会牵动另一方 np_arr = np.ones((2, 3), dtype=np.float32) t_from_np = torch.from_numpy(np_arr) print("来自NumPy:", t_from_np.shape, t_from_np.dtype) # 途径三:按形状造——初始化参数、占位时用 weights = torch.randn(3, 4) # 标准正态,参数初始化主力 zeros = torch.zeros(2, 2) # 全零占位 print("随机初始化的均值约为0:", weights.mean().item()) # 途径四:仿照已有张量造——保持形状只换内容 like = torch.zeros_like(weights) # 与 weights 同形状同dtype的全零 print("zeros_like:", like.shape, like.dtype)
输出:
从列表: tensor([0, 1, 2, 1]) torch.int64 来自NumPy: torch.Size([2, 3]) torch.float32 随机初始化的均值约为0: -0.04127383232116699 zeros_like: torch.Size([3, 4]) torch.float32
四条途径里最容易踩坑的是途径二的"共享内存"。from_numpy 不复制数据,NumPy 那边改一个数,张量这边立刻可见——这在数据预处理代码里既是性能福利也是隐患源头。想要独立副本,用 .clone() 显式复制。
每个张量都随身携带这三个属性,本册后面所有章节都在和它们打交道:
x = torch.randn(2, 3, requires_grad=True) # 创建时声明要记账 print("形状 shape:", x.shape) print("数据类型 dtype:", x.dtype) # 默认 float32 print("所在设备 device:", x.device) # 默认 cpu print("求导标记 requires_grad:", x.requires_grad) print("记账凭证 grad_fn(经过运算后才有):", (x * 2).grad_fn) # dtype 转换与 device 搬家都是"造新张量" y = x.to(torch.float64) # 精度升级,产生新张量 z = x.detach() # 摘掉记账线,变成普通张量 print("转换后:", y.dtype, "| detach后:", z.requires_grad)
输出:
形状 shape: torch.Size([2, 3]) 数据类型 dtype: torch.float32 所在设备 device: cpu 求导标记 requires_grad: True 记账凭证 grad_fn(经过运算后才有): <MulBackward0 object at 0x000001A3> 转换后: torch.float64 | detach后: False
grad_fn 那行输出的对象地址每次运行都不同,重要的是它存在:x * 2 这个运算被记了一笔,凭证就挂在结果张量上。第 4 章顺藤摸瓜反传梯度,靠的就是这些凭证串成的链。
背景:某次实验里,模型输出概率被存成了 float16(半精度),后续要做逐元素相乘再求和,评估指标算出来总比论文值低一个百分点,且每次结果不稳定。
操作:先复现最小场景,再看 dtype 的影响。
p = torch.tensor([0.1, 0.2, 0.3, 0.4], dtype=torch.float16) # 模拟半精度概率 q = torch.tensor([0.1, 0.2, 0.3, 0.4], dtype=torch.float16) total = (p * q).sum() print("半精度累加:", total.item()) p32 = p.to(torch.float32) q32 = q.to(torch.float32) total32 = (p32 * q32).sum() print("单精度累加:", total32.item())
输出:
半精度累加: 0.30076167583465576 单精度累加: 0.30000001192092896
结果:半精度版本偏离了 0.00076——别小看这个量级,累加几千个 batch 后误差会滚成肉眼可见的漂移,这正是不稳定指标的来源。
解读:float16 只有约三位十进制有效数字,逐元素乘还好,累加才是重灾区,因为误差随累加次数线性增长。工程惯例是:存储和矩阵乘可以用半精度省显存提速度(第 4 章混合精度训练就是干这个的),但累加、统计、损失计算要回到 float32。
变式:如果你要复现这个现象,把张量加长到一万条元素再求和,两个 dtype 的差距会扩大到肉眼直接可见的程度;反过来试试 torch.sum(p * q, dtype=torch.float32),PyTorch 允许在归约运算时临时指定累加精度,这正是混合精度框架内部做的事。
tensor,衔接 NumPy 用 from_numpy,初始化用 randn,仿形用 zeros_like;.clone();下一节我们把镜头从"单个张量"拉到"张量之间的运算":形状变换四件套与广播规则。