Appearance
Pandas:从表格基础到可靠数据分析
Pandas 的难点不是记住多少方法,而是始终知道:每一行代表什么、标签怎样对齐、缺失值意味着什么,以及处理后的数据是否可信。本文从小表格出发,逐步建立清洗、分组、连接、时间分析与结果验收的完整思路。
阅读与运行约定
核心示例面向 Python 3.11+、Pandas 2.2 至 3.x 的常用接口,默认行为变化单独说明;这不是完整版本矩阵的测试承诺。每个 Python 代码块均包含自己的导入和数据,可独立运行。Excel 与 Parquet 需要额外引擎,读写使用内存缓冲区,不在仓库生成数据文件。
导航目录
第一篇:认识表格与数据契约
第二篇:选取与清洗数据
第三篇:构造指标与汇总分析
第四篇:组合与重塑表格
第五篇:时间与数据交换
第六篇:性能与工程交付
1. 定位、环境与版本基线
1.1 工具定位
Pandas 像一张可以编程、按标签计算的电子表格,适合单机上的结构化数据探索、清洗和分析。它不是数据库,也不会自动把超出内存的数据变成分布式任务。
| 工具 | 更擅长的任务 | 边界 |
|---|---|---|
| Python 列表与字典 | 对象组织、控制流程 | 大量逐行计算开销较高 |
| NumPy | 同质数组与数值计算 | 按位置运算,没有业务标签 |
| Pandas | 异质表格、分组与连接 | 中间表和索引也占内存 |
| SQL 数据库 | 持久化、事务、服务端查询 | 尽量下推筛选与聚合 |
text
数据源 -> 读取与契约检查 -> 清洗与类型规范
-> 分组 / 连接 / 时间分析 -> 验收 -> 输出1.2 环境选择
新项目可在独立虚拟环境选择 3.x;维护旧项目时,不要为运行文档直接升级生产环境。
bash
python -m pip install "pandas>=3.0,<4"python
import sys
import pandas as pd
print("Python:", sys.version.split()[0])
print("Pandas:", pd.__version__)依赖范围表达选择,锁定文件用于复现。生产交付应记录精确版本、输入数据版本、时区与业务规则。2.x 项目迁移时,官方建议先升级到 2.3、处理弃用警告,再升级到 3.0。
1.3 关键版本差异
| 主题 | 建议 |
|---|---|
| 字符串推断 | 3.0 默认推断专用 str,不要靠 object 类型识别字符串 |
| 字符串缺失 | 推断的 str 使用 NaN;显式 dtype="string" 使用 pd.NA |
| Copy-on-Write | 3.0 统一启用;2.x 是可选行为,不依赖切片回写 |
| 时间精度 | 3.0 不再总推断成纳秒,时间转整数前先明确单位 |
| 分类分组 | 3.0 的 groupby 默认 observed=True,报表应显式设置 |
| 已移除接口 | DataFrame.append、Series.append 在 2.0 已移除,使用 concat |
参考:Pandas 3.0 发布说明。本文显式控制关键参数,减少对默认值的依赖。
2. 数据结构与标签对齐
核心模型
Series 是一维“值 + 索引”,DataFrame 是共享行索引的多列数据。每列可以有不同类型。索引不一定连续、不一定唯一,也不自动等于业务主键。
2.1 从小表开始
python
import pandas as pd
scores = pd.Series([90, 85], index=["u1", "u2"], name="score")
df = pd.DataFrame({"name": ["小林", "小周"], "score": scores})
assert df.shape == (2, 2)
assert df.index.tolist() == ["u1", "u2"]
assert df["score"].ndim == 1
assert df[["score"]].shape == (2, 1)
print(df)单列选择返回 Series,双层方括号保留二维表。axis=0 表示行轴,axis=1表示列轴;例如数值表沿行轴求和得到每列的和,沿列轴求和得到每行的和。
2.2 标签对齐不等于位置运算
python
import pandas as pd
left = pd.Series([10, 20], index=["a", "b"])
right = pd.Series([1, 2], index=["b", "c"])
result = left + right
assert result.loc["b"] == 21
assert result.loc[["a", "c"]].isna().all()
assert left.add(right, fill_value=0).tolist() == [10, 21, 2]
frame = pd.DataFrame(index=["b", "a"])
frame["aligned"] = left
frame["positional"] = left.to_numpy()
assert frame["aligned"].tolist() == [20, 10]
assert frame["positional"].tolist() == [10, 20]算术通常按标签并集对齐。填零只有在“缺少一方贡献视为零”时合理;双方均缺失的位置仍可能缺失。赋入 Series 按标签对齐,赋入数组按位置,因此去掉标签前必须确认长度和顺序。
2.3 索引与业务主键
python
import pandas as pd
users = pd.DataFrame({"user_id": ["001", "002"], "age": [20, 30]})
assert users["user_id"].notna().all()
indexed = users.set_index("user_id", verify_integrity=True)
assert indexed.index.is_unique
pd.testing.assert_frame_equal(indexed.reset_index(), users)verify_integrity=True 检查重复索引,但非空规则需要另行检查。多层索引适合表达多个维度,不意味着更快;交付下游时普通列通常更易理解。
3. 数据检查与业务契约
3.1 先看规模、类型和分布
| 检查 | 常用入口 | 发现的问题 |
|---|---|---|
| 样本和规模 | head、tail、shape | 表头错位、粒度不符 |
| 类型与缺失 | dtypes、info、isna | 数值读成文本、异常缺失 |
| 分布 | describe、value_counts | 极端值、非法枚举 |
| 唯一性 | is_unique、duplicated | 主键冲突、重复记录 |
| 内存 | memory_usage(deep=True) | 高开销列和索引 |
python
import pandas as pd
df = pd.DataFrame({"order_id": ["o1", "o2", "o3"],
"amount": [100, 0, 250], "status": ["paid", "new", "paid"]})
df.info() # 自身打印摘要,不要再 print 一个 None
print(df.describe(include="all"))
print(df["status"].value_counts(dropna=False))
print(df.isna().mean())
required = {"order_id", "amount", "status"}
if not required.issubset(df.columns) or not df.columns.is_unique:
raise ValueError("字段缺失或列名重复")
if df["order_id"].isna().any() or not df["order_id"].is_unique:
raise ValueError("订单主键不满足契约")
if df["amount"].isna().any() or not df["amount"].ge(0).all():
raise ValueError("金额必须非空且非负")3.2 契约不只是 dtype
整数金额也可能使用了错误货币或单位。应明确:每行的粒度、主键、必需字段、单位、时间含义、允许缺失的列、合法值域,以及异常记录是拒绝还是隔离。
文档用 assert 演示验收;生产入口使用显式异常,避免 Python 优化模式禁用断言。可空类型比较产生的缺失值可能被 .all() 默认跳过,因此非空规则要单独检查;空序列的 .all() 返回 True,因此空表也要有独立策略。
4. 索引、筛选与赋值
4.1 标签和位置分开考虑
| 写法 | 语义 | 边界 |
|---|---|---|
df["score"] | 取列 | 不用属性访问任意业务列名 |
loc | 标签选取 | 标签切片通常包含终点 |
iloc | 位置选取 | 切片不包含终点 |
at | 标签标量访问 | 按唯一标签使用 |
iat | 位置标量访问 | 位置不能越界 |
python
import pandas as pd
df = pd.DataFrame({"name": ["A", "B", "C"], "score": [60, 90, 80]},
index=[10, 20, 30])
assert df.loc[10:20, "score"].tolist() == [60, 90]
assert df.iloc[0:2, 1].tolist() == [60, 90]
assert df.at[20, "score"] == df.iat[1, 1] == 90
assert df.loc[[20], ["score"]].shape == (1, 1)整数索引中的 20 是标签,不是第 21 行。非单调或重复标签会让切片行为更复杂;先保证唯一性,必要时排序,不把标签切片当位置切片。
4.2 条件筛选
python
import pandas as pd
df = pd.DataFrame({"age": pd.Series([18, 25, None, 30], dtype="Int64"),
"city": ["北京", "上海", "北京", "北京"]})
mask = (df["age"].ge(20) & df["city"].isin(["北京", "深圳"])).fillna(False)
assert df.loc[mask].index.tolist() == [3]
assert df.loc[df["age"].between(18, 25).fillna(False)].index.tolist() == [0, 1]- 组合条件用
&、|、~,比较条件加括号,不用 Python 的and/or组合 Series。 - 布尔 Series 有自己的索引,应由同一张表生成或明确重排对齐,不能只检查长度。
query适合固定可信表达式,不能直接执行用户提交的任意表达式。外部参数用普通比较操作构造筛选。
4.3 修改原表与独立子表
python
import pandas as pd
df = pd.DataFrame({"score": [60, 90, 80]})
df["level"] = pd.Series("B", index=df.index, dtype="string")
mask = df["score"].ge(85)
df.loc[mask, "level"] = "A" # 明确修改原表
sub = df.loc[mask].copy() # 明确建立独立工作表
sub.loc[:, "score"] = 100
assert df["score"].tolist() == [60, 90, 80]
assert sub["score"].tolist() == [100]
assert df["level"].tolist() == ["B", "A", "B"]链式赋值不能承担回写职责
不要使用 df[mask]["score"] = 100,也不要期待取出一列后调用 fillna(..., inplace=True) 能更新原表。3.0 的 Copy-on-Write 使派生对象在用户可见行为上与原对象隔离,底层仍可能共享内存、延迟复制。修改原表必须直接赋给原表,2.x 也采用同样写法。
copy(deep=True) 不会递归复制 object 单元格里的列表等 Python 对象。inplace=True 不是通用省内存开关,也不保证更快;返回值按具体方法和版本判断,优先显式赋值。
5. 缺失值与可空类型
未知不等于零
缺失可能表示未采集、不适用、解析失败或尚未发生。处理前应保留原因;填零可能把“没有测量”误写成“测量结果为零”。
5.1 不靠等号判断缺失
| 类型 | 常见缺失表示 | 建议 |
|---|---|---|
| 普通浮点 | NaN | 使用 isna / notna |
可空 Int64、boolean、string | pd.NA | 保留缺失,不强转普通整数或布尔 |
| 日期时间 | NaT | 检查解析结果和时区 |
| object | 可能混合多种表示 | 尽早统一类型 |
python
import pandas as pd
age = pd.Series([20, None, 0], dtype="Int64")
assert age.isna().tolist() == [False, True, False]
assert age.dropna().tolist() == [20, 0]
flag = pd.Series([True, False, pd.NA], dtype="boolean")
assert (flag & False).tolist() == [False, False, False]
assert (flag | True).tolist() == [True, True, True]
assert pd.isna(flag.iloc[2]) # 不执行 bool(pd.NA)
missing = pd.Series([pd.NA, pd.NA], dtype="Float64")
assert missing.sum() == 0
assert pd.isna(missing.sum(min_count=1))可空布尔使用三值逻辑。count 数非缺失值,size 数元素;“没有数据”和“合计为零”要用 min_count 等规则区分。空字符串和文本 NULL 不一定是缺失,无穷大通常也不是缺失。
5.2 填充必须有业务依据
python
import pandas as pd
train = pd.Series([10.0, 20.0, None])
test = pd.Series([None, 100.0])
fill_value = train.median() # 只用训练数据拟合
if pd.isna(fill_value):
raise ValueError("训练列全缺失,无法计算填充值")
assert test.fillna(fill_value).tolist() == [15.0, 100.0]
signal = pd.Series([1.0, None, None, 4.0])
assert signal.interpolate(limit_area="inside").tolist() == [1.0, 2.0, 3.0, 4.0]| 策略 | 前提 | 风险 |
|---|---|---|
dropna(subset=...) | 关键字段不可缺失 | 样本分布改变,要统计删除率 |
| 常量填充 | 明确业务默认值 | 默认值不能伪装成观测值 |
| 均值或中位数 | 统计假设成立 | 使用验证集数据会泄露信息 |
ffill(limit=...) | 状态允许短期延续 | 先按实体分组、按时间排序 |
插值、bfill | 允许未来信息的离线修复 | 不直接用于在线预测特征 |
6. 类型转换与字符串清洗
6.1 把解析失败单独记账
python
import numpy as np
import pandas as pd
raw = pd.Series(["20", " 21 ", "bad", "", None, "inf"], dtype="string")
normalized = raw.str.strip().replace("", pd.NA)
numbers = pd.to_numeric(normalized, errors="coerce")
parse_failed = normalized.notna() & numbers.isna()
finite = pd.Series(np.isfinite(numbers.to_numpy(dtype=float, na_value=np.nan)),
index=raw.index)
valid = finite & numbers.between(0, 120).fillna(False)
valid = valid & numbers.mod(1).eq(0).fillna(False)
ages = numbers.where(valid).astype("Int64")
assert parse_failed.sum() == 1
assert ages.dropna().tolist() == [20, 21]
assert ages.isna().sum() == 4errors="coerce" 只是把失败转为缺失,不等于清洗完成。应保留原值、解析失败掩码和范围失败掩码。可信输入可用 errors="raise";astype 适合已经满足契约的数据。
订单号、邮编通常是字符串。先读成整数再转文本,无法恢复前导零。金额推荐整数最小货币单位,或明确的十进制方案,不能依赖二进制浮点承诺财务精确性。
6.2 明确字符串缺失与正则语义
python
import pandas as pd
raw = pd.Series([" Alice_01 ", "Bob_02", None, "a.b_03"], dtype="string")
clean = raw.str.strip().str.lower()
assert clean.str.contains("a.b", regex=False, na=False).tolist() == [False, False, False, True]
parts = clean.str.extract(r"^([a-z]+)_(\d{2})$")
assert parts.loc[0, 1] == "01"
assert parts.loc[3].isna().all()
print(clean.str.replace("_", "-", regex=False))
print(clean.str.split("_", n=1, expand=True))contains 默认按正则解释,字面量搜索写 regex=False,筛选时明确 na=False。正则提取失败产生缺失,也需要审计。显式字符串类型可以保留缺失,而不是把缺失值变成文本。
7. 派生列、映射与方法链
7.1 能整列计算,就不逐行调用 Python
python
import pandas as pd
df = pd.DataFrame({"qty": [2, 3, 0], "price_cents": [100, 200, 300],
"status": ["P", "N", "X"]})
result = df.assign(
amount_cents=lambda x: x["qty"] * x["price_cents"],
status_name=lambda x: x["status"].map({"P": "已支付", "N": "待支付"}),
average_cents=lambda x: x["amount_cents"].div(x["qty"].where(x["qty"].ne(0))),
)
assert result["amount_cents"].tolist() == [200, 600, 0]
assert result["status_name"].isna().sum() == 1
assert pd.isna(result.loc[2, "average_cents"])
assert "amount_cents" not in df.columns后面的 assign 表达式可以引用前面创建的列;lambda 接收当前中间表。除法前处理零分母,比先产生无穷大再替换更清楚。字典映射中未识别代码会产生缺失,必须检查。
| 操作 | 用途 | 返回形状 |
|---|---|---|
| 算术与比较 | 整列运算,优先使用 | 通常与输入对齐 |
Series.map | 字典映射或标量函数 | 等长 |
DataFrame.map | 全表逐元素函数,2.1+ | 同形 |
DataFrame.apply | 按列或按行调用函数 | 由函数返回值决定 |
pipe | 把整张表交给函数 | 由函数定义 |
apply(axis=1) 通常逐行调用 Python,不是性能优化魔法。旧教程的 applymap 应改用 DataFrame.map,但整列算术仍优先于逐元素函数。
7.2 方法链与小函数
python
import pandas as pd
def add_total(frame):
return frame.assign(total=frame["qty"] * frame["price"])
source = pd.DataFrame({"qty": [1, 2], "price": [30, 40], "debug": [0, 0]})
result = (source.drop(columns="debug")
.pipe(add_total)
.rename(columns={"total": "amount"}))
assert result["amount"].tolist() == [30, 80]
assert source.columns.tolist() == ["qty", "price", "debug"]方法链适合线性步骤,不必追求“一行写完”。遇到审计或复杂分支,为中间表命名并检查指标更容易排错。
8. 排序、重复记录与排名
8.1 重复记录由业务粒度定义
“所有列完全相同”与“同一业务键出现多次”是两件事。订单有多条商品明细可能合法,维表一个用户有两个当前状态则可能违反契约。
python
import pandas as pd
events = pd.DataFrame({
"user_id": ["u1", "u1", "u2"],
"updated_at": pd.to_datetime(["2026-01-01", "2026-01-02", "2026-01-01"]),
"event_id": [1, 2, 3], "value": [10, 20, 30],
})
assert events.duplicated("user_id", keep=False).sum() == 2
# 业务约定:时间较新优先,同一时间选择较大事件编号
latest = (events.sort_values(["user_id", "updated_at", "event_id"])
.drop_duplicates("user_id", keep="last")
.reset_index(drop=True))
assert latest["user_id"].is_unique
assert latest["value"].tolist() == [20, 30]keep="last" 只表示当前行序中的最后一条,不自动表示最新。全部排序字段相同而内容不同的冲突,应进一步裁决或拒绝。稳定排序只保留输入相对顺序,不能定义业务规则。
8.2 并列排名
python
import pandas as pd
scores = pd.Series([95, 95, 80], index=["a", "b", "c"])
assert scores.rank(method="dense", ascending=False).tolist() == [1, 1, 2]
assert scores.rank(method="min", ascending=False).tolist() == [1, 1, 3]
assert scores.nlargest(1, keep="all").index.tolist() == ["a", "b"]保留并列可能返回超过 N 条;需要恰好 N 条时指定确定的次级排序字段。sort_index 按标签排序,不按业务值排序;重置索引也不会改变业务顺序。
9. 分组聚合与组内计算
先看结果需要多少行
每组一行用聚合;每条原记录需要组内指标用变换;每组选记录用排序配合 head 等内建操作。内建操作难以表达时才考虑 GroupBy.apply。
9.1 汇总与回填
python
import pandas as pd
df = pd.DataFrame({"dept": ["研发", "研发", "销售", None],
"name": ["A", "B", "C", "D"], "salary": [100, 200, 150, 50]})
groups = df.groupby("dept", dropna=False, observed=True, sort=False)
summary = groups.agg(rows=("salary", "size"),
known_salary=("salary", "count"),
salary_sum=("salary", "sum"))
assert summary["rows"].sum() == len(df)
assert summary["salary_sum"].sum() == df["salary"].sum()
df["dept_mean"] = groups["salary"].transform("mean")
assert df["dept_mean"].tolist() == [150, 150, 150, 50]
leaders = (df.sort_values(["salary", "name"], ascending=[False, True])
.groupby("dept", dropna=False, observed=True).head(1))
assert leaders["name"].tolist() == ["B", "C", "D"]9.2 显式控制边界
| 参数或方法 | 影响 |
|---|---|
dropna=False | 保留缺失分组键,否则可能丢行 |
observed=True | 分类类型仅返回实际观测组合 |
observed=False | 可能展开未观测组合,增大结果 |
sort=False | 不主动按组键排序,不替代组内排序 |
as_index=False | 聚合键保留为普通列 |
size / count | 数行 / 数非缺失值 |
python
import pandas as pd
df = pd.DataFrame({"region": pd.Categorical(["北", "北"], categories=["北", "南"]),
"amount": pd.Series([10, pd.NA], dtype="Int64")})
observed = df.groupby("region", observed=True)["amount"].sum(min_count=1)
expanded = df.groupby("region", observed=False)["amount"].sum(min_count=1)
assert observed.index.tolist() == ["北"]
assert expanded.index.tolist() == ["北", "南"]
# 跨版本报表:先聚合实际观测组,再显式补齐未观测类别
report = observed.reindex(df["region"].cat.categories)
assert pd.isna(report.loc["南"])分类展开、缺失键和全缺失数值是不同维度,应分别验证。GroupBy.apply 对分组列的传入行为存在版本变化;确需使用时,显式选择计算列,在 2.2+ 用 include_groups=False 表达排除分组列,不依赖旧默认行为。
10. 连接基数与匹配审计
10.1 先声明连接关系
| 关系 | validate | 典型场景 |
|---|---|---|
| 一对一 | one_to_one | 同粒度数据补充 |
| 多对一 | many_to_one | 订单关联用户维表 |
| 一对多 | one_to_many | 用户关联订单 |
| 多对多 | many_to_many | 两边键都重复,不执行唯一性检查 |
同一个键在左表出现 m 次、右表出现 n 次,会产生 m × n 条匹配。多对多连接不是错误,但必须是明确的业务需求,不能用随意去重掩盖主键冲突。
python
import pandas as pd
orders = pd.DataFrame({"order_id": [1, 2, 3], "user_id": ["u1", "u1", "u3"],
"amount": [100, 200, 50]})
users = pd.DataFrame({"user_id": ["u1", "u2"], "city": ["北京", "上海"]})
if users["user_id"].isna().any() or not users["user_id"].is_unique:
raise ValueError("维表主键无效")
joined = orders.merge(users, on="user_id", how="left",
validate="many_to_one", indicator=True)
assert len(joined) == len(orders)
assert joined["amount"].sum() == orders["amount"].sum()
assert joined["_merge"].eq("left_only").sum() == 1
assert joined.loc[joined["_merge"].eq("left_only"), "order_id"].tolist() == [3]10.2 空键与未匹配记录
Pandas 的空键连接不同于常见 SQL 语义
两侧连接键均为空时,Pandas 可以把它们匹配起来。若业务规定空键不允许匹配,应先隔离左表空键记录,并拒绝或移除右表空键,再对非空键执行连接。复合键中的任一分量缺失也应明确策略。
validate 只检查连接关系,不保证所有记录都匹配。indicator=True 用于统计未匹配来源;内连接会直接丢弃未匹配行,不能只在内连接结果上检查覆盖率。连接前固定键的类型、大小写与空白规则,连接后检查行数、金额、主键和匹配率。
join 常用于索引连接,也支持关系校验。重名字段应通过明确的后缀或连接前重命名区分来源,避免下游误用错误版本的列。
11. 拼接、透视与长宽表
11.1 concat 也会对齐标签
python
import pandas as pd
a = pd.DataFrame({"id": [1], "value": [10]})
b = pd.DataFrame({"id": [2], "value": [20]})
combined = pd.concat([a, b], ignore_index=True)
assert combined["id"].tolist() == [1, 2]
left = pd.DataFrame({"x": [10, 20]}, index=["a", "b"])
right = pd.DataFrame({"y": [1, 2]}, index=["b", "a"])
wide = pd.concat([left, right], axis=1)
assert wide["y"].tolist() == [2, 1]纵向拼接按列名对齐,横向拼接按行索引对齐。ignore_index=True 只重建拼接轴标签,不检查业务主键;verify_integrity=True 检查的是拼接轴的重复标签。列结构不一致时应先核对 schema,而不是接受自动产生的缺失列。
批量收集表后一次拼接,通常比循环中反复拼接更合适。但收集所有表仍会消耗内存,不等于流式处理。
11.2 重塑与聚合是两件事
python
import pandas as pd
long = pd.DataFrame({"city": ["北京", "北京", "上海", "上海"],
"quarter": ["Q1", "Q2", "Q1", "Q2"],
"sales": [100, 120, 90, 130]})
wide = long.pivot(index="city", columns="quarter", values="sales")
restored = wide.reset_index().melt(id_vars="city", var_name="quarter", value_name="sales")
keys = ["city", "quarter"]
pd.testing.assert_frame_equal(
long.sort_values(keys).reset_index(drop=True),
restored.sort_values(keys).reset_index(drop=True),
)
counts = pd.crosstab(long["city"], long["quarter"])
assert counts.to_numpy().sum() == len(long)| 方法 | 用途 | 重复组合 |
|---|---|---|
pivot | 不聚合地从长表变宽表 | 重复索引与列键组合会报错 |
pivot_table | 聚合后生成宽表 | 必须明确 aggfunc,默认均值未必合理 |
melt | 指标列变成行 | 明确标识列和数值列 |
crosstab | 频数与比例 | normalize="index" 计算行占比 |
透视缺口是“未发生”还是“未知”,决定能否填零。宽表规模由行键数量乘列键数量决定,高基数透视可能迅速耗尽内存。
12. 时间解析、时区与重采样
12.1 先明确时间来源
python
import pandas as pd
raw = pd.Series(["2026-01-01T00:00:00Z", "bad"], dtype="string")
parsed = pd.to_datetime(raw, format="ISO8601", errors="coerce", utc=True)
assert (raw.notna() & parsed.isna()).sum() == 1
local = parsed.dt.tz_convert("Asia/Shanghai")
assert local.iloc[0].hour == 8
naive = pd.to_datetime(pd.Series(["2026-01-01 08:00"]), format="%Y-%m-%d %H:%M")
as_utc = naive.dt.tz_localize("Asia/Shanghai").dt.tz_convert("UTC")
assert as_utc.iloc[0] == parsed.iloc[0]带偏移时间可用 utc=True 统一到 UTC。无时区文本若代表本地时间,必须先 tz_localize 声明其时区,再 tz_convert;直接指定 utc=True 会把无时区值解释成 UTC,含义不同。
夏令时地区会出现重复或不存在的本地时刻,应明确 ambiguous、nonexistent 的拒绝或修正规则。Windows 等缺少系统时区数据库的环境需要可用的 tzdata。
12.2 重采样是按时间桶分组
python
import pandas as pd
df = pd.DataFrame({"time": pd.to_datetime(["2026-01-01", "2026-01-02", "2026-02-01"]),
"value": [10, 20, 30]})
monthly = df.resample("MS", on="time", closed="left", label="left")["value"].sum(min_count=1)
assert monthly.tolist() == [30, 30]
assert monthly.index.day.tolist() == [1, 1]
assert df["time"].dt.month.tolist() == [1, 1, 2]可以使用 DatetimeIndex、TimedeltaIndex、适用的 PeriodIndex,或通过 on / level 指向时间值,并非必须先设置 DatetimeIndex。
| 频率 | 语义 |
|---|---|
D、h、min | 日、小时、分钟 |
MS / ME | 月初 / 月末 |
QS / QE | 季初 / 季末 |
YS / YE | 年初 / 年末 |
偏移量里的旧 M/Q/Y 写法应迁移到相应月末、季末、年末别名;Period 的频率体系不同,不能全局替换其中的 M/Q/Y。用 closed 确定桶边界,用 label 确定结果标签,周频率还应明确周锚点。
asfreq 偏向频率重建,resample 偏向聚合。日历一天跨夏令时未必等于固定 24 小时。3.0 的时间精度可能为微秒等单位,转整数前可用 .dt.as_unit("ns") 等显式规范,但需考虑目标精度的范围和溢出风险。
13. 滚动窗口与历史特征
13.1 最近几行不等于最近几天
rolling(3) 表示最近三条记录;rolling("3D") 表示时间跨度,记录数可变。应显式设置 min_periods,明确不足窗口时是否允许输出。
python
import pandas as pd
df = pd.DataFrame({"user": ["a", "a", "a", "b", "b"],
"time": pd.to_datetime(["2026-01-01", "2026-01-02", "2026-01-03",
"2026-01-01", "2026-01-02"]),
"value": [10, 20, 30, 100, 200]})
df = df.sort_values(["user", "time"]).reset_index(drop=True)
df["previous"] = df.groupby("user")["value"].shift(1)
df["history_mean"] = df.groupby("user")["value"].transform(
lambda s: s.shift(1).rolling(2, min_periods=1).mean()
)
assert df["previous"].isna().tolist() == [True, False, False, True, False]
assert df.loc[2, "history_mean"] == 15
assert df.loc[4, "history_mean"] == 100先分组、排序,再移位和滚动,防止不同实体串数据。先移位再滚动让当前记录不进入其自身的历史特征。同一实体存在相同时间时,还必须有明确次级排序或先聚合。
python
import pandas as pd
s = pd.Series([10, 20, 40], index=pd.to_datetime(["2026-01-01", "2026-01-02", "2026-01-04"]))
previous_days = s.rolling("2D", closed="left", min_periods=1).sum()
assert pd.isna(previous_days.iloc[0])
assert previous_days.iloc[1:].tolist() == [10, 20]时间窗口通常要求时间轴有序。closed="left" 在此排除当前时刻,窗口左端包含在内。若数据晚到,应同时区分事件发生时间和系统可获知时间:按事件时间排序,并不能证明这些数据在预测时已可用。
13.2 特征计算的常见泄露
- 全量数据拟合填充值、归一化参数或类别统计,再切训练集。
- 用居中窗口、向后填充或双向插值生成线上预测特征。
- 同一用户的未来记录参与当前记录的聚合。
- 使用只有订单完成后才产生的字段预测订单是否完成。
时间近邻连接可考虑 merge_asof,但仍须排序,设置实体键、direction="backward"、容差及是否允许同一时刻匹配,并检查未匹配率。
14. CSV、JSON、Excel 与 Parquet
14.1 CSV 与分隔符文本
python
from io import StringIO
import pandas as pd
text = "user_id,amount,note\n001,100,NA\n002,,ok\n"
df = pd.read_csv(StringIO(text), dtype={"user_id": "string", "amount": "Int64", "note": "string"},
keep_default_na=False, na_values={"amount": [""]})
assert df["user_id"].tolist() == ["001", "002"]
assert df.loc[0, "note"] == "NA"
assert pd.isna(df.loc[1, "amount"])
buffer = StringIO()
df.to_csv(buffer, index=False)
buffer.seek(0)
restored = pd.read_csv(buffer, dtype=df.dtypes.to_dict(),
keep_default_na=False, na_values={"amount": [""]})
pd.testing.assert_frame_equal(df, restored)CSV 不保存完整类型元数据,读取时要恢复 schema。keep_default_na=False 可避免合法代码 NA 被误判,但应另行指定真实缺失标记。制表符文本可设置 sep="\t"。大文件使用 usecols 限制列数,编码与分隔符应来自数据协议,不靠反复猜测。
真实文件可按消费方要求使用 UTF-8 或 UTF-8 BOM;内存文本缓冲区没有文件字节编码问题。index=False 适合索引无业务价值的情况,有业务价值时应先转为普通列。
14.2 JSON
python
from io import StringIO
import pandas as pd
df = pd.DataFrame({"name": ["小林", "小周"], "score": [90, 85]})
text = df.to_json(orient="records", force_ascii=False)
restored = pd.read_json(StringIO(text), orient="records")
assert restored.to_dict("records") == df.to_dict("records")JSON 需要约定方向、日期格式和数值精度;records 不保留原索引和完整 dtype。JSON Lines 可结合 lines=True 处理逐行记录。嵌套接口对象可用 json_normalize 展平,但要先明确数组展开后的行粒度。
14.3 Excel 与 Parquet:可选依赖
以下两个示例需要分别安装引擎,不安装时无法执行。
bash
python -m pip install openpyxl pyarrowpython
from io import BytesIO
import pandas as pd
df = pd.DataFrame({"name": ["小林", "小周"], "score": [90, 85]})
buffer = BytesIO()
with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name="成绩", index=False)
buffer.seek(0)
restored = pd.read_excel(buffer, sheet_name="成绩", engine="openpyxl")
assert restored.to_dict("records") == df.to_dict("records")python
from io import BytesIO
import pandas as pd
df = pd.DataFrame({"user_id": pd.Series(["001", "002"], dtype="string"),
"amount": pd.Series([100, None], dtype="Int64")})
buffer = BytesIO()
df.to_parquet(buffer, engine="pyarrow", index=False)
buffer.seek(0)
restored = pd.read_parquet(buffer, engine="pyarrow")
pd.testing.assert_frame_equal(df, restored)| 格式 | 优点 | 注意事项 |
|---|---|---|
| CSV / TXT | 通用、可阅读 | 缺少完整 schema,体积可能较大 |
| JSON | 适合接口和嵌套数据 | 类型与精度需要协议约束 |
| Excel | 便于人工查看 | 引擎依赖、行数限制、前导零与日期转换 |
| Parquet | 列式、压缩、保存类型信息 | 引擎与跨系统类型兼容仍需验证 |
向电子表格导出不可信文本时,应按消费方规则防范公式注入,不能把 CSV 引号转义当成公式防护。不要读取不可信的 pickle 文件。生产输出还应考虑权限、临时文件写入后原子替换,以及回读验收。
15. 内存预算与分块计算
15.1 先测量,再优化
python
import numpy as np
import pandas as pd
df = pd.DataFrame({"city": ["北京", "上海"] * 500,
"score": np.arange(1000, dtype=np.int64)})
before = int(df.memory_usage(deep=True).sum())
limits = np.iinfo(np.int16)
if not df["score"].between(limits.min, limits.max).all():
raise ValueError("数值超出目标类型范围")
compact = df.assign(score=df["score"].astype("int16"), city=df["city"].astype("category"))
after = int(compact.memory_usage(deep=True).sum())
assert compact["score"].astype("int64").equals(df["score"])
print({"before_bytes": before, "after_bytes": after})不要在未检查范围时直接降位。整数运算可能溢出,浮点降位会损失精度;当前数据能装下,也不代表后续乘法和聚合不会超范围。分类类型适合低基数列,高基数时可能不省内存,新增未定义类别还需显式扩展分类。
memory_usage(deep=True) 是表内存估计,不是进程峰值内存。排序、连接、宽表重塑和类型转换都可能额外分配中间结果;Copy-on-Write 也不会消除所有复制。
15.2 分块聚合要满足可合并条件
python
from io import StringIO
import pandas as pd
text = "region,amount\nA,10\nB,20\nA,30\nB,40\nA,50\n"
totals = {}
counts = {}
with pd.read_csv(StringIO(text), chunksize=2,
dtype={"region": "string", "amount": "int64"}) as reader:
for chunk in reader:
partial = chunk.groupby("region", observed=True)["amount"].agg(["sum", "count"])
for key, row in partial.iterrows(): # 只遍历小型分组结果
totals[key] = totals.get(key, 0) + int(row["sum"])
counts[key] = counts.get(key, 0) + int(row["count"])
means = {key: totals[key] / counts[key] for key in totals}
assert totals == {"A": 90, "B": 60}
assert means == {"A": 30.0, "B": 30.0}均值不能直接平均各块均值,应累加总和与有效计数。中位数、全局去重、跨块窗口和排序需要额外状态或不同算法。上例假设分组键非空且类别数可控;分组键无限增长时,字典也会耗尽内存。
优化顺序
先减少读取的行列,再固定类型,优先内建矢量化与聚合,最后测量时间和峰值内存。若中间结果无法安全装入单机内存,应考虑数据库侧聚合或适合磁盘与分布式执行的工具,而不是继续压缩代码行数。
16. 订单分析实践与验收清单
16.1 从脏数据到可信汇总
业务约定:每行一笔订单,订单号唯一;金额以“分”为单位,只接受有界非负十进制整数字符串;无效金额进入隔离表;用户维表主键非空唯一;缺失或未匹配用户归入未知区域。示例金额上界与小数据量保证聚合不会超出整数范围。
python
from io import StringIO
import pandas as pd
raw = pd.DataFrame({
"order_id": ["o1", "o2", "o3", "o4", "o5"],
"user_id": ["u1", "u2", "u9", "u1", "u2"],
"amount_text": ["1000", "2000", "500", "bad", "-1"],
})
users = pd.DataFrame({"user_id": ["u1", "u2"], "region": ["北区", "南区"]})
if raw.empty or raw["order_id"].isna().any() or not raw["order_id"].is_unique:
raise ValueError("订单为空或主键不合法")
if users["user_id"].isna().any() or not users["user_id"].is_unique:
raise ValueError("用户维表主键不合法")
if users["region"].isna().any():
raise ValueError("维表区域缺失")
text = raw["amount_text"].astype("string").str.strip()
valid = text.str.fullmatch(r"[0-9]{1,9}", na=False)
rejected = raw.loc[~valid].assign(reason="金额不符合非负整数字符串契约")
clean = raw.loc[valid].copy()
clean["amount_cents"] = pd.to_numeric(text.loc[valid], errors="raise").astype("int64")
joined = clean.merge(users, on="user_id", how="left",
validate="many_to_one", indicator=True)
unmatched = joined["_merge"].eq("left_only")
joined["region"] = joined["region"].astype("string").fillna("未知")
report = (joined.groupby("region", as_index=False, observed=True, dropna=False)
.agg(order_count=("order_id", "size"), amount_cents=("amount_cents", "sum"))
.sort_values("region").reset_index(drop=True))
assert len(raw) == len(clean) + len(rejected)
assert len(joined) == len(clean)
assert joined["order_id"].is_unique
assert report["order_count"].sum() == len(clean) == 3
assert report["amount_cents"].sum() == clean["amount_cents"].sum() == 3500
assert len(rejected) == 2
assert unmatched.sum() == 1
assert report.set_index("region")["amount_cents"].to_dict() == {"北区": 1000, "南区": 2000, "未知": 500}
buffer = StringIO()
report.to_csv(buffer, index=False)
buffer.seek(0)
restored = pd.read_csv(buffer, dtype={"region": "string", "order_count": "int64", "amount_cents": "int64"})
pd.testing.assert_frame_equal(report, restored)
print(report)这里不是随意删除异常,而是保留拒绝记录及原因;也不是把未匹配用户丢弃,而是显式归入未知。若生产规则禁止未知用户,应在连接审计后直接拒绝,不能沿用示例策略。
16.2 从“代码运行”到“可以交付”
| 阶段 | 必查项 |
|---|---|
| 输入 | 空表策略、schema、主键、单位、时区、允许的值域 |
| 清洗 | 原始记录数、拒绝数、解析失败率、缺失处理依据 |
| 连接 | 基数、空键策略、匹配率、行数与金额守恒 |
| 聚合 | 缺失组、分类组展开、全缺失值、并列规则 |
| 输出 | 列顺序、类型、编码、确定性排序、回读一致性 |
| 运行 | 依赖版本、输入版本、耗时、峰值内存、日志与失败策略 |
16.3 高频问题定位
| 现象 | 优先检查 |
|---|---|
| 赋值后出现缺失或顺序错误 | Series 是否按标签对齐 |
| 修改子表却想更新原表 | 是否直接对原表赋值 |
| 整数转换失败 | 是否含缺失、小数、无穷大或越界值 |
| 连接后金额翻倍 | 两侧键是否重复、基数是否正确 |
| 分组后总行数减少 | 缺失键是否被默认排除 |
| 日期错了几个小时 | 是否把本地时间误解释成 UTC |
| 历史特征异常准确 | 是否用了未来信息、跨实体窗口 |
| 分块结果与全量不一致 | 跨块状态、重复键、均值合并是否正确 |
结语
可靠的数据分析不是“会调用方法”,而是能说明每一步的输入契约、标签和类型变化,以及结果为何正确。先用小数据写出可核对的预期,再覆盖空表、缺失、重复、未匹配、边界时间和超范围数值,最后才扩大规模并优化性能。