ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NumPy与PyTorch张量转换避坑指南:dtype、内存与设备

NumPy与PyTorch张量转换避坑指南:dtype、内存与设备

做深度学习的人,每天都要跟 numpy 和 pytorch 这两个库打交道,而它们之间的数据类型转换,看起来就是个“张量转来转去”的小事,实际上却是无数隐蔽 bug 的源头。我见过不少同学卡在“为什么我的损失突然变成 NaN 了”“为什么 GPU 上内存爆了”“为什么转出来的数组改了,原来的 tensor 也跟着变了”这类问题里,最后定位下来,全是 dtype 或转换方式没搞对。这篇内容,我就把这几年实打实踩过的坑、验证过的做法,从底层原理到实战代码,一次性梳理清楚,保证你看完能避开 90% 的转换陷阱。

1. 先搞清楚底层存储:numpy 和 pytorch 都离不开 dtype

1.1 dtype 到底是什么,为什么它决定内存和计算速度

dtype 是 data type 的缩写,用来描述数组里每个元素的数据类型。numpy 里的np.float32、np.int64、np.uint8,pytorch 里的torch.float32、torch.int64、torch.uint8,说的都是同一件事:这个数组或张量里的元素是多少位的、有符号还是无符号、是浮点还是整型。

你可以把 dtype 想成是装货物的“集装箱规格”。同样是装一万个数字,用int8装每个箱子只要 1 个字节,用int64装每个箱子要 8 个字节。内存占用差了 8 倍,读写带宽、运算速度自然也差出几个量级。尤其是在 GPU 上,显存是稀缺资源,你把一个本来该用float32的模型参数误转成float64,显存可能直接翻倍,训练速度立刻变慢,这往往不是代码逻辑问题,就是 dtype 选错了。

dtype 还决定了运算的语义。比如两个uint8数组相加,结果可能因为溢出而变成一个错误的小数;一个float64的数组和一个float32的数组做运算,numpy 和 pytorch 的自动提升规则也不完全一样。所以,先记住这句话:任何时候看到数据异常、内存异常、计算结果异常,第一件事就是检查 dtype 和设备。

1.2 numpy 和 pytorch 的 dtype 映射关系

numpy 和 pytorch 虽然各自定义了一套数据类型名称,但绝大多数是直接对应的,不过有几个容易混淆的点。下面这张表是老程序员手边常备的:

numpy dtypepytorch dtype位宽说明
np.float32torch.float32/torch.float32位深度学习默认精度,最常用
np.float64torch.float64/torch.double64位科学计算常用,显存/内存占用高
np.int64torch.int64/torch.long64位pytorch 中标签、索引默认类型
np.int32torch.int32/torch.int32位某些操作不支持,容易踩坑
np.int16torch.int1616位不常用
np.int8torch.int88位量化模型常用
np.uint8torch.uint88位图像数据极常用,0-255
np.bool_torch.bool8位布尔类型
np.complex64/128torch.complex64/128对应复数类型,注意 pytorch 版本支持

这里要特别提醒两个容易忽略的点。第一,pytorch 的默认整型标签是torch.int64,也就是torch.long,如果你从 numpy 转过来时数组是np.int32,直接from_numpy得到的 tensor 就是int32,喂给nn.CrossEntropyLoss大概率会报错或者精度不对,因为交叉熵损失期望目标索引是int64。第二,numpy 支持字符串类型的数组(比如dtype='<U12'),但 pytorch 的 tensor 完全不支持字符串,如果你从 CSV 读进来一个object或str类型的数组,直接转 tensor 必然报错。这种必须先做编码映射,比如把类别字符串映射成整数。

2. 最常用的转换场景:numpy 到 tensor,tensor 到 numpy

2.1 numpy 转 tensor:torch.from_numpy和torch.tensor到底选谁

先看最常用的两种写法:

import numpy as np import torch arr = np.arange(6, dtype=np.float32).reshape(2, 3) # 写法一:共享内存 t1 = torch.from_numpy(arr) # 写法二:复制数据 t2 = torch.tensor(arr)

torch.from_numpy(arr)返回的 tensor 和原来的 numpy 数组共用同一块底层内存。这意味着你对t1做任何修改,都会直接影响arr;反过来也是。这种零拷贝的方式在数据预处理、数据增强时非常香,不需要额外的内存和时间开销。

torch.tensor(arr)则一定会把数据拷贝一份,生成的新 tensor 和原 numpy 数组没有任何内存关系。此外,torch.tensor对 dtype 的推断也比较“聪明”,它总是尝试把 numpy 的 dtype 映射到 pytorch 的对应类型,但有时你传入一个np.float64,它会保留成float64,这不一定是你想要的深度学习默认精度。

在实际项目里,我的习惯是:如果是临时转换,不担心内存,就用torch.tensor;如果是要在训练循环里频繁使用同一份数据,或者希望共享内存减少拷贝,就用torch.from_numpy。但共享内存有副作用,后面专门讲。

注意,torch.from_numpy只支持数值型数组,不支持np.str_、np.object_,也不支持np.ndarray的子类像np.ma.MaskedArray。遇到底层数据不是标准连续内存的数组,最好先np.ascontiguousarray再转。

2.2 tensor 转 numpy:.numpy()与.cpu().numpy()

从 tensor 转回 numpy 有两种常见情况:

# CPU tensor 直接转 t = torch.arange(6, dtype=torch.float32).reshape(2, 3) arr = t.numpy() # 梯度张量需要先 detach t = torch.randn(2, 3, requires_grad=True) arr = t.detach().numpy()

对 CPU 上普通的 tensor,直接.numpy()得到的 numpy 数组和 tensor共享内存,修改任意一方都会影响另一方。如果你希望得到独立副本,可以先.clone().numpy()或.cpu().detach().numpy().copy()。

一个非常常见的报错是:

RuntimeError: Can't call numpy() on Tensor that requires grad. Use tensor.detach().numpy() instead.

因为带有requires_grad=True的张量参与反向传播计算图,numpy 不支持自动求导,所以框架强制你调用.detach()把它从计算图上摘下来。如果你只是想把梯度打印出来看一看,可以weight.grad.detach().numpy(),千万别直接.numpy(),那必然报错。

2.3 共享内存带来的副作用:修改一方,另一方会跟着变

这一节要重点讲,因为它造成的 bug 非常隐蔽。看例子:

arr = np.ones((2, 2), dtype=np.float32) t = torch.from_numpy(arr) t[0, 0] = 99 print(arr) # [[99. 1.] # [ 1. 1.]]

看到了吗?改了 tensor,原 numpy 数组也跟着变成了 99。反过来也一样:

t = torch.arange(4, dtype=torch.float32).reshape(2, 2) arr = t.numpy() arr[1, 1] = 123 print(t) # tensor([[ 0., 1.], # [ 2., 123.]])

这在什么场景下会坑到你?比如你对 numpy 数组做了归一化,然后torch.from_numpy转了 tensor,后来又因为别的逻辑改了 tensor 里的值,那你后面再用原 numpy 数组时就可能拿到被污染的数据。有时这种变化是有意的,但如果你不清楚底层共享内存,调试起来会非常让人抓狂。

如果需要彻底断开共享,做法是:

arr = np.ones((2, 2), dtype=np.float32) t = torch.tensor(arr) # 已经是拷贝 # 或者由共享 tensor 再 clone 一份 t2 = torch.from_numpy(arr).clone()

记住一条经验法则:用torch.tensor或者clone()换拷贝,用torch.from_numpy和.numpy()换共享。明确你此刻到底需不需要共享,就不会出诡异问题。

3. 深度转换:强制改 dtype 与精度管理

3.1 numpy 的.astype()和 pytorch 的.to(dtype)

上面讲的都是保持 dtype 不变地互转,但实际项目里更多时候还需要改变 dtype 本身。numpy 里最常用的是astype:

arr = np.array([1.2, 2.7, 3.5], dtype=np.float32) arr_int = arr.astype(np.int64) print(arr_int) # [1 2 3]

注意,astype默认返回一个新的数组,即使目标和原 dtype 一样,一般也会返回拷贝(当然不能依赖这一点)。转换时小数部分会被直接截断,不会四舍五入,这在很多数据处理场景是个隐形坑。

pytorch 里对应的方法最常见的是.to(dtype),或者快捷方法.float()、.double()、.half()、.long()、.int()等:

t = torch.tensor([1.2, 2.7, 3.5]) t_int = t.to(torch.int64) t_float = t.float()

.to()的语义更丰富,它可以同时指定设备、dtype,比如tensor.to(device='cuda', dtype=torch.float16),是统一控制的好工具。但要注意,tensor.to(dtype=...)和tensor.float()都是返回值,不会原地修改原 tensor。很多人习惯写t = t.float()赋值,如果忘写赋值,原 tensor 的 dtype 一点没变,后续代码就会用错。

.to()底层其实有拷贝,是从原 tensor 生成新 dtype 的数据,但它的内存分配不一定和原 tensor 共享。.float()等快捷方法本质上也是调用了.to(torch.float32)。

3.2 float16、float32、float64:精度选择和转换代价

深度学习里最常用的三个浮点精度是:

  • float32:大部分模型的默认参数类型,也是 PyTorch 训练时的默认精度。
  • float16:混合精度训练(AMP)中的主力,显存占用量减半,但数值范围比 float32 小很多,容易上溢/下溢。
  • float64:通常用于科学计算、数值稳定性要求高的算法,numpy 里的默认浮点类型。

从高精度转低精度,最直接的影响是精度丢失。比如:

arr64 = np.array([0.123456789, 1.0000001], dtype=np.float64) arr32 = arr64.astype(np.float32) print(arr32) # [0.12345679 1.0000001] —— 后面的有效位被舍掉了

如果数值超出目标精度的表示范围,甚至会变成 inf:

big = np.float64(1e300) print(big.astype(np.float32)) # inf,同时触发 RuntimeWarning

反过来,从 float16 升到 float32 一般不会出问题,只是白白多占了内存。所以转换的原则是:只在需要的时候升精度,不要为了“保险”把所有数据都转成 float64,否则显存和速度都会很受伤。

在混合精度训练中,一个常见操作是把模型参数在 forward 前临时转成半精度,但 loss 和 optimizer 步骤通常还是保持 float32,如果这里转换时机不对,或者把 losshalf()成 float16,很容易导致数值不稳定。我的建议是,混合精度训练直接用 PyTorch 官方的torch.amp.autocast上下文管理器,让框架自动处理精度,不要手动到处half()。

3.3 转换中的坑:溢出、截断、布尔与整数互转

这是一个特别容易被坑的领域,我列几个真实踩过的例子。

第一,浮点转整型是截断,不是四舍五入:

arr = np.array([3.7, -3.2]) print(arr.astype(np.int32)) # [3 -3]

向零方向截断,正数和负数的行为都是舍去小数部分。如果需要四舍五入,先用np.round()再用astype。

第二,有符号转无符号会“爆掉”:

x = np.array([-1, 127, 128], dtype=np.int8) print(x.astype(np.uint8)) # [255 127 128]

原因很简单,-1在内存中的二进制存储是11111111,按无符号解读就是 255。这在图像处理里特别常见,如果你图像数据里有负数或者超过 255 的值,直接转 uint8 会得到一团完全错乱的数据。正确的做法是先clip到目标范围再转。

第三,整型转布尔:0 是 False,非 0 都是 True。这个大家基本知道,但反过来布尔转数字时,True 变成 1,False 变成 0,有时会影响后续矩阵运算的语义,比如把 masked 矩阵直接astype(np.float32)会得到一堆 1 和 0,如果你本意是用布尔掩码做索引,那就没问题,但如果要保留原始值,就不能这么转。

第四,PyTorch 里float64和float32的传递。nn.Linear的权重默认是float32,如果你把输入喂成float64,许多层会直接报类型不匹配错误。所以在模型推理时,输入 tensor 的 dtype 必须和模型参数一致,一般统一转成float32后再进入模型。

4. 复杂场景实战:GPU、非连续内存、形状陷阱

4.1 GPU tensor 转 numpy 必须经过 CPU

很多新手第一次在 GPU 上跑模型,想把中间结果打印成 numpy,直接写:

t = torch.randn(3, 3, device='cuda') arr = t.numpy() # 报错

这必然报错。因为.numpy()支持的底层内存是 CPU 内存,GPU 显存里的数据需要先拷回 CPU,也就是.cpu():

arr = t.cpu().numpy()

这里有个细节:.cpu()返回的 tensor 是一个新的 CPU tensor,它与原来的 GPU tensor 不再共享显存,但之后对它.numpy()会与这个新 CPU tensor 共享内存。也就是说,你修改arr,只会影响.cpu()生成的那个临时 tensor,不会影响 GPU 上的原始数据。如果你想让 GPU 数据共享内存到 numpy,那是不可能的,除非先把它放到 CPU。

从 numpy 转到 GPU tensor 也一样:

arr = np.arange(6, dtype=np.float32) t_gpu = torch.from_numpy(arr).cuda()

注意,from_numpy先创建一个共享内存的 CPU tensor,然后.cuda()会再拷贝一份到 GPU。所以如果你不需要 CPU tensor,这个过程会有一次不必要的数据传输和临时内存开销。更直接的方式是torch.tensor(arr, device='cuda'),但它默认是拷贝,不会共享。

在训练循环里,频繁tensor.cpu().numpy()是一个很大的性能杀手。因为它会让 GPU 同步等待 CPU 拷贝,打断异步流水线。正确做法是只在每个 epoch 验证、或者最后可视化时做这种转换,不要在每个 batch 里都打印中间状态。

4.2 非连续内存:.contiguous()为什么不能随便省

先看一个经典报错:

t = torch.arange(6).reshape(2, 3).t() # 转置后 t2 = t.view(6) # RuntimeError: view size is not compatible with input tensor's size and stride

原因不是 view 大小不对,而是t在底层内存里不连续。PyTorch 的 tensor 除了数据,还保存 stride(步长),用来描述每个维度在内存里跳多少字节。转置、permute、某些切片操作会生成新的 stride,但底层数据顺序还是原来的,这时候张量变成了“视图”而不是“翻新拷贝”。

如果把这个非连续的 tensor 转 numpy 或强制转换 dtype,一些旧版 PyTorch 会直接报错:

ValueError: NumPy conversion is currently only supported for contiguous tensors.

新版本可能做了自动处理,但会悄悄多复制一份数据,而且可能导致共享内存失效,行为不够直观。所以我的习惯是,遇到非连续 tensor,要么直接.contiguous(),要么用.reshape()代替.view()。

.contiguous()会重新分配一块连续内存,把数据按逻辑顺序搬过去,返回一个新的连续 tensor。它不是“原地修改”,需要赋值给新变量。

在 numpy 转 pytorch 时也可能遇到类似问题。比如你有一个经过切片得到的 numpy 子数组,它的数据可能不是 C 风格连续(特别是Fortranorder 或转置视图)。torch.from_numpy对这种数组会给出警告或者直接报错。稳妥做法是先np.ascontiguousarray(arr)把内存排布整理成连续,再转。

arr = np.random.rand(4, 5).T # 非连续 arr_c = np.ascontiguousarray(arr) t = torch.from_numpy(arr_c)

4.3 形状与维度陷阱:0 维数组、批量维、顺序问题

转换时形状不会自动变化,但有一个经常被误解的情况:标量 tensor 转 numpy 得到的是 0 维数组,而不是普通的 Python 标量。

t = torch.tensor(3.14) arr = t.numpy() print(arr.shape) # () print(float(arr)) # 3.14

如果你只是要数值,最好用t.item(),既快又能拿到 Python 原生float或int。numpy的标量展示可能很长,而且有些库对 0 维数组的处理不友好。

另一个坑是维度顺序。numpy 通常使用(H, W, C)表示图像,而 PyTorch 里的图像张量习惯是(C, H, W)。如果你直接from_numpy把一张(H, W, C)的图喂给卷积层,会得到一堆维度错误。转换完记得permute(2, 0, 1)调整通道顺序,同时注意 permute 之后张量不连续,再接.contiguous()。

还有批量维。模型 inference 时输入要求四维(N, C, H, W),单张图片转过来是三维(C, H, W),需要用.unsqueeze(0)加批维;等输出回来要可视化时又抱成三维,用.squeeze(0)。这种操作和 dtype 转换叠加在一起时,最好写一个统一的数据预处理函数,保证输入输出的一致性。

5. 常见问题与排查技巧实录

5.1 常见报错速查表

我把这几年在 numpy/tensor 转换中遇到的典型报错整理成一张表,直接照着查:

报错信息原因解决方案
Can't call numpy() on Tensor that requires grad. Use tensor.detach().numpy()张量带梯度信息,处于计算图中先.detach().numpy()
RuntimeError: Expected object of device type cuda but got device type cputensor 与操作对象的设备不一致确认两边都在 CPU 或都.cuda();GPU tensor 转 numpy 前先.cpu()
ValueError: NumPy conversion is currently only supported for contiguous tensors张量底层内存不连续先.contiguous()再转
TypeError: can't convert np.ndarray of type numpy.str_numpy 数组是字符串类型映射为整数/数字后转换
RuntimeError: expected scalar type Float but found Double输入 dtype 与模型参数 dtype 不一致统一转成torch.float32
RuntimeWarning: overflow encountered in cast数值超出了目标 dtype 表示范围先 clip 或换更高精度
转换后的数组修改影响了原数组使用了共享内存的转换方式确认语义,用.copy()/torch.tensor断开

5.2 训练循环里的性能警告

不要小看“打印一个 tensor”这件事。如果你的代码里每个 step 都做tensor.detach().cpu().numpy(),这一瞬间 GPU 必须把数据拷到 CPU,而且可能阻塞后续的异步计算。实测同一个训练循环,在验证阶段做numpy()和只在最后做,整体 epoch 时间能差 20% 以上。所以我一般会把需要转换的中间量攒下来,一个 epoch 结束后统一处理,或者在 DataLoader 的 collate 阶段就把数据转好,运行过程中尽量不碰 numpy。

5.3 我常用的几个小技巧

第一,写一个to_numpy的包装函数,统一处理梯度、设备、连续性问题:

def to_numpy(tensor): if tensor.requires_grad: tensor = tensor.detach() if tensor.is_cuda: tensor = tensor.cpu() if not tensor.is_contiguous(): tensor = tensor.contiguous() return tensor.numpy()

这样每次一行就能拿到稳定的 numpy 数组,再也不会因为漏了detach或cpu而卡半天。

第二,在 debug 时打印张量信息,不要只打印值。一个函数可以同时打印 shape、dtype、device、是否连续:

def debug_tensor(t, name="tensor"): print(f"{name}: shape={t.shape}, dtype={t.dtype}, device={t.device}, contiguous={t.is_contiguous()}")

这比单纯print(t)有用得多,尤其是排查形状和精度问题时。

第三,检查两个数组是否共享内存,可以用 numpy 的np.shares_memory(a, b)。比如不确定from_numpy是否真的共享,直接判断一下,心里有数。

第四,在模型保存和加载时,注意参数 dtype。比如有些 checkpoint 保存的是float16,加载到 CPU 之后模型也是half(),这时喂float32的输入就可能报错。加载后统一model.float(),并在输入侧也统一 dtype,是省心做法。

这些技巧单看都不难,难的是把它们变成习惯。我个人在实际操作中的体会是,numpy 和 pytorch 的转换翻车大多不是因为 API 记不住,而是因为没搞清“共享”和“拷贝”、“CPU”和“GPU”、“连续”和“非连续”这三对概念。你把这三对概念刻进脑子,再配合一个万能的to_numpy包装函数,大部分转换问题基本就不会再来找你。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进