Python 基础体系 · 第 95/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

pandas 完整基础:Series、DataFrame、索引、缺失值、分组和连接

pandas 是 Python 中面向表格数据和带标签数据的分析库。它的核心不是“把二维数组包装成表格”,而是同时维护两类信息:

  1. 数据值:数字、字符串、日期、布尔值等。
  2. 标签结构:行索引、列名以及它们之间的对齐关系。

NumPy 的 ndarray 主要解决同质、多维数值数组问题;pandas 则在此基础上增加了索引、异构列、缺失值、分组和连接等数据操作能力。当前官方文档为 pandas 3.0.5,文中的 Copy-on-Write 和字符串类型行为以 pandas 3.0 系列为背景。(pandas.pydata.org)

本文代码使用 Python 3.14 语法。运行环境至少需要:

python -m pip install pandas numpy

先确认实际版本:

import sys
import numpy as np
import pandas as pd

print(sys.version)
print(np.__version__)
print(pd.__version__)

版本检查很重要,因为 pandas 的缺失值、字符串 dtype、Copy-on-Write 等行为可能随大版本变化。不要仅根据文章标题或本地环境猜测 API 行为。


一、理解 pandas 的核心模型

1.1 Series:带索引的一维数据

Series 可以形式化表示为:

S=(I,V,D,N)S = (I, V, D, N)

其中:

  • II:索引标签集合;
  • VV:数据值;
  • DD:数据类型 dtype
  • NN:索引和值之间的对应关系。

例如:

import pandas as pd

scores = pd.Series(
    [88, 92, 75],
    index=["Alice", "Bob", "Carol"],
    name="score",
)

print(scores)

输出:

Alice    88
Bob      92
Carol    75
Name: score, dtype: int64

这里:

  • scores.indexIndex(["Alice", "Bob", "Carol"])
  • scores.valuesscores.to_numpy() 表示底层数据;
  • scores.name 是该序列的名称;
  • 每个值通过标签与一个位置关联。
print(scores.index)
print(scores.dtype)
print(scores.name)
print(scores["Bob"])
print(scores.iloc[1])

输出:

Index(['Alice', 'Bob', 'Carol'], dtype='object')
int64
score
92
92

"Bob" 是标签,1 是位置。这两个概念必须区分。

1.2 DataFrame:多个 Series 按索引对齐

DataFrame 可以看作一组共享行索引的 Series

D=[S1,S2,,Sm]D = [S_1, S_2, \ldots, S_m]

每个 Series 是一列,列名是列标签;所有列共同拥有一个行索引。

df = pd.DataFrame(
    {
        "name": ["Alice", "Bob", "Carol"],
        "score": [88, 92, 75],
        "passed": [True, True, False],
    },
    index=[101, 102, 103],
)

print(df)

输出:

       name  score  passed
101   Alice     88    True
102     Bob     92    True
103   Carol     75   False

其结构包括:

print(df.index)     # 行索引
print(df.columns)   # 列索引
print(df.dtypes)    # 每列 dtype
print(df.shape)     # 行数、列数
print(df.ndim)      # 维度

DataFrame 的列可以是不同类型,这是它与 NumPy 同质数组的重要区别:

name      object 或 string
score     int64
passed    bool

pandas 3.0 引入了新的默认字符串类型行为,字符串列不应再简单假设为 object。需要判断字符串列时,优先使用 dtype 工具或显式指定字符串 dtype,而不是检查 dtype == object。(pandas.pydata.org)


二、创建和检查数据

2.1 从字典创建

字典的键通常成为列名,列表成为列数据:

df = pd.DataFrame({
    "city": ["Hangzhou", "Ningbo", "Hangzhou"],
    "sales": [100, 80, 120],
})

要求各列长度一致:

pd.DataFrame({
    "a": [1, 2, 3],
    "b": [10, 20],  # 长度不同
})

会抛出:

ValueError: All arrays must be of the same length

2.2 从记录列表创建

当数据来自 JSON 或 API 时,常见形式是“每行一个字典”:

records = [
    {"id": 1, "name": "Alice", "score": 88},
    {"id": 2, "name": "Bob", "score": 92},
]

df = pd.DataFrame.from_records(records)

缺失字段会产生缺失值:

df = pd.DataFrame([
    {"id": 1, "name": "Alice"},
    {"id": 2, "score": 92},
])

print(df)

输出类似:

   id   name  score
0   1  Alice    NaN
1   2    NaN   92.0

2.3 从 NumPy 数组创建

import numpy as np

array = np.array([
    [1, 10],
    [2, 20],
    [3, 30],
])

df = pd.DataFrame(array, columns=["id", "value"])

NumPy 数组通常要求数据在整体上具有统一 dtype,而 DataFrame 可以在导入后将不同列转换成不同 dtype。

2.4 快速检查

df.head()
df.tail()
df.sample(2, random_state=0)
df.info()
df.describe(numeric_only=True)

这些方法分别回答不同问题:

  • head():数据是否成功读入;
  • info():列名、非空数量和 dtype;
  • describe():数值列的统计摘要;
  • sample():随机抽查数据,而不是只检查前几行。

describe() 不是数据质量证明。例如平均值正常,并不能说明单位、重复行或异常值正确。


三、索引:标签、位置和对齐

索引是 pandas 最重要、也最容易误解的机制。

3.1 标签索引与位置索引

s = pd.Series(
    [10, 20, 30],
    index=["a", "b", "c"],
)

print(s["b"])       # 标签选择:20
print(s.iloc[1])    # 位置选择:20

loc 面向标签:

print(s.loc["b"])
print(s.loc[["a", "c"]])
print(s.loc["a":"c"])

标签切片通常包含两端:

a    10
b    20
c    30

iloc 面向整数位置:

print(s.iloc[1])
print(s.iloc[[0, 2]])
print(s.iloc[0:2])

位置切片遵循 Python 习惯,右端不包含:

a    10
b    20

不要把下面两种写法混为一谈:

s[0]       # 直接使用整数时,可能涉及标签语义变化或歧义
s.iloc[0]  # 明确表示第一个位置

工程代码中需要位置选择时,应明确使用 .iloc;需要标签选择时,应明确使用 .loc

3.2 DataFrame 的选择

df = pd.DataFrame(
    {
        "name": ["Alice", "Bob", "Carol"],
        "score": [88, 92, 75],
    },
    index=[101, 102, 103],
)

# 选择一列,结果是 Series
scores = df["score"]

# 选择多列,结果是 DataFrame
basic = df[["name", "score"]]

# 按标签选择
row = df.loc[101]
part = df.loc[[101, 103], ["name", "score"]]

# 按位置选择
row = df.iloc[0]
part = df.iloc[0:2, 0:2]

3.3 布尔索引

布尔索引的本质是:构造一个与行索引对齐的布尔序列,然后保留值为 True 的行。

result = df[df["score"] >= 80]

多个条件必须分别加括号:

result = df[
    (df["score"] >= 80)
    & (df["name"] != "Bob")
]

不能写成:

df[df["score"] >= 80 and df["name"] != "Bob"]

因为 and 需要一个单独的 Python 布尔值,而 df["score"] >= 80 返回的是整列布尔值。整列逻辑运算应使用:

  • &:逐元素逻辑与;
  • |:逐元素逻辑或;
  • ~:逐元素逻辑非。

3.4 修改数据与链式赋值

推荐把行条件和列选择放在同一个 .loc 表达式中:

df.loc[df["score"] < 80, "score"] = 80

不要依赖链式赋值:

df[df["score"] < 80]["score"] = 80

链式写法先产生中间对象,再尝试修改中间对象,无法表达“明确修改原表”的意图。pandas 3.0 中 Copy-on-Write 是默认且唯一的行为模式,索引得到的对象不会通过一次赋值意外修改另一个对象;应直接对目标对象赋值,或者显式 .copy()。(pandas.pydata.org)

subset = df.loc[df["score"] >= 80].copy()
subset["grade"] = "A"

这里 .copy() 表达了明确的所有权边界:subset 是一个独立对象,后续修改不会被当作修改 df

3.5 索引对齐

pandas 的算术运算默认按标签对齐,而不是按物理位置对齐。

a = pd.Series([10, 20], index=["x", "y"])
b = pd.Series([1, 2], index=["y", "z"])

print(a + b)

结果:

x     NaN
y    21.0
z     NaN
dtype: float64

计算过程不是:

10 + 1
20 + 2

而是:

x: 10 + 缺失
y: 20 + 1
z: 缺失 + 2

形式化地说,两个 Series 的运算先取索引并集:

I=IaIbI = I_a \cup I_b

然后对每个标签 iIi \in I 取:

ri=ai+bir_i = a_i + b_i

缺少标签对应缺失值。

如果你确实想按位置计算,应显式转换为 NumPy 数组:

result = a.to_numpy() + b.to_numpy()

但这要求两个数组长度相同,并且你已经确认“位置对应”是正确业务语义。

3.6 reindex

reindex 用来按照指定标签重新排列或扩展数据:

s = pd.Series([100, 200], index=["a", "b"])

print(s.reindex(["b", "c", "a"]))

结果:

b    200.0
c      NaN
a    100.0
dtype: float64

reindex 的关键作用是显式改变索引域。新标签没有对应值,因此产生缺失值。

可以用 fill_value 处理新增标签:

s.reindex(["a", "b", "c"], fill_value=0)

fill_value 只针对重建索引时产生的新位置;已有的缺失值需要使用 fillna

3.7 设置与重置索引

df = pd.DataFrame({
    "id": [101, 102],
    "name": ["Alice", "Bob"],
})

indexed = df.set_index("id")
restored = indexed.reset_index()

set_index("id") 把普通列提升为行索引;reset_index() 把索引恢复成普通列。

索引不一定是唯一的:

s = pd.Series([10, 20], index=["a", "a"])
print(s.loc["a"])

结果是两个值:

a    10
a    20
dtype: int64

因此不能默认:

df.loc[key]

一定返回一行。处理外部数据时,应检查:

print(df.index.is_unique)
print(df.columns.is_unique)

3.8 MultiIndex

MultiIndex 是由多个层级组成的索引。例如按城市和月份组织销售数据:

df = pd.DataFrame(
    {
        "sales": [100, 120, 80],
    },
    index=pd.MultiIndex.from_tuples(
        [
            ("Hangzhou", 1),
            ("Hangzhou", 2),
            ("Ningbo", 1),
        ],
        names=["city", "month"],
    ),
)

print(df)

输出:

                sales
city     month
Hangzhou 1         100
         2         120
Ningbo   1          80

选择某一层:

print(df.loc["Hangzhou"])

MultiIndex 适合表达层次结构,但会增加切片、排序和连接复杂度。若数据本质上是普通表格,保留为普通列往往更容易调试。


四、数据类型、向量化与表达式

4.1 dtype 是列级属性

df = pd.DataFrame({
    "id": [1, 2, 3],
    "price": [10.5, 20.0, 15.8],
    "active": [True, False, True],
    "name": ["A", "B", "C"],
})

print(df.dtypes)

一个 DataFrame 可以同时包含整数、浮点、布尔和字符串列,但一列内部仍然需要遵循某种 dtype 规则。

显式转换:

df["id"] = df["id"].astype("int64")
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df["name"] = df["name"].astype("string")

errors="coerce" 会把无法解析的值转成缺失值:

s = pd.Series(["10", "20", "bad"])
print(pd.to_numeric(s, errors="coerce"))

输出:

0    10.0
1    20.0
2     NaN
dtype: float64

这很方便,但也可能掩盖脏数据。生产流程中应同时记录被转换为缺失值的原始记录数量。

4.2 向量化计算

df["amount"] = df["price"] * df["quantity"]

这表示逐元素计算,而不需要手动编写 Python 循环:

df["amount"] = [
    price * quantity
    for price, quantity in zip(df["price"], df["quantity"])
]

向量化表达式通常更容易利用 pandas、NumPy 或底层扩展类型的批量实现。对于复杂业务规则,可以使用 np.select

import numpy as np

df["level"] = np.select(
    [
        df["amount"] >= 1000,
        df["amount"] >= 500,
    ],
    [
        "high",
        "medium",
    ],
    default="low",
)

4.3 apply 不是自动向量化

df["name_length"] = df["name"].apply(len)

它会逐个调用 Python 函数。语义清楚时可以使用,但不要把 apply 当作性能优化工具。能用列运算、字符串 .str、日期 .dt 或 NumPy 函数表达的逻辑,应优先使用这些接口。官方分组文档也指出,使用内置 GroupBy 操作通常比对每组调用 Python 用户函数更高效。(pandas.pydata.org)


五、缺失值:表示、传播和处理

缺失值表示“该位置没有可用值”,但它不等同于:

  • 数字零;
  • 空字符串;
  • False
  • 字符串 "NULL"
  • “业务上确实为零”。

5.1 常见缺失值标记

pandas 中可能遇到:

  • np.nan:NumPy 浮点数据中的缺失表示;
  • pd.NA:pandas 可空 dtype 使用的缺失标记;
  • pd.NaT:日期时间和时间间隔中的缺失标记;
  • None:Python 空对象,在 pandas 中通常也会被识别为缺失。

不同 dtype 可能采用不同内部表示。官方文档建议使用 isna()notna() 统一检测,而不是直接比较某个哨兵值。(pandas.pydata.org)

s = pd.Series([1, None, np.nan, pd.NA])

print(s.isna())
print(s.notna())

不要写:

s == np.nan

因为:

np.nan == np.nan

结果是 Falsepd.NA == pd.NA 则返回 pd.NA,不是普通布尔值。(pandas.pydata.org)

5.2 NumPy dtype 与可空 dtype

如果整数列使用 NumPy 的 int64

s = pd.Series([1, 2], dtype="int64")
s = s.reindex([0, 1, 2])

print(s)
print(s.dtype)

结果通常会变为:

0    1.0
1    2.0
2    NaN
dtype: float64

原因是传统 NumPy 整数 dtype 没有内置的缺失整数值,而 np.nan 是浮点值,因此列被提升为浮点类型。

如果要保留整数语义,应使用 pandas 可空整数类型:

s = pd.Series([1, 2, pd.NA], dtype="Int64")

print(s)
print(s.dtype)

输出:

0       1
1       2
2    <NA>
dtype: Int64

注意 "Int64" 的大写 I 与 NumPy 的 "int64" 不同。前者是 pandas 可空整数扩展 dtype。

对读入的数据,可以尝试:

df = df.convert_dtypes()

该方法会把适合的列转换成可空整数、布尔或字符串类型,但转换结果仍应通过 df.dtypes 验证。(pandas.pydata.org)

5.3 pd.NA 的三值逻辑

普通布尔逻辑只有 TrueFalse;缺失布尔值引入了第三种状态:未知。

print(True | pd.NA)    # True
print(False | pd.NA)   # <NA>
print(False & pd.NA)   # False
print(True & pd.NA)    # <NA>

直觉如下:

  • True OR 未知 一定是 True
  • False OR 未知 仍然未知;
  • False AND 未知 一定是 False
  • True AND 未知 仍然未知。

不能直接把 pd.NA 放进 if

if pd.NA:
    pass

会抛出:

TypeError: boolean value of NA is ambiguous

因为程序无法把“未知”安全地解释成真或假。(pandas.pydata.org)

5.4 缺失值在算术中的传播

s = pd.Series([10, pd.NA, 30], dtype="Int64")

print(s + 5)

结果:

0      15
1    <NA>
2      35
dtype: Int64

缺失值通常会传播,因为未知值参与计算时,结果也可能未知。

但聚合函数常常默认跳过缺失值:

s = pd.Series([10, np.nan, 30])

print(s.sum())   # 40.0
print(s.mean())  # 20.0

这不代表缺失值等于零,而是聚合函数采用了 skipna=True 的统计语义。

print(s.sum(skipna=False))  # nan

sum() 对空序列通常返回 0prod() 对空序列通常返回 1,这是聚合函数的单位元规则,不应自动解释成真实业务结果。(pandas.pydata.org)

5.5 删除缺失值

df = pd.DataFrame({
    "a": [1, None, 3],
    "b": [10, 20, None],
})

print(df.dropna())

默认删除包含缺失值的行:

     a     b
0  1.0  10.0

按列删除:

df.dropna(axis="columns")

只要求某些列非空:

df.dropna(subset=["a"])

只删除全为空的行:

df.dropna(how="all")

dropna() 的关键不是语法,而是业务问题:这条记录是“不可用”,还是只是某个字段未采集?如果删除行会造成样本选择偏差,就不能简单使用。

5.6 填充缺失值

使用常数填充:

df["a"] = df["a"].fillna(0)

使用前一个有效值:

s = pd.Series([1, None, None, 4])
print(s.ffill())

输出:

0    1.0
1    1.0
2    1.0
3    4.0
dtype: float64

使用后一个有效值:

s.bfill()

限制连续填充长度:

s.ffill(limit=1)

按组填充更符合分组数据语义:

df["temperature"] = (
    df.groupby("city")["temperature"]
      .transform(lambda group: group.ffill())
)

均值填充也必须明确统计范围:

df["score"] = df["score"].fillna(df["score"].mean())

如果不同城市的分布差异很大,全局均值可能不合理:

df["score"] = (
    df["score"]
    .fillna(df.groupby("city")["score"].transform("mean"))
)

六、排序、统计和数据转换

6.1 按标签或值排序

df.sort_index()
df.sort_values("score", ascending=False)

排序后默认保留原索引:

sorted_df = df.sort_values("score").reset_index(drop=True)

reset_index(drop=True) 会丢弃旧索引并生成从零开始的新索引。只有在索引不再具有业务意义时才适合这样做。

6.2 value_counts

s = pd.Series(["A", "B", "A", "C", "A"])
print(s.value_counts())

输出:

A    3
B    1
C    1
Name: count, dtype: int64

统计缺失值时:

s.value_counts(dropna=False)

默认不计入缺失值,这一点在数据质量统计中需要特别注意。

6.3 mapreplacewhere

map 常用于一列值到另一组值的映射:

mapping = {"A": "优秀", "B": "良好"}

s = pd.Series(["A", "B", "C"])
print(s.map(mapping))

未出现在映射表中的 "C" 会变成缺失值。

replace 更适合替换具体值:

s.replace({"A": "优秀", "B": "良好"})

where 保留满足条件的值,不满足条件的位置替换为缺失或指定值:

s = pd.Series([10, 20, 30])
print(s.where(s >= 20, other=0))

结果:

0     0
1    20
2    30
dtype: int64

七、分组:split、apply、combine

分组操作通常被概括为:

  1. Split:按照键把数据拆成多个组;
  2. Apply:对每组计算;
  3. Combine:把各组结果合并回来。

这是 pandas GroupBy 的基本模型。(pandas.pydata.org)

准备数据:

orders = pd.DataFrame({
    "city": ["Hangzhou", "Hangzhou", "Ningbo", "Ningbo", "Ningbo"],
    "category": ["book", "food", "book", "food", "food"],
    "amount": [100, 200, 80, 150, 120],
})

orders["city"] = orders["city"].astype("string")
orders["category"] = orders["category"].astype("string")

7.1 创建 GroupBy 对象

grouped = orders.groupby("city")

此时还没有产生最终统计表。groupby 返回一个分组对象,保存了分组规则和数据引用。

查看各组:

for city, group in grouped:
    print(city)
    print(group)

按多个键分组:

orders.groupby(["city", "category"])

7.2 聚合:每组变成一行

summary = (
    orders.groupby("city", as_index=False)
          .agg(
              total_amount=("amount", "sum"),
              average_amount=("amount", "mean"),
              order_count=("amount", "size"),
          )
)

print(summary)

输出:

       city  total_amount  average_amount  order_count
0  Hangzhou           300           150.0            2
1    Ningbo           350           116.666667       3

这里:

  • sum 计算每组总额;
  • mean 计算每组平均额;
  • size 计算每组行数,包括值缺失的行;
  • count 只统计非缺失值数量。

比较:

orders.groupby("city")["amount"].count()
orders.groupby("city")["amount"].size()

如果 amount 有一个缺失值,两者结果就会不同。

7.3 多列分组

summary = (
    orders.groupby(["city", "category"], as_index=False)
          .agg(total=("amount", "sum"))
)

输出的每一行对应一个 (city, category) 组合。

如果不设置 as_index=False,分组键通常会成为结果索引:

summary = orders.groupby(["city", "category"])["amount"].sum()

这会产生 MultiIndex。两种形式没有绝对对错,区别在于后续操作是否更适合使用索引层级。

7.4 transform:结果回到原行

聚合会把多行压缩成一行;transform 则返回与原表等长的结果。

orders["city_total"] = (
    orders.groupby("city")["amount"]
          .transform("sum")
)

orders["share"] = orders["amount"] / orders["city_total"]

中间结果:

       city category  amount  city_total
0  Hangzhou     book     100         300
1  Hangzhou     food     200         300
2    Ningbo     book      80         350
3    Ningbo     food     150         350
4    Ningbo     food     120         350

transform 的返回长度必须与原对象一致,因此适合:

  • 组内标准化;
  • 组内缺失值填充;
  • 计算每行占组总量比例;
  • 把组级统计量广播回明细表。

组内标准化示例:

orders["zscore"] = (
    orders.groupby("city")["amount"]
          .transform(lambda s: (s - s.mean()) / s.std())
)

如果某组只有一条记录,标准差可能为缺失值,因此还要考虑小组边界。

7.5 filter:保留满足条件的组

large_groups = orders.groupby("city").filter(
    lambda group: len(group) >= 3
)

这不是过滤单行,而是先评估整个组,再决定保留或删除该组的所有行。

7.6 apply:通用但边界更复杂

def top_order(group: pd.DataFrame) -> pd.DataFrame:
    return group.nlargest(1, "amount")

result = orders.groupby("city", group_keys=False).apply(top_order)

apply 可以表达聚合、转换或过滤无法直接表达的复杂逻辑,但返回结构可能是:

  • 标量;
  • Series;
  • DataFrame;
  • 带有额外索引层级的对象。

因此使用后应检查:

print(type(result))
print(result.index)
print(result.shape)

如果需求可以用 summeantransformfilter 等内置操作表达,优先使用内置操作,通常更清楚,也更容易获得底层优化。(pandas.pydata.org)

7.7 分组键中的缺失值

df = pd.DataFrame({
    "team": ["A", "A", None, "B"],
    "score": [10, 20, 30, 40],
})

print(df.groupby("team")["score"].sum())

默认情况下,缺失分组键通常不会作为普通分组显示。若需要把缺失键作为一组:

df.groupby("team", dropna=False)["score"].sum()

这一步必须根据业务语义决定:缺失组是“未知团队”,还是“应被排除的无效记录”。


八、连接:concatmergejoin

“连接”不是一种操作,而是几类不同的数据组合方式。

8.1 concat:沿轴拼接

纵向追加:

jan = pd.DataFrame({
    "id": [1, 2],
    "amount": [100, 200],
})

feb = pd.DataFrame({
    "id": [3, 4],
    "amount": [150, 250],
})

result = pd.concat([jan, feb], ignore_index=True)
print(result)

输出:

   id  amount
0   1     100
1   2     200
2   3     150
3   4     250

如果不使用 ignore_index=True,原索引会被保留,可能出现重复索引。

列不同的纵向拼接:

left = pd.DataFrame({"id": [1], "name": ["A"]})
right = pd.DataFrame({"id": [2], "score": [90]})

print(pd.concat([left, right], ignore_index=True))

结果:

   id name  score
0   1    A    NaN
1   2  NaN   90.0

横向拼接:

a = pd.DataFrame({"name": ["A", "B"]}, index=[10, 11])
b = pd.DataFrame({"score": [90, 80]}, index=[10, 11])

result = pd.concat([a, b], axis="columns")

横向拼接默认按索引对齐。如果索引不同,会产生缺失值。

8.2 merge:按键进行关系连接

customers = pd.DataFrame({
    "customer_id": [1, 2, 3],
    "name": ["Alice", "Bob", "Carol"],
})

orders = pd.DataFrame({
    "order_id": [101, 102, 103],
    "customer_id": [1, 2, 4],
    "amount": [100, 200, 80],
})

result = pd.merge(
    orders,
    customers,
    on="customer_id",
    how="left",
)

print(result)

输出:

   order_id  customer_id  amount   name
0       101            1     100  Alice
1       102            2     200    Bob
2       103            4      80    NaN

left 连接保留左表所有订单;找不到客户 4 时,客户字段为空。

连接类型:

  • inner:只保留两侧都存在的键;
  • left:保留左表全部键;
  • right:保留右表全部键;
  • outer:保留两侧所有键;
  • cross:笛卡尔积,不指定连接键。
pd.merge(orders, customers, on="customer_id", how="inner")
pd.merge(orders, customers, on="customer_id", how="outer")

8.3 一对多、多对一和多对多

连接前必须明确键的基数。

若客户表中每个 customer_id 唯一,而订单表中一个客户可以有多条订单,这是多对一:

result = orders.merge(
    customers,
    on="customer_id",
    how="left",
    validate="many_to_one",
)

如果客户表意外出现重复客户 ID:

customers_bad = pd.DataFrame({
    "customer_id": [1, 1],
    "name": ["Alice", "Alice-old"],
})

使用 validate="many_to_one" 会抛出错误,而不是静默产生重复订单。

若两侧连接键都重复,则可能形成多对多。例如:

左侧某键出现 mm 次,右侧出现 nn 次,连接结果会产生:

m×nm \times n

行。

这正是许多“数据量突然膨胀”的根源。连接之后应检查:

print(len(orders))
print(len(result))
print(result["order_id"].nunique())

8.4 连接键不相同

orders.merge(
    customers,
    left_on="customer_id",
    right_on="id",
    how="left",
)

当左右表字段名称不同,使用 left_onright_on

8.5 索引连接

customer_info = customers.set_index("customer_id")

result = orders.join(
    customer_info,
    on="customer_id",
    how="left",
)

DataFrame.join 更适合以索引为主要连接对象的场景;merge 更适合明确的关系键连接。官方 API 将 merge 定义为类似数据库连接的操作,并支持列连接、索引连接和基数验证。(pandas.pydata.org)

8.6 同名列与后缀

left = pd.DataFrame({
    "id": [1],
    "status": ["new"],
})

right = pd.DataFrame({
    "id": [1],
    "status": ["paid"],
})

result = left.merge(
    right,
    on="id",
    suffixes=("_left", "_right"),
)

输出:

   id status_left status_right
0   1         new         paid

如果不处理同名列,pandas 会使用默认后缀 _x_y。明确命名比事后猜测更安全。

8.7 连接来源追踪

result = orders.merge(
    customers,
    on="customer_id",
    how="outer",
    indicator=True,
)

print(result["_merge"].value_counts())

_merge 会标明每行来自:

  • left_only
  • right_only
  • both

这对核对两份数据的差异很有帮助。

8.8 pandas 连接与 SQL NULL 的差异

pandas merge 中,如果左右连接键都为缺失值,这些缺失键可能互相匹配;这与通常 SQL 连接中 NULL = NULL 不成立的行为不同。官方 merge 文档对此明确给出警告。(pandas.pydata.org)

left = pd.DataFrame({"key": [1, None], "a": ["x", "missing-left"]})
right = pd.DataFrame({"key": [1, None], "b": ["y", "missing-right"]})

print(left.merge(right, on="key", how="inner"))

因此,若缺失键不应匹配,应在连接前显式处理:

left_valid = left[left["key"].notna()]
right_valid = right[right["key"].notna()]

result = left_valid.merge(right_valid, on="key", how="inner")

不要把“连接成功”自动解释为“业务关系成立”。


九、一个端到端示例

下面把导入、清洗、连接、分组和检查串起来。

9.1 原始数据

import pandas as pd

orders = pd.DataFrame({
    "order_id": [1, 2, 3, 4, 5],
    "customer_id": [101, 102, 101, 103, 999],
    "city": ["Hangzhou", "Ningbo", "Hangzhou", "Shanghai", None],
    "amount": ["100.5", "200", "bad", "80", "50"],
})

customers = pd.DataFrame({
    "customer_id": [101, 102, 103],
    "customer_name": ["Alice", "Bob", "Carol"],
})

9.2 类型转换

orders["amount"] = pd.to_numeric(
    orders["amount"],
    errors="coerce",
)

orders["city"] = orders["city"].astype("string")

此时 "bad" 变成缺失值。检查转换结果:

print(orders[orders["amount"].isna()])

输出包含订单 3。这说明数据存在无法解析的金额,而不是金额为零。

9.3 关系连接

enriched = orders.merge(
    customers,
    on="customer_id",
    how="left",
    validate="many_to_one",
    indicator=True,
)

print(enriched)

订单 5 的客户 ID 为 999,客户表中不存在,因此:

  • customer_name 为缺失;
  • _mergeleft_only

检查未匹配订单:

unmatched = enriched[enriched["_merge"] == "left_only"]
print(unmatched)

9.4 清理可分析数据

如果金额缺失的订单不能参与销售统计:

valid = enriched.dropna(subset=["amount"])

如果城市缺失的订单也不能按城市统计:

valid = valid.dropna(subset=["city"])

这两个删除动作必须分开写,原因是每个字段的业务要求不同。

9.5 分组统计

summary = (
    valid.groupby("city", as_index=False)
         .agg(
             total_amount=("amount", "sum"),
             order_count=("order_id", "size"),
             average_amount=("amount", "mean"),
         )
         .sort_values("total_amount", ascending=False)
)

print(summary)

每一步的形状变化是:

  1. orders:原始订单明细;
  2. enriched:订单增加客户信息;
  3. valid:移除不能参与统计的记录;
  4. summary:每个城市一行。

这就是典型的 pandas 数据流:

flowchart LR
    A[原始订单] --> B[类型转换]
    B --> C[按 customer_id 连接客户表]
    C --> D{检查未匹配和缺失}
    D --> E[保留可分析记录]
    E --> F[按 city 分组]
    F --> G[聚合统计]

其中最容易出错的路径是:

  • 类型转换失败却没有检查;
  • 连接键重复导致行数膨胀;
  • 缺失城市被默默排除;
  • sum 跳过缺失值却被误解为数据完整。

十、常见失败表现与诊断方法

10.1 行数在 merge 后突然增加

首先检查连接键重复情况:

print(left["key"].value_counts(dropna=False))
print(right["key"].value_counts(dropna=False))

然后使用:

validate="one_to_one"

或:

validate="many_to_one"

如果验证失败,先解决数据模型问题,再决定是否允许多对多连接。

10.2 过滤条件结果为空

检查:

print(df["column"].dtype)
print(df["column"].unique())
print(df["column"].isna().sum())

常见原因包括:

  • 数字列实际是字符串;
  • 字符串包含前后空格;
  • 条件忽略了缺失值;
  • 使用了错误的大小写或类别名称。

例如:

df["city"] = df["city"].astype("string").str.strip()

10.3 KeyError

df["amount"]

如果抛出 KeyError,可能是:

  • 列名拼写错误;
  • 列名包含不可见空格;
  • 连接后列名被添加了后缀;
  • 误把索引名当作列名。

检查:

print(df.columns.tolist())
print(df.index.names)

10.4 TypeError: boolean value of NA is ambiguous

常见错误:

if df["flag"]:
    ...

df["flag"] 是整列,不是单个布尔值;如果列中还有 pd.NA,即使取出单值也可能无法直接转成布尔值。

应改成向量化条件:

mask = df["flag"].fillna(False)
result = df[mask]

或者明确区分三种状态:

is_true = df["flag"].eq(True)
is_missing = df["flag"].isna()

10.5 统计结果与手算不一致

检查:

print(df.dtypes)
print(df.isna().sum())
print(df.shape)
print(df.index.duplicated().sum())

然后确认:

  • 聚合是否默认跳过缺失值;
  • 分组键缺失值是否被排除;
  • 是否存在重复记录;
  • 是否在连接后重复了明细;
  • 是否发生了字符串拼接而不是数值加法。

十一、几个必须牢牢记住的边界

11.1 索引不是数据库主键

pandas 允许重复索引,因此索引默认不是唯一主键。若业务要求唯一,应显式验证:

if not df["id"].is_unique:
    raise ValueError("id 必须唯一")

11.2 缺失值不是零

pd.Series([10, np.nan]).sum()

得到 10.0,是因为 sum 默认跳过缺失值,不是因为缺失值被解释成零。

11.3 concat 不是 merge

  • concat:按轴堆叠对象;
  • merge:按键匹配关系;
  • join:常见索引连接接口。

把两张表按行追加时使用 concat;把订单与客户按 ID 关联时使用 merge

11.4 groupby 的聚合与转换不同

df.groupby("city")["amount"].sum()

结果是“每个城市一行”。

df.groupby("city")["amount"].transform("sum")

结果是“每条原始记录都有所属城市的总额”。

前者改变粒度,后者保持粒度。

11.5 位置与标签不能混用

df.iloc[0]       # 第一行
df.loc[0]        # 标签为 0 的行

如果索引是 [100, 101, 102]df.loc[0] 会失败,但 df.iloc[0] 仍然表示第一行。

11.6 类型转换可能改变数据含义

pd.to_numeric(s, errors="coerce")

会把非法字符串变成缺失值。它解决了“无法计算”的问题,但没有解决“原始数据为什么非法”的问题。数据清洗既要得到可计算结果,也要保留异常记录的可追溯性。


十二、从 NumPy 过渡到 pandas

如果已经理解 NumPy,二者可以这样对应:

NumPy pandas
ndarray Series / DataFrame
shape Series.shape / DataFrame.shape
数组位置 .iloc
标签索引 .loc
广播 按标签对齐并扩展
dtype 每列独立 dtype
np.nan NaNpd.NANaT
向量化运算 列级表达式和 NumPy ufunc
数组拼接 pd.concat
关系连接 merge / join

NumPy 的广播依据形状兼容规则工作;pandas 除了形状,还要考虑索引标签。NumPy 数组的内存布局由 shapedtypestrides 等属性描述,而 pandas 的主要抽象是带标签的数据结构,因此在 pandas 中首先要确认“哪个标签与哪个标签对应”,再考虑底层数组布局。(numpy.org)

例如:

a = pd.Series([10, 20], index=["x", "y"])
b = pd.Series([1, 2], index=["y", "x"])

print(a + b)

结果是:

x    12
y    21
dtype: int64

虽然两个序列的物理顺序不同,但标签决定了配对关系。


pandas 的基础可以归结为一条数据语义链:

  1. Series 表示带标签的一维数据;
  2. DataFrame 表示多个按索引对齐的列;
  3. Index 决定选择、对齐和连接的对应关系;
  4. 缺失值表示未知或不可用数据,并影响 dtype、逻辑和统计;
  5. groupby 改变数据的观察粒度,完成按组计算;
  6. concat 按轴组合数据,mergejoin 按关系键组合数据;
  7. validateisnashapedtypes 和唯一性检查负责验证这些语义是否仍然成立。

真正掌握 pandas,不是记住更多方法名,而是能在每次操作前回答三个问题:

  • 当前数据的粒度是什么?
  • 记录之间依据什么标签对齐?
  • 缺失、重复和连接后的行数变化是否符合业务含义?

系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。