Numpy数据存储与读取:二进制、文本与内存映射实战
上一轮我做数据交接时吃了好大一个亏给同事传一份中间结果对方张口就要CSV我说用Numpy原生的.npy格式又快又省空间他不信最后拿1.2GB的文本文件把工作站内存差点打满。其实这种场景在Numpy里很常见——大家学完数组创建、索引、广播之后往往会忽略一个非常关键的问题数据到底该怎么落地、怎么再拿回来。这篇Numpy简单教程的第六篇我就把Numpy中数据的存储和读取系统盘一遍从二进制到文本、从小数组到几十GB的大文件把常见选型和坑都讲清楚。1. 为什么明明有CSV还要学Numpy的二进制存储1.1 文本文件把时间和内存都吃掉了很多人习惯把Numpy数组直接写成CSV理由是通用、别人能打开、Excel能看。但只有真正处理过大数据的人才知道这个习惯有多贵。CSV存储的是数组每个元素的字符串形式比如一个float64的数字0.123456789在内存里只占8字节写成文本后可能变成10到20个字符体量直接翻几倍读取的时候还得逐字符解析、转类型这个过程比直接从内存块拷贝数据慢了不止一个数量级。更隐蔽的问题是精度。float64用文本保存时如果你用默认的str()转换小数位可能被截断加载回来之后数值就不再是你原来算的那个数了。做科学计算的人应该都有过这种体验存了一版CSV第二天读进来一对比发现误差飘了排查半天才发现是文本格式丢失了精度。1.2 二进制格式的底层逻辑Numpy数组在内存里并不是一堆Python对象的集合而是一整块紧凑的连续内存区配上shape、dtype、strides这些元数据来描述怎么解释这块内存。所以最自然的持久化方式就是把元数据原始二进制字节原样写到磁盘上。这正是.npy格式做的事情文件头记录 dtype、shape 等信息文件体就是数组内存块的直接拷贝。这样设计带来的好处非常直接加载快读文件时只需要解析头部然后整块数据拷进内存就行了不需要逐行转类型。体积小float64就是8字节没有任何额外字符开销。无精度损失字节是什么样读回来就是什么样。我经常用一句话说明差距对于1GB的float64数组CSV可能膨胀到2GB以上加载耗时十几秒到几十秒而.npy文件稳定在1GB左右加载通常几百毫秒到一两秒。如果你的工作流里要反复读写中间结果二进制格式几乎是最省心的选择。2. .npy与.npzsave、savez到底在做什么2.1 save和load单个数组的最小闭环Numpy自带一组专门用于二进制存储的函数核心就是save和load。先看最基础的单数组存取import numpy as np # 创建一个示例数组 arr np.arange(12).reshape(3, 4).astype(np.float32) # 保存到磁盘 np.save(my_array, arr) # 会自动补全为 my_array.npy # 重新读回来 loaded np.load(my_array.npy) print(loaded.shape, loaded.dtype) # (3, 4) float32有几个细节值得注意。第一np.save在文件名没有.npy后缀时会自动补全但如果你显式传了其他后缀它也不会纠正。第二np.load既可以读完整路径也可以不带后缀读取它内部会尝试补全.npy或.npz。第三save和load都是针对单个数组的如果你把一个字典、列表之类的Python对象塞进去npy文件里存的实际是pickle序列化后的字节这就要注意后面会讲的allow_pickle安全问题。2.2 savez与savez_compressed多数组打包别再用一堆文件堆散装实际项目中我们往往不止要存一个数组。比如训练一个模型你可能同时要保存特征矩阵、标签向量、样本ID、训练参数这些信息。如果每个数组单独存一个.npy文件会越来越多拷贝、压缩、管理都麻烦。Numpy的答案是.npz文件它本质上是一个不压缩的ZIP归档包里面的每个key都对应一个.npy文件。import numpy as np x np.random.rand(100, 10) y np.random.randint(0, 2, size100) meta np.array([experiment-2024, v1.0]) # 保存多个数组 np.savez(result.npz, featuresx, labelsy, infometa) # 读取方式 data np.load(result.npz) print(data.files) # [features, labels, info] print(data[features].shape) # (100, 10)这里有个很实用的进阶技巧用np.load打开.npz文件后得到的是一个NpzFile对象它并不像普通数组那样一次性把全部数据都读进内存。也就是说你可以先打开文件看看里面有哪些key再按需读取某一个数组。对于包含很多大数组的归档文件这种惰性读取能帮你省下大量内存。如果磁盘空间紧张建议换成np.savez_compressednp.savez_compressed(result_compressed.npz, featuresx, labelsy, infometa)它的实现是对每个数组做压缩后再放进ZIP包。features这类随机浮点数压缩率一般但包含大量重复值或零值的数组压缩率会非常可观。代价是保存和读取时多了压缩解压的CPU开销而且不能像普通.npz那样直接获得原始大小的数组映射。我的习惯是临时中间结果用savez需要长期归档或传输时用savez_compressed。2.3 文件头里到底藏了什么如果你好奇.npy文件为什么能做到跨平台读取我们可以直接打开文件看一眼它的头部结构。.npy文件的前几个字节是固定的魔数\x93NUMPY然后是版本号、头部长度、头部字典和补位对齐。头部字典里记录的就是dtype、shape和存储顺序C-contiguous还是Fortran-order。我用文本方式读取一个.npy文件时通常看到的是这样一段内容\x93NUMPY\x01\x00v\x00 {descr: f8, fortran_order: False, shape: (3, 4), }这个自描述特性意味着只要对方环境里有Numpy打开文件时就不需要额外告诉它数据类型和形状。文件本身已经把这些信息带上了。这也是我强烈不建议手动改.npy文件的原因一旦头部信息损坏Numpy可能直接抛错而且很难恢复。3. 文本存取loadtxt与genfromtxt的性能取舍3.1 savetxt把数组落成人类能看的CSV/TSV虽然二进制格式在性能和体积上完胜但有些场景绕不开文本给业务方交数据、做审计留痕、需要Excel里点开看。Numpy提供了一组文本读写函数最基础的是savetxt。import numpy as np arr np.random.rand(5, 3) # 保存为CSV显式指定分隔符和格式 np.savetxt(data.csv, arr, delimiter,, fmt%.6f, headercol1,col2,col3) # 读取 loaded np.loadtxt(data.csv, delimiter,, skiprows1)fmt参数非常关键。如果不指定默认会使用%.18e之类的高精度格式文件会大很多如果格式精度太低又会影响后续计算。我的做法是浮点数一般用fmt%.8g既保证了有效数字也不至于过度膨胀。整数数组则用fmt%d免得文件里出现1.000000e00这种反人类的内容。3.2 loadtxt性能不错但对脏数据不友好loadtxt的读取路径是用C语言实现的速度在文本读取里算是快的。常用参数包括delimiter、skiprows、usecols、dtype、unpack等。举个例子一个带表头且只需要其中两列的CSVdata np.loadtxt(data.csv, delimiter,, skiprows1, usecols(0, 2), dtypefloat)如果unpackTrue返回值会从二维数组变成一列一列的元组方便直接解包成多个一维数组x, y np.loadtxt(data.csv, delimiter,, usecols(0, 1), unpackTrue)但loadtxt的容忍度很低。缺一行、某列是 NA、或者文件头有一行不是以#开头都可能直接报错。它默认要求每行字段数一致如果文件里有空值结果基本就要翻车。这类脏数据场景我一般直接改用genfromtxt。3.3 genfromtxt处理缺失值、表头和异构列genfromtxt是Numpy文本读取里的瑞士军刀它支持自动识别表头、处理缺失值、按列转换类型。虽然速度比loadtxt慢不少但面对真实世界的凌乱文本文件它是最省事的入口。import numpy as np # 假设文件内容如下 # id,score,remark # 1,88.5,pass # 2,,fail # 3,92.0,pass data np.genfromtxt( scores.csv, delimiter,, namesTrue, dtypeNone, missing_values, filling_values0, encodingutf-8 ) print(data[id]) print(data[score])这里有几个参数值得展开。namesTrue会把第一行当成字段名返回一个结构化数组之后可以直接用data[score]访问某一列这比用整数索引清晰得多。dtypeNone是让Numpy自动推断每列类型整数当整数、字符串当字符串。missing_values和filling_values配合使用可以把空字符串或 NA 替换成指定值避免文件里的坏数据直接中断程序。如果要自定义解析逻辑比如把时间戳字符串转成秒数可以用converters参数def parse_time(s): return float(s.decode().strip()) data np.genfromtxt( log.csv, delimiter,, namesTrue, dtypeNone, converters{0: parse_time} )注意converters里的函数是Python回调会对每个元素调用一次文件很大时性能会明显下降。能不用就不用非用不可时也尽量在数据量小或预处理阶段使用。4. 大数组的救星memmap内存映射按需读取4.1 数据比内存大直接load会崩前面讲的np.load会把整个数组一次性读进内存。当数组是几十GB时这条路根本走不通轻则内存不足重则把整台机器拖死。这时候Numpy提供了一个非常有用的工具memmap即内存映射文件。内存映射的概念可以这样理解它把磁盘文件直接映射到进程的地址空间你访问某个元素时操作系统只把对应的那一段文件内容读进物理内存其余部分仍然留在磁盘上。你可以把它当成一个懒加载的Numpy数组它在外观和行为上都跟普通ndarray一致但底层数据不是一次到位的。4.2 创建和操作memmap创建一个memmap很简单import numpy as np # 创建一个新的大数组文件不存在时会自动创建 mm np.memmap( big_data.bin, dtypenp.float32, modew, shape(100000, 1000) ) # 就像普通数组一样填充数值 mm[:1000, :] np.random.rand(1000, 1000) mm[5000, 500] 3.14159 # 把修改写回到磁盘这一步在关闭前建议主动做 mm.flush()已存在的文件可以用moder或moder打开。r只读r可读可写。打开已有的memmap时shape和dtype必须和文件内容严格对应否则你会读取到完全错乱的数据。我通常会在保存这种原始二进制文件时单独用一个文本或JSON记录形状和类型防止几个月后自己忘了参数。还有一种更方便的方法直接用np.load打开一个.npy文件时传入mmap_moderNumpy会返回一个memmap对象而不是把整个数组读进内存。对于已经存在的.npy大文件这是一个改动最小、效果最明显的优化arr np.load(big_array.npy, mmap_moder) # 这时 arr 是 memmap 对象不是普通 ndarray print(arr.shape, arr.dtype)4.3 分块计算的实战当数组在磁盘上但你要训练它我处理过一个场景模拟实验生成了一个30GB的样本矩阵形状是(6000000, 1000)真实内存只有64GB但模型训练时每次只需要一个batch。如果直接np.load光加载一次就可能占掉近一半内存后续根本没法跑。用memmap改造之后训练循环大概长这样import numpy as np X np.load(samples.npy, mmap_moder) batch_size 512 num_batches X.shape[0] // batch_size for i in range(num_batches): batch X[i * batch_size:(i 1) * batch_size] # 对 batch 做特征预处理、训练等操作 # batch 切片返回的是普通ndarray内存里只有512行这里的执行逻辑是每个batch切片触发页面调度操作系统只把对应范围的磁盘块读入内存处理完释放下一个batch继续按需读取。整体上内存占用很稳定性能也能接受。不过要注意如果X的存储顺序是C-contiguous按行切片时每个batch在文件里是连续的读取效率较高如果经常按列随机访问memmap可能因为磁盘随机IO变得很慢这时反而应该考虑重新整理数据布局或换用更适合随机读取的格式。5. 边界情况与工程化避坑格式兼容、安全性和路径5.1 版本跨度和平台大小端Numpy版本之间有兼容性差异。一般来说旧版本无法识别新版本创建的.npy文件头部尤其当新版本引入了新的dtype或元数据格式时。比如我自己有一次用新版本的Numpy保存了一个包含新字符串dtype的数组交给环境里还是旧版Numpy的同事对方一读就报错。所以跨环境协作时最好统一Numpy版本或者约定一个最低版本。另一个容易忽略的是大小端问题。x86架构的机器一般是小端也就是整数和浮点数的低字节存在低地址。如果某个文件是在PowerPC这类大端平台上保存的加载后dtype会显示成f8这样的格式后续计算时你需要显式转换big_endian_arr np.load(from_ppc.npy) little_endian_arr big_endian_arr.astype(f8)5.2 allow_pickle方便背后有风险np.save在保存纯数值数组时非常安全文件里就是二进制原始数据。但如果你把一个包含Python对象的数组存进去比如obj_arr np.array([{a: 1}, [1, 2, 3]], dtypeobject) np.save(obj.npy, obj_arr, allow_pickleTrue)Numpy会使用pickle序列化这些对象。这意味着obj.npy文件本质上可以携带任意可执行代码。从某个历史版本开始np.load默认将allow_pickle设为False就是为了防止恶意文件在加载时触发不安全的pickle反序列化。所以两个原则要记牢凡是加载不可信来源的.npy、.npz文件绝不要打开allow_pickleTrue。如果需要传输数据给别人务必确认数据里没有object数组最好直接传纯数值类型。5.3 路径、权限和目录存在性看起来是小事但项目里经常有人在这里翻车。np.save(dir/data.npy)如果dir目录不存在会直接抛FileNotFoundError运行到一半才崩溃。写文件前先用os.makedirs保证目录存在import os from pathlib import Path out_dir Path(output/experiment_1) out_dir.mkdir(parentsTrue, exist_okTrue) np.save(out_dir / matrix.npy, matrix)Windows下的路径和Numpy也有过不少坑。我的建议是一律用pathlib.Path避免手写反斜杠字符串也不要依赖Numpy内部对路径的字符串处理。权限问题则集中在服务器环境确认当前用户对目标目录有写权限否则PermissionError来得猝不及防。6. 真实场景里我应该选哪种存储方案6.1 不同需求的目标和方案下面这份表格基本覆盖了我日常遇到的场景可以直接照着选场景推荐方案理由临时中间结果后续还要继续用Numpy处理.npy或.npz快、无损、简单多数组打包归档磁盘空间紧张.npz或savez_compressed一个文件搞定可压缩需要给外部系统或非技术同事看CSV/TSV通用、Excel可直接打开数据量超过内存需分块训练.npynp.load(mmap_moder)解决内存瓶颈原始数据清洗阶段文件脏乱np.genfromtxt容忍缺失值和异构类型跨语言、跨平台长期存储HDF5/Parquet等生态通用性更强6.2 我个人习惯的工程建议最后分享几个在实际项目中沉淀下来的习惯不是教科书答案但踩过坑之后你会发现它们真的有用。第一所有二进制文件必须配套元数据。.npy文件虽然自带dtype和shape但它不会告诉你这个数组代表什么、是哪个版本生成的。我现在做项目时习惯在.npz里固定放一个metakey内容是版本号、生成时间、字段说明甚至包括当时的关键超参数。哪怕只是给自己看的中间结果也建议这么做。三个月后你会感谢当时的这份记录。第二能一次读进内存的中间结果优先用.npy不要为了省事全部转成CSV。CSV只用来做最终交付或者给那些必须用文本文件的人。否则你会发现项目里到处都是体积爆炸且读起来极慢的文本文件而性能问题的源头恰恰就是你最习以为常的to_csv。第三大文件读取前先确认数据布局。用memmap时C-contiguous的数组按行随机访问基本没问题但如果你的数据是Fortran-order或者经常按列切片建议提前转成更适合访问顺序的布局否则磁盘IO会教做人。Numpy的存储和读取接口不算复杂但选错了方案会多付很多代价。希望这篇Numpy简单教程里的内容能帮你建立自己的数据落地清单先看数据量再看使用方式最后确定格式。我自己的工具箱里二进制优先、文本兜底、memmap处理超大幅面靠这几条原则基本没再为数据的保存和读取头疼过。