返回首页

Roadmap
更新于 2026年10月25日 全文 1896 字未阅读

第 4.3 节 常见问题#

这一节收集了初学者在 NumPy 向量化运算上最常卡的 10 个问题。每个问题都给出直接答案,能跑代码的都附上完整示例。

怎么用这一节 不用通读。遇到问题用 Ctrl+F 搜关键词,直接跳到对应那条。

准备一下数据,下面每题都会用到:

import numpy as np

sales = np.array([
    [120.0, 135.0, 150.0, 165.0],
    [ 80.0,  92.0,  88.0, 105.0],
    [200.0, 210.0, 195.0, 230.0],
    [ 60.0,  72.0,  85.0,  78.0],
    [150.0, 145.0, 160.0, 175.0],
])

scores = np.array([80, 90, 75, 100, 60])

Q1:什么是向量化运算?与 Python for 循环相比有什么优势?#

答: 向量化 = 用一行数组表达式替代整个 for 循环。

循环写法:

prices = np.array([100.0, 200.0, 300.0])

for i in range(len(prices)):
    prices[i] = prices[i] * 0.8

向量化写法:

prices = prices * 0.8

三个优势:

for 循环向量化
速度逐个解释执行快 30~100 倍
代码行数3 行1 行
出错机会下标越界、空数组、类型不匹配……NumPy 帮你兜住

为什么快这么多?

  1. NumPy 把数组打包成一块连续内存,CPU 缓存能高效读取;
  2. 运算在 C 层批量完成,能用 SIMD 指令一次算好几个;
  3. Python 每循环一次都要「查名字 → 取值 → 调函数 → 存回」,开销全花在调度上。

但别无脑向量化。 如果你只是想对 3 个数做点什么,写循环完全没问题。 向量化的收益随数据量增长——100 个数的时候差别不大,100 万的时候是天壤之别。

Q2:NumPy 的算术运算默认是逐元素执行的吗?#

答: 是的,默认全部逐元素(element-wise)。

a = np.array([1.0, 2.0, 3.0])
b = np.array([10.0, 20.0, 30.0])

print(a + b)    # [11. 22. 33.]   逐个加
print(a * b)    # [10. 40. 90.]   逐个乘

但 * 有个例外,必须记住:

print(a * 2)         # [2. 4. 6.]   ← 逐元素乘 2
print(a.shape)       # (3,)

而如果 a 是二维的,* 就变成矩阵乘法了!

m1 = np.array([[1., 2.], [3., 4.]])
m2 = np.array([[5., 6.], [7., 8.]])
print(m1 * m2)
[[ 5. 12.]
 [21. 32.]]

这是逐元素相乘(1*5, 2*6, 3*7, 4*8),不是矩阵乘法。

真正的矩阵乘法要用 @:

print(m1 @ m2)
[[19. 22.]
 [43. 50.]]

一句话记住:

  • * 和 + 一样,永远是逐元素;
  • 矩阵乘法用 @(或者 np.matmul)。

Q3:如何计算两个数组的差值、乘积和逐元素比值?#

答: 直接用运算符,全都是逐元素的。

a = np.array([120.0, 80.0, 200.0])
b = np.array([100.0, 90.0, 150.0])

print(a - b)      # 差值
print(a * b)      # 乘积
print(a / b)      # 逐元素比值
[ 20. -10.  50.]
[12000.  7200. 30000.]
[1.2        0.88888889 1.33333333]

两个数组形状不一样也能算——这就回到广播了:

print(a - np.array([10.0, 20.0]))    # (3,) 和 (2,) → 报错
ValueError: operands could not be broadcast together with shapes (3,) (2,)

但长度相同就行:

print(a - np.array([10.0, 20.0, 30.0]))
[110.  60. 170.]

想「每个数除以它的总和」这种比例?

print(a / a.sum())
[0.3 0.2 0.5]

结果自动加起来等于 1——这就是「占比」。

Q4:如何计算归一化差值?如何避免分母为零?#

答: 公式:(a - b) / (|a| + |b|)。

a = np.array([120.0, 80.0, 200.0])
b = np.array([100.0, 90.0, 150.0])

print((a - b) / (np.abs(a) + np.abs(b)))
[ 0.09090909 -0.05882353  0.14285714]

分母为零的两种情况:

  1. 两个数都是 0 → 0 / 0 → nan;
  2. 还有一个更隐蔽的:两个数接近 0(比如 1e-300)→ 结果变得巨大。
zero = np.array([0.0])
print((zero - zero) / (np.abs(zero) + np.abs(zero)))
[nan]

三种解决办法:

办法一:给分母加一个极小值(最常用)。

denom = np.abs(a) + np.abs(b) + 1e-9
print((a - b) / denom)

办法二:用 np.where 把分母为 0 的位置单独挑出来处理。

denom = np.abs(a) + np.abs(b)
denom = np.where(denom == 0, 1.0, denom)     # 把 0 换成 1
print((a - b) / denom)

办法三:用 np.divide 的 where 参数(最规范)。

result = np.zeros_like(a)
np.divide(a - b, np.abs(a) + np.abs(b),
          out=result, where=(np.abs(a) + np.abs(b)) != 0)
print(result)

初学阶段用办法一就够了。 1e-9 足够小,对正常数据没有任何影响,但能挡住 0 除 0。

Q5:如何使用 np.arctan2() 根据二维向量的分量计算角度?#

答: np.arctan2(y, x),返回弧度。

import numpy as np

points = np.array([
    [3.0, 4.0],
    [1.0, 0.0],
    [0.0, 2.0],
    [-1.0, -1.0],
])

angles = np.degrees(np.arctan2(points[:, 1], points[:, 0]))
print(angles)
[  53.13010235    0.           90.         -135.        ]

三个要点:

  1. 参数顺序是 (y, x),跟数学写法一致;
  2. 结果要套 np.degrees() 才变成角度(弧度 → 度);
  3. points[:, 1] 是 y、points[:, 0] 是 x,别搞反。

为什么不用 np.arctan(y / x)?

print(np.arctan2(2.0, 0.0))                             # 1.5707963267948966  ✓ 正好 90°
print(np.arctan(np.array([2.0]) / np.array([0.0])))     # [nan]  ✗ 除以 0

而且 arctan 分不出象限:arctan(-1/1) 和 arctan(1/-1) 都会得到 -45°, 但它们明明是不同方向。arctan2 用两个参数就能判断象限。

参数写反不会报错,但结果全错。

print(np.degrees(np.arctan2(points[:, 0], points[:, 1])))
[  36.86989765   90.            0.         -135.        ]

写完拿一个已知答案的点验一下(比如 (1, 0) 必须是 0°),能省掉几小时的调试。

Q6:sum()、mean()、min() 和 max() 如何通过 axis 参数指定计算方向?#

答: axis 就是「要消掉哪一维」。

import numpy as np

sales = np.array([
    [120.0, 135.0, 150.0, 165.0],
    [ 80.0,  92.0,  88.0, 105.0],
    [200.0, 210.0, 195.0, 230.0],
    [ 60.0,  72.0,  85.0,  78.0],
    [150.0, 145.0, 160.0, 175.0],
])

print(sales.sum(axis=0))     # 每列
print(sales.sum(axis=1))     # 每行
print(sales.sum())           # 全部
[610. 654. 678. 753.]
[570. 365. 835. 295. 630.]
2695.0

四个函数全都支持 axis:

print(sales.mean(axis=0))
print(sales.min(axis=0))
print(sales.max(axis=0))
[122.  130.8 135.6 150.6]
[60. 72. 85. 78.]
[200. 210. 195. 230.]

还有一些进阶的聚合函数也支持 axis:

print(np.median(sales, axis=0))    # 中位数
print(sales.std(axis=0))           # 标准差
print(sales.cumsum(axis=0))        # 累计和
[120. 135. 150. 165.]
[49.95998399 47.86397393 42.79532685 53.77211173]
[[120. 135. 150. 165.]
 [200. 227. 238. 270.]
 [400. 437. 433. 500.]
 [460. 509. 518. 578.]
 [610. 654. 678. 753.]]

要「位置」而不是「值」,用 arg 前缀的版本:

print(sales.argmax(axis=0))    # 每列最大值是第几行
print(sales.argmin(axis=1))    # 每行最小值是第几列
[2 2 2 2]
[0 0 2 0 1]

Q7:axis=0 和 axis=1 分别表示什么?#

答: axis=0 对每一列,axis=1 对每一行。

这个说法容易记反,因为「0」让人想到「第 0 行」。 正确的记法是:axis = 「要消掉(压缩掉)的那一维」。

sales = np.array([
    [120.0, 135.0, 150.0, 165.0],
    [ 80.0,  92.0,  88.0, 105.0],
    [200.0, 210.0, 195.0, 230.0],
    [ 60.0,  72.0,  85.0,  78.0],
    [150.0, 145.0, 160.0, 175.0],
])

axis=0: 消掉「第 0 维」(行),每一行都被压成一个数 → 每列一个数。

print(sales.sum(axis=0))
[610. 654. 678. 753.]

结果长度是 4 = 列数。

axis=1: 消掉「第 1 维」(列),每一列都被压成一个数 → 每行一个数。

print(sales.sum(axis=1))
[570. 365. 835. 295. 630.]

结果长度是 5 = 行数。

一个万能判断法: 先算结果该有几个数。

  • 我要「每个城市(5 个)一个数」→ 结果长度 5 → 行数 → 用 axis=1;
  • 我要「每个季度(4 个)一个数」→ 结果长度 4 → 列数 → 用 axis=0。

结果长度 = 被压缩掉的维度之外的维度数量。

Q8:sort() 和 argsort() 有什么区别?#

答: sort() 给你「排好序的数」,argsort() 给你「每个数排第几位」。

scores = np.array([80, 90, 75, 100, 60])

print(np.sort(scores))
[ 60  75  80  90 100]
print(np.argsort(scores))
[4 2 0 1 3]

argsort() 的结果怎么读?

  • 排好序的第 0 个数(60)原来是第 4 个 → scores[4] = 60 ✓
  • 排好序的第 1 个数(75)原来是第 2 个 → scores[2] = 75 ✓
  • 排好序的第 2 个数(80)原来是第 0 个 → scores[0] = 80 ✓
  • 排好序的第 3 个数(90)原来是第 1 个 → scores[1] = 90 ✓
  • 排好序的第 4 个数(100)原来是第 3 个 → scores[3] = 100 ✓

argsort 最大的用处:让「名字」跟着「数字」一起排。

names = np.array(["小明", "小红", "小刚", "小美", "小李"])

order = np.argsort(scores)[::-1]      # 从大到小的名次
print(names[order])
['小美' '小红' '小明' '小刚' '小李']

两个补充:

  1. np.sort() 不改原数组,它返回一个新数组;想原地排序用 arr.sort();
  2. 二维数组排序也要写 axis,不写会默认按最后一个轴排。
matrix = np.array([[3., 1.], [2., 4.]])
print(np.sort(matrix, axis=0))   # 每一列各自排
print(np.sort(matrix, axis=1))   # 每一行各自排
[[2. 1.]
 [3. 4.]]
[[1. 3.]
 [2. 4.]]

Q9:什么是向量的范数?如何使用 np.linalg.norm() 计算向量长度?#

答: 范数就是「向量的长度」。

最常见的是欧几里得范数(L2 范数),也就是两点之间的直线距离:

‖v‖ = √(v₁² + v₂² + ... + vₙ²)
import numpy as np

v = np.array([3.0, 4.0])
print(np.linalg.norm(v))        # 5.0

对二维数组算「每一行的长度」:

vectors = np.array([
    [3.0, 4.0],
    [1.0, 0.0],
    [6.0, 8.0],
])

print(np.linalg.norm(vectors, axis=1))
[ 5.  1. 10.]

axis=1 的意思还是那句老话:消掉列维,每一行压成一个数(它的长度)。

keepdims=True 保留结构,方便后面广播:

norms = np.linalg.norm(vectors, axis=1, keepdims=True)
print(norms.shape)     # (3, 1)
(3, 1)

其他常见的范数:

print(np.linalg.norm(v, ord=1))      # L1:绝对值之和 = 7.0
print(np.linalg.norm(v, ord=2))      # L2:默认,= 5.0
print(np.linalg.norm(v, ord=np.inf))  # 最大绝对值 = 4.0

实战里最常见的三种范数用途:

  • L2 —— 算欧氏距离(聚类、KNN、推荐系统里天天用);
  • L1 —— 「曼哈顿距离」,网格地图上的路径长度;
  • np.inf —— 「最大分量」,做归一化时常用(把所有数除以最大的那个)。

Q10:如何结合条件判断、广播机制和矩阵运算,实现基于范数的门控变换?#

答: 三步走:算范数 → 造开关 → 用 np.where 做分支。

import numpy as np

vectors = np.array([
    [3.0, 4.0],
    [1.0, 0.0],
    [6.0, 8.0],
])

# 第 1 步:算范数(keepdims=True 让形状保持 (3, 1),方便广播)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)

# 第 2 步:造开关(这就是「条件判断」的向量化版)
gate = norms > 3

# 第 3 步:np.where 做分支(两个分支都整块算好,再按开关挑)
result = np.where(gate, 2 * vectors - 1, vectors)
print(result)
[[ 5.  7.]
 [ 1.  0.]
 [11. 15.]]

三个部分各自对应什么:

步骤用到的 NumPy 能力
算范数np.linalg.norm(axis=1, keepdims=True)
造开关广播 + 比较运算(norms > 3)
做分支np.where(条件, A, B)

为什么这里三个都离不开?

  • 没有 keepdims=True,norms 是 (3,),跟 (3, 2) 的 vectors 广播不上;
  • 没有广播,你没法一次算出 3 个范数;
  • 没有 np.where,你就得写 for 循环逐个判断。

换一种门控规则试试(更贴近业务):

totals = np.array([570.0, 365.0, 835.0, 295.0, 630.0])

# 超过 500 万的重点城市给 1.5 倍系数,其余 1.0 倍
factors = np.where(totals > 500, 1.5, 1.0)
print(totals * factors)
[ 855.   365.  1252.5  295.   945. ]

这就是机器学习里常说的「门控(gating)」:用一个条件把数据分成两拨,分别处理。

这一节最该记住的一句话: 「条件判断」在 NumPy 里不是 if,而是 np.where(条件, A, B)。 一旦你接受这个转换思路,99% 的循环都能变成一行。

小结#

这一节的 10 个问题,其实都围绕**「怎么把循环翻译成一行」**:

  1. 向量化 = 一行数组表达式,快 30~100 倍,还不容易写错;
  2. 算术运算默认逐元素,矩阵乘法要用 @;
  3. 差值 / 乘积 / 比值 直接用 - * /,形状不同就靠广播;
  4. 归一化差值:(a-b)/(abs(a)+abs(b)),分母加 1e-9 防 0;
  5. np.arctan2(y, x) 算角度,记得 np.degrees() 转角度、参数别写反;
  6. 聚合函数 全都支持 axis;
  7. axis=0 对列、axis=1 对行,判断法:先想结果该有几个数;
  8. sort() 给值、argsort() 给名次,后者能让名字跟着数字排;
  9. np.linalg.norm(arr, axis=1) 算每行长度,keepdims=True 是广播的钥匙;
  10. 范数门控三步:算范数 → 造开关 → np.where 做分支。

下一章预告 最后一章我们把注意力放在**「数组还能怎么折腾」上:怎么提取每行的极值和它对应的名字、怎么用分位数做缩尾处理**(压住极端值)、怎么把连续数值量化成离散区间、怎么把一维序列切成一个个数据帧,还有 tile / repeat / diff / concatenate / corrcoef 这些数组实用工具——全是真实数据清洗里天天要用的手艺。

阅读 --