Python 基础体系 · 第 95/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
pandas 完整基础:Series、DataFrame、索引、缺失值、分组和连接
pandas 是 Python 中面向表格数据和带标签数据的分析库。它的核心不是“把二维数组包装成表格”,而是同时维护两类信息:
- 数据值:数字、字符串、日期、布尔值等。
- 标签结构:行索引、列名以及它们之间的对齐关系。
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 可以形式化表示为:
其中:
- :索引标签集合;
- :数据值;
- :数据类型
dtype; - :索引和值之间的对应关系。
例如:
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.index是Index(["Alice", "Bob", "Carol"]);scores.values或scores.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:
每个 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 的运算先取索引并集:
然后对每个标签 取:
缺少标签对应缺失值。
如果你确实想按位置计算,应显式转换为 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
结果是 False。pd.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 的三值逻辑
普通布尔逻辑只有 True 和 False;缺失布尔值引入了第三种状态:未知。
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() 对空序列通常返回 0,prod() 对空序列通常返回 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 map、replace 和 where
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
分组操作通常被概括为:
- Split:按照键把数据拆成多个组;
- Apply:对每组计算;
- 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)
如果需求可以用 sum、mean、transform、filter 等内置操作表达,优先使用内置操作,通常更清楚,也更容易获得底层优化。(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()
这一步必须根据业务语义决定:缺失组是“未知团队”,还是“应被排除的无效记录”。
八、连接:concat、merge 和 join
“连接”不是一种操作,而是几类不同的数据组合方式。
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" 会抛出错误,而不是静默产生重复订单。
若两侧连接键都重复,则可能形成多对多。例如:
左侧某键出现 次,右侧出现 次,连接结果会产生:
行。
这正是许多“数据量突然膨胀”的根源。连接之后应检查:
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_on 和 right_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为缺失;_merge为left_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)
每一步的形状变化是:
orders:原始订单明细;enriched:订单增加客户信息;valid:移除不能参与统计的记录;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 |
NaN、pd.NA、NaT |
| 向量化运算 | 列级表达式和 NumPy ufunc |
| 数组拼接 | pd.concat |
| 关系连接 | merge / join |
NumPy 的广播依据形状兼容规则工作;pandas 除了形状,还要考虑索引标签。NumPy 数组的内存布局由 shape、dtype 和 strides 等属性描述,而 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 的基础可以归结为一条数据语义链:
Series表示带标签的一维数据;DataFrame表示多个按索引对齐的列;Index决定选择、对齐和连接的对应关系;- 缺失值表示未知或不可用数据,并影响 dtype、逻辑和统计;
groupby改变数据的观察粒度,完成按组计算;concat按轴组合数据,merge和join按关系键组合数据;validate、isna、shape、dtypes和唯一性检查负责验证这些语义是否仍然成立。
真正掌握 pandas,不是记住更多方法名,而是能在每次操作前回答三个问题:
- 当前数据的粒度是什么?
- 记录之间依据什么标签对齐?
- 缺失、重复和连接后的行数变化是否符合业务含义?
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:NumPy 完整基础:ndarray、形状、广播、索引、向量化和内存布局
- 下一篇:Python 数据可视化:Matplotlib、Seaborn、坐标、布局和可解释图表
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论