机器学习中的范数:从数学符号到调参杠杆
1. 为什么 norms 不是“数学考试题”,而是你调参时最常摸到的那根杠杆?
刚入行做机器学习的时候,我总把 norm 当成线性代数课本里一个带双竖线的符号——写在公式里显得很酷,但实际跑模型时,它好像只出现在损失函数那一行小字里,被我随手 copy-paste 过去,从没想过它到底在后台干了什么。直到有次我训练一个简单的房价预测模型,用 MSE(均方误差)作为损失,结果模型在几个异常高价房上疯狂震荡,验证集 loss 上蹿下跳,像坐过山车。我调学习率、改 batch size、加 dropout,折腾三天毫无起色。最后灵机一动,把损失函数换成了 MAE(平均绝对误差),也就是 $L^1$ norm 的均值形式,模型立刻稳如老狗,收敛速度还快了一倍。那一刻我才真正明白:norm 不是装饰性的数学符号,它是你亲手拧在模型身上的 第一道调节旋钮 ,它直接决定了你的模型对“错误”的敏感方式、对“异常值”的容忍底线,甚至影响梯度下降时每一步该迈多大、往哪偏。
这正是本篇要讲透的核心:norm 是机器学习中
最基础、最高频、也最容易被低估的建模语言
。它不是抽象理论,而是你每天写
loss = torch.nn.MSELoss()
或
model.compile(loss='mse')
时,背后那个默默执行计算的引擎。关键词“norm”、“machine learning”、“linear algebra”、“Python”、“Numpy”——它们共同指向一个事实:
真正的 ML 工程师,必须能用手写出 norm 的计算过程,能看懂它的导数长什么样,能说出为什么在推荐系统里常用 $L^2$ 正则,在金融风控里倾向 $L^1$ 正则,而不是只会调库里的一个参数名。
本文不讲定义复述,不堆公式推导,而是带你回到代码现场,用 Python/Numpy 一行行敲出 norm 的本质,画出它的几何形状,算出它的梯度方向,最终让你在下次调试模型时,能自信地说:“这个 loss 不稳?换 $L^1$ 试试,它对离群点更钝感。” 或者 “这个权重太稀疏?加个 $L^2$ 正则压一压,别让某个特征权重一家独大。” 这就是 norm 的真实力量——它把高维向量的“整体表现”,压缩成一个可比较、可求导、可优化的标量数字,而这个数字,就是你和模型对话的语言。
2. Norm 的底层逻辑:为什么我们非得把一堆数字“压扁”成一个数?
2.1 从“一堆误差”到“一个分数”:建模的本质需求
想象你正在开发一个短视频推荐算法,目标是预测用户对某条视频的观看时长(单位:秒)。你训练好模型,拿 1000 条新视频数据做测试,得到 1000 个预测值和 1000 个真实值。两者相减,你手头立刻有了一个长度为 1000 的误差向量
error = [e1, e2, ..., e1000]
。现在问题来了:这个向量本身无法告诉你模型好坏。
e1 = -5
(预测少了 5 秒)和
e500 = +120
(预测多了整整 2 分钟)混在一起,你没法说“总体还不错”或者“差得太离谱”。你需要一个
单一、可量化、可排序的指标
,来给这个 1000 维的误差打个分。
这就是 norm 的诞生场景。它不是一个凭空造出来的数学游戏,而是为了解决一个极其朴素的工程问题: 如何把高维空间里的一团乱麻(误差、权重、梯度),用一个数字概括其“大小”或“强度”? 这个数字,就是模型的“成绩单”。
提示:Norm 的核心功能不是“计算”,而是“降维映射”。它把一个 n 维向量 $\bs{x} \in \mathbb{R}^n$,通过一个满足特定公理的函数 $f: \mathbb{R}^n \to \mathbb{R}_{\geq 0}$,映射到一个非负实数。这个映射必须满足三条铁律:非负性($f(\bs{x}) \geq 0$)、零向量唯一性($f(\bs{x}) = 0$ 当且仅当 $\bs{x} = \bs{0}$)、齐次性($f(k\bs{x}) = |k| f(\bs{x})$)和三角不等式($f(\bs{x}+\bs{y}) \leq f(\bs{x}) + f(\bs{y})$)。这四条规则共同保证了这个“单分数”能真实反映向量的“尺度感”,不会因为坐标系旋转或缩放而失真。
2.2 为什么是“长度”?——几何直觉的锚点
初学者最容易卡在“norm 就是长度”这个说法上。长度?向量在纸上画出来才叫长度,一个包含 100 万用户点击行为的特征向量,它的“长度”是什么?这里的关键在于 类比迁移 。我们从最熟悉的二维平面出发:
- 向量 $\bs{u} = [3, 4]$,从原点 $(0,0)$ 指向点 $(3,4)$。它的欧氏长度(Pythagorean theorem)是 $\sqrt{3^2 + 4^2} = 5$。
- 这个 5,直观上就是尺子量出来的距离。它代表了从起点到终点的“最短路径”。
现在,把这个直觉推广到 n 维:
- 向量 $\bs{x} = [x_1, x_2, ..., x_n]$,可以看作是从 n 维空间原点 $(0,0,...,0)$ 指向点 $(x_1,x_2,...,x_n)$ 的一条“超空间对角线”。
- 它的 $L^2$ norm $\norm{\bs{x}}_2 = \sqrt{x_1^2 + x_2^2 + ... + x_n^2}$,就是这条对角线在 n 维空间中的“欧氏长度”。
这个推广之所以成立,是因为 $L^2$ norm 完美继承了二维长度的所有几何性质:它满足三角不等式(两点间直线最短),它在坐标轴旋转下保持不变(旋转不改变距离),它对每个维度的贡献是平方加权(强调大偏差)。所以,当你看到
np.linalg.norm(weights)
时,你看到的不是一个抽象数字,而是模型所有权重参数在“参数空间”里构成的那个点,距离原点有多远。这个距离越小,说明模型越“简单”、越“保守”;距离越大,说明模型越“复杂”、越“激进”。这就是正则化(Regularization)的思想源头——我们不希望模型的“参数长度”失控。
2.3 为什么不止一种 norm?——不同任务需要不同的“尺子”
如果 norm 只是“长度”,那为什么还有 $L^1$、$L^2$、$L^\infty$ 这么多种?答案很简单: 世界上没有一把万能尺子,只有最适合当前任务的尺子。 它们测量的是同一个向量,但关注的焦点完全不同。
-
$L^1$ norm(曼哈顿距离) :$\norm{\bs{x}} 1 = \sum {i=1}^{n} |x_i|$。它把向量各维度的绝对值“累加”起来。想象你在纽约曼哈顿街区开车,不能斜穿楼群,只能沿横纵街道走,从 $(0,0)$ 到 $(3,4)$,你必须走 $|3| + |4| = 7$ 个街区。$L^1$ 关注的是 所有维度偏差的总和 ,对每一个小错误都“锱铢必较”,但它对单个巨大错误的惩罚,和对多个中等错误的惩罚,是线性叠加的。这使得它在特征选择(Feature Selection)中大放异彩——因为它的解倾向于产生大量零权重(Lasso 回归),自动帮你剔除不重要的特征。
-
$L^2$ norm(欧氏距离) :$\norm{\bs{x}} 2 = \sqrt{\sum {i=1}^{n} x_i^2}$。它把各维度偏差“平方后求和再开方”。还是 $(0,0)$ 到 $(3,4)$,你走直线,距离是 5。$L^2$ 关注的是 偏差的“能量”或“强度” ,因为平方操作会急剧放大大的偏差($10^2 = 100$,而 $1^2 = 1$)。这使得它对离群点(outlier)极其敏感。在回归任务中,一个预测错 100 秒的样本,其平方误差是 10000,而 10 个预测错 10 秒的样本,总平方误差才 1000。$L^2$ 会不惜一切代价去修正那个 100 秒的错误,因为它“太刺眼”。这也是为什么 MSE 损失函数如此普遍——它强迫模型优先保证大多数样本的精度,对极少数坏样本“痛下杀手”。
-
$L^\infty$ norm(切比雪夫距离) :$\norm{\bs{x}}_\infty = \max_i |x_i|$。它只关心向量中 绝对值最大的那个元素 。从 $(0,0)$ 到 $(3,4)$,你只看最大的那个坐标变化,即 4。$L^\infty$ 关注的是 最坏情况下的表现 。在鲁棒控制(Robust Control)或对抗样本防御中,我们不关心平均误差,只关心模型在最恶劣输入下的最大输出偏差,这时 $L^\infty$ 就是天然的度量工具。
选择哪种 norm,本质上是在选择你希望模型优化的 目标函数的哲学 。是追求整体平滑($L^2$),还是追求稀疏简洁($L^1$),还是追求最坏情况可控($L^\infty$)?这个问题没有标准答案,只有根据业务场景的权衡。
3. 实操拆解:用 Python/Numpy 亲手“捏”出每一个 norm
3.1 环境与工具链:为什么是 Numpy,而不是纯 Python?
在开始写代码前,我们必须明确一个前提:
Norm 的计算,99% 的场景都发生在向量或矩阵上,而 Numpy 就是为这种批量数值计算而生的。
如果你用纯 Python 的
for
循环去算一个百万维向量的 $L^2$ norm,那等待你的将是漫长的 CPU 煎熬和内存爆炸。Numpy 的
np.linalg.norm()
函数,其底层是高度优化的 C/Fortran 代码,它利用了 CPU 的 SIMD(单指令多数据)指令集,可以一次性对数百个浮点数进行平方、加法运算,速度比纯 Python 快上百倍。
import numpy as np
import time
# 构造一个 100 万维的随机向量
large_vector = np.random.randn(1000000)
# 方法1:纯 Python for 循环 (极度不推荐!)
start = time.time()
sum_sq = 0
for x in large_vector:
sum_sq += x ** 2
l2_python = np.sqrt(sum_sq)
time_python = time.time() - start
# 方法2:Numpy 向量化 (推荐!)
start = time.time()
l2_numpy = np.linalg.norm(large_vector, ord=2)
time_numpy = time.time() - start
print(f"Pure Python time: {time_python:.4f}s")
print(f"Numpy time: {time_numpy:.4f}s")
print(f"Speedup: {time_python/time_numpy:.1f}x")
# 输出示例:Pure Python time: 0.284s, Numpy time: 0.002s, Speedup: 142.0x
这个速度差异,就是工业级 ML 和玩具级 demo 的分水岭。所以,我们的所有实操,都将基于 Numpy 的向量化操作展开。它不仅是工具,更是思维方式—— 永远思考“如何让整个数组一起运算”,而不是“如何遍历每一个元素”。
3.2 从零实现 $L^1$、$L^2$、$L^\infty$ norm:理解公式背后的代码逻辑
让我们抛开
np.linalg.norm()
,亲手用最基础的 Numpy 操作,把三个核心 norm “搭”出来。这不仅能加深理解,更能让你在调试时,一眼看穿库函数的内部逻辑。
def manual_l1_norm(x):
"""手动实现 L1 norm: sum of absolute values"""
return np.sum(np.abs(x))
def manual_l2_norm(x):
"""手动实现 L2 norm: sqrt of sum of squares"""
return np.sqrt(np.sum(x ** 2))
def manual_linf_norm(x):
"""手动实现 L-infinity norm: max of absolute values"""
return np.max(np.abs(x))
# 测试向量
test_vec = np.array([3, -4, 0, 5])
print(f"Test vector: {test_vec}")
print(f"L1 norm (manual): {manual_l1_norm(test_vec)}") # 3+4+0+5 = 12
print(f"L2 norm (manual): {manual_l2_norm(test_vec)}") # sqrt(9+16+0+25) = sqrt(50) ≈ 7.07
print(f"L∞ norm (manual): {manual_linf_norm(test_vec)}") # max(3,4,0,5) = 5
这段代码清晰地揭示了每个 norm 的“配方”:
-
$L^1$
:
np.abs(x)把所有负数变正,np.sum()把它们全加起来。简单、直接、线性。 -
$L^2$
:
x ** 2对每个元素平方(自动处理负数),np.sum()求和,np.sqrt()开方。关键在于平方操作,它赋予了大值以不成比例的权重。 -
$L^\infty$
:
np.abs(x)取绝对值,np.max()找出最大值。它完全忽略了其他所有维度的信息,只盯着那个“最出风头”的元素。
注意:
np.linalg.norm(x, ord=1)等价于manual_l1_norm(x),ord=2等价于manual_l2_norm(x),ord=np.inf等价于manual_linf_norm(x)。ord参数就是“order”的缩写,对应公式中的 $p$ 值。
3.3 $L^p$ norm 的通用实现:探索 p 值的魔法
前面我们只看了 $p=1, 2, \infty$ 这三个特例。但 norm 的家族远不止于此。$L^p$ norm 的通用公式是 $\norm{\bs{x}}_p = (\sum_i |x_i|^p)^{1/p}$。
p
就是那个可以调节的“锐度旋钮”。让我们写一个通用函数,并可视化不同
p
值对同一个向量的影响:
def lp_norm(x, p):
"""通用 Lp norm 实现"""
if p == np.inf:
return np.max(np.abs(x))
else:
return (np.sum(np.abs(x) ** p)) ** (1/p)
# 创建一个二维向量,方便可视化
x = np.linspace(-5, 5, 100)
y = np.linspace(-5, 5, 100)
X, Y = np.meshgrid(x, y)
Z = np.zeros_like(X)
# 计算不同 p 值下的 norm 值(在每个网格点上)
p_values = [0.5, 1, 2, 5, 10]
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
axes = axes.flatten()
for i, p in enumerate(p_values):
# 对每个 (X,Y) 点,计算向量 [X,Y] 的 Lp norm
Z = (np.abs(X) ** p + np.abs(Y) ** p) ** (1/p)
im = axes[i].contourf(X, Y, Z, levels=20, cmap='viridis')
axes[i].set_title(f'L{p} norm (p={p})', fontsize=14)
axes[i].set_aspect('equal')
# 第六个图:展示 p->∞ 的极限(即 L∞ norm)
Z_inf = np.maximum(np.abs(X), np.abs(Y))
im = axes[5].contourf(X, Y, Z_inf, levels=20, cmap='viridis')
axes[5].set_title(r'$L^\infty$ norm (p→∞)', fontsize=14)
axes[5].set_aspect('equal')
plt.colorbar(im, ax=axes[5], shrink=0.8)
plt.tight_layout()
plt.show()
这张图是理解
p
值意义的钥匙:
- p=0.5(左上) :轮廓线非常“尖锐”,几乎只沿着坐标轴延伸。这意味着它极度偏好“稀疏”解——向量中只有一个维度非零,其他都为零。这在某些极端稀疏编码中会出现。
- p=1(中上) :轮廓是完美的菱形(diamond)。这是 $L^1$ 的标志性形状,它鼓励解落在坐标轴上,从而产生稀疏性。
- p=2(右上) :轮廓是完美的圆形。这是 $L^2$ 的标志,它对所有方向一视同仁,没有偏好,解是“平滑”的。
-
p=5, 10(左下、中下)
:轮廓从圆形逐渐“膨胀”成一个接近正方形的形状。
p越大,轮廓越“方”,意味着它越来越接近 $L^\infty$,即只关心最大值。 - p=∞(右下) :轮廓是完美的正方形。这正是 $L^\infty$ norm 的几何表示——只要向量的最大绝对值相同,它就认为“长度”一样。
这个可视化告诉我们:
p
值不是随便选的,它直接塑造了你的优化目标的“地形图”。
选
p=1
,你就是在一片菱形山谷里找最低点,最低点必然在棱上(稀疏);选
p=2
,你就是在一片圆形盆地里找最低点,最低点可能在任何地方(稠密);选
p=∞
,你就是在一座正方形高原上找最低点,最低点只取决于最高那座山峰(最坏情况)。
3.4 矩阵 norm:Frobenius norm 是如何工作的?
当 norm 的对象从向量升级到矩阵时,事情并没有变得复杂,只是多了一层“展平”(flattening)的操作。最常见的矩阵 norm 是 Frobenius norm,它被定义为矩阵所有元素的平方和再开方:$\norm{\bs{A}} F = \sqrt{\sum {i,j} a_{ij}^2}$。这本质上就是把矩阵 $\bs{A} \in \mathbb{R}^{m \times n}$ 当作一个长度为 $m \times n$ 的向量,然后计算它的 $L^2$ norm。
# 创建一个 3x2 的矩阵
A = np.array([[1, 2],
[6, 4],
[3, 2]])
print("Matrix A:")
print(A)
print(f"Shape: {A.shape}")
# 方法1:手动展平并计算 L2 norm
A_flat = A.flatten() # 或 A.ravel()
frob_manual = np.sqrt(np.sum(A_flat ** 2))
print(f"Frobenius norm (manual): {frob_manual:.6f}")
# 方法2:使用 np.linalg.norm
frob_builtin = np.linalg.norm(A, ord='fro')
print(f"Frobenius norm (builtin): {frob_builtin:.6f}")
# 方法3:利用迹(trace)的性质:||A||_F^2 = trace(A^T A)
A_T_A = A.T @ A
frob_trace = np.sqrt(np.trace(A_T_A))
print(f"Frobenius norm (via trace): {frob_trace:.6f}")
# 验证三者相等
assert np.allclose(frob_manual, frob_builtin) and np.allclose(frob_builtin, frob_trace)
print("All methods agree!")
Frobenius norm 在深度学习中无处不在:
-
权重衰减(Weight Decay)
:
torch.nn.L1Loss或torch.nn.MSELoss的正则项,通常就是lambda * torch.norm(weight, p=2),即对权重矩阵施加 Frobenius norm 约束,防止其过大。 -
模型压缩
:在低秩近似(Low-rank Approximation)中,我们寻找一个秩为
r的矩阵 $\bs{B}$,使其 Frobenius norm 距离 $\norm{\bs{A} - \bs{B}}_F$ 最小。这就是著名的 SVD(奇异值分解)的应用场景。
4. 核心价值:Norm 如何驱动模型训练——从损失函数到梯度下降
4.1 Norm 作为损失函数:MSE 与 MAE 的实战对比
Norm 最直接的应用,就是作为损失函数(Loss Function)。它把模型的预测误差向量 $\bs{e} = \bs{y}
{true} - \bs{y}
{pred}$ 映射成一个标量,这个标量就是我们要最小化的“目标”。
MSE
(均方误差)和
MAE
(平均绝对误差)就是两个最经典的例子。
# 模拟一个简单的回归问题:预测房屋价格
np.random.seed(42)
n_samples = 100
X = np.random.randn(n_samples, 1) * 10 # 特征:某种综合评分
y_true = 2 * X.flatten() + 10 + np.random.randn(n_samples) * 2 # 真实价格:线性关系 + 噪声
# 添加一个明显的离群点(outlier)
y_true[0] += 50 # 第一个样本的价格被严重高估
# 计算 MSE 和 MAE 损失
def mse_loss(y_pred, y_true):
error = y_true - y_pred
return np.mean(error ** 2) # 即 (1/n) * ||error||_2^2
def mae_loss(y_pred, y_true):
error = y_true - y_pred
return np.mean(np.abs(error)) # 即 (1/n) * ||error||_1
# 假设我们有一个非常差的初始预测(全部预测为 0)
y_pred_bad = np.zeros_like(y_true)
mse_bad = mse_loss(y_pred_bad, y_true)
mae_bad = mae_loss(y_pred_bad, y_true)
print(f"Initial bad prediction (all zeros):")
print(f" MSE Loss: {mse_bad:.2f}")
print(f" MAE Loss: {mae_bad:.2f}")
# 现在,假设我们有一个不错的预测(接近真实值)
y_pred_good = y_true.copy() - np.random.randn(n_samples) * 0.5
mse_good = mse_loss(y_pred_good, y_true)
mae_good = mae_loss(y_pred_good, y_true)
print(f"\nGood prediction (slight noise):")
print(f" MSE Loss: {mse_good:.2f}")
print(f" MAE Loss: {mae_good:.2f}")
# 关键:计算离群点对两种损失的贡献占比
outlier_error = y_true[0] - y_pred_bad[0] # 离群点的误差
outlier_mse_contribution = (outlier_error ** 2) / (n_samples * mse_bad)
outlier_mae_contribution = np.abs(outlier_error) / (n_samples * mae_bad)
print(f"\nContribution of the outlier to total loss:")
print(f" MSE contribution: {outlier_mse_contribution*100:.1f}%")
print(f" MAE contribution: {outlier_mae_contribution*100:.1f}%")
运行结果会清晰地显示:
- MSE Loss :离群点的贡献占比可能高达 30%-40%!因为它的误差是 50,平方后是 2500,而其他 99 个样本的误差平方和可能只有 500。MSE 为了降低这 2500,会不惜牺牲其他所有样本的精度。
- MAE Loss :离群点的贡献占比可能只有 5%-10%。因为它的绝对误差是 50,而其他样本的绝对误差总和可能是 1000。MAE 更关注“大多数”样本的平均表现。
这就是为什么在存在已知离群点的业务场景(如电商交易额预测,偶尔会有天价订单),工程师会首选
MAE
或
Huber Loss
(一种 MSE 和 MAE 的混合体);而在追求整体拟合精度、且数据质量较高的场景(如图像像素重建),
MSE
依然是黄金标准。
4.2 Norm 的导数:梯度下降的“燃料”从何而来?
损失函数的价值,不仅在于衡量好坏,更在于它能提供 梯度(Gradient) ——即损失函数关于模型参数的偏导数。梯度指明了参数应该朝哪个方向、以多大步长调整,才能让损失下降。而 norm 的导数,恰恰是这个过程的核心动力源。
让我们聚焦于最常用的
Squared $L^2$ norm
,即 $\norm{\bs{w}}_2^2 = \bs{w}^\top \bs{w}$。它之所以被广泛用于正则项(如
weight_decay
),一个极其关键的原因就是它的导数
极其简单、高效、且不耦合
。
# 定义一个权重向量 w
w = np.array([1.0, 2.0, 3.0, 4.0])
print(f"Weight vector w: {w}")
# 计算 squared L2 norm
squared_l2 = np.sum(w ** 2)
print(f"Squared L2 norm: {squared_l2}")
# 计算其关于 w 的梯度(导数)
# 根据微积分:d/dw_i (w_i^2) = 2*w_i
gradient_squred_l2 = 2 * w
print(f"Gradient of squared L2 norm: {gradient_squred_l2}")
# 对比:计算原始 L2 norm 的梯度
l2_norm = np.sqrt(squared_l2)
# d/dw_i (sqrt(w1^2 + w2^2 + ...)) = w_i / sqrt(w1^2 + w2^2 + ...)
gradient_l2 = w / l2_norm
print(f"Gradient of L2 norm: {gradient_l2}")
# 关键区别:squared L2 的梯度是 2*w,每个分量只依赖于自身
# 而 L2 norm 的梯度是 w / ||w||,每个分量都依赖于所有 w_j 的值(通过 ||w||)
# 这意味着计算 L2 norm 的梯度需要先算一次范数,再做除法,计算量更大,且在 w 接近零时不稳定(除零风险)
这个对比揭示了
squared L2
的巨大优势:
-
计算效率高
:
2*w是一个简单的标量乘法,Numpy 可以瞬间完成。 -
梯度解耦
:第
i个权重的更新,只由它自己决定,与其他权重无关。这使得优化过程更稳定、更可预测。 -
数值稳定性好
:没有除法,不存在
w接近零时的奇点问题。
在 PyTorch 或 TensorFlow 中,当你设置
weight_decay=1e-4
时,框架内部做的就是:
loss_total = loss_data + weight_decay * (1/2) * torch.norm(weight, p=2)**2
注意那个
(1/2)
,它就是为了抵消导数中的
2
,让最终的梯度项变成
weight_decay * weight
,形式更简洁。
4.3 Dot Product 与 Norm 的深层联系:余弦相似度的由来
Norm 的另一个隐藏技能,是它与点积(Dot Product)的深刻绑定。公式 $\bs{x}^\top \bs{y} = \norm{\bs{x}}_2 \cdot \norm{\bs{y}}_2 \cdot \cos\theta$ 揭示了点积的几何本质:它等于两个向量长度的乘积,再乘以它们夹角的余弦值。这意味着,如果你只关心两个向量的“方向”是否一致,而不关心它们的“长度”(即大小),那么你可以将点积归一化,得到 余弦相似度(Cosine Similarity) :
$$\text{cosine_similarity}(\bs{x}, \bs{y}) = \frac{\bs{x}^\top \bs{y}}{\norm{\bs{x}}_2 \cdot \norm{\bs{y}}_2} = \cos\theta$$
这个值永远在 $[-1, 1]$ 之间,
1
表示完全同向,
-1
表示完全反向,
0
表示正交(无关)。
# 计算两个文本向量的余弦相似度(简化版)
# 假设我们有两个词向量(例如,用 Word2Vec 训练得到)
vec_apple = np.array([0.8, 0.2, 0.1, 0.9]) # "apple" 的向量表示
vec_orange = np.array([0.7, 0.3, 0.2, 0.8]) # "orange" 的向量表示
vec_car = np.array([0.1, 0.9, 0.8, 0.2]) # "car" 的向量表示
def cosine_similarity(x, y):
return np.dot(x, y) / (np.linalg.norm(x) * np.linalg.norm(y))
print(f"Cosine similarity (apple, orange): {cosine_similarity(vec_apple, vec_orange):.4f}")
print(f"Cosine similarity (apple, car): {cosine_similarity(vec_apple, vec_car):.4f}")
# 结果会显示 apple 和 orange 的相似度远高于 apple 和 car,这符合语义直觉
在推荐系统、搜索引擎、NLP 任务中,余弦相似度是计算“相关性”的基石。它之所以有效,正是因为 $L^2$ norm 提供了一个完美的“长度”度量,让我们能把“方向”这个信息单独剥离出来。没有 norm,就没有余弦相似度;没有余弦相似度,现代信息检索和语义理解将寸步难行。
5. 实战避坑指南:那些只有踩过才知道的 norm 坑
5.1 坑一:“Norm” 不等于 “Normalize”——概念混淆的灾难
这是新手最容易掉进去的坑。
norm
(范数)是一个
标量值
,表示向量的“大小”。而
normalize
(归一化)是一个
操作
,指的是将一个向量除以它的 norm,从而得到一个长度为 1 的单位向量(unit vector)。
# 错误示范:以为 norm 就是归一化
v = np.array([3, 4])
norm_v = np.linalg.norm(v) # norm_v = 5.0,这是一个数字
# print(v / norm_v) # 这才是归一化!但很多人会误以为 norm_v 就是归一化后的向量
# 正确做法:显式地进行归一化
v_normalized = v / norm_v
print(f"Original vector: {v}")
print(f"Norm (scalar): {norm_v}")
print(f"Normalized vector: {v_normalized}")
print(f"Norm of normalized vector: {np.linalg.norm(v_normalized)}") # 应该是 1.0
# 为什么重要?在计算余弦相似度时,如果你忘了归一化,结果就完全错了
# cos_sim = dot(v1, v2) / (norm(v1) * norm(v2)),如果你传入的 v1, v2 已经是单位向量,
# 那么分母就是 1*1=1,cos_sim 就直接等于 dot(v1, v2)。但如果你传入的是未归一化的向量,
# 却错误地只用了 dot(v1, v2),那结果就毫无意义。
实操心得
:在代码中,永远给变量起清晰的名字。
vector_norm
表示一个数字,
vector_unit
表示一个单位向量。在函数接口设计上,如果一个函数返回的是单位向量,函数名一定要包含
unit
或
normalized
,比如
get_embedding_unit()
,而不是模糊的
get_embedding_norm()
。
5.2 坑二:
np.linalg.norm()
的
ord
参数陷阱
np.linalg.norm()
的
ord
参数,表面看是
p
值,但它的取值和含义有微妙的差别,稍不注意就会出错。
v = np.array([1, 2, 3])
# ord=1 -> L1 norm
print(f"ord=1: {np.linalg.norm(v, ord=1)}") # 1+2+3 = 6
# ord=2 -> L2 norm (default)
print(f"ord=2: {np.linalg.norm(v, ord=2)}") # sqrt(1+4+9) = sqrt(14) ≈ 3.74
# ord=np.inf -> L∞ norm
print(f"ord=np.inf: {np.linalg.norm(v, ord=np.inf)}") # max(1,2,3) = 3
# 陷阱1:ord=0 不是 L0 norm!
# np.linalg.norm(v, ord=0) 计算的是向量的“0-范数”,即非零元素的个数,但这只是一个特例,
# 并不满足 norm 的四条公理(特别是齐次性),所以严格来说,它不是一个 norm。
print(f"ord=0: {np.linalg.norm(v, ord=0)}") # 3 (因为所有元素都非零)
# 陷阱2:ord=-1, -2 等负数,计算的是“倒数范数”,即 (sum |x_i|^(-p))^(1/(-p)),这在实践中极少使用。
# 例如,ord=-1 计算的是
更多推荐
所有评论(0)