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

第 23 章 向量化性能优化大全:告别 apply

内容摘要

[第 16 章](16-性能优化与实战案例.md)介绍了性能优化总原则。本篇给出**最全的 apply→向量化 对照手册**:`np.where`、`np.select`、`np.clip`、字符串向量化、分组向量化、`numba` 加速,以及性能剖析方法。学完本篇,你的 pandas 代码将又快又优雅。

第 16 章介绍了性能优化总原则。本篇给出最全的 apply→向量化 对照手册:np.where、np.select、np.clip、字符串向量化、分组向量化、numba 加速,以及性能剖析方法。学完本篇,你的 pandas 代码将又快又优雅。

目录

1. 向量化思维

2. 条件逻辑的向量化

3. 查找/映射的向量化

4. 字符串处理的向量化

5. 数值运算的向量化

6. 分组与窗口的向量化

7. 日期处理的向量化

8. numba:终极加速

9. 性能剖析方法

10. 性能优化清单(速查)

11. 常见坑与注意事项

12. 本章小结与练习


1. 向量化思维

核心心法:对"整列"说话,不对"每个元素"说话。

import pandas as pd
import numpy as np
import time

n = 1_000_000
df = pd.DataFrame({
    "a": np.random.rand(n),
    "b": np.random.rand(n),
    "city": np.random.choice(["北京", "上海", "广州", "深圳"], n),
    "text": np.random.choice(["订单A100", "订单B200", "退款C300"], n),
})

# ❌ apply + lambda:逐元素
t0 = time.time()
df["r1"] = df.apply(lambda r: r["a"] * 2 + r["b"], axis=1)
t_apply = time.time() - t0

# ✅ 向量化
t0 = time.time()
df["r2"] = df["a"] * 2 + df["b"]
t_vec = time.time() - t0

print(f"apply 逐行: {t_apply:.3f}s | 向量化: {t_vec:.3f}s | 提速 {t_apply/t_vec:.0f}x")

2. 条件逻辑的向量化

2.1 if/else → np.where

# ❌ apply
# df["grade"] = df.apply(lambda r: "高" if r["a"] > 0.7 else "低", axis=1)

# ✅ np.where
df["grade"] = np.where(df["a"] > 0.7, "高", "低")

2.2 多分支 if/elif → np.select

# ❌ apply 多分支
# def f(r):
#     if r["a"] > 0.8: return "A"
#     elif r["a"] > 0.5: return "B"
#     elif r["a"] > 0.2: return "C"
#     return "D"
# df["lvl"] = df.apply(f, axis=1)

# ✅ np.select
df["lvl"] = np.select(
    [df["a"] > 0.8, df["a"] > 0.5, df["a"] > 0.2],
    ["A", "B", "C"],
    default="D",
)

2.3 区间判断 → pd.cut

# ✅ pd.cut 分箱(替代手写区间判断)
df["band"] = pd.cut(
    df["a"],
    bins=[0, 0.3, 0.6, 1.0],
    labels=["低", "中", "高"],
    include_lowest=True,
)

2.4 边界截断 → np.clip

# ❌ apply 截断
# df["a_clip"] = df["a"].apply(lambda x: 0.2 if x < 0.2 else (0.8 if x > 0.8 else x))

# ✅ np.clip
df["a_clip"] = np.clip(df["a"], 0.2, 0.8)

2.5 条件判断的 pandas 原生方法

# 最大值/最小值
df["max_ab"] = np.maximum(df["a"], df["b"])
df["min_ab"] = np.minimum(df["a"], df["b"])

# 按条件取两列值
df["pick"] = np.where(df["a"] > df["b"], df["a"], df["b"])

3. 查找/映射的向量化

3.1 字典映射 → map(不用 apply)

# ✅ map 字典(比 apply 快且简洁)
city_map = {"北京": "北方", "上海": "南方", "广州": "南方", "深圳": "南方"}
df["region"] = df["city"].map(city_map)

3.2 查另一张表 → merge(不用 apply 逐行查)

# ❌ apply 逐行查字典
# price_map = {...}
# df["price"] = df["city"].apply(lambda c: price_map.get(c))

# ✅ merge 表连接
city_info = pd.DataFrame({"city": ["北京", "上海"], "level": ["一线", "一线"]})
df2 = df.merge(city_info, on="city", how="left")

3.3 归一化/标准化 → 直接向量运算

# 最小-最大归一化
df["a_norm"] = (df["a"] - df["a"].min()) / (df["a"].max() - df["a"].min())

# Z-score
df["a_z"] = (df["a"] - df["a"].mean()) / df["a"].std()

4. 字符串处理的向量化

# ✅ 用 .str 访问器(内部向量化)
df["text_upper"] = df["text"].str.upper()
df["has_A"] = df["text"].str.contains("A", na=False)

# 提取数字
df["num"] = df["text"].str.extract(r"(\d+)").astype(int)

# 替换
df["text_clean"] = df["text"].str.replace(r"\D", "", regex=True)

# 拆分
df[["kind", "code"]] = df["text"].str.split(r"(?<=\D)(?=\d)", expand=True)
需要正则时优先 .str.extract/.str.replace,它们本身就是向量化的。复杂逐条逻辑才考虑 apply。

5. 数值运算的向量化

# 四则运算
df["c"] = df["a"] * df["b"] + 1

# numpy 通用函数
df["a_sqrt"] = np.sqrt(df["a"])
df["a_log"] = np.log1p(df["a"])          # log(1+x) 防 0
df["a_round"] = np.round(df["a"], 2)

# 聚合类
df["a_rank"] = df["a"].rank()
df["a_pct"] = df["a"].rank(pct=True)

# 累计
df["a_cumsum"] = df["a"].cumsum()
df["a_cummax"] = df["a"].cummax()

6. 分组与窗口的向量化

# ✅ transform 代替 groupby.apply(保持行数)
df["city_mean"] = df.groupby("city")["a"].transform("mean")
df["a_city_ratio"] = df["a"] / df["a_city_mean"]

# ✅ 分组排名
df["city_rank"] = df.groupby("city")["a"].rank()

# ✅ 窗口
df["a_roll3"] = df["a"].rolling(3).mean()
df["a_exp"] = df["a"].ewm(span=10).mean()

# ✅ 差分/平移
df["a_diff"] = df["a"].diff()
df["a_lag1"] = df["a"].shift(1)

对照表:

| 需求 | ❌ 慢写法 | ✅ 快写法 |

|------|-----------|-----------|

| 组内均值填充 | groupby.apply(lambda g: ...) | groupby.transform("mean") |

| 组内归一化 | apply 循环 | transform("sum") + 除法 |

| 组内排名 | apply | groupby.rank() |

| 窗口均值 | 手写循环 | rolling(n).mean() |


7. 日期处理的向量化

# ✅ .dt 访问器全向量化
df["日期"] = pd.to_datetime("2025-01-01")
df["year"] = df["日期"].dt.year
df["month"] = df["日期"].dt.month
df["weekday"] = df["日期"].dt.dayofweek

# ✅ 时间差
df["间隔天数"] = (pd.Timestamp("2025-02-01") - df["日期"]).dt.days

# ✅ 重采样/滚动都在底层向量化
# df.set_index("日期").resample("W").sum()

8. numba:终极加速

对无法向量化的复杂逐行逻辑,用 numba JIT 编译到机器码:

# 安装:pip install numba
import numba

@numba.jit(nopython=True)
def complex_logic(a, b):
    n = len(a)
    out = np.empty(n)
    for i in range(n):
        # 无法向量化的复杂业务逻辑
        if a[i] > 0.5 and b[i] > 0.3:
            out[i] = (a[i] + b[i]) ** 2
        elif a[i] < 0.1:
            out[i] = 0
        else:
            out[i] = np.log1p(a[i] * b[i])
    return out

t0 = time.time()
df["r_numba"] = complex_logic(df["a"].values, df["b"].values)
print(f"numba: {time.time()-t0:.3f}s")
numba 需要纯 NumPy 数组(.values)、函数内只能用 NumPy 操作。适合绝对无法向量化的循环逻辑,通常比 apply 快数十倍。

9. 性能剖析方法

9.1 %timeit(Jupyter)

# %timeit df["a"] * 2
# %timeit df["a"].apply(lambda x: x * 2)

9.2 timeit 模块(脚本)

import timeit

t = timeit.timeit(
    'df["a"] * 2',
    setup="import pandas as pd; import numpy as np; df = pd.DataFrame({'a': np.random.rand(100000)})",
    number=100,
)
print(f"平均耗时: {t/100*1000:.2f} ms")

9.3 内存剖析

print(df.memory_usage(deep=True))
print(df.dtypes.value_counts())

9.4 定位慢操作

# 慢操作通常来自:
# 1. apply 逐行
# 2. for 循环 + loc
# 3. 字符串正则逐条
# 4. 多次 IO

# 用 cProfile 定位(脚本):
# python -m cProfile -s cumtime my_script.py

10. 性能优化清单(速查)

| 场景 | 最优做法 |

|------|----------|

| 四则运算 | 直接 + - * / |

| 条件赋值 | np.where / np.select |

| 区间分箱 | pd.cut / pd.qcut |

| 截断 | np.clip |

| 字典映射 | .map() |

| 查表 | .merge() |

| 字符串 | .str 访问器 |

| 组内变换 | groupby.transform |

| 窗口 | .rolling().mean() 等 |

| 日期成分 | .dt 访问器 |

| 复杂循环 | numba |

| 大文件 | 分块 + 指定 dtype |


11. 常见坑与注意事项

| 坑 | 现象 | 解决办法 |

|----|------|----------|

| np.where 忘加括号 | 结果错误 | 条件用括号包住 |

| select 条件重叠 | 取第一个匹配 | 保持条件互斥或顺序正确 |

| numba 用 pandas 对象 | 报错 | 传 .values,函数内只用 NumPy |

| 过度优化 | 代码难读 | 先正确后优化,只优化热点 |

| 优化后结果不一致 | 边界行为差异 | 小样本对比验证 |

| str.replace 忘 regex=False | 特殊字符误替换 | 纯文本替换用 regex=False |


12. 本章小结与练习

小结

  • 心法:对整列说话,不对元素说话;
  • 条件:np.where / np.select / pd.cut / np.clip;
  • 映射:map / merge;
  • 字符串:.str 全向量化;
  • 分组窗口:transform / rank / rolling / ewm;
  • 终极:numba 加速复杂循环;
  • 验证:%timeit / memory_usage。

练习题

1. 把一段 apply 多分支代码改写为 np.select。

2. 用 pd.cut 替代手写区间判断。

3. 对比 apply 与 transform 计算组内均值填充的耗时。

4. 用 numba 实现一个无法向量化的循环并对比速度。

5. 用 %timeit 对比 df["a"]*2、apply、map 三种写法。


下一篇预告:第 24 章 案例一:电商订单与销售分析 —— 完整业务分析实战第一弹。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 23 章 向量化性能优化大全:告别 apply》完整推广海报
DISCUSSION

文章回复

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