上一课你把循环里的 Python 慢操作改写成了 NumPy 的向量化与广播。现在我们把同样的"数组直觉"搬到 PyTorch 的张量(tensor)上——这是从零写训练循环之前的最后一块工程地基。好消息是:张量的索引、广播、向量化几乎就是 NumPy;坏消息是,它多了三个会让你反复栽跟头的属性——device(设备)、dtype(数据类型)、requires_grad(是否记录梯度)。本课就专门吃掉这三者引发的最高频报错,下一课再用 requires_grad 进入 autograd。
读完这一课,你将能够
- 说清张量比 ndarray 多了哪三个属性,并在代码里读出它们。
- 判断一个操作(切片 /
from_numpy/ transpose)是否共享内存,预测改一个会不会动另一个。 - 解释为什么 transpose 后
.view会报错,并用.contiguous()或.reshape修复。 - 用
.to(device)把张量和模型搬到同一设备,避免"设备不匹配"错误。 - 选对
dtype(float32 / bf16 / int64),并用 NumSpy 演示预判 dtype 提升 / 截断 / 精度丢失会发生什么;同时知道 PyTorch 的提升规则与 NumPy 略有不同。
张量 = ndarray + 三个属性
机制直觉。把张量想成一块连续的数字内存(buffer)加上一套"怎么读它"的元数据:形状(shape)、步长(stride)、起点。NumPy 的 ndarray 已经是这个结构了。PyTorch 在它之上挂了三个新标签:
device(设备):这块内存在哪——CPU 内存还是某张 GPU 的显存(cpu/cuda:0)。dtype(数据类型):每个元素几个字节、怎么解释(float32/bfloat16/int64…)。NumPy 也有,但在深度学习里它直接关系到显存和精度。requires_grad(是否记录梯度):要不要为这个张量记录计算图、以便反向传播求梯度。这是 NumPy 完全没有的,下一课主角。
除了这三个标签,张量的"数组操作"和 NumPy 几乎一一对应。下面这格用 NumPy 复习几个会原样迁移过来的操作——把它当成"张量也是这样"的预演。
import numpy as np
x = np.arange(12).reshape(3, 4) # 形状 (3, 4)
print("shape:", x.shape, "dtype:", x.dtype)
# 索引 / 切片 —— torch 写法完全一样
print("第0行:", x[0])
print("最后一列:", x[:, -1])
# 广播:(3,4) 与 (4,) 相加,行向量自动复制到每一行
bias = np.array([10, 20, 30, 40])
print("广播加法:\n", x + bias)
# 向量化归约
print("按列求和:", x.sum(axis=0))
print("按行均值:", x.mean(axis=1))
真实 API(指针)。把上面的 np. 换成 torch.、axis= 换成 dim=,几乎就是 PyTorch。差别全在那三个新属性上:
import torch
x = torch.arange(12).reshape(3, 4) # 默认 dtype=int64, device=cpu
print(x.shape, x.dtype, x.device, x.requires_grad)
# torch.Size([3, 4]) torch.int64 cpu False
# 创建浮点张量并要求梯度
w = torch.randn(3, 4, dtype=torch.float32, requires_grad=True)
print(w.dim(), w.sum(dim=0)) # dim 替代 numpy 的 axis
# 三个属性都能在创建时指定
t = torch.zeros(2, 2, dtype=torch.float32, device="cpu")
from_numpy 的零拷贝陷阱
机制直觉。很多"为什么我没改它它却变了"的 bug,本质都是两个变量指向同一块内存。NumPy 里切片返回的是视图(view)而不是副本,这正好可以用来建立"共享内存"的直觉——torch.from_numpy 干的就是同一件事。
import numpy as np
a = np.array([1, 2, 3, 4], dtype=np.float32)
b = a[1:3] # 切片 = view,与 a 共享同一块内存
b[0] = 99 # 改 b
print("a =", a) # a[1] 也变成 99 !
print("b =", b)
print("共享内存?", np.shares_memory(a, b)) # True
c = a.copy() # 显式拷贝才独立
c[0] = -1
print("copy 后 a 不变:", a)
跑一下:a 变成 [1, 99, 3, 4]。这就是"共享内存"的感觉——b 不是新数组,只是同一块内存的另一种读法。
真实 API(指针)。torch.from_numpy(a) 与 a 共享内存,改一个动另一个;想要独立副本就显式 .clone() 或 torch.tensor(a)(后者会拷贝)。
import numpy as np, torch
a = np.array([1.0, 2.0, 3.0], dtype=np.float32)
t = torch.from_numpy(a) # 零拷贝:t 和 a 共享内存
t[0] = 99.0
print(a) # [99. 2. 3.] —— numpy 数组也变了!
# 反方向同理
t.numpy()[1] = -1.0
print(a) # [99. -1. 3.]
# 想要独立副本:
t2 = torch.tensor(a) # 拷贝一份
t3 = torch.from_numpy(a).clone()
- 注意
from_numpy只在 CPU 上、且 dtype 能直接对应(numpy 数组本身是该 dtype 的原生连续数组)时共享内存。一旦.to("cuda")或.to(torch.float64)触发了类型/设备转换,就会产生新内存,不再共享。 - 三个易混入口对比:
torch.from_numpy(a)在可行时共享;torch.tensor(a)总是拷贝;torch.as_tensor(a)则在可行时共享、否则拷贝。不确定就用torch.tensor,要省内存且确定能共享才用前两者。
连续性(contiguous):view 要求连续,reshape 不一定
机制直觉。张量逻辑上是多维的,物理上却是一条一维内存。默认按"行优先"(C order)摆放:先放完第 0 行,再放第 1 行……transpose / permute 这类操作只改步长(stride)、不动内存——逻辑视图转置了,物理顺序原封不动。.view 要求"内存顺序正好就是新形状想要的顺序",所以转置后的张量不连续,.view 会拒绝;.reshape 则在必要时悄悄帮你拷贝一份连续内存。
import numpy as np
a = np.arange(6).reshape(2, 3) # 行优先
print("a=\n", a)
print("物理内存顺序:", a.ravel()) # [0 1 2 3 4 5]
print("C连续?", a.flags['C_CONTIGUOUS']) # True
t = a.T # transpose:逻辑变,内存没动
print("t=\n", t) # [[0 3] [1 4] [2 5]]
print("t C连续?", t.flags['C_CONTIGUOUS']) # False
print("t 物理内存仍是:", t.ravel(order='K')) # [0 1 2 3 4 5]
# reshape 在不连续时自动拷贝,给出"按逻辑顺序"的结果
print("t.reshape(6):", t.reshape(6)) # [0 3 1 4 2 5]
关键观察:t 逻辑上转置成 [[0 3] [1 4] [2 5]],但底层内存还是 0 1 2 3 4 5(ravel(order='K') 按物理布局读,所以仍是原序)。NumPy 的 reshape 发现这块内存不连续,自动拷贝出按逻辑顺序排列的 [0 3 1 4 2 5]。PyTorch 的 .view 不会替你拷贝——它直接抛 RuntimeError,逼你显式选择 .contiguous() 或 .reshape。
真实 API(指针)。这是最常见的"view 报错"现场和两种修复:
import torch
a = torch.arange(6).reshape(2, 3)
t = a.transpose(0, 1) # 形状 (3, 2),不连续
print(t.is_contiguous()) # False
# t.view(6)
# RuntimeError: view size is not compatible with input tensor's size and
# stride ... use .reshape(...) instead.
# 修复一:先连续化,再 view
print(t.contiguous().view(6)) # tensor([0, 3, 1, 4, 2, 5])
# 修复二:直接用 reshape(需要时自动拷贝,否则等价 view)
print(t.reshape(6)) # tensor([0, 3, 1, 4, 2, 5])
- 记忆口诀:不确定连不连续,就用
.reshape;只有在你明确知道张量连续、且想保证零拷贝时才用.view。 - C 连续(行优先)vs F 连续(列优先,Fortran order):深度学习里默认且几乎只用 C 连续,知道这个词即可,不必深究。
设备搬运:张量和模型必须同处一地
机制直觉。CPU 内存和 GPU 显存是两块物理上分开的内存,CPU 上的数字和 GPU 上的数字没法直接相加。深度学习里最高频的运行时错误之一就是:模型参数在 GPU、输入数据还在 CPU,两者一相乘就报 device mismatch。规矩很简单——参与同一次运算的张量必须在同一个 device 上。
真实 API(指针)。用 .to(device) 搬运,并且约定一个 device 变量贯穿全程:
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
x = torch.randn(8, 16) # 默认在 cpu
model_w = torch.randn(16, 4, device=device)
# x @ model_w # 若 device=cuda 会报:
# RuntimeError: Expected all tensors to be on the same device,
# but found at least two devices, cuda:0 and cpu!
x = x.to(device) # 把数据搬到同一设备
y = x @ model_w # 现在 OK
print(y.device)
# 模型也一样要搬(下一课的 nn.Module):model = model.to(device)
# .cuda() 是 .to("cuda") 的简写;.cpu() 同理
.to(...)既能换设备也能换 dtype:x.to(device, torch.float32)。换到不同设备/类型时会产生新张量(拷贝),原张量不变。- 把数据搬上 GPU 通常发生在每个 batch;模型只在训练开始时搬一次。
dtype:选对精度,预判不匹配
机制直觉。dtype 决定每个数字占几个字节、能表示多大范围、多少精度。常见三类:
| dtype | 字节 | 用途 |
|---|---|---|
float32 | 4 | 默认浮点。训练/推理的通用选择,精度足够。 |
bfloat16 (bf16) | 2 | 混合精度训练。指数位与 float32 相同,所以可表示的数量级范围几乎一样,但尾数大幅缩短、精度低;省一半显存、算得快。 |
int64 (long) | 8 | 整数。索引、类别标签(如交叉熵的 target)默认就是它。 |
用 NumPy 把"dtype 不匹配会发生什么"这件事看清楚。注意:截断 / 溢出回绕的规则 NumPy 和 PyTorch 一致,但混合 int 与 float 的具体提升目标两者不同——下面先看 NumPy 的行为,PyTorch 的差异在参考块里说明。
import numpy as np
# 1) 混合 dtype 运算:NumPy 自动提升到能容纳两者的类型
xi = np.array([1, 2, 3], dtype=np.int64)
yf = np.array([0.5, 0.5, 0.5], dtype=np.float32)
z = xi + yf
print("提升结果 dtype:", z.dtype, z) # float64(不是 float32!)
# 为什么是 float64 而不是 float32?因为 int64 的精度需求超过 float32 能安全容纳的范围,
# NumPy 于是提升到更宽的 float64。这正是"混合 int 与低位浮点要小心"的典型坑。
# 2) 向下转换会截断/溢出回绕
big = np.array([100, 200, 300], dtype=np.int64).astype(np.int8)
print("int64 -> int8:", big) # [100 -56 44] 超出 [-128,127] 回绕
# 提示:新版 numpy 对这种溢出可能给出 DeprecationWarning/RuntimeWarning,
# 看到黄色/红色警告不代表程序出错,只是提醒你正在丢信息。
# 3) float32 精度丢失
f64 = np.array([0.1], dtype=np.float64)
print("float64:", float(f64[0]))
print("float32:", float(f64.astype(np.float32)[0])) # 0.10000000149...
真实 API(指针)。PyTorch 的 dtype 规则和 NumPy 有两处差别要记住:① 混合 int/float 时 PyTorch 倾向保留 float32,不会像 NumPy 那样升到 float64;② 很多操作根本不会自动提升,而是直接拒绝、抛 RuntimeError。
import torch
a = torch.tensor([1, 2, 3]) # int64
b = torch.tensor([1.0, 2.0, 3.0]) # float32
# 差别①:混合 int64 + float32,PyTorch 结果是 float32(不是 NumPy 的 float64)
print((a + b).dtype) # torch.float32
# 差别②:很多 op 不自动提升,dtype 不匹配直接报错。例如交叉熵
# (F = torch.nn.functional;交叉熵细节第6课讲,这里只看一点:标签 dtype 必须是 int64/long):
# import torch.nn.functional as F
# loss = F.cross_entropy(logits, labels.float()) # 报错:labels 必须是 long,不能是 float
x = torch.randn(4).to(torch.bfloat16) # 转 bf16
print(x.dtype) # torch.bfloat16
y = a.float() # int64 -> float32 的简写
z = a.to(torch.float32) # 等价
调一调,观察现象
微任务 1:把切片换成 copy,观察共享是否断开。
- 改一个数:把
b = a[1:3]改成b = a[1:3].copy()。 - 预期现象:改
b[0]后a不再变化,shares_memory变False。 - 为什么:切片是视图、共享内存;
.copy()分配了一块新内存。这正对应 torch 里from_numpy(共享)与torch.tensor(a)(拷贝)的区别。
import numpy as np
a = np.array([1, 2, 3, 4], dtype=np.float32)
b = a[1:3].copy() # 改这里:去掉 .copy() 再跑一次对比
b[0] = 99
print("a =", a)
print("共享内存?", np.shares_memory(a, b))
微任务 2:reshape 一个连续 vs 不连续的数组,看结果顺序。
- 改一个数:把
m = a与m = a.T互换。 - 预期现象:连续时 reshape 得
[0 1 2 3 4 5];转置后得[0 3 1 4 2 5]。 - 为什么:reshape 按逻辑顺序读元素;转置改了逻辑顺序但没动物理内存,于是结果重排。关键对照:NumPy 的 reshape 在不连续时会自动拷贝并照常返回;而 torch 里此时
.view(6)会直接抛 RuntimeError,必须显式.contiguous()或改用.reshape。这是本课最高频的 view 报错来源。
import numpy as np
a = np.arange(6).reshape(2, 3)
m = a.T # 改这里:换成 m = a 再跑一次
print("连续?", m.flags['C_CONTIGUOUS'])
print("reshape(6):", m.reshape(6))
微任务 3:向下转 dtype,触发溢出回绕。
- 改一个数:把
np.int8换成np.int16。 - 预期现象:int8 下
300回绕成44;int16 范围够大,原样保留。 - 为什么:dtype 决定可表示范围,超出就回绕。深度学习里把 float32 标签误转 float16、或把大索引塞进小整型,都会悄悄出错。(新版 numpy 可能对溢出给出警告,属正常提醒,不是程序崩溃。)
import numpy as np
v = np.array([100, 200, 300], dtype=np.int64)
print(v.astype(np.int8)) # 改这里:换成 np.int16
动手练习
- 共享内存侦探。写一个 NumPy 例子:构造数组
a,做三种操作——切片、reshape(连续时)、.T——分别用np.shares_memory(a, ...)判断它们是否与a共享内存。预测后再跑。然后用一句话写出对应的 torch 结论:from_numpy共享、clone不共享。 - 修复 view 报错。给定一段(写在纸上或注释里的)torch 伪代码:
x = torch.arange(24).reshape(2, 3, 4); y = x.permute(1, 0, 2); z = y.view(2, 12)。指出哪一行会报错、为什么,并写出两种修复(用.contiguous()和用.reshape),说明它们在内存上的区别。 - 设备/精度清单。为一个"把 CPU 上的 float64 numpy 数组喂给 GPU 上 float32 模型"的场景,按顺序写出需要做的转换调用(device 与 dtype 各一次),并解释如果漏掉任一步会触发哪类报错。
掌握自检
- 我能说出张量比 ndarray 多的三个属性,并在打印里认出它们。
- 给定一个操作,我能预测它是否共享内存、改一个会不会动另一个。
- 我能解释 transpose 后
.view报错的原因,并写出.contiguous().view(...)和.reshape(...)两种修复。 - 我能用一个
device变量把数据和模型搬到一起,并认出 "device mismatch" 报错的成因。 - 我知道 float32 / bf16 / int64 各自的典型用途,能预判向下转 dtype 时的截断/溢出,也知道 PyTorch 混合 int/float 的提升目标与 NumPy 不同。
可以先放过的点
- in-place 操作的细节(
add_、mul_这类带下划线的方法会原地改内存)。现在只需记住:它们存在、能省显存、但和 autograd 会冲突。等到下一课讲 autograd、看到 "a leaf Variable that requires grad is being used in an in-place operation" 报错时再回来。 - F 连续 / 内存步长(stride)的精确计算。知道"转置不动内存、所以不连续"就够写代码了。等到你要手写高性能 CUDA kernel 或调试奇怪的 stride bug 时再深入。
- bf16 vs fp16 的取舍、混合精度的 GradScaler。这属于训练优化。等到模型大到显存吃紧、或训练慢需要提速时再回来。
pin_memory/ 异步.to(non_blocking=True)等数据搬运优化。等到 DataLoader 成为瓶颈时再学。
顺带交代路线:本课的 PyTorch 块只是"参考指针",真正的手写实现在后两课——第4课会带你手写一个可跑的 micrograd(自己实现 autograd),第5课手写迷你 Module。先把机制吃透,封装的库随后自然顺手。