跳到正文

NumPy数组与向量化计算完全指南

pandas、matplotlib、scikit-learn 全都建立在同一个对象上:NumPy 的 ndarray(n-dimensional array,多维数组)。它的行为和你熟悉的 Python 列表差得很远——切片可能是视图而不是副本,两个形状不同的数组能直接相加,axis 参数还经常搞反。这些地方出错时不一定报错,只是结果悄悄不对。

import numpy as np
a = np.array([1.5, 2.0, 3.25, 4.0]) # 从列表构造
b = np.zeros(5) # 全 0
c = np.ones((2, 3)) # 2 行 3 列全 1
d = np.arange(0, 10, 2) # 起点、终点(不含)、步长
e = np.linspace(0, 1, 5) # 起点、终点(含)、元素个数
print(a)
print(b)
print(c)
print(d)
print(e)
[1.5 2. 3.25 4. ]
[0. 0. 0. 0. 0.]
[[1. 1. 1.]
[1. 1. 1.]]
[0 2 4 6 8]
[0. 0.25 0.5 0.75 1. ]

arangelinspace 容易混:前者给的是步长,元素个数由算式决定,np.arange(0, 1, 0.1) 里元素个数是 10 不是 11;后者给的是元素个数,步长自动算,端点一定包含。要画一条从 0 到 1 的等距网格,用 linspace 更不容易错。

zerosones 默认返回 float64,因为这是后续运算里最安全的选择。想明确指定类型就加 dtype=int。需要「形状一样但内容待填」的空数组时用 np.empty(shape),它不保证内容为 0,只是不初始化,速度快一点点。

m = np.arange(12).reshape(3, 4)
print(m)
print("shape:", m.shape, "ndim:", m.ndim, "size:", m.size, "dtype:", m.dtype)
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
shape: (3, 4) ndim: 2 size: 12 dtype: int64

四个属性要分清:shape 是各维长度的元组,ndim 是维度个数,size 是元素总数,dtype 是元素类型。reshape 不复制数据,返回的是同一块内存的另一个视图,所以 m.reshape(4, 3) 改一个元素,原数组也跟着变。

dtype 有个跨平台的坑:同样的 np.array([1, 2, 3]),在 Linux 和 macOS 上是 int64,在 Windows 上是 int32。默认浮点是 float64,各平台一致。代码在 Mac 上跑得通、在 Windows 上溢出或者精度变差时,先查 dtype,别急着怀疑算法。

索引与切片:先问是视图还是副本

Section titled “索引与切片:先问是视图还是副本”

Python 列表的切片一定产生新列表,NumPy 却不是。

x = np.arange(10)
view = x[2:5]
view[0] = 999
print(x)
print(view)
[ 0 1 999 3 4 5 6 7 8 9]
[999 3 4]

view 把原数组改了。基础切片(连续区间加步长)返回视图(view),和原数组共享内存,这样设计是为了省内存;而布尔索引和花式索引返回副本(copy)

y = x[x % 2 == 1] # 布尔索引,返回副本
print(y)
y[:] = 0
print(y)
print(x)
[ 1 999 3 5 7 9]
[0 0 0 0 0 0]
[ 0 1 999 3 4 5 6 7 8 9]

x 不受影响。判断两个数组是否共享内存可以用 np.shares_memory(x, view)。拿不准的时候最省事的办法是显式 view = x[2:5].copy(),代价是一次内存拷贝,换来确定性。

二维数组用逗号分隔两个轴:m[1, 2] 取第 2 行第 3 列,m[:, 0] 取第一列,m[0, :] 取第一行。两层中括号的 m[0][0] 结果相同(m[0] 拿到的是视图),但每多一层索引就多创建一个中间对象,代码里统一写成 m[0, 0] 更规范,读写切片时也不会绕。

R 用户注意:R 的下标从 1 开始,Python 从 0 开始;R 里 m[1, ] 取第一行,NumPy 里 m[1, ] 取的是第二行。从 R 转过来时这类差一错误最难自查,因为不报错,只是取错数据。

这是最容易记反的一个参数。记法只有一条:axis 指向要压缩掉的那根轴

m = np.array([[1, 2, 3],
[4, 5, 6]])
print(m.sum())
print(m.sum(axis=0))
print(m.sum(axis=1))
21
[5 7 9]
[ 6 15]

axis=0 消掉行方向,把每列压成一个数;axis=1 消掉列方向,把每行压成一个数。换一种说法:axis=0 是「向下走」,axis=1 是「向右走」。

print(m.mean(axis=0), m.mean(axis=0).shape)
print(m.mean(axis=1), m.mean(axis=1).shape)
[2.5 3.5 4.5] (3,)
[2. 5.] (2,)

注意结果形状的变化:(2, 3) 沿 axis=0 聚合得到 (3,),轴被压没了。这正是下一节广播能自动对上的原因,也是为什么需要 keepdims=True 的时候要显式写出来。

NumPy 允许形状不同的数组直接运算,规则是从最右边的维度开始逐个对齐,维度相同或者其中一个是 1 就能拉伸,缺失的维度当成 1

m = np.array([[1, 2, 3],
[4, 5, 6]]) # (2, 3)
row = np.array([10, 20, 30]) # (3,)
print(m + row)
print((m + row).shape)
[[11 22 33]
[14 25 36]]
(2, 3)

(3,) 被当成 (1, 3),然后沿行方向复制成 (2, 3)。它没有真的开一块新内存,所以 m + rownp.tile 出的结果一样,内存开销却小得多。

反过来按行中心化时容易写错:

m - m.mean(axis=1) # 想减掉每行的均值
ValueError: operands could not be broadcast together with shapes (2,3) (2,)

从右边对齐是先比 32,不等也不是 1,直接报错。正确写法是让均值保持二维:

row_mean = m.mean(axis=1, keepdims=True) # (2, 1)
print(row_mean)
print(m - row_mean)
[[2.]
[5.]]
[[-1. 0. 1.]
[-1. 0. 1.]]

keepdims=True 让结果保持 (2, 1),右边对齐时第一维 2 对上 2、第二维 1 对上 3,广播成功。等价写法是 m.mean(axis=1).reshape(-1, 1)。看到 could not be broadcast 时,先把两个 shape 打印出来从右往左对一遍,几乎每次都能立刻找到问题。

同样是给一百万个元素开平方,两条路差出一个数量级:

import time
import numpy as np
n = 1_000_000
t0 = time.perf_counter()
total = 0.0
for v in range(n):
total += v ** 0.5
t_loop = time.perf_counter() - t0
t0 = time.perf_counter()
result = np.sqrt(np.arange(n)).sum()
t_vec = time.perf_counter() - t0
print(f"for 循环 {t_loop:.3f} s,向量化 {t_vec:.4f} s")
print("两种写法结果一致:", np.isclose(total, result))
for 循环 0.089 s,向量化 0.0031 s
两种写法结果一致: True

快的原因不是「NumPy 用了什么黑魔法」,而是三件事叠加:数组里元素类型统一且连续存放,不需要对每个元素做 Python 对象的拆箱装箱;循环在 C 层完成,省掉了字节码解释的开销;编译器还能把同一批运算做 SIMD 并行。数据量越大差距越明显,一百个元素的数组用哪种写法都无所谓。

反过来说,向量化也有不适合的场景:

  • 元素之间有依赖的迭代,比如 x[i] 依赖 x[i-1],写不成数组运算,只能用循环或者 scipy.signal.lfilter 这类专门的函数。
  • 循环体里要调用一个只接受标量的第三方 Python 函数。这时别指望 np.vectorize——它在文档里明说了是为了写法方便而不是性能,本质还是 Python 循环。真需要提速得考虑 numba 或者改写算法。
  • 逻辑复杂到写成向量化之后没人看得懂。可读性也是成本,一段跑 0.2 秒但三个月后自己都读不懂的代码,不如多写三行循环。

条件赋值用 np.where,不要用循环加 if:

arr = np.array([1.2, -0.4, 3.8, -2.1])
print(np.where(arr > 0, arr, 0.0))
[1.2 0. 3.8 0. ]

把上面几件事串起来。sklearn.datasets.load_iris() 返回的是一个字典(准确说是 Bunch,可以通过属性访问的字典),特征在 data 里,标签在 target 里。

import numpy as np
from sklearn.datasets import load_iris
iris = load_iris()
X = iris.data # ndarray,不是 DataFrame
y = iris.target
print(X.shape, X.dtype)
print(X[:3])
print(y[:3])
(150, 4) float64
[[5.1 3.5 1.4 0.2]
[4.9 3. 1.4 0.2]
[4.7 3.2 1.3 0.2]]
[0 0 0]

很多人第一次拿到它就直接 X.head(),然后报 AttributeError: 'numpy.ndarray' object has no attribute 'head'load_iris() 不是 pandas 的读取函数,想要 DataFrame 要额外说明:load_iris(as_frame=True).frame 才会返回带列名的 DataFrame,这部分见 /python/basics/pandas-basics/

接着做 z 标准化(z-score standardization):每个特征减去自己的均值,再除以自己的标准差。axis=0 得到 4 个特征的均值和标准差,形状都是 (4,),正好和 (150, 4) 从右边对齐。

mu = X.mean(axis=0)
sigma = X.std(axis=0)
Z = (X - mu) / sigma
print(mu)
print(sigma)
print(Z[:5])
print("标准化后的列均值:", Z.mean(axis=0))
print("标准化后的列标准差:", Z.std(axis=0))
[5.84333333 3.05733333 3.758 1.19933333]
[0.82530129 0.43441097 1.75940407 0.75969263]
[[-0.90068117 1.01900435 -1.34022653 -1.3154443 ]
[-1.14301691 -0.13197948 -1.34022653 -1.3154443 ]
[-1.38535265 0.32841405 -1.39706395 -1.3154443 ]
[-1.50652052 0.09821729 -1.45390138 -1.3154443 ]
[-1.02184904 1.24920112 -1.34022653 -1.3154443 ]]
标准化后的列均值: [ 1.48029737e-16 1.25849895e-16 -1.18329136e-16 -1.03540857e-16]
标准化后的列标准差: [1. 1. 1. 1.]

两份标准差口径不同,值得留个心:X.std(axis=0) 默认除以 n,是总体标准差;pandas 的 .std() 默认除以 n−1,是样本标准差。同一份 iris 数据用 pandas 算出来是 0.828066, 0.435866, 1.765298, 0.762238,和上面的数不一样。这不是 bug,是自由度(degrees of freedom)口径的差别。scikit-learn 的 StandardScaler 用的是除以 n 的口径,所以手算的 ZStandardScaler().fit_transform(X) 结果一致。

另外注意均值那一行:理论上是 0,实际是 1e-16 量级的浮点数。任何浮点比较都不要用 ==,一律用 np.allclose(a, b) 或者 np.isclose

拿数组做 if 条件。 if arr > 0: 会抛 ValueError: The truth value of an array with more than one element is ambiguous。想判断「全部满足」用 (arr > 0).all(),判断「完全相同」用 np.array_equal(a, b)

往列表里 append 再转数组。 np.append 每次都会重新分配整块内存,放进循环里是 O(n²)。已知长度就 np.empty(n) 预分配,未知长度就先存 Python 列表,循环结束后一次性 np.array(list)

整数溢出静默回绕。 小整数类型不会自动升级:

small = np.array([127], dtype=np.int8)
print(small + 1)
[-128]

不会报错,也不会自动升级成更大的类型。处理图像像素、传感器原始读数这类数据时要留意 dtype 的表示范围。

花式索引的维度顺序。 X[[0, 2, 4], [1, 3, 2]] 取的是三个点 (0,1)(2,3)(4,2),不是一个子矩阵。要按行列交叉取子块,用 np.ix_

R 那边有向量、矩阵、数组三套并存的类型,Python 这边统一成一个 ndarray,少了一层心智负担。R 的向量和索引规则可以参考 /r/basics/data-types/ 对照着看,特别是索引起点和回收规则(recycling)的差异。

数组会建、会切、会算了,下一步是把它装进带行列标签的结构。/python/basics/pandas-basics/ 讲 Series 与 DataFrame,那是日常分析真正打交道的东西。