Pandas 的学习曲线断点几乎不在 API 数量上,而在心智模型:很多人把它当成"能放多种类型的二维数组",于是写出 df[0] 这种按位置取列的代码,然后在列名是字符串时拿到 KeyError。另一种情况是把 SettingWithCopyWarning 当成噪音忽略,最后发现赋值没生效。这篇按"模型 → 读取 → 选择 → 清洗 → 聚合 → 合并 → 时间 → 性能边界"的顺序把主干走一遍。
1. 心智模型:带标签的二维表
Series = 一维带标签数组;DataFrame = 共享同一套行索引的 Series 字典。关键认知:索引是标签,不是位置。
import pandas as pd
df = pd.DataFrame({"uid": [1, 2, 3], "amt": [10.0, 20.0, 30.0]})
df.index = ["a", "b", "c"] # 行标签换成字符串
print(df.loc["b", "amt"]) # 典型输出:20.0
print(df.iloc[1, 1]) # 典型输出:20.0
| 概念 | 是什么 | 常见误解 |
|---|---|---|
index |
行标签,可重复、可非数值 | 以为等于行号 |
columns |
列标签 | 以为可以像 numpy 那样按位置取列 |
dtype |
每列的物理类型 | 以为整张表只有一种类型 |
| 视图/副本 | 切片可能返回视图 | 以为赋值一定写回原表 |
2. 读取:read_csv 与 read_sql
df = pd.read_csv(
"orders.csv",
dtype={"uid": "int64", "phone": "string"}, # 显式类型,禁用推断
encoding="utf-8", # 或 "gb18030"
parse_dates=["created_at"],
na_values=["", "NULL", "-"],
keep_default_na=True,
)
import sqlite3
with sqlite3.connect("app.db") as conn:
df = pd.read_sql("select * from orders where created_at >= ?", conn, params=("2026-01-01",))
| 参数 | 典型坑 | 处理 |
|---|---|---|
dtype |
工号/手机号/邮编被推断成 int64,前导零丢失 |
显式指定 "string" |
encoding |
Windows 导出的 GBK 文件读成乱码或 UnicodeDecodeError |
先试 utf-8,再试 gb18030 |
parse_dates |
日期留成字符串,后续比较变成字典序 | 读取时就解析 |
na_values |
业务用 "NULL"/"-" 表示空,pandas 不认 |
显式声明 |
usecols / nrows |
百 MB 文件全量读进来才发现只要三列 | 先探结构再读 |
read_sql 的坑不在 pandas 而在驱动:SQL 里做聚合比读进来再用 pandas 聚合快得多,尤其当表在远端数据库上。
3. 选择:loc、iloc 与布尔索引
df.loc["a":"b", ["uid", "amt"]] # 标签切片,含右端点
df.iloc[0:2, 0:2] # 位置切片,不含右端点
df[df["amt"] > 15] # 布尔索引
df.loc[df["amt"] > 15, "amt"] # 布尔 + 列选择,最推荐
| 方式 | 依据 | 右端点 | 返回 |
|---|---|---|---|
loc |
标签 | 包含 | 行/列按标签 |
iloc |
位置 | 不包含 | 行/列按位置 |
[] |
列名(字符串)或布尔 Series | —— | 视输入而定,语义含糊 |
at / iat |
单个标量 | —— | 标量,最快 |
两个必须记住的差异:loc 切片含右端点("a":"b" 取到 b),iloc 沿用 Python 习惯不含。以及 df[布尔 Series] 是行过滤,df["col"] 是列选择,df[列名列表] 才是多列——同一个方括号三种语义,所以生产代码建议一律写 loc/iloc。
4. 缺失值:isna / fillna / dropna
df.isna().sum() # 每列缺失数
df["amt"].fillna(df["amt"].median()) # 用中位数填
df.dropna(subset=["uid"]) # 只按关键列删行
df["amt"].fillna(0, inplace=False) # 显式不原地改
| 方法 | 语义 | 何时用 |
|---|---|---|
dropna() |
删含缺失的行 | 缺失比例极低且随机 |
fillna(常数) |
填固定值 | 业务上缺失等价于某个默认值 |
fillna(统计量) |
填中位数/均值 | 数值特征,缺失与大小无关 |
ffill / bfill |
前/后向填充 | 时间序列,前提是"值不变" |
isna() |
布尔掩码 | 先统计再决定策略 |
"缺失"和"零"是两种语义。金额缺失表示"没发生交易",填 0 会把"未发生"和"发生了但金额为 0"混成一类,直接污染 mean()。同理,fillna(0) 之后 isna() 再也救不回来——清洗前先备份原始列或整表。
5. 分组聚合:agg 与 transform
g = df.groupby("city", dropna=False)
g["amt"].agg(["count", "sum", "mean"]) # 每组一个标量,行数变少
g["amt"].transform("mean") # 每组一个标量,回填到原行
df.assign(share=df["amt"] / g["amt"].transform("sum")) # 组内占比
| 方法 | 输出形状 | 典型用途 |
|---|---|---|
agg |
每组一行 | 汇总报表 |
transform |
与原表同形 | 组内标准化、占比、去均值 |
apply |
由函数决定 | 复杂逻辑,最慢,兜底用 |
filter |
原表子集 | 过滤掉不满足条件的组 |
size / count |
每组一行 | 前者含 NaN,后者不含 |
agg 和 transform 混用是最高频的错误来源:需要"回填到原表"时用 agg 会得到形状不匹配的赋值错误,需要"每组一行"时用 transform 会得到一堆重复值。
6. 合并:四种 how 与重复键放大
out = orders.merge(users, on="uid", how="left", validate="many_to_one")
how |
保留 | 缺失侧填充 |
|---|---|---|
inner |
两表都有的键 | 无 |
left |
左表全部 | 右表缺的补 NaN |
right |
右表全部 | 左表缺的补 NaN |
outer |
并集 | 两侧都可能补 NaN |
重复键导致的笛卡尔放大是 merge 最危险的行为:左表某 uid 出现 3 次、右表同 uid 出现 2 次,合并后该用户变成 6 行,金额被重复计三次。防御手段是 validate=:
validate 取值 |
含义 |
|---|---|
"one_to_one" |
两侧键都唯一 |
"one_to_many" |
左侧唯一 |
"many_to_one" |
右侧唯一(最常见) |
"many_to_many" |
不检查,默认行为 |
合并前后各查一次行数(len(before) <= len(after) 未必成立,但放大倍数应可解释),是比事后对账便宜得多的习惯。
7. 排序、去重与时间序列基础
df.sort_values(["city", "amt"], ascending=[True, False])
df.drop_duplicates(subset=["uid"], keep="last")
df["created_at"] = pd.to_datetime(df["created_at"], errors="coerce", utc=True)
daily = df.set_index("created_at").resample("D")["amt"].sum()
| 操作 | 关键参数 | 坑 |
|---|---|---|
sort_values |
ascending 可为列表 |
多列排序时长度必须对齐 |
drop_duplicates |
subset、keep |
不写 subset 按整行比对,几乎删不掉 |
to_datetime |
errors="coerce" |
脏值变 NaT 而不是抛错,需事后统计 |
resample |
"D"/"W"/"M" |
必须先把时间列设为索引 |
dt 访问器 |
.dt.floor("h") |
非 datetime dtype 会报错 |
resample 的语义是"按时间桶聚合",空桶默认补 0(对 sum)而不补 NaN,做"日均单量"时要意识到这一点。
8. 链式写法与 pipe
def only_valid(df):
return df[df["amt"] > 0]
out = (
df.pipe(only_valid)
.assign(month=lambda d: d["created_at"].dt.to_period("M"))
.groupby("month", as_index=False)["amt"].sum()
.sort_values("amt", ascending=False)
)
链式的价值是让每一步都可单独摘出来测试;pipe 的作用是让自定义函数也能插进链里(df.pipe(f) 等价于 f(df))。代价是中途无法打印中间结果——调试时把链拆成几段变量,别在链里塞 print。
9. 排错
| 现象 | 根因 | 处理 |
|---|---|---|
SettingWithCopyWarning |
在切片结果上赋值,可能是副本 | 先 .copy(),或改用 .loc[行, 列] = 值 |
数值列变成 object |
混入字符串或 None |
pd.to_numeric(errors="coerce") 后查 NaN |
inplace=True 之后原变量没变 |
链式调用返回副本,原地改的是临时对象 | 改成 df = df.dropna() 显式赋值 |
| merge 后行数暴涨 | 键重复 | 加 validate=,检查键唯一性 |
KeyError: 列名 |
列名带空格或大小写不一致 | df.columns.str.strip() 后核对 |
| 大文件读取 OOM | 全量载入内存 | usecols/chunksize/dtype 降精度 |
inplace 的误用值得单独说:df.dropna(inplace=True) 有效,但 df.dropna(inplace=True).sort_values(...) 里第二个调用作用在 None 上会直接报错;而 df["a"].fillna(0, inplace=True) 在部分版本上根本改不到原表。统一风格——要么全用赋值,要么全用 inplace,不要混。
10. 边界:什么时候别用 pandas
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 数据超过内存 | DuckDB / polars lazy | 流式或列式的内存模型 |
| 只是过滤聚合后入库 | SQL | 让数据库做它擅长的事 |
| 单机多核大表计算 | polars | 原生并行、无索引开销 |
| 强类型管线、需要 schema | polars / Pydantic + Arrow | 显式 schema 更可控 |
| 交互式探索小表(万行级) | pandas | 生态最全,够用 |
| 生产服务里的逐行处理 | 纯 Python / numpy | pandas 单行访问开销大 |
判断阈值不用记精确数字:当你在 pandas 里写 iterrows() 或反复 apply 时,说明该换工具或换写法了。pandas 的强项是整列向量化操作,逐行逻辑是它最慢的路径。
11. 自测
df.loc["a":"b"]与df.iloc[0:1]在"是否含右端点"上有什么区别?- 金额列缺失填 0,会让后续
mean()产生什么偏差?为什么这属于语义错误而不是技术错误? agg与transform的输出形状分别是什么?什么场景必须用后者?- 两表按
uid合并后行数变成原来的 6 倍,最可能的原因是什么?用什么参数提前拦住? SettingWithCopyWarning出现时,为什么"加.copy()"有时也不是正解?- 什么信号出现时,说明这份数据处理该从 pandas 换成 polars 或 SQL?