📖 本篇简介:本篇讲 pandas 与底层引擎 NumPy 的"无缝互操作":Series/DataFrame 与 ndarray 如何互转(含to_numpy的 copy 语义、pandas 3.0 字符串 dtype)、np.where/np.select做向量化条件列、np.nan/np.inf的传播与清洗、np.sqrt/np.log等 ufunc 整列广播、以及 dtype 视角下什么时候该把数据"降回"NumPy。前置知识:第 46 章 NumPy 数组基础与创建 与 第 47 章 NumPy 广播与向量化计算。学完后你能在 pandas 里熟练调用 NumPy 算子做高性能列处理,并避开 NaN/inf/object 数组的经典坑。
pandas 本质上是"给 NumPy 数组套了一层索引和对齐",理解两者的边界,就是理解 pandas 性能与报错的边界。
目录
1. pandas 与 NumPy 的底层关系
2. SeriesDataFrame 与 ndarray 互转
3. np.where 与 np-select 向量化条件分支
4. np-nan 与 np-inf 的传播和处理
5. ufunc 对整列整表的向量化应用
6. dtype 视角什么时候该把数据转回 NumPy
7. 性能实测用 np 替代 apply
8. 常见坑清单
9. 本章小结与练习题
1. pandas 与 NumPy 的底层关系
很多人学了很久 pandas,却没说清"Series 和 ndarray 到底差在哪"。一句话:
- 一个数值型 Series ≈ 一段连续的 NumPy 数组 + 一组行索引 + 名字(name);
- 一个 DataFrame ≈ 多段同长度的 NumPy 数组(按列存储,列式)+ 行索引 + 列索引。
所以 pandas 绝大多数"逐列运算"最后都退化成 NumPy 向量化运算,性能是同一量级;而你写的 apply、applymap、Python 级 lambda 才是真正慢的部分。
下表是最常用的对照:
| 操作 | pandas 写法 | 落到 NumPy 后 |
|------|-------------|----------------|
| 取底层数组 | s.to_numpy() | np.ndarray |
| 两列相加 | df["a"] + df["b"] | 两个 ndarray 元素级相加 |
| 开平方 | np.sqrt(df["a"]) | 直接调用 NumPy ufunc |
| 条件取数 | np.where(df["x"]>0, 1, 0) | 布尔掩码 + where |
| 缺失判断 | s.isna() | 对底层数组做 != self 判定 |
理解这层关系后,后面所有"协同"操作都只是在这层膜两边来回搬运数据。
2. Series/DataFrame 与 ndarray 互转
2.1 正向:pandas → NumPy
import numpy as np
import pandas as pd
s = pd.Series([1.0, 2.0, 3.0], name="x")
# 方式一(推荐):.to_numpy()
arr = s.to_numpy()
print(arr, arr.dtype)
# [1. 2. 3.] float64
# 方式二:np.array(...) 等价,但总是拷贝
arr2 = np.array(s)
print(arr2, arr2.dtype)
# [1. 2. 3.] float64
[1. 2. 3.] float64
[1. 2. 3.] float64
2.2 copy 参数的语义(pandas 3.0 CoW 重点)
to_numpy(copy=False) 表示"尽量不拷贝,直接给你底层视图"。但在 pandas 3.0 里,Copy-on-Write 默认开启,返回的数组为了不破坏 CoW 语义会被标记成只读:
# copy=False:与原 Series 共享内存,但返回只读数组
a = s.to_numpy(copy=False)
print("共享内存:", np.shares_memory(s.to_numpy(), a)) # True
print("是否只读:", not a.flags.writeable) # True
try:
a[0] = 999
except ValueError as e:
print("写入失败 ->", e)
# assignment destination is read-only
# copy=True:一定拷贝一份新数组,可自由修改,不影响原数据
b = s.to_numpy(copy=True)
b[0] = -1
print("改 b 后 s =", s.tolist()) # [1.0, 2.0, 3.0],原数据不受影响
共享内存: True
是否只读: True
写入失败 -> assignment destination is read-only
改 b 后 s = [1.0, 2.0, 3.0]
⚠️ 常见坑:旧版 pandas(< 3.0)里to_numpy(copy=False)返回的是可写视图,很多老教程直接arr[0] = x改原数据。pandas 3.0 起 CoW 默认开启后,这种写法会直接报ValueError: assignment destination is read-only。要拿到"可写且与原数据解耦"的数组,一律copy=True。
2.3 pandas 3.0 字符串 dtype 与 NumPy 的关系
pandas 3.0 起,字符串列默认 dtype 是 str(StringDtype),不再是 object。把它 to_numpy() 后会退化成 NumPy 的 object 数组(因为 NumPy 至今没有原生变长字符串向量):
ss = pd.Series(["a", "b", "c"])
print("字符串 Series dtype:", repr(ss.dtype))
out = ss.to_numpy()
print("to_numpy 结果:", out, "-> numpy dtype:", out.dtype)
字符串 Series dtype: <StringDtype(na_value=nan)>
to_numpy 结果: ['a' 'b' 'c'] -> numpy dtype: object
也就是说,字符串列"转回 NumPy"后拿到的是 dtype=object 的 Python 对象数组,失去了 NumPy 的数值向量化优势,后续别再对它做 np.sqrt 这类数值运算。
2.4 反向:NumPy → pandas
# 从二维数组构造 DataFrame,用 columns 指定列名(否则自动用 0,1,2...)
data = np.arange(12).reshape(4, 3)
df = pd.DataFrame(data, columns=["A", "B", "C"])
print(df)
print(df.dtypes)
# 从一维数组构造 Series
s1 = pd.Series(np.array([10, 20, 30]))
print(s1.tolist(), s1.dtype)
A B C
0 0 1 2
1 3 4 5
2 6 7 8
3 9 10 11
A int64
B int64
C int64
dtype: object
[10, 20, 30] int64
2.5 转置场景
DataFrame 转置 df.T 后,行/列索引互换,底层数组仍是同一块内存的"转置视图"(可能非连续)。需要传给 sklearn 等只接受 C-contiguous 数组的库时,记得 .copy():
df_t = df.T
print("df.T shape:", df_t.shape, "| columns:", df_t.columns.tolist())
print("df.T.to_numpy() shape:", df_t.to_numpy().shape)
df.T shape: (3, 4) | columns: [0, 1, 2, 3]
df.T.to_numpy() shape: (3, 4)
3. np.where 与 np.select 向量化条件分支
这是 pandas 里最常用的"二选一/多分支列生成"工具,等价于 Excel 的 IF 与嵌套 IFS,但全程走 NumPy,不进 Python 逐行循环。
3.1 np.where:二分支
df = pd.DataFrame({"score": [95, 60, 78, 40, 88]})
df["level_where"] = np.where(df["score"] >= 60, "及格", "不及格")
print(df)
score level_where
0 95 及格
1 60 及格
2 78 及格
3 40 不及格
4 88 及格
3.2 np.select:多分支
np.where 嵌套三层就很难读了,多分支直接上 np.select(条件列表, 取值列表, default=...):
conds = [df["score"] >= 90, df["score"] >= 75, df["score"] >= 60]
choices = ["优", "良", "及格"]
df["level_select"] = np.select(conds, choices, default="不及格")
print(df)
score level_where level_select
0 95 及格 优
1 60 及格 及格
2 78 及格 良
3 40 不及格 不及格
4 88 及格 良
条件是按顺序判断的(先命中先生效),所以要把"最严格"的条件放最前面。
3.3 与 apply 结果一致,但快几十倍
# 等价的 apply 写法(慢,但结果一致)
df["level_apply"] = df["score"].apply(
lambda x: "优" if x >= 90 else "良" if x >= 75 else "及格" if x >= 60 else "不及格"
)
print("两者是否一致:", (df["level_select"] == df["level_apply"]).all())
# 两者是否一致: True
正确性上 np.select 和 apply 完全等价;性能差距在百万行数量级上才明显,第 7 节会实测。
4. np.nan 与 np.inf 的传播和处理
4.1 NaN 与 inf 不是一回事
s = pd.Series([1.0, np.nan, 3.0, np.inf, -np.inf, 5.0])
print("isna: ", s.isna().tolist())
print("notna:", s.notna().tolist())
isna: [False, True, False, False, False, False]
notna: [True, False, True, True, True, True]
注意:`isna()` 只认 `NaN`,不认 `inf`。+np.inf / -np.inf 在 pandas 眼里是"合法的无穷大数",不会被当成缺失。这是最常见的认知误区。
4.2 inf 对聚合的影响
print("sum:", s.sum(), "| mean:", s.mean())
print(s.describe())
sum: nan | mean: nan
count 5.0
mean NaN
std NaN
min -inf
25% 1.0
50% 3.0
75% NaN
max inf
dtype: float64
数组里只要同时存在 NaN 和 inf,sum/mean 直接给出 NaN(还会在 numpy 侧打 RuntimeWarning: invalid value encountered in reduce)。describe 里 min=-inf、max=inf,分位数被污染。
4.3 处理方式
# 方式一:把 inf 统一替换成 NaN,再交给 fillna / dropna
s2 = s.replace([np.inf, -np.inf], np.nan)
print("replace 后:", s2.tolist())
print("sum:", s2.sum(), "mean:", s2.mean())
# 方式二:clip 把超出范围的值"裁"到上下界(inf 直接被夹到边界)
s3 = s.clip(lower=-10, upper=10)
print("clip 后:", s3.tolist())
replace 后: [1.0, nan, 3.0, nan, nan, 5.0]
sum: 9.0 mean: 3.0
clip 后: [1.0, nan, 3.0, 10.0, -10.0, 5.0]
配合 fillna 是标准套路:先 replace(inf, nan),再 fillna(0) 或 fillna(列均值),数据就能干净地进建模。
5. ufunc 对整列整表的向量化应用
NumPy 的通用函数(ufunc)接受数组/Series/DataFrame 作为输入,返回同形状的 pandas 对象(保留索引和列名),这是 pandas 最"白嫖"NumPy 性能的方式:
d = pd.DataFrame({"a": [1, 4, 9, 16], "b": [1, 2, 3, 4]})
print(np.sqrt(d)) # 开平方,整表广播
print(np.log(d)) # 自然对数
print(np.abs(pd.Series([-1, 2, -3])).tolist()) # 绝对值
print(np.round(pd.Series([1.234, 2.567]), 2).tolist()) # 保留 2 位小数
a b
0 1.0 1.000000
1 2.0 1.414214
2 3.0 1.732051
3 4.0 2.000000
a b
0 0.000000 0.000000
1 1.386294 0.693147
2 2.197225 1.098612
3 2.772589 1.386294
[1, 2, 3]
[1.23, 2.57]
常用 ufunc 速查:
| ufunc | 作用 | pandas 里的等价/常用场景 |
|-------|------|--------------------------|
| np.abs | 绝对值 | 残差、误差分析 |
| np.sqrt | 平方根 | 标准化、距离 |
| np.log / np.log1p | 对数/对数(1+x) | 长尾收入、价格平滑 |
| np.exp | e 的幂 | 反 log、概率还原 |
| np.round(x, n) | 四舍五入到 n 位 | 报表输出 |
| np.clip(a, lo, hi) | 截断到区间 | 去异常值、压缩 inf |
| np.where | 条件三元 | 生成标签列 |
| np.select | 多分支条件 | 分层/打标 |
注意 np.log(0) 会得到 -inf,np.sqrt(负数) 会得到 NaN(并告警),所以做特征工程时记得先 clip 或 replace。
6. dtype 视角:什么时候该把数据转回 NumPy
6.1 三类列的对照
df = pd.DataFrame({
"num": [1, 2, 3], # int64,纯数值
"obj": ["1", "2", "3"], # pandas 3.0 默认 str
"mix": [1, "a", 2.5], # 混了数字和字符串 -> object
})
print(df.dtypes)
print("obj 列 to_numpy dtype:", df["obj"].to_numpy().dtype)
print("mix 列 to_numpy dtype:", df["mix"].to_numpy().dtype)
num int64
obj str
mix object
dtype: object
obj 列 to_numpy dtype: object
mix 列 to_numpy dtype: object
只有 int64/float64 这类数值 dtype的列,转 NumPy 后才是真正的连续数值数组、享受向量化加速;str/object 列转过去都是 Python 对象数组,NumPy 数值算子用不上。
6.2 astype 与 to_numeric 修类型
# 整列安全转型
print("astype float:", df["num"].astype(float).tolist())
# 字符串里夹脏数据时用 to_numeric + coerce,转不动的变 NaN
s_num = pd.to_numeric(pd.Series(["10", "20", "xx", "30"]), errors="coerce")
print("to_numeric coerce:", s_num.tolist(), s_num.dtype)
astype float: [1.0, 2.0, 3.0]
to_numeric coerce: [10.0, 20.0, nan, 30.0] float64
6.3 什么时候必须转回 NumPy
| 场景 | 为什么要转 |
|------|------------|
| 喂给 scikit-learn / PyTorch / TensorFlow | 这些库的 fit/predict 接受 ndarray(pandas 也能吃,但保险起见 .to_numpy()) |
| 自己写的数值算法只吃 ndarray | 例如手写梯度下降、自定义距离矩阵 |
| 需要 np.linalg 线性代数 | inv/svd/eig 不接受 DataFrame |
| 极致性能热循环 | 把整表一次性 .to_numpy() 再进循环,避免反复封装 pandas 对象 |
口诀:数据处理留在 pandas(有索引、有标签、好调试),模型输入交给 NumPy(连续内存、无对象开销)。
7. 性能实测:用 np 替代 apply
下面这段在 200 万行数据上对比"条件打 0/1 标签"的两种写法,结果是本机实跑:
import time
rng = np.random.default_rng(42)
big = pd.DataFrame({"v": rng.normal(0, 1, 2_000_000)})
t0 = time.perf_counter()
big["a_where"] = np.where(big["v"] > 0, 1, 0)
t_where = time.perf_counter() - t0
t0 = time.perf_counter()
big["a_apply"] = big["v"].apply(lambda x: 1 if x > 0 else 0)
t_apply = time.perf_counter() - t0
print(f"np.where : {t_where*1000:8.1f} ms")
print(f"apply : {t_apply*1000:8.1f} ms")
print(f"加速倍数 : {t_apply/t_where:6.1f}x")
print("结果一致:", (big["a_where"] == big["a_apply"]).all())
np.where : 26.7 ms
apply : 1526.3 ms
加速倍数 : 57.3x
结果一致: True
结论:
- 同样的逻辑,
np.where走 C 层一次性算完,apply是 Python 逐行调用 lambda; - 200 万行上差了约 57 倍;行数越多,差距越稳定;
- 凡是能用"向量化布尔 + NumPy 函数"表达的,都不要写
apply(lambda ...)。
可替换 apply 的 NumPy/pandas 原语对照:
| 你想做的事 | 别写 | 改用 |
|------------|------|------|
| 条件打标签 | apply(lambda x: 1 if x>0 else 0) | np.where / np.select |
| 开平方/对数 | apply(np.sqrt) | np.sqrt(s) |
| 逐行加权求和 | apply(lambda r: r.a*0.3+r.b*0.7, axis=1) | df.a*0.3 + df.b*0.7 |
| 字符串截断 | apply(lambda x: x[:3]) | s.str[:3](pandas 字符串访问器) |
8. 常见坑清单
坑 1:NaN 悄悄传播
print((np.array([1.0, np.nan]) + 1).tolist()) # [2.0, nan]
print(np.nan > 0) # False
[2.0, nan]
False
只要数组里有一个 NaN,整条聚合结果都被污染;NaN 和任何值比较都返回 False,导致 filter(s > 0) 会把 NaN 行直接丢掉。处理前先 dropna() 或 fillna()。
坑 2:object 数组上的 numpy 运算"看起来能跑"
o = pd.Series([1, 2, 3], dtype=object)
print(o.dtype)
print(o * 2) # 不报错,但走 Python 逐元素,慢
object
0 2
1 4
2 6
dtype: object
object dtype 下 o * 2 不报错(因为 Python 对象支持乘法),但完全没了向量化加速,做 np.sqrt(o) 还会报错。看到 `dtype: object` 先 `pd.to_numeric(..., errors="coerce")` 把它救回数值列。
坑 3:to_numpy 的 copy 语义
| 写法 | 是否拷贝 | 可写性 | 是否影响原数据 |
|------|----------|--------|----------------|
| s.to_numpy() | 默认(CoW 下通常拷贝) | 可写 | 不影响 |
| s.to_numpy(copy=False) | 共享内存 | pandas 3.0 只读 | 不能通过它改原值 |
| s.to_numpy(copy=True) | 一定拷贝 | 可写 | 不影响 |
| np.array(s) | 一定拷贝 | 可写 | 不影响 |
不要假设"共享内存就能改原数据"——3.0 里这条路被 CoW 堵死了。
坑 4:DataFrame 列顺序与数组形状不匹配
d2 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
try:
d2["C"] = np.array([10, 20]) # 长度 2 ≠ 行数 3
except ValueError as e:
print("长度不符:", str(e)[:60])
# 给两列同时赋值:形状必须是 (n_rows, 2),且按列顺序贴
d2["D"], d2["E"] = np.array([[100, 200], [300, 400], [500, 600]]).T
print(d2)
长度不符: Length of values (2) does not match length of index (3)
A B D E
0 1 4 100 200
1 2 5 300 400
2 3 6 500 600
赋二维数组时,NumPy 数组的列会按 DataFrame 当前的列顺序贴上去——列一多就容易对错位,建议先 np.column_stack 拼好,或显式 df.loc[:, ["D","E"]] = arr。
9. 本章小结与练习题
小结
1. pandas 列底层就是 NumPy 数组,to_numpy() / np.array(...) 是两个标准出口;pandas 3.0 CoW 下 copy=False 返回只读视图。
2. np.where / np.select 是向量化打标主力,正确性等价于 apply,但快几十倍。
3. isna() 不认 inf;inf 会污染聚合,需 replace 或 clip 处理。
4. np.sqrt/log/abs/round/clip 等 ufunc 可以直接吃 Series/DataFrame,整表广播。
5. str/object 列转 NumPy 后是 Python 对象数组,享受不到数值向量化;建模前用 astype / to_numeric 修回数值类型。
6. 把 apply(lambda...) 替换成 NumPy 向量化原语,是 pandas 性能优化里性价比最高的一招。
练习题
1. 给定 df = pd.DataFrame({"v": [12, -5, 0, 8, -1]}),用 np.where 新增一列 sign:正数记 正、负数记 负、0 记 零,并写出等价的 np.select 写法。
2. 为什么 pd.Series([1.0, np.inf, 3.0]).sum() 在不同写法下结果不同?先用 isna() 判断一下 inf 是不是缺失,再用 replace + fillna(0) 给出一个"安全求平均"的版本。
3. 解释下面两段代码在 pandas 3.0 下的行为差异:
a = s.to_numpy(copy=False); a[0] = 999
b = s.to_numpy(copy=True); b[0] = 999
为什么一个报错、一个不报错?
4. 用 np.sqrt / np.log 对 df = pd.DataFrame({"x":[1,4,9,16]}) 做向量化变换,并思考:如果 x 列里出现了负数或 0,np.sqrt / np.log 会产生什么?怎么预先处理?
5. (性能题)生成 n=1_000_000 行、列 a~N(0,1)、b~N(0,1) 的 DataFrame,分别用 apply(lambda r: r.a*0.3 + r.b*0.7, axis=1) 和 df.a*0.3 + df.b*0.7 计算新列,用 time.perf_counter 计时并对比加速比,结果写入表格。
6. (排错题)下面代码想把 shape 为 (3,2) 的数组按列贴到 new_a, new_b 两列上,运行却报 ValueError: too many values to unpack (expected 2)。请指出原因并给出修正写法(提示:思考"行优先解包"与"按列贴"的区别,需要用到转置)。
df = pd.DataFrame({"old_a":[1,2,3], "old_b":[4,5,6]})
arr = np.array([[10,20],[30,40],[50,60]]) # shape (3,2)
df["new_a"], df["new_b"] = arr # 这里报错
下一篇预告:第 49 章 Excel 常用功能与 pandas 对照 —— 把你在 Excel 里天天用的筛选、排序、透视表、VLOOKUP,逐一翻译成 pandas 操作。
文章回复
0 条公开回复