“pandas 的综合练习”这几个字,乍看像教科书目录里的一行小标题,真正动手做过的人都知道,它更像一次全面体检——你的数据思维、代码习惯、性能意识会在这套练习里被一次性照出来。我带过几个刚转数据的同学,也帮业务同事改过不少脚本,发现一个共性:pandas 的 API 谁都能背出七八个,但真正卡住人的从来不是groupby怎么写,而是“这份数据该先动哪一刀”“这个结果为什么对不上”“这段代码跑十分钟到底慢在哪”。所以这篇不打算罗列函数手册,我想把一次完整的 pandas 综合练习拆成几个可复用的关卡,从造数据、清洗、选取、聚合一路走到多表连接和性能调优,把每一步的取舍理由、参数计算和踩坑记录都摊开讲。不管你是刚学完基础语法想找项目练手的新人,还是已经能写脚本、却总被数据质量折磨的熟手,都能从里面挑到能直接抄作业的片段。
1. 综合练习的关卡设计:先想清楚练什么
1.1 别把练习做成 API 默写
很多人练 pandas 的方式是打开一份教程,把merge、pivot_table、apply挨个敲一遍,敲完觉得自己会了,一接真实需求立刻原形毕露。问题出在练习的目标设定上:默写 API 练的是记忆,而真实工作考的是判断。判断什么?判断这份数据该用长表还是宽表、缺失值是删还是填、这一列该存成字符串还是分类变量、这段循环能不能向量化。这些东西教程里通常一句“根据业务情况决定”就带过去了,恰恰是最需要反复练的部分。
我的建议是把每次练习都当成一次小型交付:给定一个模糊需求,比如“看看最近几个月的销售走势,找出异常波动的品类”,然后自己决定数据从哪来、要洗到什么程度、输出什么形态的结果。这个过程中你会自然遇到类型报错、索引错位、聚合结果对不齐等问题,而这些问题的解决过程才是练习的真正价值。默写十个函数不如完整跑通一条链路,因为链路里藏着的坑是函数文档不会写的。
还有一点要提醒:练习时别怕把代码写丑。我见过不少人为了追求“优雅”,在还没搞清数据长什么样的时候就急着上链式调用,结果一行.pipe()里套了五个操作,出错时连哪一步炸的都定位不到。先用最笨的方式分步写、每步打印shape和head(),等链路跑通了再重构,这才是靠谱的顺序。
1.2 一套四层递进的练习结构
我把 pandas 的综合练习分成四层,每层有明确的验收标准。这样设计的好处是你能清楚知道自己卡在哪一层,而不是笼统地觉得“pandas 好难”。
| 层级 | 训练目标 | 典型任务 | 验收标准 |
|---|---|---|---|
| 第一层 基础操作 | 建立数据形状感 | 读取、查看、选取行列、简单筛选 | 能不看文档写出loc与iloc的正确用法 |
| 第二层 数据治理 | 处理脏数据 | 缺失值、重复值、类型转换、异常值 | 处理前后行数、内存、类型变化都有记录 |
| 第三层 分析建模 | 从明细到结论 | 分组聚合、透视、多表连接、时间序列 | 结果能与手工核算的数字对上 |
| 第四层 性能工程 | 让代码跑得动 | 向量化、分块、dtype 优化 | 同一任务耗时下降一个量级且有对比数据 |
这个表不是拿来贴墙上的,是拿来对号入座的。如果你在第二层反复卡壳,说明问题不在语法,而在于你对“什么算脏数据”没有判断标准;如果你第三层结果总是对不上,多半是连接键或索引没对齐,这是综合练习里最高频的翻车点。第四层看着像进阶内容,其实只要数据量上到百万行,它就是必答题,躲不掉。
我个人的习惯是每次练习都留一份“变更日志”,记录每一步操作让数据从多少行变成多少行、多少列、内存占多少。这份日志看着琐碎,排查问题时却是救命稻草——结果不对时,你能快速定位是哪一步把行数搞丢了。
2. 练习环境与模拟数据准备
2.1 环境与版本:三个容易踩的坑
第一个坑是版本差异。pandas 2.x 相比 1.x 有几个破坏性变化,最典型的是DataFrame.append被彻底移除,很多老教程还在教你用df.append(new_row),复制过来直接报AttributeError。正确的替代是pd.concat([df, new_df], ignore_index=True),注意要显式传ignore_index,否则索引会重复。另外 pandas 2.0 引入了写时复制机制(Copy-on-Write),到 3.0 成为默认行为,这意味着链式赋值的行为会变,依赖旧行为的代码可能悄悄出问题。练习时最好固定一个版本,把版本号记在脚本开头的注释里,别让环境差异背锅。
第二个坑是可选依赖。read_excel需要openpyxl或xlrd,读写 parquet 需要pyarrow,做类型推断加速也需要pyarrow。这些包不装,报错信息往往不够直白,新手容易懵。我的做法是在练习环境里一次性装齐:pip install pandas numpy pyarrow openpyxl matplotlib,省得中途打断思路。
第三个坑是显示设置。默认情况下 pandas 只显示前几行后几列,中间用省略号糊过去,初学者容易误以为数据就长这样。练习时建议在脚本开头统一设置:
import pandas as pd pd.set_option("display.max_columns", 50) pd.set_option("display.width", 160) pd.set_option("display.float_format", lambda x: f"{x:,.2f}")这样打印出来的表更接近真实视图,浮点数也不会被科学计数法搞得看不懂。这些设置不影响计算,但极大影响你读数据的效率。
2.2 三张表模拟真实业务:订单、用户、商品
练习数据最好自己造,因为真实数据涉及隐私且不好控制。造数据的关键是“带着毛病造”——故意留缺失、留重复、留格式不一致,这样后面的清洗环节才有东西可练。下面这套代码生成三张关联表,规模控制在几十万行,普通笔记本跑起来毫无压力。
import numpy as np import pandas as pd rng = np.random.default_rng(42) n_user, n_sku, n_order = 20_000, 800, 200_000 # 用户表:故意混入少量缺失城市和重复手机号 users = pd.DataFrame({ "user_id": np.arange(1, n_user + 1), "city": rng.choice(["A市", "B市", "C市", "D市", None], n_user, p=[0.35, 0.25, 0.2, 0.15, 0.05]), "reg_date": pd.to_datetime("2023-01-01") + pd.to_timedelta( rng.integers(0, 500, n_user), unit="D"), "level": rng.choice(["普通", "银卡", "金卡"], n_user, p=[0.7, 0.22, 0.08]), }) users.loc[users.sample(30, random_state=1).index, "city"] = None # 商品表:价格用对数正态分布,更接近真实的长尾 sku = pd.DataFrame({ "sku_id": np.arange(1, n_sku + 1), "category": rng.choice(["数码", "家居", "食品", "服饰", "母婴"], n_sku), "price": np.round(rng.lognormal(mean=4.2, sigma=0.6, size=n_sku), 2), }) # 订单表:日期跨 180 天,数量带少量异常大值 orders = pd.DataFrame({ "order_id": np.arange(1, n_order + 1), "user_id": rng.integers(1, n_user + 1, n_order), "sku_id": rng.integers(1, n_sku + 1, n_order), "qty": rng.integers(1, 6, n_order), "order_date": pd.to_datetime("2024-01-01") + pd.to_timedelta( rng.integers(0, 180, n_order), unit="D"), }) orders.loc[rng.choice(n_order, 200, replace=False), "qty"] = 999这套数据有三个刻意设计:用户表有缺失城市,订单表有异常数量,日期字段是标准的datetime64。另外三张表通过user_id和sku_id关联,正好练多表连接。生成之后先别急着分析,花五分钟做一次“数据体检”:看行数、看类型、看缺失比例、看数值分布。这个习惯我觉得比任何技巧都重要,因为绝大多数分析错误都源于一开始就没看清数据。
体检的常用组合是df.info()看类型和非空数、df.describe(include="all")看分布、df.isna().mean()看缺失比例。这三条命令下来,数据的大致情况就清楚了。
3. 数据读取与类型治理:最容易被跳过但最值钱的一环
3.1 read_csv 的参数,什么时候必须动
read_csv看起来是最没技术含量的函数,实际上它是整个链路里最值得花时间调的地方。因为类型推断一旦出错,后面所有计算都是在错误基础上叠加。我总结了几个必须手动干预的场景。
日期字段一定要显式指定parse_dates。pandas 的自动推断对2024/01/05这类格式经常失手,读进来是字符串,后面做时间筛选就会报TypeError。正确写法是pd.read_csv(path, parse_dates=["order_date"])。如果格式比较特殊,先用pd.to_datetime(..., errors="coerce")转换,再用isna()找出转不出来的行,这比在读取阶段硬猜要稳得多。
金额和编号字段要显式指定dtype。像订单号、身份证号这类长数字,如果被推断成int64或float64,前导零会丢,精度也可能出问题,统一按str读最安全。金额字段则要防浮点误差,通常读成float64后再做round处理,涉及精确对账时建议转成以分为单位的整数。
大文件读取要会用usecols和chunksize。前者只读需要的列,一份 100 列的表只读 8 列,内存能省一个数量级;后者分块迭代,适合内存吃紧的机器。还有一个参数low_memory=False,它的作用是让 pandas 一次性读完整个文件再做类型推断,避免分块推断导致的类型不一致警告。这个参数不是什么性能开关,纯粹是消除混合类型警告用的,别迷信。
注意:
read_csv报DtypeWarning时不要直接忽略,多半是某列混了数字和字符串,后面做运算会出问题,最好用dtype显式指定后重新读。
3.2 dtype 与内存:category 到底省多少
这一节我想掰开算一笔账,因为“用 category 省内存”这句话流传太广,却没几个人算过具体省多少。假设有一列字符串,100 万行,只有 5 个不同取值。
object 类型下的存储是这样的:每行存一个指针,64 位系统占 8 字节,合计 8MB;每个字符串值在内存里是独立的 Python 对象,不算短字符串驻留的情况下,一个对象头加内容大概 50 多字节,但关键在于——如果每行都是独立构造的字符串对象,实际开销会远高于理论值。而 category 类型下,底层只存一个整数编码数组,5 个取值用int8就够,每行 1 字节,合计 1MB,另加一个 5 项的字典。粗略对比,这一列就能从几十 MB 压到 1MB 出头。
| 存储方式 | 每行开销 | 100 万行估算 | 额外开销 |
|---|---|---|---|
| object | 指针 8 字节 + 字符串对象 | 数十 MB | 每行独立对象 |
| category | int8 编码 1 字节 | 约 1 MB | 一个 5 项字典 |
所以像城市、品类、等级这类低基数列,转 category 是稳赚的。但 category 有两个坑必须记住。一是排序:category 默认按字典里出现的顺序排,不是字母序也不是数值序,做排序或比较时要显式传categories参数指定顺序,否则结果会反直觉。二是groupby的observed参数:对 category 列分组时,默认会把所有类别都列出来,即使某些类别在数据里根本不存在,结果就是一堆计数为 0 的行。pandas 2.x 之后建议显式写observed=True,只保留真实出现的类别。
我一般的处理顺序是:先info()看各列类型和非空数,再把低基数字符串列转 category,把重复出现的日期转成合适的精度,最后用memory_usage(deep=True)对比转换前后的内存。deep=True这个参数不能省,默认的memory_usage()只算指针,不算字符串实际占用的空间,会严重低估。
4. 清洗链路:缺失值、重复值、异常值三连
4.1 缺失值处理的决策逻辑
缺失值处理最忌讳“看到空就填 0”。填什么、删不删,取决于这个字段缺失的含义。我习惯先做一件事:统计缺失比例,并按缺失模式分组看看。
missing = df.isna().mean().sort_values(ascending=False) missing = missing[missing > 0]如果某列缺失比例超过六成,先别急着删,想想这列是不是本来就只有特定场景才有值,比如“优惠券金额”只在用券的订单里有值,那缺失就是业务含义,填 0 反而是对的,但更合理的做法是保留空值并额外加一列“是否使用优惠券”的标记。如果缺失比例在一两成,且该列是数值型,中位数填充通常比均值稳,因为均值受极端值影响大。如果是分类型,用众数或者单独设一个“未知”类别都可以,我更倾向后者,因为“未知”本身携带信息。
还有一个常见误区是把缺失值填成 0 之后直接参与求和,结果没发现问题,做均值时才发现被稀释了。涉及到比率、均值这类指标,缺失值的处理方式会直接改变结论,所以每做一次填充,都要在变更日志里记一笔:哪一列、填了什么、填了多少行。
对于时间序列数据,缺失处理还要考虑顺序。按时间排序后可以使用ffill()前向填充,但这只适用于“状态类”字段,比如用户等级,不适用于“事件类”字段,比如订单金额,后者填出来的值是虚构的。我一般会在脚本里显式区分这两类字段,写注释说明为什么这么填,避免几个月后自己都看不懂。
4.2 重复值与异常值:别急着 drop
重复值分两种:完全重复和业务主键重复。完全重复用df.duplicated().sum()一查便知,去重用drop_duplicates()。但业务主键重复才是大头,比如同一个订单号出现两次,可能是重复上报,也可能是订单拆分,处理方式完全不同。
我的标准流程是先备份,再确认,最后才动手:
dup = orders[orders.duplicated(subset=["order_id"], keep=False)] print(dup.sort_values("order_id").head(20)) print("重复主键数:", dup["order_id"].nunique())打印出来肉眼看几行,基本就能判断是哪种情况。如果整行完全一样,直接保留第一条;如果只有金额或时间不同,那就得按业务规则取最新一条或求和。这里没有通用答案,但有一个原则:去重前必须能说清楚为什么这些行算重复,说不清就先别删。
异常值处理更容易走过场。像上面模拟的qty=999,用describe()看最大最小值就能发现,但真实数据里异常值往往藏得很深。我常用的两个手段:一是用四分位距,Q1 - 1.5*IQR到Q3 + 1.5*IQR之外算异常;二是用分位数截断,比如把 0.1% 和 99.9% 之外的当作极端值单独看。这两种方法各有盲区,前者对偏态分布过于敏感,后者会掩盖真实的极端事件,所以我都不会直接删除,而是先生成一列标记,看看这些异常行集中在哪些用户、哪些品类,再决定是修正、剔除还是保留。
注意:异常值不等于错误值。促销期间的大额订单、新用户的首单,都可能表现为统计上的异常,直接删掉会扭曲结论。先分类,再处理。
5. 索引与选取:把 loc/iloc 练成肌肉记忆
5.1 四种取值方式的适用边界
pandas 的取值方法可以画成一个四象限:按标签还是按位置、取单值还是取切片。loc按标签,iloc按位置,at按标签取单值,iat按位置取单值。新手最容易混的是loc和iloc的切片行为——loc[1:3]包含右端点,iloc[1:3]不包含,这个差异在拿行号切片时会导致结果差一行,而且不会报错,非常隐蔽。
我的记忆方法是:带i的都跟 Python 原生列表一致,左闭右开;不带i的是标签语义,两端都含。取单个值时优先用at和iat,比loc、iloc快不少,虽然日常差异不明显,但写在循环里就很可观了。
布尔索引是另一个高频场景。多个条件组合时,每个条件都要用括号包起来,用&和|连接,不能用and和or,后者在数组上会直接报“truth value is ambiguous”。这个报错我敢说每个 pandas 用户都见过,原因就是 Python 的and要求返回单个布尔值,而 Series 是一整列布尔值。
mask = (orders["qty"] > 0) & (orders["order_date"] >= "2024-03-01") subset = orders.loc[mask, ["order_id", "qty"]]写成orders[qty > 0 & date > x]这种省略括号的形式,运算优先级会导致报错,这是新手最常踩的语法坑。
5.2 SettingWithCopyWarning 的真实成因
这个警告大概是 pandas 里最让人烦躁的提示,很多人直接加个.copy()了事,根本没搞懂原因。它的本质是:你通过链式索引拿到的可能是一个视图,也可能是副本,pandas 无法确定,于是警告你“这么改可能不会改到原始数据”。
# 不推荐:链式索引,改的是副本还是视图说不准 df[df["qty"] > 10]["qty"] = 0 # 推荐:一次定位到底 df.loc[df["qty"] > 10, "qty"] = 0正确做法就是这一句:用loc把行条件和列名放在同一次索引里,一次性定位到目标位置。这样语义明确,也省掉了中间对象的创建。
但还有一种情况是切片本身就需要一份独立的副本,比如你从大表里抽一部分做实验:
sample = orders.loc[orders["qty"] > 10].copy() sample["qty"] = sample["qty"].clip(upper=5)显式.copy()是对的,但要明白你在干什么:你在内存里多存了一份数据。数据量大的时候,随手 copy 会让内存翻倍,这是另一个层面的代价。所以我的原则是:需要改原表就用loc直接改,需要独立实验才 copy,并且实验完及时del。
6. 分组聚合与透视:从明细到结论的关键一跳
6.1 groupby 的拆分-计算-合并,脑子里要有画面
groupby的执行过程可以想成三步:先按某个键把所有行分成若干组,再对每组独立算一个结果,最后把结果拼回一张表。理解这三步,很多反直觉的行为就解释得通了。比如为什么groupby之后直接打印看不到数据,因为这时还处在“拆分完成、尚未计算”的中间状态,必须接一个聚合函数才会触发计算。
最常用的组合是agg:
result = (orders .groupby("category", observed=True) .agg(订单数=("order_id", "count"), 总销量=("qty", "sum"), 平均单价=("price", "mean")) .reset_index())这里的“命名聚合”写法值得单独说一下:新列名=(源列, 聚合函数),好处是结果列名直接可控,不用算完再rename,也不会出现多层列索引。老写法agg({"qty": ["sum", "mean"]})会产生 MultiIndex 列,后续取值要写result[("qty", "sum")],麻烦且容易出错,我现在基本不用了。
还有一个参数observed=True要养成习惯。对 category 列分组时,如果不加这个参数,pandas 会把字典里所有类别都输出一遍,包括数据里根本没出现的类别,结果就是凭空多出一堆 0 值行。这个行为在 pandas 2.x 之后有变化,但显式写出来永远没错。
6.2 agg、transform、apply:三者的选择标准
这三个函数经常被混用,判断标准其实一句话:看你要的结果形状跟原表一不一样。
agg是降维的,每组合并成一个值,结果行数等于组数。transform是保持形状的,每组算出来的值会广播回原来的每一行,结果行数等于原表行数。apply最灵活,能返回任意形状,但速度也最慢。
举个例子就清楚了。要算每个品类的销量总和,用agg;要算每个订单的销量占该品类总销量的比例,用transform:
orders["cat_total"] = orders.groupby("category", observed=True)["qty"].transform("sum") orders["ratio"] = orders["qty"] / orders["cat_total"]如果用agg做这件事,就得先算出汇总表,再merge回原表,多写好几行。transform把这两步合成一步,是我用得最多的函数之一。
apply什么时候用?当每个组的处理逻辑没法用现成聚合函数表达时,比如要对每组做一个自定义的回归或排序取前几。但要注意,apply是逐组调用 Python 函数,组数一多就慢得明显。我的经验是:能用agg或transform表达的,绝不写apply;实在要写,先确认组数在几千以内,否则考虑换向量化写法或换工具。
透视表pivot_table本质上是groupby的另一种表达,适合二维交叉的场景,比如“行是品类、列是月份、值是销量”。它有一个容易忽略的参数margins=True,能直接加一行一列的合计,做报表时特别省事。缺点是合计行的标签叫 “All”,如果后续要做计算,记得先用drop把这些行去掉,否则会重复计算。
7. 多表连接与时间序列:综合练习的重头戏
7.1 merge 的四种连接与校验参数
多表连接出错是综合练习里最高频的翻车点,而且往往错得很安静——结果能跑出来,行数悄悄变多或变少,你不核对根本发现不了。避免这个问题的关键是两个习惯:连接前看键的唯一性,连接后用validate和indicator校验。
先看键。左表的user_id是否唯一?右表是否唯一?如果不唯一,连接就会产生笛卡尔积,行数暴涨。检查方法很简单:
users["user_id"].is_unique orders["user_id"].is_uniquemerge有一个validate参数,把这个检查内置化了:
| 参数值 | 含义 | 适用场景 |
|---|---|---|
one_to_one | 两边键都唯一 | 主表对主表 |
one_to_many | 左唯一、右可重复 | 用户对订单 |
many_to_one | 左可重复、右唯一 | 订单对商品 |
many_to_many | 两边都可重复 | 慎用,容易爆炸 |
传了validate之后,如果实际关系不符合声明,pandas 会直接抛错,而不是默默给你一张错表。这个参数我强烈建议每次连接都写上,宁可多敲几个字符。
indicator=True会在结果里加一列_merge,标记每行来自左表、右表还是两边都有。做左连接之后,用它筛选_merge == "left_only"就能快速找出匹配不上的记录,比写子查询方便得多。这一步在做数据核对时特别有用——比如订单表里有些sku_id在商品表里找不到,说明商品主数据缺失,这本身就是一条值得反馈的业务发现。
连接方式的选择也要想清楚。inner只保留两边都有的,left保留左表全部,right反过来,outer取并集。默认是inner,但业务里更常见的是left,因为主表通常是订单、日志这类明细数据,不希望因为维表缺失就把明细丢掉。
7.2 时间序列:resample 与 rolling 的参数怎么定
时间序列是 pandas 的招牌功能,也是参数最多、最容易配错的地方。resample用于重采样,rolling用于滑动窗口,两者经常配合使用。
resample的频率字符串要记牢几个常用的:D是天,W是周,MS是月初,M是月末,Q是季度末。这里有个坑:M和MS的区别在于时间戳落在周期的末尾还是开头,做月度汇总时前者更常见,但如果你要和别的数据对齐,用哪个必须统一,否则日期会差出整整一个月。周频W默认以周日为一周结束,想改成周一开始要写W-MON。
daily = (orders .set_index("order_date") .resample("MS") .agg(订单数=("order_id", "count"), 销量=("qty", "sum")))注意resample要求索引是时间类型,所以先用set_index把日期设为索引。这一步很多人会忘,然后报“只能对 DatetimeIndex 重采样”。
rolling的窗口参数有两个:window是窗口大小,min_periods是最少需要多少个有效值才开始计算。默认情况下min_periods等于窗口大小,这意味着前 6 天都是空值,做 7 日移动平均时前几天全是 NaN。如果你的目的是尽早得到结果,把min_periods设小一些,比如min_periods=1,代价是前几天的窗口不完整,指标波动会比较大。这个取舍没有标准答案,但一定要有意识地做,而不是被默认值牵着走。
daily["ma7"] = daily["销量"].rolling(window=7, min_periods=1).mean()做同比环比时,shift是利器。daily["销量"].shift(1)就是前一天的值,用当前值除以它减一就是日环比。跨月的同比要按周期偏移,用shift(freq="MS")而不是简单的行位移,否则中间缺几天数据就会错位。这个细节我在实际项目里踩过,当时数据缺了一个周末,行位移出来的同比差了三天,报出去的数字全是错的。
8. 性能优化:从十分钟到十秒
8.1 向量化、避免 iterrows、慎用 apply
pandas 慢,十次有九次是因为写了逐行循环。iterrows返回的是 Series,每次迭代都要构造对象,百万行下来耗时以分钟计。同样的任务用向量化通常能压到秒级。
举个典型例子:按数量分档。循环写法是遍历每一行判断区间,向量化写法用pd.cut:
orders["qty_level"] = pd.cut(orders["qty"], bins=[0, 1, 3, 5, np.inf], labels=["1件", "2-3件", "4-5件", "5件以上"])pd.cut做的是等宽分箱,pd.qcut做的是等频分箱,后者适合把用户按消费额分成人数相近的四组。这两个函数比手写apply快得多,而且不用你写条件判断,少了一堆出错的可能。
另一个高频场景是条件赋值。np.where是向量化的 if-else:
orders["is_bulk"] = np.where(orders["qty"] >= 5, "是", "否")如果条件多于两个,np.select可以接:
conditions = [orders["qty"] >= 5, orders["qty"] >= 2] choices = ["大单", "中单"] orders["order_type"] = np.select(conditions, choices, default="小单")这套写法比嵌套apply快一到两个数量级,而且可读性并不差。
那apply是不是完全不能用?也不是。当你的逻辑涉及多列交互、且没法用现成函数表达时,axis=1的apply仍然是最直观的选择。关键是要知道自己在付什么代价——每行构造一次 Series,然后调用 Python 函数。所以我的建议是:数据量在十万行以内,apply用着没问题;百万行以上,先想办法向量化,实在不行再用np.vectorize或分块处理。
eval和query值得一提。df.query("qty > 3 and price < 100")的写法比布尔索引简洁,底层用 numexpr 引擎时对大表有加速效果。但它对列名的要求比较严格,列名带空格或特殊字符就要用反引号包起来,变量引用要用@前缀,这些都是容易忘的语法。
8.2 分块读取与内存控制
数据量超过内存怎么办?两个思路:分块、降精度。
分块读取靠chunksize,配合一个累加器:
result = [] for chunk in pd.read_csv("big.csv", chunksize=200_000, dtype={"user_id": "int32"}): agg = chunk.groupby("category", observed=True)["qty"].sum() result.append(agg) final = pd.concat(result).groupby(level=0).sum()注意最后要再聚合一次,因为同一个品类可能出现在多个块里,直接append会得到重复的组。这个细节如果漏了,结果会偏大,而且偏得很隐蔽。
降精度是另一个大杀器。int64换成int32,内存砍一半;float64换成float32,精度损失在大多数分析场景可以接受,内存同样砍一半。日期字段如果不是需要精确到纳秒,用datetime64[s]也能省不少。这些操作叠加起来,一份 4GB 的表压到 1GB 以内是很常见的。
| 优化手段 | 典型收益 | 风险 |
|---|---|---|
| int64 → int32 | 约 50% | 数值超过 21 亿会溢出 |
| float64 → float32 | 约 50% | 有效位数降到约 7 位 |
| object → category | 视基数而定,可达 90% | 排序语义变化 |
| 分块处理 | 内存可控 | 需二次聚合 |
最后提醒一点:优化要看数据说话,不要凭感觉。每次优化前后都用time.perf_counter()或%timeit测一下,用memory_usage(deep=True)看内存变化。我见过有人花半天把循环改成向量化,结果发现原来的数据只有几千行,根本没必要——优化的前提是先知道瓶颈在哪。
9. 常见问题与排查速查实录
9.1 报错与结果异常对照表
下面这张表是我这些年被问得最多、也是自己踩得最多的问题,按现象分类整理。遇到问题时先对表,能省下大量搜索时间。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 布尔索引报 ambiguous | 用and/or连接条件 | 改用&/` |
| 赋值后原表没变 | 链式索引改的是副本 | 改用.loc[行条件, 列名] = 值 |
| 连接后行数暴涨 | 连接键不唯一 | 先查is_unique,加validate参数 |
| 连接后行数变少 | 用了默认的 inner 连接 | 确认是否需要改成left |
| 分组结果多出 0 值行 | category 列未加observed | 加observed=True |
| 时间筛选报 TypeError | 日期列还是字符串 | 用pd.to_datetime转换 |
| 聚合结果全是 NaN | 列类型是 object,非数值 | 先astype或to_numeric |
| 内存占用远超文件大小 | object 列基数高 | 转 category,降精度 |
| 排序结果不符合预期 | category 按字典序排 | 显式指定categories顺序 |
| 重采样报错 | 索引不是日期类型 | 先set_index再resample |
这张表不是让你背,是让你在卡住的时候有个方向。很多报错信息其实说得很清楚,只是新手被英文吓住了,逐字读一遍往往就能定位。
9.2 我踩过的几个坑,写下来给后来人
第一个坑是索引没重置。做完筛选、排序、连接之后,索引还是原来的,直接用来做赋值或者拼接,就会出现“对不齐”的问题——明明看着是同一行,赋进去的值却错位了。我现在的习惯是:只要经过筛选或连接,接着就来一句reset_index(drop=True),虽然多一行代码,但能避免绝大部分对齐问题。drop=True不能省,否则原来的索引会变成一列普通字段,混进数据里。
第二个坑是inplace=True的滥用。这个参数看着省事,实际坑不少:它不一定真的省内存,而且会破坏链式调用的可读性,出问题时也不好回溯。pandas 官方这些年一直在弱化它,我的建议是除非内存极度紧张,否则一律用赋值式写法df = df.dropna(),语义清晰,调试方便。
第三个坑是中文列名和路径。列名里有空格或特殊符号时,query里要加反引号,属性访问df.列名会失败,所以我一律用df["列名"]的方式,稳。路径里有中文时,Windows 下偶尔会遇到编码问题,读写时显式指定encoding="utf-8"或者干脆把数据放在纯英文路径下,能省不少事。
第四个坑是==比较浮点数。价格、金额这类字段做等值判断时,浮点误差会导致明明相等的两个值判为不等。正确做法是比较差值是否小于一个阈值,比如(a - b).abs() < 1e-6。或者从一开始就把金额转成整数分来存,从根上绕开这个问题。
第五个坑,也是最值钱的一条:结果一定要交叉验证。分组求和出来的总数,应该等于原始表的总和;连接之后的行数,应该能用主键唯一性推出来;比率的分子分母,应该能手工抽查几行对上。我养成了一个习惯,每跑完一个关键步骤,就打印一次shape和一个总数校验值。这套动作花不了十秒,但抓住过好几次因为数据源更新导致的静默错误。
这套练习我自己反复做过几轮,每一轮的重点都不一样:第一轮熟悉语法,第二轮打磨清洗流程,第三轮死磕性能和边界情况。真正让我觉得“会用了”的,不是能背出多少函数,而是拿到一份陌生数据时,知道先看什么、先改什么、哪里可能出问题。比如现在看到一张新表,我会条件反射地先跑info()和缺失统计,再看看主键唯不唯一,这套动作已经变成肌肉记忆了。你也可以给自己定个规矩:每次练习结束前,写三行备注,记下这次卡在哪、怎么解决的、下次怎么避免。攒上十几条,你就有了一份属于自己的避坑手册,比任何教程都贴合你的实际场景。