4. 二进制格式基础与NumPy数组持久化

在科学计算中,文本格式(如CSV)虽然便于人眼阅读,但存在精度损失、体积膨胀和解析缓慢的问题。当数组规模达到百万级元素时,二进制持久化几乎是唯一务实的选择。NumPy 提供了两种原生二进制容器:单数组的 .npy 与多数组打包的 .npz。理解它们的字节级结构,是后续与 C/Fortran 程序、原始二进制流互操作的前提。

一、.npy 文件的物理结构

.npy 文件由三部分组成:一个 6 字节的魔数 \x93NUMPY、2 字节的版本号(主版本+次版本),随后是一个 2 或 4 字节的头部长度字段,最后是头部字典与数组数据本体。头部字典是一个 Python 字面量形式的字符串,描述数组的 dtype、形状(shape)、Fortran 顺序标志(fortran_order)等元信息。读取时,解析器先校验魔数,再按头部长度定位数据起始偏移,之后便可直接按 dtype 解释后续字节。

关键点在于:数据本体是连续的原始字节,没有任何分隔符或压缩。这意味着 .npy 的载荷部分与“原始二进制文件”在字节层面完全同构,差别仅在于前面多了一段自描述头部。这一性质正是互转的理论基础。

二、字节序与内存布局

字节序(endianness)决定多字节数值在内存中的排列方向。NumPy 的 dtype 字符串中,< 表示小端(低位字节在前),> 表示大端,= 表示本机序。例如 int32 值 1 在小端下字节为 01 00 00 00,大端下为 00 00 00 01。跨平台交换数据时,若两端字节序不一致而不做转换,读出的数值将完全错误——这是二进制互操作最常见的故障源。

内存布局则涉及 C 顺序(行优先)与 Fortran 顺序(列优先)。对于二维数组 A[i, j],C 顺序下相邻元素沿最后一维连续,Fortran 顺序下沿第一维连续。NumPy 默认使用 C 顺序,但通过 np.asfortranarray 或 order='F' 可切换。布局不一致时,即使字节序正确,按错误顺序解释也会得到转置般的错乱结果。因此互转前必须显式确认 dtype.byteorder 与 flags['C_CONTIGUOUS']。

.npy 文件结构与原始二进制互转关系 .npy 文件字节布局 魔数 \x93NUMPY 版本 1.0 头长度 2/4 字节 头部字典 dtype / shape / fortran_order 数组数据本体(原始字节) 连续、无分隔、无压缩 剥离头部 原始二进制文件 .bin 仅含数据字节,需外部约定 dtype/shape int32 值 1 的字节序对比 小端 <: 01 00 00 00 大端 >: 00 00 00 01 2×3 数组内存布局 C 顺序: a00 a01 a02 a10 a11 a12 F 顺序: a00 a10 a01 a11 a02 a12 互转核心:.npy 载荷 ≡ 原始二进制;字节序与布局必须两端一致,否则数值错乱

三、.npz 的多数组打包

.npz 本质是一个 ZIP 归档,内部每个成员都是一个独立的 .npy 文件,成员名即数组键名。使用 np.savez 保存多个数组,np.load 读取时返回一个类似字典的对象,按需惰性加载各成员。由于 ZIP 支持 DEFLATE 压缩,np.savez_compressed 可显著减小体积,代价是读写时多一次解压开销。对于含大量重复值或稀疏结构的科学数据,压缩收益往往可观;而对已接近随机分布的浮点数据,压缩率有限。

四、与原始二进制互转的实现

从 NumPy 数组写出原始二进制,核心是 tofile 或 tobytes。前者直接写入文件,后者返回字节串。写入前必须确保数组是连续且字节序明确的:

import numpy as np

a = np.arange(6, dtype='<i4').reshape(2, 3)   # 小端 int32
a.astype('<i4').tofile('data.bin')            # 写出原始字节

# 读回时必须显式提供 dtype 与 shape
b = np.fromfile('data.bin', dtype='<i4').reshape(2, 3)
assert np.array_equal(a, b)

反向操作中,np.fromfile 只依据 dtype 解释字节流,形状信息完全丢失,必须由调用方按约定 reshape。这正是原始二进制“轻量但无自描述”的代价。若数据来自大端机器,应写成 dtype='>i4',或读入后用 byteswap() 转换。

与 .npy 互转时,可借助 np.load 读出数组再 tofile;反之,用 fromfile 重建数组后 np.save。若需保留 Fortran 布局,应在保存时传 order='F',并在读取端用相同顺序解释。

五、验证策略

互转是否成功,不能只看“文件生成了”。可靠的验证包含三层:其一,字节级比对,对同一数组分别经 .npy 载荷与 tofile 写出的文件做逐字节比较;其二,数值回读比对,用 np.array_equal 或对浮点用 np.allclose 并设定合理容差;其三,元信息核对,检查 dtype、shape、flags['C_CONTIGUOUS'] 与 dtype.byteorder 是否符合预期。对跨平台场景,还应在一端故意以错误字节序读取,确认能得到可识别的异常数值,从而证明字节序处理逻辑确实生效。

实践建议:在项目内约定“原始二进制必须附带一份 JSON 元数据(dtype、shape、order、byteorder)”,可弥补 .bin 无自描述的缺陷,同时保留其零开销优势。

综上,.npy 是“自描述头部 + 原始载荷”的组合,.npz 是多个 .npy 的 ZIP 封装;原始二进制则是剥离头部后的纯载荷。三者互转的技术关键始终落在字节序与内存布局这两个维度上,而验证则需同时覆盖字节、数值与元信息三个层面。