# pandas

Source: https://codewiki.com/zh/datascience/pandas-guide/

> - **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()` 条件。

<!-- quick -->

```python
# file: 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())
```

```text
{'region': 'str', 'revenue': 'float64', 'paid': 'bool'}
         region  revenue
order_id                
1001       East    120.0
1004       West    210.0
```

<!-- /quick -->

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

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

### 2. 在读取边界清洗类型

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

```python
# file: 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())
```

```text
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()` 模拟。

```python
# file: 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))
```

```text
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` 明确检查，而不是假设数据始终干净。

```python
# file: 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))
```

```text
 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`。没有这项检查，两笔订单会各自匹配两行，订单数和收入汇总都会翻倍。

## 陷阱

> **陷阱:** 链式选择后赋值看起来合理，却没有明确的单一写入目标。在 pandas 3.0 的写时复制语义下，`df[df["paid"]]["status"] = "ready"` 不会更新原 `DataFrame`。

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

> **陷阱:** 两个对象长度相同，不代表它们会按位置计算。索引标签不同或顺序不同时，自动对齐可能产生缺失值，也可能把值配给错误的业务实体。

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

> **陷阱:** 普通 `bool` 与整数类型不能完整表达缺失值，自动推断可能把列提升为浮点或通用对象类型。`pd.NA` 的真值也不明确，`if value:` 会抛出异常。

**修复方法：** 在输入边界选择 `boolean`、`Int64`、`Float64` 或 `string` 等合适类型，并用 `isna()` 检测缺失。对每列分别定义缺失策略，转换后断言 `dtype` 与缺失数量。

> **陷阱:** 不带基数检查的连接可能因右表重复键产生笛卡尔式扩增。pandas 还会让两侧的空键彼此匹配，这与常见 SQL 数据库的空值连接行为不同。

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

> **陷阱:** `groupby()` 默认会丢弃分组键为缺失值的行。报表仍能运行并给出整洁结果，但分组金额之和可能小于输入金额之和。

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

<!-- deep -->

## 标签、类型与所有权契约

### 对齐先于计算

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()`。

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

<!-- /deep -->

[检查点: datascience/pandas-guide](https://codewiki.com/zh/datascience/pandas-guide/#checkpoint)

## 延伸阅读

- [pandas 用户指南：十分钟入门](https://pandas.pydata.org/docs/user_guide/10min.html)
- [pandas 用户指南：索引与选择](https://pandas.pydata.org/docs/user_guide/indexing.html)
- [pandas 用户指南：缺失数据](https://pandas.pydata.org/docs/user_guide/missing_data.html)
- [pandas 用户指南：分组](https://pandas.pydata.org/docs/user_guide/groupby.html)
- [pandas 用户指南：合并、连接与拼接](https://pandas.pydata.org/docs/user_guide/merging.html)
- [pandas 用户指南：写时复制](https://pandas.pydata.org/docs/user_guide/copy_on_write.html)
