第 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. ]这就是「门控」在业务里的样子:用一个条件,把数据分成两拨,分别处理。
本节小结#
- 向量化 = 把循环搬到 C 层,快几十倍,代码还短;
- 循环翻译三步:数组代替
for、np.where代替if、不用append; - 归一化差值:
(a - b) / (|a| + |b|),分母记得防 0; - 角度特征:
np.degrees(np.arctan2(y, x)),参数顺序是(y, x); - 聚合函数:
sum/mean/max/min/std/median/cumsum, 加argmax/argmin拿位置; axis=0对每一列,axis=1对每一行,「压缩掉哪一维,结果就剩几个数」;sort()给排序后的值,argsort()给每个值的名次;np.linalg.norm(arr, axis=1)算每行长度,配keepdims=True方便广播;np.where(条件, A, B)是向量化版的 if-else,配合范数就能做「条件变换」。
下一节预告 这一章的概念有点多,第 4.2 节会用 5 道动手题把它们串起来练一遍——从「算归一化差值」到「用范数决定要不要做线性变换」。练完你就能把本章的技巧用到自己的数据上了。