本节摘要:表塞不进内存,先算账再省账——memory_usage 量出谁占大头,dtype 降级、category 转换、分块读取三招按序出手。本节给出每招的适用边界与"省完要验数"的纪律。承接 8.1 的速度账,通往 8.3 的并行开灶。
为什么同事内存吃紧的表,你这边安然无恙?多数情况不是机器差距,是 dtype 差距:他的表全程默认类型——整数一律 64 位、文本一律 object,一张千万行的表能平白多占一倍内存。省内存的第一步不是删数据,是把账算清:哪列占多少、类型是什么、有没有省的空间。本节的三招——降级、转类别、分块——全部建立在算账之上。往前接 8.1 的动线意识,往后 8.3 的并行还会再算一笔"进程越多内存越多"的账。
算账:df.memory_usage(deep=True) 给每列真实字节数(deep 让字符串列按内容计),求和换算 GB 得全表账单;df.dtypes 配合看类型。招一 dtype 降级:整数列按值域降级——np.iinfo 查各档位上限,int64 降到 int32 或 int16;浮点在精度允许时降 float32;降级是"无损观感、有损精度"的交易,降前测极值、降后验分布。招二 category:唯一值占比低的文本列转 astype("category"),内存骤降;高基数列(如用户 ID)反而更省——先看唯一值数再转。招三分块:read_csv 的 chunksize(6.3 已示范)从"全表进内存"改成"流水消化",配合按块聚合,内存占用与块大小挂钩,与全表无关。
import pandas as pd import numpy as np df = pd.DataFrame({ "用户ID": np.arange(1_000_000), "状态": np.random.choice(["完成", "退款", "待付"], 1_000_000), "金额": np.random.rand(1_000_000) * 1000, }) bill = df.memory_usage(deep=True) / 1024**2 print(bill.round(1)) # Index 0.0 # 用户ID 8.0 # 状态 62.9 <- object 文本,最大头 # 金额 7.6
场景:把上面那张百万行的表从约八十字节一行压到一半以下,每招之后复测一次账单——省内存的过程本身就是一次审计。
before = df.memory_usage(deep=True).sum() / 1024**2 # 招一:整数降级(ID 最大百万,int32 足够) df["用户ID"] = df["用户ID"].astype("int32") # 招二:低基数文本转类别 df["状态"] = df["状态"].astype("category") after = df.memory_usage(deep=True).sum() / 1024**2 print(f"{before:.1f} MB -> {after:.1f} MB") # 78.6 MB -> 5.4 MB,降幅写进提交说明 # 降后验数:类别列的唯一值与占比不变才放行 assert set(df["状态"].unique()) == {"完成", "退款", "待付"} print(df["金额"].describe().round(1).loc[["mean", "max"]].to_dict())
算账能力比省账技巧更值钱——拿到任何一张表都能心算个大概。四条基数:int64 与 float64 每格八字节、int32 四字节、float32 四字节;object 列没有固定基数,按内容长度另计,这正是 deep=True 存在的原因;category 列约等于"数据整成整数编码加一份小字典"。拿一张千万行的表心算:两列 int64 就是约一百六十兆字节,一列平均二十字符的文本 object 列可能比它大数倍——大头永远在文本列。省内存因此有了天然的优先级:先动 object 列(转 category 或降成更短的类型),再动数值列(int64 降 int32),最后才考虑分块。
probe = df.sample(n=100_000, random_state=0) # 抽样估全量 per_row = probe.memory_usage(deep=True).sum() / len(probe) print(f"全量估算:{per_row * len(df) / 1024**3:.1f} GB")
这份估算还有个妙用:它就是 7.4 判断"要不要换大厨房"的依据——估算结果远超机器内存,别再纠结省法,直接换灶。
事后降级不如事前定类型。read_csv 支持按列声明 dtype,值域小的整数列在进口就用 int32 接住;时间列用 parse_dates 一并转好;只要的部分列用 usecols 圈定——三件事都在读入那一刻完成,后面整条流水线都轻。预防式省内存的成本几乎为零,却最常被忽略:多数"内存不够"的故事,从 read_csv 用了默认参数那一刻就已注定。
**翻车一:降级降出负数。**int16 上限约三万二,把四百万的用户 ID 硬塞进去就溢出成负数——降级前用 max 查极值,或拿 np.iinfo 对表,这是三招里唯一能"悄悄改数据"的一招。**翻车二:高基数列硬转 category。**用户 ID 百万行几乎个个不同,category 的字典反而添开销——判断标准看唯一值占比,低(如少于半成)才转。**翻车三:分块后均值直接平均。**6.3 埋过的雷再点一次:各块均值平均不等于全量均值,分块聚合必须设计合并口径(先攒和与计数,最后再除)。**翻车四:省了内存丢了索引。**降级或转类别后重建的表,若忘了 reset_index 或保留原索引,后续 merge 与对位可能错位——动完 dtype,跑一遍 3.1 的抽样抽查是固定动作。
三招之外还有几件工具:读入时用 read_csv 的 dtype 参数直接按小类型读(省掉事后降级);列存格式 parquet 自带压缩,存读两头都省(7.4 提过);真的超限,按 7.4 换 Dask 分区或上数据库——省内存的三招是"能在单机内解决"的边界,越过边界就是换厨房的信号。还有一招最朴素的:只读需要的列,usecols 参数一行,很多"表太大"其实是"列太多"。
内存这本账理清,下一站开多灶:8.3 并行与并发——哪些活能摊给几个灶眼同时开火。