返回首页

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

第 4.1 节 核心知识点#

预计学习时长: 45 分钟

本节目标:

  • 说得清为什么数组运算比 for 循环快;
  • 会把「for 循环 + if 判断」翻译成一行数组表达式;
  • 会算归一化差值、用 np.arctan2() 构造角度特征;
  • 熟练使用 sum / mean / max / min 和它们的 axis 参数;
  • 分得清 sort() 和 argsort();
  • 会用 np.linalg.norm() 算向量长度,并用它做条件变换。

先搞清楚:向量化到底快在哪#

一句话:NumPy 把循环搬到了底层 C 代码里,还把数据打包成一块连续内存批量处理。

Python 的循环是这样的:

prices = [100.0, 200.0, 300.0]
for i in range(len(prices)):
    prices[i] = prices[i] * 0.8

每一步都要做三件事:从列表里取出元素 → 调用乘方 → 再塞回列表。 而且列表里的每个数字都是独立的 Python 对象,散落在内存各处,CPU 缓存根本用不上。

NumPy 的写法:

import numpy as np

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

一次都不循环。 底层做的是:把 3 个 double 打包在连续的 24 字节内存里 → 交给 C 层的 SIMD 指令 → 一次算 4 个(甚至 8 个)。

数据量越大,差距越夸张。 1000 个元素的数组,NumPy 通常比 Python 循环快 30~100 倍。

向量化真正的价值不只是「快」,还有「不容易写错」。

data = np.array([-3.0, 2.0, 0.0, 5.0])

# 循环写法:下标越界、空列表、类型不匹配……全都要自己操心
result = []
for x in data:
    result.append(x * 2 if x > 0 else 0)

# 向量化写法:一行,边界情况 NumPy 帮你处理
result = np.where(data > 0, data * 2, 0)

代码从 4 行变成 1 行,能出的 bug 直接少了一大半。

把循环翻译成向量化的三步#

不管多复杂的循环,都能按这三步翻译:

Python 循环里写的数组运算里对应什么
for x in arr:arr(整个数组一起算)
if x > 0: ... else: ...np.where(条件, A, B)
result.append(...)数组本身(不用 append)

下面每个知识点都会用这个思路来讲。

知识点 1:归一化差值(Normalized Difference)#

问题: 我有两组数,想比较它们「差多少」,但两组数的量级可能差很远。

比如两个模型预测的价格:一个在 100 附近,一个在 10000 附近。 直接算差值没意义(10000 - 9999 = 1,看起来「几乎一样」?其实是 0.01%)。

解法:除以「它们俩的绝对值之和」,把结果压到 -1 ~ 1 之间。

import numpy as np

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

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

怎么读这个数?

  • 0.0909 → a 比 b 大了约 9%;
  • -0.0588 → a 比 b 小了约 5.9%;
  • 0.1429 → a 比 b 大了约 14%。

注意分母的坑:如果两个数都是 0,会除以 0。

zero = np.array([0.0])
print((zero - zero) / (np.abs(zero) + np.abs(zero)))   # nan

安全的写法是给分母加一个极小值:

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

或者用 np.where 把 0 的位置单独挑出来处理。

这个公式在遥感、信号处理里特别常见,也叫「归一化差值指数」。

知识点 2:角度特征构造(Angle Features)#

问题: 我有一堆二维向量(点),想算出每个点的方向角——也就是「这个点从原点看过去,指向哪个方向」。

解法:np.arctan2(y, x)。

import numpy as np

points = np.array([
    [3.0, 4.0],     # 第 1 个点 (3, 4)
    [1.0, 0.0],     # 第 2 个点 (1, 0)
    [0.0, 2.0],     # 第 3 个点 (0, 2)
    [-1.0, -1.0],   # 第 4 个点 (-1, -1)
])

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

四个点分别对应:

点角度解释
(3, 4)53.13°在第一象限,斜率 4/3
(1, 0)0°正右方
(0, 2)90°正上方
(-1, -1)-135°第三象限

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

因为 arctan 只能处理 -90° ~ 90°,而且 x = 0 会直接除以 0。 arctan2(y, x) 同时用了两个参数,它能正确判断象限、也不会除以 0。

arctan2 的参数顺序是 (y, x),不是 (x, y)! 记反了角度就整个不对,而且不会报错——这种 bug 最难查。 口诀:先 y 后 x,跟数学里 atan2(y, x) 的写法完全一致。

为什么先套一层 np.degrees()? arctan2 返回的是弧度(-π ~ π),而人习惯看角度(-180° ~ 180°)。 np.degrees() 就是「弧度转角度」。

print(np.arctan2(points[:, 1], points[:, 0]))    # 弧度
[ 0.92729522  0.          1.57079633 -2.35619449]

顺带一提:这个「取某一列」的写法叫「列切片」。 points[:, 1] 是「第 1 列」,points[:, 0] 是「第 0 列」。 把它们当成 y 和 x 传进去,就得到了每个点的方向。 这个 [:, 1] 的技巧在机器学习的数据处理里天天用,一定要记住。

知识点 3:聚合函数(Aggregation Functions)#

问题: 一张 5 行 4 列的表,我要知道每列的合计、每行的平均分、最大值在哪……

解法:用 sum / mean / max / min,配 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],
])

按列聚合(axis=0):

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

每一列的合计: Q1 = 610,Q2 = 654,Q3 = 678,Q4 = 753。

按行聚合(axis=1):

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

每个城市的总销售额。

axis 的含义,一句话记住:

写法意思结果长度
arr.sum(axis=0)对每一列求和列数(4)
arr.sum(axis=1)对每一行求和行数(5)
arr.sum()全部加起来1 个数

「压缩掉哪一维,结果就剩几个数。」

axis=0 最容易搞混——因为「0」让人想到「第 0 行」, 但它其实是「沿着第 0 维压缩」,也就是对每一列做运算。 记法:把 axis 想成「要消掉的那一维」。

四个最常用的聚合函数:

print(sales.mean(axis=0))    # 每列的平均
print(sales.max(axis=0))     # 每列的最大
print(sales.min(axis=0))     # 每列的最小
print(sales.std(axis=0))     # 每列的标准差
[122.  130.8 135.6 150.6]
[200. 210. 195. 230.]
[60. 72. 85. 78.]
[49.95998399 47.86397393 42.79532685 53.77211173]

还要「位置」而不是「值」?用 argmax / argmin:

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

读法: 第 0 行最大值在第 3 个位置(165),最小值在第 0 个位置(120)。

「值」和「位置」配对使用,是数据处理里最常见的套路:

city_names = np.array(["北京", "上海", "广州", "深圳", "成都"])
totals = sales.sum(axis=1)
print(city_names[totals.argmax()])    # 广州

先用 argmax 找到「第几个」,再用它去索引名字数组—— 这就是从数字里「查出名字」的标准做法。

还有几个进阶的:

print(np.median(sales, axis=0))    # 中位数(不受极端值影响)
print(sales.cumsum(axis=0))        # 累计和
[120. 135. 150. 165.]
[[120. 135. 150. 165.]
 [200. 227. 238. 270.]
 [400. 437. 433. 500.]
 [460. 509. 518. 578.]
 [610. 654. 678. 753.]]

知识点 4:排序与排序索引(Sort and Argsort)#

问题: 我想把一组数从小到大排好;或者——我只想知道「第几大的数排在第几位」。

排序用 sort():

import numpy as np

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

print(np.sort(scores))
[ 60  75  80  90 100]

注意:np.sort() 返回的是一个新数组,原数组不变。

想知道「排序后每个位置原来是第几个」?用 argsort():

print(np.argsort(scores))
[4 2 0 1 3]

这个数组的意思是:

  • 排好序的第 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 ✓

sort 和 argsort 的区别,一句话:

  • sort() 给你「排好序的数」;
  • argsort() 给你「每个数排在第几位」。

要数值用 sort,要「谁是谁」用 argsort。

argsort 的经典用途:按销量排城市名。

import numpy as np

city_names = np.array(["北京", "上海", "广州", "深圳", "成都"])
totals = np.array([570.0, 365.0, 835.0, 295.0, 630.0])

order = np.argsort(totals)          # 从小到大的名次
print(city_names[order])             # 按名次排好的城市
['深圳' '上海' '北京' '成都' '广州']

要「从大到小」?两种写法:

print(np.argsort(totals)[::-1])      # 名次反过来
print(city_names[np.argsort(totals)[::-1]])
[2 4 0 1 3]
['广州' '成都' '北京' '上海' '深圳']

二维数组也能排序:

sales = np.array([
    [120.0, 135.0, 150.0, 165.0],
    [ 80.0,  92.0,  88.0, 105.0],
])
print(np.sort(sales, axis=0))        # 每一列各自排序
print(np.sort(sales, axis=1))        # 每一行各自排序
[[ 80.  92.  88. 105.]
 [120. 135. 150. 165.]]
[[120. 135. 150. 165.]
 [ 80.  88.  92. 105.]]

axis 一定要写清楚。 np.sort(sales) 不写 axis 会默认按最后一个轴排序, 结果可能不是你想要的。养成习惯,永远显式写 axis。

知识点 5:基于范数门控的线性变换(Norm-Gated Linear Transform)#

问题: 我想对一组向量做线性变换(比如 y = 2x - 1), 但只对「长度足够大」的向量做——太短的向量(比如接近零向量)变换出来没意义。

这就是「门控(gating)」的思路:用一个条件决定「做不做」。

第一步:算范数(向量长度)。

import numpy as np

vectors = np.array([
    [3.0, 4.0],     # 长度 5
    [1.0, 0.0],     # 长度 1
    [6.0, 8.0],     # 长度 10
])

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

np.linalg.norm(arr, axis=1) 的意思是「算每一行的长度」—— 也就是经典公式 √(x² + y²)。

第二步:用范数算出「要不要做」的开关。

norms = np.linalg.norm(vectors, axis=1, keepdims=True)   # (3, 1)
print(norms)
[[ 5.]
 [ 1.]
 [10.]]

keepdims=True 的作用:保留原来的维度结构。 不用它的话,norms 是 (3,)(三个数挤在一起), 用它就是 (3, 1)(每个数占一行),这样才能跟原数组 (3, 2) 广播。

keepdims=True 是广播的万能钥匙。 它的作用就是「把被压缩的维度保留成 1」,这样形状永远能对上。 记不住的话,每次广播报错就加上它试试。

第三步:用 np.where 做「满足条件才变换」。

gate = norms > 3          # (3, 1) 的 True/False 表
print(gate)
[[ True]
 [False]
 [ True]]
transformed = np.where(gate, 2 * vectors - 1, vectors)
print(transformed)
[[ 5.  7.]
 [ 1.  0.]
 [11. 15.]]

读法:

  • 第 0 行长度 5 > 3 → 做了变换:2*[3,4] - 1 = [5, 7] ✓
  • 第 1 行长度 1 < 3 → 保持原样:[1, 0] ✓
  • 第 2 行长度 10 > 3 → 做了变换:2*[6,8] - 1 = [11, 15] ✓

np.where(条件, A, B) 是向量化版的 if...else。

# 循环版
rows = []
for row in vectors:
    if np.linalg.norm(row) > 3:
        rows.append(2 * row - 1)
    else:
        rows.append(row)

# 向量化版
result = np.where(norms > 3, 2 * vectors - 1, vectors)

三个参数分别是:什么时候选 A、选什么 A、否则选什么 B。 这是本章最该记住的函数,因为它能表达几乎所有的条件逻辑。

换个更贴近业务的例子:按销售额给城市分级。

import numpy as np

city_names = np.array(["北京", "上海", "广州", "深圳", "成都"])
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(factors)
print(totals * factors)
[1.5 1.  1.5 1.  1.5]
[ 855.   365.  1252.5  295.   945. ]

这就是「门控」在业务里的样子:用一个条件,把数据分成两拨,分别处理。

本节小结#

  1. 向量化 = 把循环搬到 C 层,快几十倍,代码还短;
  2. 循环翻译三步:数组代替 for、np.where 代替 if、不用 append;
  3. 归一化差值:(a - b) / (|a| + |b|),分母记得防 0;
  4. 角度特征:np.degrees(np.arctan2(y, x)),参数顺序是 (y, x);
  5. 聚合函数:sum / mean / max / min / std / median / cumsum, 加 argmax / argmin 拿位置;
  6. axis=0 对每一列,axis=1 对每一行,「压缩掉哪一维,结果就剩几个数」;
  7. sort() 给排序后的值,argsort() 给每个值的名次;
  8. np.linalg.norm(arr, axis=1) 算每行长度,配 keepdims=True 方便广播;
  9. np.where(条件, A, B) 是向量化版的 if-else,配合范数就能做「条件变换」。

下一节预告 这一章的概念有点多,第 4.2 节会用 5 道动手题把它们串起来练一遍——从「算归一化差值」到「用范数决定要不要做线性变换」。练完你就能把本章的技巧用到自己的数据上了。

阅读 --