pandas

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

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

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

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) 如Int64、boolean和string能在保留领域类型的同时表示缺失值。检测时统一使用isna()或notna(),不要依赖相等比较或真值转换。

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

分组与连接改变形状

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

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

输入与输出是边界

read_csv()等读取器会同时解析数据并推断模式。对于含义明确的字段,应有意设置usecols、dtype、na_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)。

修复方法: 在输入边界选择boolean、Int64、Float64或string等合适类型,并用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_one、one_to_many、many_to_one或many_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上编辑 报告错误 讲清楚了吗?