第4章封面

如果只允许你记住本书一个词,那就是「向量化」(vectorization)。第 4 章教你把「对每个元素做循环」的思维,替换成「对整块数据发指令」。这不只是优雅,是 10~100 倍的速度差。

⚡ 先看差距:为什么不用循环

import numpy as np
my_arr = np.arange(1_000_000)
my_list = list(range(1_000_000))

%timeit my_arr * 2               # 715 μs
%timeit [x * 2 for x in my_list] # 48.8 ms —— 慢约 68 倍!

差距的来源:NumPy 数组是同类型的连续内存块,运算直接由底层 C 代码批量执行;而 Python 列表是「指针数组」,每次操作都要解释一个完整对象。NumPy 的算法通常快 10~100 倍、内存更省。

NumPy向量化性能

🏗️ ndarray:形状与类型

data = np.array([[1.5, -0.1, 3.0], [0.0, -3.0, 6.5]])
data.shape    # (2, 3)   两行三列
data.dtype    # dtype('float64')  全体元素同类型
data.ndim     # 2

# 常用创建函数
np.zeros(10); np.ones((3, 6)); np.empty((2, 2))  # empty 只分配不清零
np.arange(15).reshape(3, 5)   # 0..14 排成 3×5
np.full((2,2), 7); np.eye(3)  # 全 7 矩阵 / 单位阵

dtype 是门派规矩:一个数组只能有一种类型。int64float64bool、字符串等,arr.astype(np.float64) 显式转型(总是返回新数组)。

🚨 最重要的坑:切片是视图

arr = np.arange(10)
s = arr[5:8]
s[:] = 99          # 改切片……
arr                # array([0,1,2,3,4,99,99,99,99,9]) 原数组也变了!

NumPy 刻意让切片返回视图(同一块内存的窗口)——大数据时省内存、速度快。想要独立副本,必须 arr[5:8].copy()。这个特性和 Python 列表(切片即拷贝)相反,是无数 bug 的根源。

🎯 三种高级索引

布尔索引——按条件筛,数据分析使用率最高:

names = np.array(["Bob", "Joe", "Will", "Bob"])
arr = np.arange(8).reshape(4, 2)

arr[names == "Bob"]              # 选出 Bob 的行
arr[(names == "Bob") | (names == "Will")]  # | 或 &(不能用 and/or!)
arr[arr[:, 1] < 4] = 0           # 条件赋值

花式索引——用整数列表按任意顺序取行:

arr[[3, 0, 1]]       # 第3、0、1行,按这个顺序
arr[[-1, -3]]        # 负数从尾倒数

转置与轴交换arr.Tarr.swapaxes(0,1),线性代数的入场券:np.dot(arr.T, arr)

🎉 广播(Broadcasting):不同形状也能算

arr = np.arange(4).reshape(2, 2)   # [[0,1],[2,3]]
arr + np.array([10, 20])           # 每行加 [10,20]
#  (2,2) 与 (2,) 相加:(2,1)×(2,) → 广播成 (2,2)

规则口诀:从右往左对齐维度,两边长度相等或有一边为 1 即可广播。如 (4,3) + (1,3) ✅、(4,3) + (4,) ❌(要从右对齐,右边 3≠4)。广播让「每个减去列均值」「归一化」这类操作一行写完。

🛠️ ufunc 与条件逻辑

ufunc(通用函数)是逐元素运算的 C 加速版:np.sqrtnp.expnp.maximum(x, y)np.add……二元 ufunc 返回数组,一元 ufunc 吃数组吐数组。

np.where 是向量化版的三元表达式

xarr, yarr = np.array([1.1, 2.2]), np.array([3.3, 4.4])
cond = np.array([True, False])
np.where(cond, xarr, yarr)      # [1.1, 4.4]

# 经典应用:把正数换成 2、负数换成 -2
np.where(arr > 0, 2, -2)
# 或「替换 + 保底」:>0 取 arr 本身,否则取 -2
np.where(arr > 0, arr, -2)

统计方法全部是「轴感知」的:

rng = np.random.default_rng(seed=12345)   # 第3版推荐的新随机数API
arr = rng.standard_normal((5, 4))

arr.mean(); arr.sum(); arr.std()
arr.mean(axis=0)      # 沿行方向压 → 每列的均值
arr.cumsum(axis=1)    # 逐行累加
(arr > 0).sum()       # 正数个数(布尔数组求和)
(arr > 0).any()       # 有没有正数
(arr > 0).all()       # 是不是全是正数
np.unique(names)      # 排序去重,等价于 set 但保序输出
np.in1d / np.isin(x, ["Bob", "Will"])   # 成员判断的向量化版

🎲 实战:随机游走的两种写法

书里的招牌例子——模拟「抛硬币走步」:

# 单次游走:1000 步
rng = np.random.default_rng(seed=12345)
draws = rng.integers(0, 2, size=1000)   # 0/1
steps = np.where(draws > 0, 1, -1)      # 向量化三元
walk = steps.cumsum()                   # 累积位置
walk.min(), walk.max()                  # 最远到过哪
(np.abs(walk) >= 10).argmax()           # 首次到达 ±10 的时间

注意那个漂亮的技巧:布尔数组 .argmax() 返回第一个 True 的位置——「第一次发生某事是什么时候」的向量化答案。

然后是「同时模拟 5000 次游走」:把 1000 步 × 5000 次的随机数做成 (5000, 1000) 矩阵,cumsum(axis=1) 一次性算完,hits30 = (np.abs(walks) >= 30).any(axis=1) 统计触界比例——纯 Python 循环要几秒,NumPy 一瞬间。

💾 其他实用件

arr = np.arange(10)
np.save("some_array.npy", arr)      # 二进制存(推荐,快)
np.load("some_array.npy")

from numpy.linalg import inv, qr     # 线性代数子模块
X = rng.standard_normal((5, 3))
inv(X.T @ X)                          # @ 是矩阵乘法

numpy.random 的老 API(np.random.seed/randn)在书里被标注为旧式;新代码统一用 default_rng() 生成 Generator 对象,统计性质更好、更安全。

📌 本章小结:向量化 = 对整块数据发指令;切片是视图,要副本用 .copy();布尔索引(&/|)和花式索引是筛选利器;np.where 是向量化三元;axis 参数决定沿哪个方向压;随机数用 default_rng()。数组思维练成后,pandas 的向量化操作就是换了个标签的同一件事。