Skip to content

Pandas:从表格基础到可靠数据分析 ​

Pandas 的难点不是记住多少方法,而是始终知道:每一行代表什么、标签怎样对齐、缺失值意味着什么,以及处理后的数据是否可信。本文从小表格出发,逐步建立清洗、分组、连接、时间分析与结果验收的完整思路。

阅读与运行约定

核心示例面向 Python 3.11+、Pandas 2.2 至 3.x 的常用接口,默认行为变化单独说明;这不是完整版本矩阵的测试承诺。每个 Python 代码块均包含自己的导入和数据,可独立运行。Excel 与 Parquet 需要额外引擎,读写使用内存缓冲区,不在仓库生成数据文件。

导航目录 ​

第一篇:认识表格与数据契约 ​

第二篇:选取与清洗数据 ​

第三篇:构造指标与汇总分析 ​

第四篇:组合与重塑表格 ​

第五篇:时间与数据交换 ​

第六篇:性能与工程交付 ​


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-Write3.0 统一启用;2.x 是可选行为,不依赖切片回写
时间精度3.0 不再总推断成纳秒,时间转整数前先明确单位
分类分组3.0 的 groupby 默认 observed=True,报表应显式设置
已移除接口DataFrame.append、Series.append 在 2.0 已移除,使用 concat

参考:Pandas 3.0 发布说明。本文显式控制关键参数,减少对默认值的依赖。


核心模型

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.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.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.1 不靠等号判断缺失 ​

类型常见缺失表示建议
普通浮点NaN使用 isna / notna
可空 Int64、boolean、stringpd.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.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() == 4

errors="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.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.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 按标签排序,不按业务值排序;重置索引也不会改变业务顺序。


先看结果需要多少行

每组一行用聚合;每条原记录需要组内指标用变换;每组选记录用排序配合 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.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.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.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.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.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 pyarrow
python
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.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.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
历史特征异常准确是否用了未来信息、跨实体窗口
分块结果与全量不一致跨块状态、重复键、均值合并是否正确

结语

可靠的数据分析不是“会调用方法”,而是能说明每一步的输入契约、标签和类型变化,以及结果为何正确。先用小数据写出可核对的预期,再覆盖空表、缺失、重复、未匹配、边界时间和超范围数值,最后才扩大规模并优化性能。