Numpy学到第六篇,数组的创建、切片、运算、广播机制应该都摸得差不多了。但我发现很多学习者在数组操作上玩得挺溜,一到“怎么把算好的结果存下来”“下次怎么快速加载”就开始凭感觉乱来——有人图省事直接np.savetxt存了个大矩阵,结果文件大得离谱,读起来慢如蜗牛;有人用np.save存了多个数组,回头却不知道怎么取出想要的。其实Numpy的数据存储和读取一点都不复杂,核心就是npy、npz这两种二进制格式,外加用于对接外部工具的文本格式。这篇教程就把它们彻底讲透,包含API的每个参数含义、底层的文件结构逻辑、以及我实际写代码时踩过的坑,保证你下次存数据时不用再纠结。
这篇内容适合刚接触Numpy不久的初学者,也适合已经写了几个月但没系统整理过存取方案的人。看之前你只需要知道ndarray是什么、dtype大概怎么回事就够了,剩下的我来带你过一遍。
1. Numpy数据存储与读取的底层逻辑
1.1 先搞清楚npy格式的本质:它到底快在哪
np.save保存的.npy文件是二进制格式。这个“二进制”三个字,就是它快、小的根本原因。
作为对比,文本格式(比如np.savetxt生成的文件)把数组的每个数字转换成字符串再写进磁盘,比如3.141592653589793这个浮点数在文本里占16个字符;而二进制格式直接把这个double在内存里的8个字节原样写进文件。一个40万浮点数的数组,文本存可能要几十MB,二进制只占3.2MB左右。差距就是这么来的。
.npy文件为什么能做到不用额外传shape和dtype就能还原数组?因为在文件最前面有一段文件头(header),里面记录了三个关键信息:文件格式标识(魔数)、版本号、以及一个dict,里面包含了数组的shape、dtype、是否C连续等信息。
有个生活化的类比:文本格式就像把仓库里的货物一件件拍照发给你,每张照片几MB,你收几百张照片再对着照片把货搬回去;二进制格式就像直接给你一个仓库货架分布图加货物序号清单,vec格式也对——它直接把货架区域的二进制内容打包传过去,你只要按图索骥就能原样恢复。
这意味着什么?意味着np.load加载.npy文件的时候,不需要逐行解析字符串、不需要猜测数据类型,而是直接根据文件头里的元信息把数据区的字节块映射成ndarray。所以加载一个几百MB的.npy文件,往往比加载同样内容的几十MB文本文件还要快得多——因为它几乎没有解析开销。
理解这一点对你后续选择存储方案特别有用:只要数据是给程序用的、不打算人肉打开看的,一律优先二进制格式。
1.2 存储格式选型:npy、npz、txt三种方案怎么权衡
很多教程上来就教你三个API各怎么用,但没说什么时候用哪个。我根据实际项目经验,给你一张对比表,照着选就行:
| 维度 | .npy | .npz(含压缩版) | .txt / .csv |
|---|---|---|---|
| 文件体积 | 小 | 小(压缩后更小) | 可能非常大 |
| 读取速度 | 极快 | 快(压缩版需解压) | 慢 |
| 写入速度 | 极快 | 快(压缩版写入稍慢) | 慢 |
| 是否保留dtype | 保留 | 保留 | 不保留,需重新指定 |
| 是否保留shape | 保留 | 保留 | 不保留,需重新指定 |
| 能否用Excel/记事本打开 | 不能 | 不能 | 能 |
| 跨语言读取 | 需要对应库支持 | 需要对应库支持 | 任何语言都能读 |
| 多数组支持 | 不支持,单个数组 | 支持,可存任意多个 | 单个2D数组 |
看完表格,选型思路就清晰了:
- 数据是中间结果或最终结果,只在Python里用:无脑
.npy,如果你只有一个数组,或者存多个数组但不太在意体积,用.npz。 - 数组比较大,且多个数组中有些有冗余(比如多个版本计算结果):用
np.savez_compressed,压缩率通常能到30%到50%。 - 数据需要交付给别人,而且对方可能不用Python:
np.savetxt或np.savetxt加delimiter参数输出csv,牺牲体积换取通用性。 - 数据量特别大(GB级别),并且需要频繁局部读取:先看下面实操部分讲的
np.load的mmap_mode参数,文本格式完全不适用。
我之前有段时间习惯把所有的中间结果都存成.txt,想着“以后好查看”。后来项目数据量大起来,一个20万行乘100列的浮点矩阵,存成txt用了整整300MB,读取一次要十几秒。换成.npy之后,文件只有80MB,读取时间降到0.2秒左右。从那以后我养成了习惯:人需要看的数据才存文本,程序需要读的数据一律二进制。
2. 核心API详解与实操要点
2.1 np.save和np.load:单数组的标配操作
这是最基础的一组API,用法非常直白:
import numpy as np # 假设这是一个算好的矩阵 result = np.arange(24).reshape(4, 6) # 保存 np.save('result.npy', result) # 加载 loaded = np.load('result.npy') print(np.array_equal(result, loaded)) # Truenp.save有两个参数值得注意:arr和allow_pickle。第一个不用说,第二个牵扯到一个非常经典的安全问题。
allow_pickle默认为True,表示允许用Pickle协议序列化数组里的对象。什么意思呢?如果你的数组的dtype是object(比如数组里存了Python对象、函数引用甚至类实例),np.save会把它们通过Pickle序列化存进去。问题是:Pickle是出了名的不安全——加载一个恶意构造的Pickle文件,可能直接在加载时执行任意代码。所以官方文档和很多安全指南都警告:永远不要np.load一个不可信来源的npy文件。
实践中的做法是:如果你只是存数值型数组(float、int这类),把allow_pickle=False显式传上,这样万一有人给你的路径指向了一个恶意的pickle文件,加载会直接报错而不是中招。
np.save('result.npy', result, allow_pickle=False) np.load('result.npy', allow_pickle=False) # 安全再讲一个新手最容易懵的细节:np.load打开.npz文件时,返回的并不是ndarray,而是一个类似dict的NpzFile对象。你仍然可以用loaded.f.xxx或者loaded['xxx']访问,但要注意用完之后最好关闭文件,否则会占用文件句柄。特别是你在大循环里反复np.load的时候,不close会导致文件句柄耗尽,程序报“Too many open files”。
loaded = np.load('multi.npz') data1 = loaded['a'] # 用完记得关 loaded.close() # 或者用上下文管理器 with np.load('multi.npz') as data: a = data['a']2.2 np.savez与np.savez_compressed:多数组打包存储
实战里很少只存一个数组。比如你训练一个模型,想把训练好的权重矩阵、偏置向量、还有当时的配置参数一起存档。np.savez就是干这个的:
w = np.random.randn(128, 64) b = np.zeros(64) config = np.array([0.01, 0.5, 100]) # 学习率、动量、迭代次数 np.savez('model_ckpt.npz', weight=w, bias=b, config=config) # 加载 with np.load('model_ckpt.npz') as ckpt: w2 = ckpt['weight'] b2 = ckpt['bias'] cfg2 = ckpt['config']注意np.savez的第一个参数是文件名,后面的参数用关键字参数=数组的形式,这样.npz里面的key就是关键字名。如果你用位置参数传数组,key默认是arr_0、arr_1这种,不方便记忆,也不建议这么做。
np.savez_compressed用法完全一样,只是多了压缩步骤,文件体积更小,代价是保存和加载时会有解压开销。实际测试下来,对于0和1比较多的数组(比如one-hot编码、mask矩阵),压缩率非常可观,经常能压到原来的十分之一;对于已经接近随机分布的浮点数据,压缩率一般般,可能只有20%到30%的缩减。
从性能角度说,np.savez本质是把多个数组分别走一遍np.save的逻辑,再打包成一个zip。所以如果你在循环里一个数组一个数组地往同一个.npz追加,是行不通的——要么一次性把所有数组都传进去,要么改用下面这个技巧:
data_dict = {} for i in range(10): data_dict[f'arr_{i}'] = np.random.rand(1000) np.savez('batch_data.npz', **data_dict)利用字典解包的方式,在动态组织多个数组时非常方便。
2.3 savetxt / loadtxt:文本格式的正确打开方式
虽然我strongly推荐二进制,但有些场景绕不开文本格式。比如你要把处理好的数据提供给使用R或者Excel的同事,或者你想把数据文件提交到某个只接受csv的平台上。
np.savetxt的基本用法:
data = np.random.rand(10, 4) np.savetxt('data.csv', data, delimiter=',', header='col1,col2,col3,col4', comments='', fmt='%.6f')几个参数逐个讲:
delimiter:分隔符。默认是空格,输出csv通常设为,。header:文件头字符串。注意默认会在header前面加一个#注释符,如果不想加,要把comments参数设为空字符串'',否则读文件的程序可能不认识带#的header。fmt:格式字符串,控制浮点数的输出精度。默认%.18e会把每个数字写成科学计数法,文件又大又难读。一般设成%.6f就够用了。newline:换行符,跨平台处理时留意一下。Windows下想生成能被Excel正常打开的csv,可以设为'\r\n'。
读取这边对应的是np.loadtxt和它的进阶版np.genfromtxt:
# 基本读取 arr = np.loadtxt('data.csv', delimiter=',', skiprows=1) # 处理有缺失值的情况,genfromtxt更灵活 arr2 = np.genfromtxt('data.csv', delimiter=',', skip_header=1, dtype=float, missing_values='NA', filling_values=0.0)np.loadtxt要求每行数据格式基本一致,中间不能有缺失值;np.genfromtxt则能做更宽容的处理:把指定的字符串当作缺失值,并填充为filling_values指定的值。如果你的数据文件来自外部,存在某些行缺字段的情况,直接用np.loadtxt大概率报错,这时候换np.genfromtxt。
还有一个实用的注意点:文本读写时,数组的shape和dtype信息会丢失。如果你的原始数据是int64类型,存成文本再读回来,默认会变成float64(因为文本里的数字被ps成浮点数了)。想要保留整型,必须手动指定dtype=int;更坑的是,如果原始数据里恰好有NaN,指定dtype=int直接报错——文本格式在这方面的表达力天然不足,遇到这种情况就老老实实回到二进制方案。
3. 完整实操:从存储到读取的实战演练
3.1 标准化流程:把一批中间结果可靠地存下来
光讲API不够,我把实战里的一套标准流程写给你。假设你正在做一个图像特征提取的任务,每张图片提取出一个2048维的特征向量,一批一共5000张图。你想把特征和对应的标签存下来,供后面训练分类器用。
推荐方案:以字典形式一次性打包成.npz,并且附上一个元信息数组。
import numpy as np # 模拟特征和标签 features = np.random.rand(5000, 2048).astype(np.float32) labels = np.random.randint(0, 10, size=5000) # 保存所有内容,包括一条描述信息 np.savez_compressed('features_demo.npz', features=features, labels=labels, meta=np.array(['float32', 'shape=(5000,2048)', '10_classes']))等一下,我把保存写成了np.savez_compressed。对于2048维的float32特征矩阵,实际体积在40MB左右,特征本身近似随机分布,压缩率不高。但万一有冗余,压缩能省不少空间。这里我用astype(np.float32)而不是默认的float64——在特征存储场景中,float32的精度完全足够,体积直接减半。
加载并验证:
with np.load('features_demo.npz') as data: feats = data['features'] labs = data['labels'] meta = data['meta'] # 一致性验证 assert feats.shape == (5000, 2048) assert feats.dtype == np.float32 assert len(labs) == 5000 print('加载成功,特征矩阵:', feats.shape, feats.dtype)你可能注意到我在meta里存了一个字符串数组。这招是我自己摸索出来的:二进制格式保存的数据,脱离当时的代码环境之后,别人(或者三个月后的你自己)很难记得这个文件到底存的是什么。养成存元信息的习惯——shape、dtype、生成日期、列含义——能让存档文件自解释。
这里还有一个容易忽略的坑:先压缩再加载。np.savez_compressed在加载时会解压,如果你的代码对环境内存有严格要求,注意解压过程会临时占用一份完整数组大小的内存。对于几百MB的文件,加载时内存峰值可能是文件体积的好几倍。遇到这种情况,建议改用np.savez(不压缩),换取更低的内存峰值。
3.2 用memmap处理超大数组:不必一次性读进内存
实际工作中会遇到比内存还大的数组。比如一个超大规模的用户行为矩阵,形状可能是(1000000, 2000),即便用float32也需要8GB内存,很多机器根本装不下。
Numpy的np.load支持mmap_mode参数,可以把文件以内存映射的方式打开——数据还在磁盘上,但你可以像访问一个普通ndarray一样去切片查询,Numpy会在需要时才加载对应数据块到内存:
# 先创建一个大文件(可能需要几分钟) big_arr = np.random.rand(500000, 1000).astype(np.float32) # 约2GB np.save('big_arr.npy', big_arr) # 用memmap模式打开,不整体读入 mmap_arr = np.load('big_arr.npy', mmap_mode='r') # 现在可以像普通数组一样操作,但内存占用很低 row_slice = mmap_arr[10000:10100, :] # 只加载这一段 print(row_slice.shape, row_slice.mean())这里有个关键点我必须强调:mmap_mode='r'返回的对象是只读的,如果你尝试修改它,会报错。要修改怎么办?用mmap_mode='r+',这样可以在内存映射模式下直接对文件做增量修改——但请注意,这种修改是原地写回磁盘的,一旦数据格式变了(比如改变了数组长度),就不能这么操作。
mmap_mode在处理超大文件时是神器,但也有自己的局限。它要求文件内部的数据布局是连续且固定的,因此只能配合.npy文件使用,不适用于压缩的.npz。另外,频繁的随机切片访问可能比一次性加载更慢——因为每次切片都要触发磁盘I/O。如果程序要对整个数组做多轮迭代,还是应该分批读入内存处理,而不是指望mmap解决一切。
3.3 数据校验与版本管理:别让坏数据悄悄溜进你的管道
数据存储的另一个痛点是“怎么知道存坏了没有”。我建议在保存和加载之间加一道校验,尤其是数据很重要、或者程序需要夜间批量处理大量文件时。
最简单的校验是保存时顺手算一个哈希:
import hashlib def save_with_hash(filename, arr): np.save(filename, arr) with open(filename, 'rb') as f: file_hash = hashlib.md5(f.read()).hexdigest() with open(filename + '.md5', 'w') as f: f.write(file_hash) def load_with_check(filename): with open(filename + '.md5', 'r') as f: expected = f.read().strip() with open(filename, 'rb') as f: actual = hashlib.md5(f.read()).hexdigest() if actual != expected: raise RuntimeError(f'文件完整性校验失败: {filename}') return np.load(filename)注意不要滥用hashlib对整个大文件做MD5——对于GB级别的文件,计算哈希本身就要好几秒。如果你的场景是频繁加载且每个文件都很大,建议只在关键的产线环节做校验,或者把校验降级为抽样检查(比如只对前4KB做哈希)。
还有一个管理习惯很值得养成:文件名里带上版本或时间信息。我习惯用xxx_v1.2_20250610.npy这种命名,配合上面说的meta数组,基本可以杜绝“我到底是加载了哪个版本”的问题。如果你在跑实验对比,多版本同时存在的时候,这种命名能帮你省下大量排查时间。
4. 常见问题与排查技巧实录
4.1 文件加载报错的几种典型情况
我把这几年用Numpy存取数据时遇到过的加载失败场景整理了一下,按出现频率排序:
| 报错现象 | 常见原因 | 解决思路 |
|---|---|---|
FileNotFoundError: No such file or directory | 路径写错,或文件名没加扩展名 | np.save会自动补.npy,但np.load不会自动找;显式检查路径,或统一封装函数 |
ValueError: Cannot load file... | 文件损坏,或根本不是npy格式 | 用file命令看文件头,确认是npy;损坏文件只能重新生成 |
TypeError: data type not understood | 用旧方式手动指定了未知的dtype字符串 | 检查dtype写法,如np.dtype('float32')没问题,但'np.float32'这种写法不对 |
PermissionError | 文件被别的程序占用,或只读权限 | 确认没有其他进程打开着同一个文件,Windows下尤其常见 |
| 加载成功但shape不对 | 文件中的元信息与预期不一致 | 打印loaded.shape、loaded.dtype对照source端输出 |
有一条排查路径可以快速定位问题:加载失败的时候先不要急着看代码,用文本工具打开npy文件看一眼开头几个字节。正常.npy文件的开头是\x93NUMPY这几个魔数字节(显示为不可打印字符),你如果看到的是人类可读的文本(比如0.123456这种),说明文件根本不是npy格式。这个判断30秒内就能完成,比反复调试代码快得多。
还有一种情况是文件20%损坏或截断,np.load会报一个EOFError或者ValueError。这类问题通常不是代码bug,而是磁盘写入时意外中断(比如断电、进程被杀)。如果你是在循环里批量保存很多文件,建议先写一个临时文件,写完再os.rename成正式文件名,这样即使保存到一半崩了,正式文件名指向的也一定是完整文件。
4.2 不同版本Numpy之间的兼容性坑
Numpy的.npy格式有一个小版本演进的历史:Numpy 1.x早期版本生成的.npy文件头结构与后期略有差异。官方在设计文件头时加入了版本字段,新版本Numpy可以读旧版本文件,但旧版本Numpy读新版本文件时,会直接报类似ValueError: Version mismatch的错误。
更麻烦的是,如果你用allow_pickle=True存了包含Python对象的数组,那么同一条Pickle的兼容性还会受Python版本影响——Python 3.8下pickle出来的对象,用Python 3.11加载有时会报ModuleNotFoundError。要彻底避免这类问题,最稳妥的做法是:
- 统一团队的运行环境(写一个requirements或环境锁文件);
- 数据交付时明确标注生成环境的Python和Numpy版本;
- 如果一定要跨环境共享数据,数值型数组用
allow_pickle=False,对象型数据干脆不要用Numpy存,改存成JSON或其他结构化格式。
4.3 存储与读取性能优化经验
最后分享几个性能相关的笔记。
第一个是批量写文件时的I/O瓶颈。如果你循环100次、每次都在磁盘上np.save一个小文件,速度会被磁盘寻道时间和系统调用开销拖垮。更好的做法是攒一批数据在内存里,合并成一个大数组后再一次性写盘。比如你要保存1000个(64, 64)的小矩阵,直接循环存1000个文件非常慢;改成先在一个(1000, 64, 64)的大数组里攒着,或者用np.savez打包成一个文件,性能立刻提升几个量级。
第二个是关于压缩等级。np.savez_compressed内部用的zip压缩,默认级别是ZIP_DEFAULT,具体来说它不允许你手动调节压缩等级。如果你发现压缩太慢,可以绕道走一遍gzip或lz4,但那样就失去了Numpy原生格式的便利性。我的建议是:压缩速度本来就比不压缩慢,除非文件特别大或者存储空间紧张,否则别为了省那几十MB而拖慢管道,直接用np.savez不压缩就好。
第三个是关于内存峰值。np.savez_compressed在保存时需要对每个数组进行压缩,压缩过程会产生一份与被压缩数组相同大小的临时缓冲。如果数组是(100000, 100000)的float64(约80GB),你的机器会直接内存爆炸。处理超大数组之前,先规划好:要么用memmap,要么把数组切块逐块压缩,要么降级为np.save(不压缩,写盘时不需要额外大缓冲)。
第四个是避免重复载入。如果你的程序需要反复读取同一个npy文件里的同一个数组,不要每次用都调用np.load,那会反复触发磁盘I/O和内存分配。正确做法是启动时加载一次,把结果缓存在内存里,后续逻辑引用这个缓存。我见过不少人因为忽略了这一点,五分钟就能跑完的程序硬生生拖到十几分钟。
最后分享一个小习惯
我个人在实际项目里,最后一定会做一个统一的存储工具函数,封装save、load、校验、元信息注入这几件事。一次性把这个封装做好,后面所有的训练脚本、分析脚本都走同一个入口,出问题时只需要在一个地方排查。举一个简单的封装雏形:
import numpy as np import hashlib import os def smart_save(filename, **arrays): arrays['_meta'] = np.array([ f'created_with_numpy_{np.__version__}', f'shapes={ {k: str(v.shape) for k, v in arrays.items()} }' ], dtype=object) np.savez_compressed(filename, **arrays) def smart_load(filename): with np.load(filename, allow_pickle=False) as data: result = {k: data[k] for k in data.files if not k.startswith('_meta')} meta_info = data['_meta'] print('加载元信息:', meta_info) return result注意这里_meta用了dtype=object来存放不定长字符串,因此smart_load里allow_pickle=False会报错——实际情况你可以把元信息改成等长的字符串数组,或者专门用一个JSON文件放元信息。我这里只是给你展示思路,别直接照抄。
踩过几次坑之后,我最大的感受是:Numpy的读写API本身很简单,真正的复杂度都藏在“这个文件会保存多久、会被谁在什么环境下读取、会不会损坏、要不要压缩”这些工程问题里。把这些边界情况提前想清楚,数据管道才能稳稳跑下去。希望这篇教程能帮你以后少踩一些我踩过的坑。