0X. ML Coding
本页整理常见手写模型。面试时不只要写出能跑的代码,还要主动说明:
- 输入输出 shape。
- 时间/空间复杂度。
- 数值稳定性。
- 边界条件。
- 和框架实现的差异。
1. KMeans
原理就两步循环:分配(每个点归到最近的中心)+ 更新(每个簇的均值当新中心),直到中心不再移动。
先写核心版(面试建议先写这个)
面试时不要一上来就写 class 和一堆 self。先用十几行核心逻辑证明你懂原理、NumPy 向量化扎实,再主动说“如果要实际部署,我会封装成 fit/predict,并处理空簇等边界情况”,然后再补下面的完整版。
import numpy as np
def kmeans(X, k, max_iter=100):
# 1. 随便选 k 个点当中心(最简单的初始化)
centroids = X[np.random.choice(X.shape[0], k, replace=False)]
for _ in range(max_iter):
# 2. 算距离,找归属(这步最容易写晕)
distances = np.sum((X[:, None, :] - centroids[None, :, :]) ** 2, axis=2)
labels = np.argmin(distances, axis=1)
# 3. 更新中心
new_centroids = np.array([X[labels == i].mean(axis=0) for i in range(k)])
# 4. 如果中心不动了,就停止
if np.all(centroids == new_centroids):
break
centroids = new_centroids
return labels, centroids
注意这个极简版有两个已知取舍:空簇会得到
NaN(下面完整版用 fallback 处理),且np.all(centroids == new_centroids)对浮点不稳(完整版用tol)。面试时能主动指出这两点就是加分项。
广播机制拆解(算距离那行)
distances = np.sum((X[:, None, :] - centroids[None, :, :]) ** 2, axis=2) 是最容易写晕、也最常考的一行。它一次性算出「\(n\) 个点到 \(k\) 个中心」的距离矩阵,靠的是 NumPy 广播:
-
X形状 \((n, d)\),X[:, None, :]插入一个空维 → \((n, 1, d)\)。 -
centroids形状 \((k, d)\),centroids[None, :, :]→ \((1, k, d)\)。 - 相减时把大小为 1 的维广播拉伸对齐 → \((n, k, d)\),物理意义是「每个点分别减去每个中心」的坐标差 \((\Delta x, \Delta y, \dots)\)。
-
** 2后.sum(axis=2)沿最后一维(坐标维 \(d\))求和,即 \(\sum(\Delta x)^2\),降维得到 \((n, k)\) 的平方距离矩阵。 - 再
np.argmin(distances, axis=1):axis=1是第二维(\(k\) 个中心;NumPy 轴从 0 数起),对每一行(每个点)挑距离最小的那一列 → 长度 \(n\) 的 label。
这样避免了两层 Python for 循环,是向量化标准写法(把 np. 换成 torch. 几乎就是 PyTorch)。
K-Means++ 初始化
纯随机初始化很看运气:若初始中心挤在一起,收敛慢、易陷入差的局部最优。K-Means++ 只改初始化(后面 assign/update 完全不变),让初始中心尽量相互远离:
- 从数据里随机选 1 个点作为第一个中心。
- 对每个点 \(x\),算它到已选中心里最近那个的距离 \(D(x)\)。
- 以正比于 \(D(x)^2\) 的概率抽下一个中心(离已有中心越远,被选中概率越大)。
- 重复 2–3 直到选够 \(k\) 个。
def kmeans_pp_init(X, k, rng=None):
rng = np.random.default_rng() if rng is None else rng
n = X.shape[0]
centroids = [X[rng.integers(n)]] # 1. 随机第一个中心
for _ in range(1, k):
# 每个点到「最近已选中心」的平方距离 D(x)^2
d2 = np.min([((X - c) ** 2).sum(axis=1) for c in centroids], axis=0)
probs = d2 / d2.sum() # 2-3. 正比于 D(x)^2 的概率
centroids.append(X[rng.choice(n, p=probs)])
return np.array(centroids, dtype=float)
通常比纯随机收敛更快、结果更稳,是 sklearn KMeans 的默认初始化。
面试版实现
import numpy as np
class KMeans:
def __init__(self, n_clusters, max_iter=100, tol=1e-4, random_state=None):
self.n_clusters = n_clusters
self.max_iter = max_iter
self.tol = tol
self.random_state = random_state
self.centroids = None
self.inertia_ = None
def _init_centroids(self, X):
rng = np.random.default_rng(self.random_state)
n_samples = X.shape[0]
if self.n_clusters > n_samples:
raise ValueError("n_clusters cannot exceed n_samples")
indices = rng.choice(n_samples, size=self.n_clusters, replace=False)
return X[indices].astype(float)
def _assign(self, X, centroids):
# squared distances: shape (n_samples, n_clusters)
distances = ((X[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
return np.argmin(distances, axis=1), distances
def _update(self, X, labels, old_centroids):
new_centroids = np.empty_like(old_centroids)
for k in range(self.n_clusters):
points = X[labels == k]
if len(points) == 0:
# Empty-cluster fallback: keep the previous centroid.
# Other choices: reinitialize to farthest point or random point.
new_centroids[k] = old_centroids[k]
else:
new_centroids[k] = points.mean(axis=0)
return new_centroids
def fit(self, X):
X = np.asarray(X, dtype=float)
centroids = self._init_centroids(X)
for _ in range(self.max_iter):
labels, distances = self._assign(X, centroids)
new_centroids = self._update(X, labels, centroids)
shift = np.linalg.norm(new_centroids - centroids)
centroids = new_centroids
if shift < self.tol:
break
labels, distances = self._assign(X, centroids)
self.centroids = centroids
self.inertia_ = distances[np.arange(X.shape[0]), labels].sum()
return self
def predict(self, X):
if self.centroids is None:
raise RuntimeError("Call fit before predict.")
X = np.asarray(X, dtype=float)
labels, _ = self._assign(X, self.centroids)
return labels
Example
np.random.seed(42)
X = np.random.rand(100, 2)
model = KMeans(n_clusters=3, random_state=42)
model.fit(X)
print(model.centroids)
print(model.inertia_)
关键追问
- 为什么不用
sqrt? 最近 centroid 的 argmin 不受平方根影响,平方距离更省。 - 收敛到全局最优吗? 不保证,只保证目标函数单调不增并收敛到局部最优或稳定点。
- 空簇怎么办? 保留旧 centroid、随机重置、或重置到当前误差最大的点。
- 复杂度? 每轮 \(O(nkd)\),其中 \(n\) 是样本数,\(k\) 是簇数,\(d\) 是维度。
- 实际优化? k-means++ 初始化、多次随机重启、标准化特征。
2. Logistic Regression
名字叫回归,其实是二分类算法,三步走:
- 线性打分:\(z=w^\top x+b\)。
- 压进概率:用 Sigmoid \(\sigma(z)=1/(1+e^{-z})\) 把 \(z\) 从 \((-\infty,+\infty)\) 映射到 \((0,1)\),代表属于正类的概率。
- 算损失并更新:用二元交叉熵衡量猜得准不准,再用梯度下降更新 \(w,b\)。
先写核心版
import numpy as np
def logistic_regression_train(X, y, lr=0.1, max_iter=1000):
n_samples, n_features = X.shape
w = np.zeros(n_features)
b = 0.0
for _ in range(max_iter):
z = X @ w + b # 1. 前向:线性分数
y_pred = sigmoid(z) # 2. 过 Sigmoid 变概率
# 3. 梯度(求导后非常干净:预测值 - 真实值)
dw = (1 / n_samples) * (X.T @ (y_pred - y))
db = (1 / n_samples) * np.sum(y_pred - y)
# 4. 梯度下降更新
w -= lr * dw
b -= lr * db
return w, b
数值稳定实现
Sigmoid:公式与它的坑
\[ \sigma(z)=\frac{1}{1+e^{-z}} \]
照着这个式子直接写 1 / (1 + np.exp(-z)),当 \(z\) 是很大的负数(比如 \(-1000\))时,\(e^{-z}=e^{1000}\) 直接溢出成 inf,得到 inf 甚至 nan。
解决办法是分子分母同乘 \(e^{z}\),得到一个等价形式:
\[ \sigma(z)=\frac{1}{1+e^{-z}}=\frac{e^{z}}{1+e^{z}} \]
两个写法数学上完全相同,数值行为却相反:
- \(z\ge0\) 时用左边,指数部分是 \(e^{-z}\le1\),安全。
- \(z<0\) 时用右边,指数部分是 \(e^{z}<1\),也安全。
所以按符号分两支,保证永远只对负数取指数。
import numpy as np
def sigmoid(z):
z = np.asarray(z)
out = np.empty_like(z, dtype=float) # 创建一个和 z 维度相同、用来装结果的空数组
pos = z >= 0 # 找到所有 >= 0 的位置(布尔掩码 Boolean Mask)
neg = ~pos # 找到所有 < 0 的位置(按位取反)
# 1. 正数区域:用 1 / (1 + exp(-z)),此时 exp(-z) <= 1,不会溢出
out[pos] = 1.0 / (1.0 + np.exp(-z[pos]))
# 2. 负数区域:用 exp(z) / (1 + exp(z)),此时 exp(z) < 1,同样不会溢出
exp_z = np.exp(z[neg])
out[neg] = exp_z / (1.0 + exp_z)
return out
Binary Cross-Entropy:同一个坑的另一面
原始公式(记 \(p=\sigma(z)\)):
\[ \mathcal L=-\big[y\log p+(1-y)\log(1-p)\big] \]
问题在于先求出 \(p\) 再取对数:\(p\) 一旦被浮点舍入成 0 或 1,就撞上 \(\log 0=-\infty\)。把 \(p=\sigma(z)\) 代进去化简,可以得到一个只用 logits、且不会出现 \(\log0\) 的等价式:
\[ \mathcal L=\max(z,0)-zy+\log\!\left(1+e^{-|z|}\right) \]
\(\max(z,0)\) 与 \(|z|\) 配合,保证指数项永远是 \(e^{\text{负数}}\in(0,1]\),怎么算都不溢出。
def binary_cross_entropy_with_logits(logits, y):
"""稳定形式:max(z, 0) - z*y + log(1 + exp(-|z|));直接吃 logits,不要先算概率"""
logits = np.asarray(logits, dtype=float)
y = np.asarray(y, dtype=float)
# -np.abs(logits) 保证指数恒为负 → exp 落在 (0, 1],永不溢出
# np.log1p(t) 算的是 log(1+t),t 很小时比 np.log(1+t) 精度高得多
loss = np.maximum(logits, 0) - logits * y + np.log1p(np.exp(-np.abs(logits)))
return loss.mean() # 对 batch 求平均
class LogisticRegressionGD:
def __init__(self, lr=0.1, max_iter=1000, l2=0.0, fit_intercept=True):
self.lr = lr # 学习率
self.max_iter = max_iter # 迭代轮数
self.l2 = l2 # L2 正则强度,0 表示不加正则
self.fit_intercept = fit_intercept
self.w = None
self.loss_history = [] # 记录每轮 loss,用来画收敛曲线 / 调试
def _add_intercept(self, X):
"""在 X 最左边拼一列全 1,把截距 b 吸收成 w[0],公式简化为纯矩阵乘"""
if not self.fit_intercept:
return X
ones = np.ones((X.shape[0], 1))
return np.hstack([ones, X])
def fit(self, X, y):
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float).reshape(-1) # 拉平成 (n,),防止 (n,1) 触发广播 bug
Xb = self._add_intercept(X)
n_samples, n_features = Xb.shape
self.w = np.zeros(n_features) # BCE + 线性 logit 是凸问题,零初始化就够
self.loss_history = []
for _ in range(self.max_iter):
logits = Xb @ self.w # 1. 前向只算到 logits,不提前求概率
probs = sigmoid(logits) # 2. 转成概率
# 3. 梯度:对 logits 求导后是 (p - y),链式回到 w 就是 X^T (p - y) / n
grad = Xb.T @ (probs - y) / n_samples
if self.l2 > 0: # 4. L2 正则的梯度就是 lambda * w
reg = self.w.copy()
if self.fit_intercept:
reg[0] = 0.0 # 截距不参与正则(它只是整体基准,不该被收缩)
grad += self.l2 * reg
self.w -= self.lr * grad # 5. 梯度下降更新
# 6. 用更新后的参数记录 loss,曲线才反映当前模型
logits = Xb @ self.w
loss = binary_cross_entropy_with_logits(logits, y)
if self.l2 > 0: # 正则项也要计入 loss,否则曲线和目标函数对不上
reg_w = self.w[1:] if self.fit_intercept else self.w
loss += 0.5 * self.l2 * np.dot(reg_w, reg_w)
self.loss_history.append(loss)
return self
def predict_proba(self, X):
"""输出正类概率,落在 (0, 1)"""
X = np.asarray(X, dtype=float)
Xb = self._add_intercept(X)
return sigmoid(Xb @ self.w)
def predict(self, X, threshold=0.5):
"""阈值默认 0.5,但生产中应由 precision/recall 的业务成本决定"""
return (self.predict_proba(X) >= threshold).astype(int)
Example
np.random.seed(42)
X = np.random.randn(100, 2)
true_w = np.array([1.0, -2.0])
logits = X @ true_w + 0.2
y = (sigmoid(logits) > 0.5).astype(int)
model = LogisticRegressionGD(lr=0.1, max_iter=1000, l2=1e-3)
model.fit(X, y)
pred = model.predict(X)
print("accuracy:", (pred == y).mean())
print("weights:", model.w)
关键追问
- 为什么不直接
np.log(y_hat)? 当概率接近 0 或 1 时会出现log(0),应使用 logits 形式的稳定 BCE。 - 梯度是什么? 对 logits 的梯度为 \(\hat y-y\),所以参数梯度是 \(X^\top(\hat y-y)/n\)。
- MSE 做 Logistic Regression 是凸的吗? 一般不是。BCE + linear logits 是凸的。
- 为什么 intercept 不正则化? 截距控制整体基准概率,通常不希望被 L2 收缩。
- 生产中阈值一定是 0.5 吗? 不一定,阈值由业务成本、Precision/Recall 和校准决定。
3. Multiple Linear Regression
多元 = 多个特征(\(y=w_1x_1+\dots+w_nx_n+b\)),不要和多项式回归(引入 \(x^2,x^3\) 等高次项)搞混。
和 KMeans 的“盲人摸象、迭代逼近”不同,线性回归有上帝视角:对均方误差求导令其为 0,可以直接解出闭式解(Normal Equation),不用写迭代循环。
先写核心版
_add_intercept 做的事就是:在 \(X\) 最左边拼一列全 1。这样常数项 \(b\) 就变成了 \(w_0\times 1\),被吸收进权重向量,公式简化为纯矩阵乘法 \(y=X_{\text{new}}\theta\)(theta[0] 即截距)。
import numpy as np
def linear_regression(X, y):
# 左边拼一列 1 当截距项:b 变成权重向量的第 0 项
Xb = np.hstack([np.ones((X.shape[0], 1)), X])
# lstsq 基于 SVD,比显式求逆稳定得多
theta, *_ = np.linalg.lstsq(Xb, y, rcond=None)
return theta # theta[0] 是截距 b,其余是各特征权重
lstsq 是什么?要手写它吗?
不用手写。 np.linalg.lstsq 是 LAPACK 提供的最小二乘求解器(内部走 SVD 或 QR 分解),面试里直接调用即可——手撕 SVD 不是这道题的考点。真正要能讲清的是为什么用它,而不是照抄闭式解:
\[ \theta=(X^\top X)^{-1}X^\top y \]
这个式子适合写在纸上,不适合写进代码。三种写法从差到好:
| 写法 | 问题 |
|---|---|
inv(X.T @ X) @ X.T @ y | 显式求逆,最不稳;且 \(X^\top X\) 的条件数是 \(X\) 的平方,共线性一强就炸 |
solve(X.T @ X, X.T @ y) | 不求逆、改解线性方程组,好一些;但仍要构造 \(X^\top X\),条件数平方的问题还在 |
lstsq(X, y) | 根本不构造 \(X^\top X\),直接对 \(X\) 分解求最小二乘;\(X\) 秩亏时还给最小范数解 |
所以标准答法是:先写出闭式解证明你懂推导,再说「实际实现我会调 lstsq,因为它不显式求逆、也不构造条件数被平方的 \(X^\top X\)」。
面试版实现(封装成类)
import numpy as np
class LinearRegressionClosedForm:
def __init__(self, fit_intercept=True):
self.fit_intercept = fit_intercept
self.theta = None # 拟合后是长度 (n_features + 1) 的权重向量
def _add_intercept(self, X):
"""拼一列全 1,让截距 b 变成 theta[0]"""
if not self.fit_intercept:
return X
ones = np.ones((X.shape[0], 1))
return np.hstack([ones, X])
def fit(self, X, y):
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float)
Xb = self._add_intercept(X)
# 比显式算 inv(X.T @ X) 稳得多;rcond=None 采用新版默认的奇异值截断规则
# 返回值依次是:解、残差平方和、X 的秩、奇异值
self.theta, residuals, rank, singular_values = np.linalg.lstsq(
Xb, y, rcond=None
)
# rank < Xb.shape[1] 就说明特征间存在完全共线,此时解不唯一
return self
def predict(self, X):
X = np.asarray(X, dtype=float)
Xb = self._add_intercept(X) # 预测时也必须拼同样的一列 1
return Xb @ self.theta
Example
X = np.array([
[1, 2],
[2, 3],
[3, 4],
[4, 5],
[5, 6],
])
y = np.array([5, 7, 9, 11, 13])
model = LinearRegressionClosedForm()
model.fit(X, y)
X_new = np.array([
[6, 7],
[7, 8],
])
print("theta:", model.theta)
print("pred:", model.predict(X_new))
为什么不要显式求逆?
原始公式是:
\[ \theta=(X^\top X)^{-1}X^\top y \]
但显式计算逆矩阵数值不稳定,且当 \(X^\top X\) 奇异或病态时会失败。更好的做法:
-
np.linalg.lstsq:基于更稳定的分解求最小二乘。 -
np.linalg.pinv:使用伪逆。 - Ridge:当共线性强时加入 L2 正则。
Ridge 版本
闭式解只需在 \(X^\top X\) 上加一个 \(\alpha I\):
\[ \theta=(X^\top X+\alpha I)^{-1}X^\top y \]
这一项的作用不只是「防过拟合」——加在对角线上会把最小的那些奇异值抬起来,\(X^\top X\) 即使原本奇异也变得可逆,所以 Ridge 天然能处理共线性。
def ridge_regression(X, y, alpha=1.0, fit_intercept=True):
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float)
if fit_intercept:
Xb = np.hstack([np.ones((X.shape[0], 1)), X]) # 拼截距列
else:
Xb = X
n_features = Xb.shape[1]
reg = alpha * np.eye(n_features) # alpha * I,加在 X^T X 的对角线上
if fit_intercept:
reg[0, 0] = 0.0 # 截距项不正则化
# 用 solve 而不是 inv:解方程比求逆更稳、更快
theta = np.linalg.solve(Xb.T @ Xb + reg, Xb.T @ y)
return theta
3.1 多项式回归
多项式回归不是新算法,只是「特征扩展 + 多元线性回归」:把 \(x\) 展开成 \([1, x, x^2, \dots]\) 当作新特征,再直接套用上面的线性回归求解器。
def polynomial_regression_1d(x, y, degree=3):
# x: (N,) 一维数据;把 x 变成矩阵 [1, x, x^2, x^3]
# x**0 刚好全是 1,顺便把截距项也搞定了
X_poly = np.column_stack([x ** i for i in range(degree + 1)])
# X_poly 形状 (N, degree+1),完全变成了多元线性回归
theta, *_ = np.linalg.lstsq(X_poly, y, rcond=None)
return theta
面试常问的坑:
- 过拟合:degree 越高越容易剧烈震荡去穿过每个训练点(Runge 现象),泛化极差 → 用 Ridge/Lasso 把高次项权重压向 0。
- 维度灾难:多特征做高次展开会产生大量交叉项(\(x_1x_2\)、\(x_1^2x_2\) …),特征数急剧膨胀。
- 面试极少让从零手写,多作为概念题;关键是能说清「它本质就是特征工程 + 线性回归」。
4. Softmax
Softmax 把一堆没有约束的原始分数(logits)变成和为 1 的概率分布:
\[ \operatorname{softmax}(z)_i=\frac{e^{z_i}}{\sum_{j}e^{z_j}} \]
分子取指数保证结果为正、并放大分数差距,分母是所有项之和保证归一化。
稳定实现
真正写的是下面这个等价形式(分子分母同乘 \(e^{-\max_j z_j}\)):
\[ \operatorname{softmax}(z)_i=\frac{e^{z_i-\max_j z_j}}{\sum_k e^{z_k-\max_j z_j}} \]
import numpy as np
def softmax(x, axis=-1):
x = np.asarray(x, dtype=float)
# 减去每行最大值:数学上完全等价,但把最大的指数压成 e^0 = 1,杜绝上溢
x_shifted = x - np.max(x, axis=axis, keepdims=True)
exp_x = np.exp(x_shifted)
# keepdims=True 保留被求和的那一维((batch, 1) 而不是 (batch,)),否则广播会错位
return exp_x / exp_x.sum(axis=axis, keepdims=True)
为什么不写成更短的那个版本? 你可能见过这种写法:
def softmax(x): # 只对「一维向量」正确
x = x - np.max(x)
e = np.exp(x)
return e / e.sum()
单条 logits 向量喂进去没问题,但一带 batch 就错了:np.max(x) 取的是整个矩阵的最大值,e.sum() 把所有样本的所有类别加成一个标量。结果每行不再各自归一化,而是整个矩阵加起来才等于 1,概率全被稀释成 \(1/\text{batch}\) 量级。
所以只要输入可能有 batch 维,就必须写 axis=-1, keepdims=True 让每行独立归一化。面试时写带 axis 的版本,并主动说明这个区别。
Cross-Entropy
多分类交叉熵只看真实类别那一项的概率:
\[ \mathcal L=-\frac1N\sum_{i=1}^{N}\log p_{i,y_i}, \qquad p_{i,k}=\operatorname{softmax}(z_i)_k \]
实现上不要先算 softmax 再取 log,而是把两步合并成 log-softmax,除法变减法、也不会出现 \(\log 0\):
\[ \log\operatorname{softmax}(z)_k =z_k-\max_j z_j-\log\sum_{m}e^{z_m-\max_j z_j} \]
def cross_entropy_from_logits(logits, y):
"""
logits: shape (batch, num_classes),未经 softmax 的原始分数
y: shape (batch,),整数类别标签(不是 one-hot)
"""
logits = np.asarray(logits, dtype=float)
y = np.asarray(y, dtype=int)
# 1. 同样先减每行最大值做稳定化
shifted = logits - logits.max(axis=1, keepdims=True)
# 2. log-softmax:log(e^s / Σe^s) = s - log(Σe^s),全程没有除法、也不会 log(0)
log_probs = shifted - np.log(np.exp(shifted).sum(axis=1, keepdims=True))
# 3. 花式索引取出每个样本真实类别那一项:第 i 行取第 y[i] 列
# 等价于「和 one-hot 相乘再求和」,但不必真的构造 one-hot 矩阵
return -log_probs[np.arange(logits.shape[0]), y].mean()
两个必说的点
- 为什么要减最大值? 直接算 \(e^{z}\),当 \(z\) 很大(如 1000)时会溢出成
inf。减去每行最大值在数学上完全等价(分子分母同乘 \(e^{-\max z}\)),却把指数压回安全范围。这是手写 Softmax 时的必答加分项。 - 交叉熵吃 logits 还是概率? 交叉熵本质是 \(-\log(\text{真实类别的预测概率})\):猜得准(概率接近 1)惩罚趋近 0,猜得离谱(概率接近 0)惩罚巨大。实现上直接吃 logits 更好——用 log-sum-exp 把 softmax 和 log 合并(上面
cross_entropy_from_logits的写法),少一次中间求概率的舍入,数值更稳、速度更快,这也是 PyTorchCrossEntropyLoss的做法;合并后梯度还特别干净:softmax 概率 \(-\) one-hot 标签。若手上只有概率,必须np.clip(p, 1e-15, 1)防log(0)。
5. Scaled Dot-Product Attention
\[ \operatorname{Attention}(Q,K,V) =\operatorname{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}+M\right)V \]
四步:\(QK^\top\) 算出每个 query 和每个 key 的匹配分 → 除 \(\sqrt{d_k}\) 防止 softmax 饱和 → 加 mask 屏蔽不该看的位置 → softmax 归一成权重后去加权 \(V\)。
单头 Attention
import numpy as np
def softmax(x, axis=-1):
x = x - np.max(x, axis=axis, keepdims=True) # 稳定化:减去每行最大值
exp_x = np.exp(x)
return exp_x / np.sum(exp_x, axis=axis, keepdims=True)
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q: shape (batch, q_len, d_k)
K: shape (batch, kv_len, d_k)
V: shape (batch, kv_len, d_v)
mask: optional, broadcastable to (batch, q_len, kv_len).
本实现约定 True = 该位置可见(不同框架约定相反,面试务必说清)
Returns:
output: shape (batch, q_len, d_v)
weights: shape (batch, q_len, kv_len)
"""
d_k = Q.shape[-1]
# 1. 打分并缩放:swapaxes(-1,-2) 是转置最后两维,(q_len,d_k)@(d_k,kv_len) -> (q_len,kv_len)
# 除 sqrt(d_k):点积是 d_k 项之和、方差随维度线性增长,不缩放会让 softmax 饱和、梯度消失
scores = Q @ np.swapaxes(K, -1, -2) / np.sqrt(d_k)
# 2. 屏蔽:把不可见位置换成一个极小值,softmax 后权重 ≈ 0
# 必须在 softmax 之前做,否则归一化分母里仍混进了这些位置
if mask is not None:
scores = np.where(mask, scores, -1e9)
# 3. 沿 kv_len 维归一化:每个 query 对所有 key 的注意力加起来为 1
weights = softmax(scores, axis=-1)
# 4. 用权重加权求和 V,得到每个 query 位置的输出
output = weights @ V
return output, weights
Causal Mask
def causal_mask(q_len, kv_len=None):
if kv_len is None:
kv_len = q_len
# np.tril 取下三角(含对角线):第 i 行只有前 i+1 列为 True
# 即位置 i 只能看到 <= i 的位置,看不到未来
return np.tril(np.ones((q_len, kv_len), dtype=bool))
batch, seq_len, d_model = 2, 4, 8
Q = np.random.randn(batch, seq_len, d_model)
K = np.random.randn(batch, seq_len, d_model)
V = np.random.randn(batch, seq_len, d_model)
mask = causal_mask(seq_len)[None, :, :]
out, weights = scaled_dot_product_attention(Q, K, V, mask=mask)
print(out.shape) # (2, 4, 8)
print(weights.shape) # (2, 4, 4)
关键追问
- 为什么除以 \(\sqrt{d_k}\)? 防止点积方差随维度增大,导致 softmax 饱和。
- Mask 用 True 还是 False 表示可见? 面试时必须说清楚。上面代码使用 True 表示可见。
- 为什么用
-1e9? 让 masked position 的 softmax 权重近似 0。真实框架常用 dtype 对应的最小值。 - 复杂度? 标准 attention 时间和注意力矩阵显存为 \(O(n^2)\)。
6. Multi-Head Attention
多头 = 三个臭皮匠顶个诸葛亮:让 \(h\) 个「专家」从不同视角同时读这句话(有的头盯指代关系、有的盯动宾搭配……),最后把各自的结论拼起来。
split_heads 的本质就是切蛋糕(reshape):总维度 \(d_{\text{model}}\) 平分给 \(h\) 个头,每头 \(d_{\text{head}}=d_{\text{model}}/h\)(如 \(512/8=64\)),形状从 \((B, L, d_{\text{model}})\) 变成 \((B, h, L, d_{\text{head}})\)。把 \(h\) 独立成一个维度后,这 \(h\) 个头就能并行算 attention;算完再 concat 拼回 \((B, L, d_{\text{model}})\),最后过 \(W_o\) 融合。
核心版(先切蛋糕,再并行算,最后拼回去)
import numpy as np
def multi_head_attention_core(Q, K, V, num_heads):
batch_size, seq_len, d_model = Q.shape
d_k = d_model // num_heads # 每个头分到的维度(比如 512 / 8 = 64)
# 1. split_head:把 [batch, seq, d_model] 变成 [batch, num_heads, seq, d_k]
# (实际代码中通常先通过线性层 Linear 投影,再 reshape)
Q = Q.reshape(batch_size, seq_len, num_heads, d_k).swapaxes(1, 2)
K = K.reshape(batch_size, seq_len, num_heads, d_k).swapaxes(1, 2)
V = V.reshape(batch_size, seq_len, num_heads, d_k).swapaxes(1, 2)
# 2. 丢进 Attention 核心(num_heads 个头同时并行计算)
scores = (Q @ K.swapaxes(-1, -2)) / np.sqrt(d_k)
weights = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
weights = weights / np.sum(weights, axis=-1, keepdims=True)
out = weights @ V # 形状是 [batch, num_heads, seq_len, d_k]
# 3. concat(拼回去):把 head 和 d_k 重新合体变回 [batch, seq_len, d_model]
out = out.swapaxes(1, 2).reshape(batch_size, seq_len, d_model)
return out
NumPy 实现
import numpy as np
def split_heads(x, num_heads):
"""
切蛋糕:把 d_model 平分给 num_heads 个头
x: shape (batch, seq_len, d_model)
return: shape (batch, num_heads, seq_len, d_head)
"""
batch, seq_len, d_model = x.shape
assert d_model % num_heads == 0 # 必须整除,否则没法平分
d_head = d_model // num_heads
# 先把最后一维拆成 (num_heads, d_head)
x = x.reshape(batch, seq_len, num_heads, d_head)
# 再把 num_heads 换到前面 —— 让它变成"批次维",后面 h 个头就能并行算 attention
return np.transpose(x, (0, 2, 1, 3))
def combine_heads(x):
"""
拼回去:split_heads 的逆操作
x: shape (batch, num_heads, seq_len, d_head)
return: shape (batch, seq_len, d_model)
"""
batch, num_heads, seq_len, d_head = x.shape
x = np.transpose(x, (0, 2, 1, 3)) # 先把 seq_len 换回第 1 维
return x.reshape(batch, seq_len, num_heads * d_head) # 再把 h 个头首尾相接
def multi_head_attention(X, Wq, Wk, Wv, Wo, num_heads, mask=None):
"""
X: shape (batch, seq_len, d_model)
Wq/Wk/Wv/Wo: shape (d_model, d_model)
mask: optional, shape broadcastable to (batch, num_heads, seq_len, seq_len)
"""
# 1. 三次线性投影,把同一个 X 变成 Q/K/V 三种视角(三组权重互相独立,不共享)
Q = X @ Wq
K = X @ Wk
V = X @ Wv
# 2. 切成多头:注意是先投影再切,等价于每个头有自己的小投影矩阵
Q = split_heads(Q, num_heads)
K = split_heads(K, num_heads)
V = split_heads(V, num_heads)
# 3. 每个头各算各的 attention。这里 d_head = d_model / h,
# 所以缩放用的是 sqrt(d_head) 而不是 sqrt(d_model)
d_head = Q.shape[-1]
scores = Q @ np.swapaxes(K, -1, -2) / np.sqrt(d_head)
if mask is not None:
scores = np.where(mask, scores, -1e9)
weights = softmax(scores, axis=-1)
context = weights @ V # (batch, num_heads, seq_len, d_head)
# 4. 拼回 d_model,再过输出投影 Wo 把各头的结论融合起来
# 没有 Wo 的话各头结果只是简单并排堆着,从未交互
context = combine_heads(context)
return context @ Wo, weights
Example
batch, seq_len, d_model, num_heads = 2, 5, 16, 4
X = np.random.randn(batch, seq_len, d_model)
Wq = np.random.randn(d_model, d_model) / np.sqrt(d_model)
Wk = np.random.randn(d_model, d_model) / np.sqrt(d_model)
Wv = np.random.randn(d_model, d_model) / np.sqrt(d_model)
Wo = np.random.randn(d_model, d_model) / np.sqrt(d_model)
mask = causal_mask(seq_len)[None, None, :, :]
out, weights = multi_head_attention(X, Wq, Wk, Wv, Wo, num_heads, mask=mask)
print(out.shape) # (2, 5, 16)
print(weights.shape) # (2, 4, 5, 5)
6.1 RoPE(旋转位置编码)
原理与直觉
传统绝对位置编码把位置向量加到词向量上,长文本外推表现不佳。RoPE 换了个思路:用旋转表达位置。
把词向量的每两个通道看作平面上的一个箭头,位置 \(m\) 就把这个箭头旋转 \(m\theta_i\) 的角度(不同通道用不同频率 \(\theta_i\),低维转得快、高维转得慢)。
关键性质:两个分别处于位置 \(m\)、\(n\) 的向量做点积(算 Attention Score)时,绝对旋转角自动抵消,结果只依赖相对距离 \(m-n\):
\[ \langle R_m q,\ R_n k\rangle = q^\top R_{n-m} k \]
这正契合语言里「相对距离比绝对位置更重要」的直觉,也是 LLaMA / Qwen / ChatGLM 等主流开源模型的标配。
实现
import numpy as np
def apply_rope(x, freq_base=10000.0):
"""
x: (batch_size, seq_len, num_heads, d_k),d_k 必须是偶数(两两配对做二维旋转)
"""
batch_size, seq_len, num_heads, d_k = x.shape
assert d_k % 2 == 0, "d_k 必须是偶数才能进行两两旋转"
# 1. 每个特征对的旋转频率 theta_i = base^(-2i/d_k)
i = np.arange(0, d_k, 2)
theta = 1.0 / (freq_base ** (i / d_k)) # (d_k/2,)
# 2-3. 位置索引 m 与角度 m*theta 的外积
m = np.arange(seq_len)
freqs = np.outer(m, theta) # (seq_len, d_k/2)
# 4. cos/sin 各复制一遍,对齐交错排列的 d_k 维
cos_val = np.repeat(np.cos(freqs), 2, axis=-1)[None, :, None, :] # (1, seq_len, 1, d_k)
sin_val = np.repeat(np.sin(freqs), 2, axis=-1)[None, :, None, :]
# 5. 旋转:对每一对 (x_even, x_odd) 做 x*cos + (-x_odd, x_even)*sin
swapped = np.stack([-x[..., 1::2], x[..., ::2]], axis=-1)
swapped = swapped.reshape(batch_size, seq_len, num_heads, d_k)
return x * cos_val + swapped * sin_val
对每一对通道 \((x_{2i},x_{2i+1})\) 展开就是标准的二维旋转:
\[ x'_{2i}=x_{2i}\cos(m\theta_i)-x_{2i+1}\sin(m\theta_i),\qquad x'_{2i+1}=x_{2i+1}\cos(m\theta_i)+x_{2i}\sin(m\theta_i) \]
关键追问
- 为什么
np.repeat(..., 2)而不是np.tile? 这份实现用的是交错排列(相邻两维为一对),所以每个角度要连续复制两次与 \((x_{2i},x_{2i+1})\) 对齐。若改用「前半 / 后半」配对的实现(HuggingFace 常见的rotate_half),则要用np.tile并相应改配对方式——两种约定不能混用。 - 验证方式? 旋转是正交变换,所以 保范数;位置 0 不旋转(等于原向量);把同一对向量放到不同绝对位置、保持相对距离不变,点积应完全相同。
- 怎么扩长? 位置插值(Position Interpolation)把每步转角调小,让同样的角度范围装下更长序列;另有 NTK-aware、YaRN 等改进。
- 作用在哪? 只对 Q 和 K 施加(因为只有它们进入点积),不作用于 V。
6.2 LayerNorm
原理与直觉
深层网络里每层输出的数值分布会剧烈漂移,导致训练不稳。LayerNorm 对单个样本内部的所有特征维做归一化(均值 0、方差 1),再乘可学习的 \(\gamma\)、加 \(\beta\) 恢复表达能力:
\[ y=\gamma\odot\frac{x-\mu}{\sqrt{\sigma^2+\epsilon}}+\beta \]
与 BatchNorm 的区别(面试必考):BatchNorm 对「一个 batch 内所有样本的同一维」求统计,受 batch 大小影响极大;LayerNorm 只看单个样本自己,与 batch 大小、序列长度完全无关,因此适配变长序列和逐 token 的自回归推理,是 NLP / Transformer 的标配。
实现
import numpy as np
def layer_norm(x, gamma, beta, eps=1e-5):
"""
x: (batch_size, seq_len, d_model)
gamma, beta: (d_model,) 可学习缩放与偏移
"""
mean = np.mean(x, axis=-1, keepdims=True) # 沿特征维
variance = np.var(x, axis=-1, keepdims=True)
x_norm = (x - mean) / np.sqrt(variance + eps) # eps 防止分母为 0
return gamma * x_norm + beta
关键追问
- 为什么
axis=-1? 必须沿特征维 \(d_{\text{model}}\),不能沿 batch 或 seq 维;写错轴是最常见的 bug。 -
eps放在根号里还是外面? 主流实现(含 PyTorch)是 \(\sqrt{\sigma^2+\epsilon}\),放在根号内。 - RMSNorm 有什么不同? 去掉减均值和 \(\beta\),只用均方根缩放 \(x/\sqrt{\operatorname{mean}(x^2)+\epsilon}\cdot\gamma\),更省算力,现代 decoder-only LLM 常用。
- 放在残差前还是后? Post-Norm(原论文)深层易梯度消失、需 warmup;Pre-Norm 让残差成为无阻碍主干、训练更稳,是现代 LLM 标配。
6.3 Beam Search
原理与直觉
生成文本时每步只取概率最高的词(Greedy)容易陷入局部最优;穷举所有组合又会指数爆炸。Beam Search 是折中:同时保留当前得分最高的 \(k\) 条路径(\(k\) = beam size),每步扩展后再全局剪枝回 \(k\) 条,直到遇到结束符,最后挑总分最高的一条。
分数用累计对数概率(把连乘变连加,避免概率连乘下溢):
\[ \text{score}(y_{1:t})=\sum_{i=1}^{t}\log p(y_i\mid y_{<i}) \]
实现
import numpy as np
def beam_search(step_fn, start_token, eos_token, beam_size=3, max_len=10,
length_penalty=0.0):
"""
step_fn(seq) -> 长度为 vocab 的 log-prob 向量(给定已生成序列,预测下一个 token)
返回得分最高的 (score, seq)
"""
beams = [(0.0, [start_token])] # (累计 log-prob, 序列)
finished = []
for _ in range(max_len):
candidates = []
for score, seq in beams:
if seq[-1] == eos_token: # 已结束,移入 finished
finished.append((score, seq))
continue
log_probs = step_fn(seq)
for tok in np.argsort(log_probs)[-beam_size:]: # 每束只展开 top-k
candidates.append((score + log_probs[tok], seq + [int(tok)]))
if not candidates:
break
candidates.sort(key=lambda t: t[0], reverse=True)
beams = candidates[:beam_size] # 全局剪枝回 k 条
finished.extend(beams)
def norm(item): # 长度惩罚
s, seq = item
return s / (len(seq) ** length_penalty) if length_penalty else s
return max(finished, key=norm)
关键追问
- 为什么要长度惩罚? 累计 log-prob 恒为负,越长分越低,不加惩罚会偏爱短句。常用 \(\text{score}/|y|^{\alpha}\)(\(\alpha\approx0.6\sim1.0\))。
- Beam Search 保证最优吗? 不保证。每步全局只留 \(k\) 条,可能把「当下分低、后面才翻盘」的路径提前剪掉,所以它是启发式近似而非精确搜索。
- beam 越大越好吗? 不是。\(k\) 增大计算量线性增长,且实践中过大反而更易产生通用、乏味的句子(beam search curse);开放式生成常改用 top-k / top-p 采样。
- 复杂度? 每步约 \(O(k\cdot V)\)(\(V\) 为词表),整体 \(O(k V \cdot L)\)。
- 和 Greedy 的关系? \(k=1\) 时 Beam Search 退化为 Greedy Search。
6.4 Top-K / Top-P 采样
原理与直觉
Greedy 每步取最大值,输出死板且容易复读;直接按全词表采样又会偶尔抽到概率极低的垃圾 token(词表 15 万个,长尾加起来的概率不小)。两种截断办法:
- Top-K:只留概率最高的 \(K\) 个,重新归一化后采样。简单,但 \(K\) 是固定的——分布尖锐时放进太多垃圾,分布平坦时又砍掉合理选项。
- Top-P(Nucleus):把 token 按概率从高到低排序,取累计概率刚好超过 \(p\) 的最小集合。候选集大小随分布自动伸缩,是它相对 Top-K 的核心优势。
温度在截断之前作用于 logits:\(p_i=\dfrac{\exp(z_i/T)}{\sum_j\exp(z_j/T)}\),\(T<1\) 让分布更尖,\(T>1\) 更平。
实现
import numpy as np
def softmax(x):
"""这里输入是单条一维 logits,所以可以用不带 axis 的简版(见第 4 节的说明)"""
x = x - np.max(x)
e = np.exp(x)
return e / e.sum()
def top_k_sampling(logits, k=50, temperature=1.0, rng=None):
"""logits: shape (vocab,);返回采样得到的 token id"""
rng = rng or np.random.default_rng()
# 温度缩放要在 softmax 之前作用于 logits;max(..., 1e-8) 防止 T=0 时除零
logits = np.asarray(logits, dtype=float) / max(temperature, 1e-8)
k = min(k, logits.size) # k 比词表还大时退化成全采样
# argpartition 只保证第 k 大的元素就位、左右两边各自不排序,O(V) 比全排序 O(VlogV) 快
idx = np.argpartition(logits, -k)[-k:] # 取最大的 k 个的下标
probs = softmax(logits[idx]) # 只在候选集内归一化(截断后必须重新归一)
return int(rng.choice(idx, p=probs))
def top_p_sampling(logits, p=0.9, temperature=1.0, rng=None):
"""核采样:取累计概率刚好超过 p 的最小候选集"""
rng = rng or np.random.default_rng()
logits = np.asarray(logits, dtype=float) / max(temperature, 1e-8)
probs = softmax(logits)
order = np.argsort(probs)[::-1] # 从大到小
cumsum = np.cumsum(probs[order])
cutoff = np.searchsorted(cumsum, p) + 1 # 至少保留 1 个
idx = order[:cutoff]
kept = probs[idx] / probs[idx].sum() # 截断后重新归一化
return int(rng.choice(idx, p=kept))
关键追问
- 截断后为什么必须重新归一化? 砍掉长尾后剩下的概率加起来小于 1,不归一化就不是合法分布,
rng.choice也会直接报错。 - Top-K 一定要全排序吗? 不用。
np.argpartition是 \(O(V)\) 的选择算法,只保证第 \(k\) 位左右分开、不保证组内有序,比 \(O(V\log V)\) 的全排序快。Top-P 因为需要累计和,才不得不排序。 - \(T\to0\) 会怎样? 退化成 greedy(最大值的概率趋于 1)。实现上要防除零,所以写成
max(temperature, 1e-8);工程里通常直接判断T == 0就走 argmax。 - 两者能一起用吗? 能,而且常见。工业实现一般先温度缩放,再 top-k 粗筛,再 top-p 细筛,最后采样。
- 为什么不直接在全词表上采样? 长尾 token 单个概率极低,但数量巨大,累积起来仍会被抽中,一旦抽到就毁掉整句。截断的本质是「砍掉不可能选项,保留合理的随机性」。
7. 决策树:信息熵与信息增益
决策树每一步要决定「先问哪个特征、在哪切」,标准是:切完之后子集最纯。
- 熵(Entropy):\(H(y)=-\sum_c p_c\log_2 p_c\),衡量混乱程度。全是同一类 → \(H=0\)(最纯);各类均匀 → \(H\) 最大(最乱)。
- 信息增益(Information Gain):\(IG=H(\text{父})-\sum_{\text{子}}\frac{n_{\text{子}}}{n}H(\text{子})\),即「切分前的混乱度 \(-\) 切分后子集混乱度的加权平均」。谁让混乱度下降最多(\(IG\) 最大),就选谁分裂。
import numpy as np
def calculate_entropy(y):
# y 是标签数组,比如 [0, 0, 1, 1, 1]
n_samples = len(y)
if n_samples == 0:
return 0.0
_, counts = np.unique(y, return_counts=True) # 统计各类别频次
probabilities = counts / n_samples
# 核心公式:Entropy = - sum(p * log2(p));np.unique 保证 p>0
return -np.sum(probabilities * np.log2(probabilities))
def information_gain(y_parent, y_left, y_right):
n = len(y_parent)
# 子节点熵的加权平均,权重是落进该子节点的样本占比
# 必须加权:否则一个只有 2 个样本的纯净子节点会把整个分裂的评分骗高
child = (len(y_left) / n) * calculate_entropy(y_left) \
+ (len(y_right) / n) * calculate_entropy(y_right)
# 切分前的混乱度 - 切分后的期望混乱度,越大说明这一刀切得越有效
return calculate_entropy(y_parent) - child
面试最常让手写的就是 calculate_entropy;Gini 不纯度 \(1-\sum_c p_c^2\) 是另一个常见替代(CART 默认)。
8. SVM(支持向量机)
面试极少让手写完整的 SMO 优化算法(太长),重点是能讲清几何直觉:
- 最大间隔:能把两类分开的超平面有无数条,SVM 要找走在正中间、两边最空旷的那条——即让「离它最近的点(支持向量)到它的间隔」最大化,泛化更好。
- 核技巧(Kernel Trick):线性不可分时(比如一类被另一类包围),用核函数(如 RBF 高斯核)把数据隐式映射到高维空间,在高维里一刀切开,而无需显式计算高维坐标。
- 代码通常直接调库;
C越大越不容忍误分类(间隔越硬、越容易过拟合)。
from sklearn.svm import SVC
clf = SVC(kernel='rbf', C=1.0) # RBF(高斯核)支持向量机
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
8.1 Cosine Similarity
原理与直觉
\[ \cos(a,b)=\frac{a\cdot b}{\|a\|\,\|b\|} \]
分子是内积,分母把两个向量的长度除掉,所以它只看方向、不看长度,取值范围 \([-1,1]\)。
这正是它在检索和 RAG 里的价值:一篇长文档的 embedding 模长通常更大,如果直接用内积,长文档会天然占便宜;除掉模长之后,比较的才是「语义方向像不像」。
和欧氏距离的关系:如果向量已做 L2 归一化,则 \(\|a-b\|^2=2-2\cos(a,b)\)——两者单调等价。所以向量库通常先把所有向量归一化,之后只需算内积,省掉除法。
实现
import numpy as np
def cosine_similarity(a, b, eps=1e-8):
"""两个一维向量的余弦相似度"""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + eps))
def cosine_similarity_matrix(A, B, eps=1e-8):
"""
A: shape (n, d) B: shape (m, d)
返回 shape (n, m),A 中每一行与 B 中每一行的相似度
"""
A = np.asarray(A, dtype=float)
B = np.asarray(B, dtype=float)
A_norm = A / (np.linalg.norm(A, axis=1, keepdims=True) + eps)
B_norm = B / (np.linalg.norm(B, axis=1, keepdims=True) + eps)
return A_norm @ B_norm.T # 归一化之后,内积就是余弦
关键追问
- 为什么要
eps? 零向量的模长是 0,直接除会得到nan。检索场景里空文本、全 padding 的行都可能产生零向量。 - 批量版为什么先归一化再矩阵乘? 归一化是 \(O(nd)\),之后一次矩阵乘就能拿到全部 \(n\times m\) 对的相似度,交给 BLAS 跑;逐对循环则是 Python 层的 \(O(nm)\) 次调用,慢几个数量级。
- 余弦相似度是距离吗? 不是。它越大越相似,与距离方向相反;常用 \(1-\cos\) 当作「余弦距离」,但它不满足三角不等式,不是严格的度量。
- 什么时候不该用它? 当模长本身有意义时(比如用词频计数向量表示强度、或推荐里的置信度),归一化会把这部分信息扔掉。
8.2 Precision / Recall / F1
原理与直觉
从混淆矩阵出发(以正类为关注对象):
\[ \text{Precision}=\frac{TP}{TP+FP},\qquad \text{Recall}=\frac{TP}{TP+FN},\qquad F_1=\frac{2PR}{P+R} \]
一句话记法:
- Precision(查准率):我说是正的里面,真的有多少。分母是「我预测的正类」,管的是别误报。
- Recall(查全率):真正的正类里面,我抓到了多少。分母是「实际的正类」,管的是别漏报。
两者天然对立:把阈值调低,什么都判成正类 → recall 冲到 1、precision 崩掉;阈值调高只报最有把握的 → precision 高、recall 低。F1 是两者的调和平均,用调和平均而不是算术平均,是因为它对偏科更狠——0.9 和 0.1 的算术平均是 0.5,F1 只有 0.18。
实现
import numpy as np
def precision_recall(y_true, y_pred, eps=1e-12):
"""二分类,标签为 0/1;返回 precision、recall、f1"""
y_true = np.asarray(y_true).astype(int)
y_pred = np.asarray(y_pred).astype(int)
tp = int(np.sum((y_pred == 1) & (y_true == 1)))
fp = int(np.sum((y_pred == 1) & (y_true == 0)))
fn = int(np.sum((y_pred == 0) & (y_true == 1)))
precision = tp / (tp + fp + eps)
recall = tp / (tp + fn + eps)
f1 = 2 * precision * recall / (precision + recall + eps)
return precision, recall, f1
def precision_recall_multiclass(y_true, y_pred, num_classes, average='macro'):
"""多分类:对每个类别做一次 one-vs-rest,再平均"""
scores = []
weights = []
for c in range(num_classes):
p, r, f = precision_recall(y_true == c, y_pred == c)
scores.append((p, r, f))
weights.append(np.sum(y_true == c))
scores = np.array(scores, dtype=float)
if average == 'macro': # 每类等权,小类同样重要
return tuple(scores.mean(axis=0))
weights = np.array(weights, dtype=float) # weighted:按样本数加权
return tuple(scores.T @ weights / weights.sum())
关键追问
- 分母为 0 怎么办? 一个正类都没预测出来时 \(TP+FP=0\)。加
eps只是防崩,业务上应显式约定返回 0 并告警——这种情况通常意味着阈值或训练本身有问题。sklearn 的做法是zero_division参数。 - 为什么不用 accuracy? 类别不平衡时它没有信息量:1% 正例的数据,全预测成负类就有 99% accuracy,而 recall 是 0。
- macro 和 micro 有什么区别? macro 对每个类别单独算再取平均,小类和大类等权;micro 是把所有类别的 TP/FP/FN 汇总后再算,被大类主导(多分类单标签下 micro-F1 等于 accuracy)。关心稀有类就看 macro。
- precision 和 recall 谁更重要? 取决于错误的代价:垃圾邮件过滤怕误杀正常邮件 → 重 precision;癌症筛查怕漏诊 → 重 recall。也可以用 \(F_\beta\) 调整偏好,\(\beta>1\) 偏 recall。
- 和 PR-AUC 的关系? 上面算的是某一个阈值下的一组数;扫遍所有阈值把 (recall, precision) 连成曲线,其下面积就是 PR-AUC,不受阈值选择影响。不平衡数据上它比 ROC-AUC 更敏感(见 02. 模型评估与指标)。
目录
| 章节 |
|---|
| 00. 机器学习核心概念 |
| 01. 基础与神经网络机制 |
| 02. 模型评估与指标 |
| 04. 经典机器学习 |
| 05. NLP、RNN 与词向量 |
| 06. LLM 基础 |
| 07. 训练与系统 |
| 08. 对齐与 RLHF |
| 09. 推理与部署 |
| 12. ML Coding |
| 参考资料 |