RSS
菜单
全部文章快讯开发科技深度热点

第 48 章 NumPy 与 pandas 协同

内容摘要

本篇讲 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 数组基础与创建](46-NumPy数组基础与创建.md) 与 [第 47 章 NumPy 广播与向量化计算](47-NumPy广播与向量化计算.md)。学完后你能在 pandas 里熟练调用 NumPy 算子做高性能列处理,并避开 NaN/inf/object 数组的经典坑。

📖 本篇简介:本篇讲 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 操作。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 48 章 NumPy 与 pandas 协同》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。