pandas

用明确的索引、数据类型与连接基数处理表格数据,并写出可验证的数据清洗、聚合和合并流程。

难度 进阶 时长 标准深度约 13分钟
版本 Python 3.14
what

pandas 用带标签的 SeriesDataFrame 表示表格数据,并提供选择、清洗、聚合、连接与输入输出操作。

when

数据能够放进内存,而且你需要在 Python 中检查与转换异构列时使用 pandas;流式扫描或超出内存的数据需要别的执行引擎。

how

先定义索引、列的 dtype、缺失值策略与键基数,再用 .loc、向量化表达式、命名聚合和带 validate 的连接组成流程。

是什么,为什么存在

pandas 是 Python 的表格数据处理库。它的核心对象是 数据帧(DataFrame) :行、列和数据类型共同组成的二维带标签结构。单列通常是 序列(Series) ,它同时携带值、数据类型与索引。

Python 的列表和字典能保存记录,却不会自动提供按列运算、标签对齐、缺失值传播、分组聚合或关系连接。pandas 把这些操作放进一套统一接口,使你可以从 CSV、数据库查询或内存对象开始,经过可检查的转换,产出新的表或统计结果。

pandas 最适合能放进单机内存的结构化数据,以及需要交互式检查的清洗和分析任务。特征工程、实验分析、报表准备和小到中等规模的 ETL 中经常会遇到它。它不是数据库,也不自动保存约束、事务或查询计划;进入 pandas 后,这些保证需要由代码重新表达。

一张表不只是一个二维值矩阵。行 索引对齐(index alignment) 决定不同对象如何配对,每列的 数据类型(dtype) 决定值如何表示,键是否唯一则决定连接会不会扩增行数。可靠的 pandas 代码把这些属性当作数据契约,而不只检查列名。

工作原理

两种带标签的容器

Series 是一维值序列,只有一个行索引。DataFrame 是共享同一行索引的多列集合,列名构成另一条标签轴。df["revenue"] 返回 Series,而 df[["revenue"]] 保留二维结构并返回 DataFrame;这一区别会影响后续选择、连接和返回类型。

每列有自己的 dtype,所以同一张 DataFrame 可以同时保存整数、浮点数、字符串、布尔值和日期。object 只说明列中保存了 Python 对象,不等于“这是字符串列”。读取边界最好显式指定关键列类型,并在清洗后检查 df.dtypes

选择就是坐标规则

.loc 按标签选择,.iloc 按整数位置选择。标签切片的结束标签包含在结果中,而位置切片与普通 Python 切片一样不包含结束位置。.at.iat 适合读取或写入单个标量,但不会改变标签与位置的语义。

布尔条件也是一条带索引的 Series。使用 &|~ 组合条件时,每个比较都要加括号,因为 Python 运算符优先级与自然语言不同。最终选择应一次写进 .loc[rows, columns],这样赋值目标和返回形状都清楚。

表达式选择规则结果形状
df["revenue"]按标签选择一列Series
df[["revenue"]]按列标签列表选择DataFrame
df.loc["a":"c"]包含终点的标签切片取决于匹配行
df.iloc[0:3]不含终点的位置切片最多三行

运算会对齐标签

两个 Series 做算术运算时,pandas 默认先按索引标签配对,再对匹配位置计算。标签只出现在一侧时,结果会包含该标签并产生缺失值。这个规则让来自不同来源的数据可以安全对齐,也会让本想按位置计算的代码悄悄得到更多行。

只有领域含义确实是位置对应时,才应显式转成数组或重置索引。转换为 NumPy 数组会丢掉标签保护,因此要先验证长度和排序。看到意外的 NaN 时,先比较索引,而不是立即填零。

缺失值属于类型契约

pandas 使用多种缺失标记,包括浮点列中的 NaN、日期列中的 NaT,以及可空扩展类型中的 pd.NA 可空数据类型(nullable dtype) Int64booleanstring 能在保留领域类型的同时表示缺失值。检测时统一使用 isna()notna(),不要依赖相等比较或真值转换。

清洗策略必须按列决定:未知客户编号通常不能用 0 代替,缺失金额也不一定等于零。解析失败是拒绝、修正还是转成缺失值,应在 to_numeric()to_datetime()errors 参数附近表达并测试。

分组与连接改变形状

groupby() 实现拆分、应用、合并:先根据键形成组,再对每组执行聚合或转换。agg() 通常减少行数,transform() 则返回与原对象同索引的结果,适合计算组内占比或中心化值。命名聚合会同时固定输出列名、输入列和聚合函数,便于审查。

merge() 按键实现数据库式连接,但输入表的唯一性约束不会自动继承。validate="many_to_one" 等参数会把预期基数变成运行时检查,indicator=True 则说明每行来自左表、右表还是两边。连接之后还要检查行数、未匹配键和关键金额汇总。

输入与输出是边界

read_csv() 等读取器会同时解析数据并推断模式。对于含义明确的字段,应有意设置 usecolsdtypena_values 和日期解析。一个整洁样本不能证明生产列只有一种表示形式。

输出选项同样携带语义。to_csv(index=False) 会丢弃行索引,所以有业务含义的索引必须先变回普通列。不同格式保留的数据类型和元数据并不相同;应重新读取一份小型产物并比较模式,而不是假设往返转换无损。

可维护的流程会把边界检查明确写出:

  1. 选择预期列并统一名称。
  2. 把值解析为声明的类型,并记录失败项。
  3. 执行键、空值、范围与基数约束。
  4. 转换数据,再验证输出形状与不变量汇总。

示例

以下四个示例使用同一类订单与客户数据,依次展示选择、清洗、聚合和连接。所有输出都由 Python 3.14.3 与 pandas 3.0.5 实际生成。

1. 建表并按标签选择

第一步把稳定的业务键设为索引,再用一个布尔条件和明确的列列表选择已付款订单。缺失收入不会通过 notna() 条件。

select_orders.py
import pandas as pd

orders = pd.DataFrame(
    {
        "order_id": [1001, 1002, 1003, 1004],
        "region": ["East", "West", "East", "West"],
        "revenue": [120.0, 75.5, None, 210.0],
        "paid": [True, False, True, True],
    }
).set_index("order_id")

paid_orders = orders.loc[
    orders["paid"] & orders["revenue"].notna(),
    ["region", "revenue"],
]

print(orders.dtypes.astype(str).to_dict())
print(paid_orders.to_string())
{'region': 'str', 'revenue': 'float64', 'paid': 'bool'}
         region  revenue
order_id                
1001       East    120.0
1004       West    210.0

set_index() 返回带 order_id 标签的新对象;没有使用 inplace=True,所以所有权变化出现在赋值语句中。.loc 的两个维度分别描述行条件与输出列,结果仍是二维 DataFrame

输出还暴露了推断出的类型。金额列因为包含 None 而成为 float64,布尔列保持 bool。如果订单编号必须保留前导零,就应在读取时把它声明为字符串,而不是事后从整数猜回原值。

2. 在读取边界清洗类型

CSV 默认推断可能把 001 读成整数 1,因此客户编号在读取时就指定为 string。链式方法每一步返回一个可检查的新结果,最后再明确使用可空浮点类型。

clean_customers.py
from io import StringIO

import pandas as pd

raw_csv = StringIO(
    """customer_id,segment,spend
001, retail ,19.50
002,,unknown
002,retail,24.00
003,SMB,31.25
"""
)

customers = pd.read_csv(
    raw_csv,
    dtype={"customer_id": "string"},
    na_values=["unknown"],
)
cleaned = (
    customers.assign(
        segment=lambda frame: frame["segment"].str.strip().str.lower().fillna("unassigned"),
        spend=lambda frame: pd.to_numeric(frame["spend"], errors="coerce"),
    )
    .drop_duplicates(subset="customer_id", keep="last")
    .reset_index(drop=True)
)
cleaned["spend"] = cleaned["spend"].astype("Float64")

print(cleaned.to_string(index=False))
print(cleaned.dtypes.astype(str).to_dict())
customer_id segment  spend
        001  retail   19.5
        002  retail   24.0
        003     smb  31.25
{'customer_id': 'string', 'segment': 'str', 'spend': 'Float64'}

na_values 把领域中的哨兵文本映射为缺失值,字符串访问器再统一分类标签。errors="coerce" 会把其他无法解析的金额也变成缺失值;生产代码必须统计这些值,避免解析问题被安静吞掉。

这里按客户编号保留最后一行,只是明确的示例策略。真实数据需要时间戳或版本列证明“最后”代表最新记录,并在去重后断言键唯一。否则输入顺序变化会改变结果。

3. 聚合并回填组内结果

命名聚合生成一行一个地区的摘要,transform("sum") 则为每个原始订单返回所属地区的总额。两者用途不同,不需要用 apply() 模拟。

summarize_orders.py
import pandas as pd

orders = pd.DataFrame(
    {
        "region": ["East", "East", "West", "West", "East"],
        "channel": ["web", "store", "web", "store", "web"],
        "revenue": [120.0, 80.0, 150.0, 50.0, 100.0],
    }
)

summary = (
    orders.groupby("region", as_index=False)
    .agg(
        orders=("revenue", "size"),
        revenue=("revenue", "sum"),
        avg_order=("revenue", "mean"),
    )
    .sort_values("revenue", ascending=False)
)
orders["region_share"] = orders["revenue"] / orders.groupby("region")["revenue"].transform("sum")

print(summary.to_string(index=False, formatters={"avg_order": "{:.2f}".format}))
print(orders[["region", "channel", "region_share"]].round(3).to_string(index=False))
region  orders  revenue avg_order
  East       3    300.0    100.00
  West       2    200.0    100.00
region channel  region_share
  East     web         0.400
  East   store         0.267
  West     web         0.750
  West   store         0.250
  East     web         0.333

as_index=False 让分组键保留为普通列,便于随后导出或连接。每个命名聚合都能从输出列追溯到源列和函数,不会产生难读的多级列索引。

transform() 保留原行索引,因此右侧结果能安全赋给 orders["region_share"]。如果分组总额可能为零,除法前还需要定义零分母策略,并检查结果是否为有限值。

4. 验证连接基数

订单可以多次引用同一个客户,而客户维表中的 customer_id 必须唯一。这正是 many_to_one,应由 validate 明确检查,而不是假设数据始终干净。

join_customers.py
import pandas as pd

orders = pd.DataFrame(
    {
        "order_id": [1001, 1002, 1003, 1004],
        "customer_id": [10, 11, 10, 13],
        "total": [80.0, 125.0, 45.0, 60.0],
    }
)
customers = pd.DataFrame(
    {
        "customer_id": [10, 11, 12],
        "tier": ["gold", "silver", "bronze"],
    }
)

joined = orders.merge(
    customers,
    on="customer_id",
    how="left",
    validate="many_to_one",
    indicator=True,
)
joined["tier"] = joined["tier"].fillna("unmatched")
report = joined.groupby("tier", as_index=False).agg(
    orders=("order_id", "size"),
    revenue=("total", "sum"),
)

print(joined[["order_id", "customer_id", "tier", "_merge"]].to_string(index=False))
print(report.to_string(index=False))
 order_id  customer_id      tier    _merge
     1001           10      gold      both
     1002           11    silver      both
     1003           10      gold      both
     1004           13 unmatched left_only
     tier  orders  revenue
     gold       2    125.0
   silver       1    125.0
unmatched       1     60.0

客户 10 被两笔订单引用是合法的,而客户 13 没有匹配项。indicator 把这个事实留在结果中,业务规则可以选择拒绝、隔离或标记,而不是让缺失等级无声流入报表。

如果客户表中 10 出现两次,validate="many_to_one" 会直接抛出 MergeError。没有这项检查,两笔订单会各自匹配两行,订单数和收入汇总都会翻倍。

陷阱

修复方法: 在一个 .loc 调用中同时指定行与列:df.loc[df["paid"], "status"] = "ready"。如果本来就要独立结果,先显式调用 .copy(),再修改副本。不要用关闭写时复制或忽略警告来修补含糊的所有权。

修复方法: 在运算前检查索引唯一性与集合关系。领域语义按标签对应时,让 pandas 对齐并检查未匹配标签;领域语义按位置对应时,先验证排序和长度,再显式使用数组。不要对意外结果直接 fillna(0)

修复方法: 在输入边界选择 booleanInt64Float64string 等合适类型,并用 isna() 检测缺失。对每列分别定义缺失策略,转换后断言 dtype 与缺失数量。

修复方法: 连接前验证键的唯一性与空值策略,调用 merge(..., validate=...),并用 indicator=True 审计来源。连接后比较行数、未匹配键数量和不应变化的金额总和。

修复方法: 先统计分组键缺失行,并根据业务规则补充标签、拒绝数据,或明确写出 dropna=False。聚合后执行守恒检查,例如比较输入与分组结果的订单数和收入总额。

深入 标签、类型与所有权契约

标签、类型与所有权契约

对齐先于计算

pandas 的二元运算不是先取两个底层数组再逐位置计算。它会先构造标签关系:Series 对齐行索引,DataFrame 同时对齐行索引和列标签。结果通常包含标签并集,因此一侧缺少的坐标会产生缺失值。

重复索引会让这种关系更复杂,因为一个标签可能对应多行。依赖唯一键的接口应在边界检查 index.is_unique,而不是等待下游结果异常。需要比较两个索引时,可以明确检查 equals()、差集和顺序,而不仅是长度。

索引本身也有类型。整数标签 1 与字符串标签 "1" 不相等,时区不同的时间索引可能无法代表同一时间语义。把索引重置为默认整数前,要先确认它不是唯一保存的业务键。

类型决定允许的状态

dtype 同时限制表示范围、缺失能力和运算行为。金额用二进制浮点数可能需要容差或最小货币单位整数;标识符应使用字符串,避免丢失前导零;日期应明确时区。这里没有适合所有列的统一“优化类型”函数。

可空扩展类型通过 pd.NA 表达未知值,并采用三值逻辑。例如,未知布尔值与 True 组合后可能仍是未知。过滤前必须决定未知条件是排除、保留还是拒绝,不能依赖隐式真值。

类型转换也是验证边界。errors="raise" 适合必须合法的输入,errors="coerce" 适合把坏值隔离出来继续检查。若选择后者,应保存或统计新产生的缺失值,否则清洗会抹掉错误来源。

写时复制改变了赋值模型

写时复制(Copy-on-Write) 在 pandas 3.0 中默认启用。由另一个 pandas 对象派生出的对象在用户视角上表现为副本:修改派生对象不会顺带修改原对象。实现可以在只读阶段共享底层数据,并在首次写入前复制,但应用代码不应依赖内部块是否共享。

这个模型消除了许多“视图还是副本”的不确定副作用,却不让链式赋值变得正确。链式表达式先产生中间对象,随后只向该中间对象写入,无法把更新送回最初的 DataFrame。单次 .loc 赋值才明确指出目标对象。

浅复制 copy(deep=False) 也受写时复制保护:后续写入一侧不会改变另一侧。对于列中保存的可变 Python 对象,这不等于递归复制对象图。所有权边界仍应避免把可变容器藏在 object 列中。

基数是连接的模式

关系数据库通常把唯一键和外键保存在模式中,内存中的 DataFrame 没有自动继承这些约束。连接的预期关系应明确为 one_to_oneone_to_manymany_to_onemany_to_many,并通过 validate 执行。

many_to_many 有时符合领域模型,例如订单与促销之间的关联表。但如果没有先估算匹配关系,结果大小可能远超任一输入。此时应先按键统计匹配数,并明确后续聚合如何避免重复计量。

连接验证只检查基数,不证明领域键正确。大小写、前后空格、时区和不同单位都可能让键看似匹配或不匹配。可靠流程会在连接前规范化键,在连接后检查来源分布与业务不变量。

用形状与不变量闭环

形状是每项 pandas 操作都能观察到的结果。选择可能保留或减少轴,聚合会减少分组,transform() 保留源索引,连接则会按类型增加或减少行。应声明预期关系,而不是写死某个测试样本的行数。

有效的不变量来自业务领域。左连接应保留左侧每笔订单,分区收入报表应保持收入总额,去重后的客户表应具有唯一客户键。这些检查能发现语法和 API 都正确、数据规则却错误的 pandas 程序。

小型对抗样本比大型随机样本更容易暴露问题。样本应包含乱序标签、重复键、未匹配键、一个缺失分组值,以及每个范围边界附近的值。预期结果要简单到可以手算。

断言可以把这些契约变成可执行检查。键数量与总额可使用普通断言;如果索引、列、数据类型、缺失值和数据都必须与预期表一致,则使用 pandas.testing.assert_frame_equal()

预期表应保持小而直接。若测试使用与实现相同的分组或连接步骤重建预期值,它可能重复同一个实现错误,而不是发现错误。

延伸阅读

检查点

5个问题 · 1 道输出预测题 · 1 道找错题

前置内容 Getting started 即将上线 NumPy
下一篇 Polars Matplotlib 绘图 Feature engineering 即将上线 Statistics fundamentals 即将上线
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?