📑 本页目录(点开跳转)
附录 C · 手撕代码速查
📌 面试官说「来,白板写一个 XXX」的时候用。不要通读,
Ctrl+F搜你要的那一个。
🧭 先说清楚这份附录和附录 B 的区别
它们是两种完全不同的代码,混着用两边都会坏。
| 附录 B · 代码速查 | 附录 C(这里) | |
|---|---|---|
| 定位 | 能跑的模板 | 能默写的核心 |
| 写法 | 调 sklearn / PyTorch 高层 API | 只用 numpy / 裸 torch,不许调那个 API 本身 |
| 目的 | 让你三分钟把实验跑起来 | 让面试官看到你知道每一步的张量形状 |
| 生产里用哪个 | ⭐ 用 B | ❌ 别用 C —— 框架的实现有数值稳定处理、有算子融合、有 CUDA 核 |
⚠️ 所以这份附录不是「更好的实现」,是「拆开给你看的实现」。
这里只收哪些题
⭐ 收录判据只有一条:正文已经把原理讲透了、只缺代码形态的。
教程正文有一条硬规矩——「代码要能直接复制到空文件跑」,这条规矩把全站的代码都塑造成了「调高层 API」的样子。结果是:一个把 298 页读透的人,能讲清楚 $\text{softmax}(QK^\top/\sqrt d)V$ 里 $\sqrt d$ 是怎么来的(第 14 章推过方差),但从没写过一行张量代码。这份附录补的就是那一跳。
每一题三段式:一句话说清它在干什么 → 20–40 行能默写的实现 → 一到两个最容易写错的地方 → 🔗 回到讲原理的那一章。
无论写哪一题,面试官都会追这三件事
| 追问 | 你要能立刻答 |
|---|---|
| 形状对不对 | 每一行之后张量是什么 shape。⭐ 写的时候就把 shape 写在注释里,是最省事的防错手段 |
| 边界情况 | 空输入 / 除零 / 全相同的分数 / batch=1 / 全是同一类 |
| 复杂度 | 时间和空间各是多少,瓶颈在哪一行 |
⚠️ 关于「实跑」:下面每段代码都在干净的 Python 进程里单独跑过(
python xxx.py,不是在已经 import 过 numpy 的交互式环境里),环境为 Python 3.13 / numpy 2.4 / torch 2.13。每段输出都贴在代码下面,是真实跑出来的。但请注意版本差异——不同 torch 版本浮点末位可能不同。
1️⃣ Softmax + 交叉熵:前向和反向
这段在干什么:把一排任意实数(logits)变成和为 1 的概率,再算「真实那一类的概率有多低」当损失;反向那三行是整个深度学习里最该背下来的一个梯度。
import numpy as np
def softmax(z): # z: (N, C)
z = z - z.max(axis=1, keepdims=True) # ⭐ 减最大值,防 exp 溢出
e = np.exp(z)
return e / e.sum(axis=1, keepdims=True)
def cross_entropy(z, y): # y: (N,) 整数标签
p = softmax(z) # (N, C)
n = z.shape[0]
loss = -np.log(p[np.arange(n), y] + 1e-12).mean() # ⭐ 只取真实类那一项
grad = p.copy()
grad[np.arange(n), y] -= 1.0 # ⭐ dL/dz = p − onehot(y)
grad /= n # ⭐ 前向用了 mean(),反向就要除 n
return loss, grad
np.random.seed(0)
z = np.random.randn(4, 3) * 2
y = np.array([0, 2, 1, 1])
loss, grad = cross_entropy(z, y)
print(f"loss = {loss:.4f}")
# 数值梯度校验(和第 8 章 / 附录 B 的 grad_check 是同一招)
eps, num = 1e-5, np.zeros_like(z)
for i in range(z.shape[0]):
for j in range(z.shape[1]):
zp = z.copy(); zp[i, j] += eps
zm = z.copy(); zm[i, j] -= eps
num[i, j] = (cross_entropy(zp, y)[0] - cross_entropy(zm, y)[0]) / (2 * eps)
print("解析梯度 vs 数值梯度,最大误差:", np.abs(grad - num).max())
loss = 3.0697
解析梯度 vs 数值梯度,最大误差: 2.2784607534021006e-10
⚠️ 最容易错:
- 忘了
z - z.max()。z里出现 90 这种数,np.exp(90)就溢出成inf,整个输出变nan。⭐ 减一个常数不改变 softmax 的结果(分子分母同时约掉 $e^{-c}$),所以这是白送的稳定性。 - 前向
.mean()了,反向忘了/n。梯度会大 N 倍,表现是「换了 batch size 就要重调学习率」,很难查。 - 追问「为什么 $\partial L/\partial z$ 这么干净」:softmax 的雅可比矩阵和 $\log$ 的导数正好抵消掉。这就是第 3 章那句「交叉熵配 Sigmoid,梯度正好是(预测 − 真实)」的多分类版本。
🔗 原理在:03 · 线性模型(为什么分类不用 MSE 用交叉熵)、08 · 反向传播(数值梯度校验)
2️⃣ 缩放点积注意力(含因果掩码)
这段在干什么:拿 Q 去和每个 K 算相关度 → 除以 $\sqrt{d_k}$ → softmax 成权重 → 按权重把 V 加权求和。一次可微分的、软的查表。
import torch
import torch.nn.functional as F
def attention(q, k, v, causal=False): # q,k,v: (B, H, L, d)
d = q.size(-1)
s = q @ k.transpose(-2, -1) / d ** 0.5 # ⭐ 除 √d_k:第 14 章那个方差推导
if causal:
L = s.size(-1)
mask = torch.ones(L, L, dtype=torch.bool, device=q.device).triu(1) # ⭐ 严格上三角
s = s.masked_fill(mask, float("-inf")) # ⭐ 填 -inf,不是填 0
w = s.softmax(dim=-1) # (B, H, L, L)
return w @ v, w # (B, H, L, d)
torch.manual_seed(0)
q, k, v = (torch.randn(2, 4, 6, 16) for _ in range(3))
out, w = attention(q, k, v, causal=True)
print("输出形状", tuple(out.shape), " 权重形状", tuple(w.shape))
print("第 1 个位置只看得到自己:", w[0, 0, 0].tolist())
print("每行权重和:", w.sum(-1)[0, 0].tolist())
ref = F.scaled_dot_product_attention(q, k, v, is_causal=True)
print("与 PyTorch 官方实现最大误差:", (out - ref).abs().max().item())
输出形状 (2, 4, 6, 16) 权重形状 (2, 4, 6, 6)
第 1 个位置只看得到自己: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]
每行权重和: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
与 PyTorch 官方实现最大误差: 2.384185791015625e-07
⚠️ 最容易错:
- 掩码要填
-inf,不能填 0。填 0 之后 softmax 还会给它 $e^0=1$ 的权重——不但没挡住,还成了权重最大的那几个。 triu(1)里的1不能少。diagonal=0会把对角线(看自己)也挡掉,第一行全是-inf,softmax 直接出nan。- 除的是 $\sqrt{d_k}$(每个头的维度),不是 $\sqrt{d_{model}}$。单头时两者相同,多头时差 $\sqrt{h}$ 倍——这是最容易在多头那一步埋进去的错。
- 追问「为什么要除」:QK 点积的方差是 $d$,$d=512$ 时标准差约 22.6,softmax 输入这么大会饱和成 one-hot、梯度趋近 0 训不动。除以 $\sqrt d$ 把方差拉回 1。
🔗 原理在:14 · 通往 Transformer($\sqrt{d_k}$ 的方差推导、注意力 = 可微的软查表)
3️⃣ 多头注意力 MHA ⭐ 最高频的一道
这段在干什么:把 $d_{model}$ 劈成 $h$ 份,每份独立做一次上面那个注意力(各抓一种模式),最后拼回来再过一个输出投影混合。
import torch
import torch.nn as nn
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, n_head):
super().__init__()
assert d_model % n_head == 0 # ⭐ 必须整除
self.h, self.dk = n_head, d_model // n_head
self.qkv = nn.Linear(d_model, 3 * d_model) # 一次算完 Q K V
self.proj = nn.Linear(d_model, d_model) # ⭐ 输出投影,别漏
def forward(self, x, causal=True): # x: (B, L, d_model)
B, L, D = x.shape
q, k, v = self.qkv(x).chunk(3, dim=-1) # 各 (B, L, D)
# ⭐ 必须先 view 再 transpose:(B,L,D) -> (B,L,h,dk) -> (B,h,L,dk)
q, k, v = [t.view(B, L, self.h, self.dk).transpose(1, 2) for t in (q, k, v)]
s = q @ k.transpose(-2, -1) / self.dk ** 0.5 # (B, h, L, L)
if causal:
m = torch.ones(L, L, dtype=torch.bool, device=x.device).triu(1)
s = s.masked_fill(m, float("-inf"))
o = s.softmax(-1) @ v # (B, h, L, dk)
o = o.transpose(1, 2).contiguous().view(B, L, D) # ⭐ 拼回 (B, L, D)
return self.proj(o)
torch.manual_seed(0)
mha = MultiHeadAttention(64, 8)
x = torch.randn(2, 10, 64)
print("输出形状", tuple(mha(x).shape))
print("参数量", sum(p.numel() for p in mha.parameters()))
输出形状 (2, 10, 64)
参数量 16640
⚠️ 最容易错(这三条是这道题的全部考点):
- ⭐
view和transpose的顺序反了。必须view(B, L, h, dk).transpose(1, 2)。直接写成view(B, h, L, dk)会把不同位置的向量塞进同一个头——形状完全正确、不报错、还能训,但语义全错。这是白板上最常见的一个错,也是面试官最想抓的一个。 - 拼回去之前要
.contiguous()。transpose只改了 stride 没搬内存,直接view会报view size is not compatible with input tensor's size and stride。 - 别漏最后的
self.proj。没有它,$h$ 个头各算各的、从头到尾没混合过,多头就退化成了「$h$ 个互不通气的小注意力」。 - 追问「参数量怎么来的」:$3d^2+3d$(qkv)$+\ d^2+d$(proj)$=4d^2+4d$。$d=64$ 时 $4\times4096+256=16640$,和跑出来的一致。
🔗 原理在:14 · 通往 Transformer(多头 = CNN 的多个卷积核,各抓一种模式)
4️⃣ LayerNorm
这段在干什么:对一个样本自己的所有特征做归一化(横着算),再用两个可学参数把尺度放回来。
import torch
import torch.nn as nn
class LayerNorm(nn.Module):
def __init__(self, dim, eps=1e-5):
super().__init__()
self.g = nn.Parameter(torch.ones(dim)) # ⭐ 缩放,初始 1
self.b = nn.Parameter(torch.zeros(dim)) # ⭐ 平移,初始 0
self.eps = eps
def forward(self, x): # x: (..., dim)
mu = x.mean(-1, keepdim=True) # ⭐ 只在最后一维统计
var = x.var(-1, unbiased=False, keepdim=True) # ⭐ 有偏方差,分母是 n
return (x - mu) / torch.sqrt(var + self.eps) * self.g + self.b
# ↑ eps 在 sqrt 里面
torch.manual_seed(0)
x = torch.randn(3, 5, 8) * 4 + 7
mine, ref = LayerNorm(8), nn.LayerNorm(8)
print("与 nn.LayerNorm 最大误差:", (mine(x) - ref(x)).abs().max().item())
y = mine(x)
print("归一化后一个样本的均值 / 标准差:",
round(y.mean(-1)[0, 0].item(), 6), round(y.std(-1, unbiased=False)[0, 0].item(), 4))
与 nn.LayerNorm 最大误差: 3.5762786865234375e-07
归一化后一个样本的均值 / 标准差: -0.0 1.0
⚠️ 最容易错:
- 方差必须用有偏(
unbiased=False,分母 n)。写unbiased=True数值就和框架对不上,小dim时差得很明显。 eps要放在sqrt里面:$\sqrt{\sigma^2+\epsilon}$,不是 $\sqrt{\sigma^2}+\epsilon$。- 归一化的是最后一维,不是 batch 维——这一行就是它和 BatchNorm 的全部区别。
- 追问「为什么 Transformer 用 LN 不用 BN」:① 序列长度可变,padding 会污染 batch 统计量 ② 推理时 batch=1,BN 只能吃 running stats,分布一偏移就崩;LN 不跨样本,batch=1 和 batch=1000 结果完全一样。
🔗 原理在:10 · 正则化全家桶(BN / LN 的归一化维度对比、为什么 Transformer 用 LN)
5️⃣ BatchNorm(含 train / eval 分支和 running stats)
这段在干什么:对一个 batch 内的同一个特征做归一化(竖着算)。⭐ 难点全在「训练和推理用的统计量不是同一个」这件事上。
import torch
import torch.nn as nn
class BatchNorm1d(nn.Module):
def __init__(self, dim, eps=1e-5, momentum=0.1):
super().__init__()
self.g = nn.Parameter(torch.ones(dim))
self.b = nn.Parameter(torch.zeros(dim))
self.register_buffer("running_mean", torch.zeros(dim)) # ⭐ buffer 不是 Parameter
self.register_buffer("running_var", torch.ones(dim))
self.eps, self.momentum = eps, momentum
def forward(self, x): # x: (N, dim)
if self.training: # ⭐ 训练:用这一批的统计量
mu = x.mean(0)
var = x.var(0, unbiased=False) # ⭐ 归一化用有偏
n = x.shape[0]
with torch.no_grad():
self.running_mean.mul_(1 - self.momentum).add_(self.momentum * mu)
# ⭐ 但 running_var 存的是无偏(× n/(n−1)),和 PyTorch 对齐
self.running_var.mul_(1 - self.momentum).add_(
self.momentum * var * n / (n - 1))
else: # ⭐ 推理:用滑动平均
mu, var = self.running_mean, self.running_var
return (x - mu) / torch.sqrt(var + self.eps) * self.g + self.b
torch.manual_seed(0)
mine, ref = BatchNorm1d(4), nn.BatchNorm1d(4)
for _ in range(20):
x = torch.randn(32, 4) * 3 + 1
mine(x); ref(x) # 训练模式,累积 running stats
print("running_mean 误差:", (mine.running_mean - ref.running_mean).abs().max().item())
print("running_var 误差:", (mine.running_var - ref.running_var).abs().max().item())
mine.eval(); ref.eval()
xt = torch.randn(5, 4)
print("eval 模式输出误差:", (mine(xt) - ref(xt)).abs().max().item())
running_mean 误差: 1.1920928955078125e-07
running_var 误差: 0.0
eval 模式输出误差: 1.1920928955078125e-07
⚠️ 最容易错:
- ⭐ 归一化用有偏方差,但
running_var存的是无偏方差(要乘 $n/(n-1)$)。PyTorch 就是这么实现的,这一行是和框架对不齐的头号原因,也是面试官最喜欢追的细节。 running_mean/running_var要register_buffer,不能是Parameter:它们不参与梯度更新(所以更新要包在torch.no_grad()里),但必须跟着state_dict一起存盘。- 忘了写
else分支 = 推理时还在用当前 batch 的统计量。batch=1时方差恒为 0,输出直接爆掉;batch=8时结果每次都不一样。 - 追问「BN 的三个坑」:小 batch(<16)统计不准 → 换 GroupNorm;BN 前面的卷积不需要 bias(会被减掉);微调冻结主干时记得
bn.eval(),否则 running stats 被新数据带跑。
🔗 原理在:10 · 正则化全家桶(BN 的三个坑、BN vs LN)
6️⃣ Dropout(训练 / 推理的差异在哪)
这段在干什么:训练时按概率 $p$ 随机把一部分激活置零,逼网络别依赖某个特定神经元。⭐ 考点从来不是「随机置零」,是「置零之后期望变了,怎么补回来」。
import torch
def dropout(x, p=0.5, training=True):
if not training or p == 0:
return x # ⭐ 推理:原样返回,什么都不做
if p == 1:
return torch.zeros_like(x) # 边界情况,否则下面除 0
mask = (torch.rand_like(x) >= p).float() # ⭐ 保留概率是 1−p
return x * mask / (1 - p) # ⭐ inverted dropout:训练时就除回来
torch.manual_seed(0)
x = torch.ones(200000)
y = dropout(x, p=0.3, training=True)
print(f"被置零的比例 {(y == 0).float().mean():.4f} (应 ≈ p = 0.3)")
print(f"训练时输出均值 {y.mean():.4f} 推理时输出均值 {dropout(x, 0.3, False).mean():.4f}")
被置零的比例 0.2988 (应 ≈ p = 0.3)
训练时输出均值 1.0018 推理时输出均值 1.0000
⚠️ 最容易错:
- ⭐ 缩放放在哪一边。丢掉 30% 之后,这一层输出的期望只剩原来的 70%,下一层看到的输入分布就变了。两种补法: ① inverted dropout(现代框架全都用这个)——训练时除以 $1-p$,推理时零成本; ② 原始 dropout——训练时不动,推理时整层乘 $1-p$。 两者数学上等价,但②让推理多一次乘法。⭐ 面试答出「为什么选①」比背出公式更值钱。
p是丢弃概率不是保留概率。掩码要写rand >= p(保留 $1-p$),写反了就是丢 70% 留 30%。- 推理时忘了关:自检方法很简单——同一个输入连跑两次,输出不一样就是忘了
model.eval()。 - 追问「为什么有效」:① 逼网络不能依赖某个特定神经元 → 学到冗余表示 ② 每次训练的是一个不同的子网络 → 隐式集成(1000 个神经元的层相当于集成 $2^{1000}$ 个网络的平均)。
🔗 原理在:10 · 正则化全家桶(Dropout 为什么防过拟合、忘了 eval() 的两个症状)
7️⃣ 卷积前向 Conv2d
这段在干什么:一个小窗口在图上滑,每停一次做一次「逐元素相乘再求和」。⭐ 写对这段的关键是记住:核在空间上滑动,但在通道上是「一次吃掉全部」。
import numpy as np
def conv2d(x, w, b=None, stride=1, pad=0):
N, Cin, H, W = x.shape # x: (N, Cin, H, W)
Cout, _, kh, kw = w.shape # w: (Cout, Cin, kh, kw)
Ho = (H + 2 * pad - kh) // stride + 1 # ⭐ 输出尺寸公式,向下取整
Wo = (W + 2 * pad - kw) // stride + 1
xp = np.pad(x, ((0, 0), (0, 0), (pad, pad), (pad, pad))) # ⭐ 只补 H/W 两维
out = np.zeros((N, Cout, Ho, Wo))
for i in range(Ho):
for j in range(Wo):
patch = xp[:, :, i*stride:i*stride+kh, j*stride:j*stride+kw] # (N,Cin,kh,kw)
# ⭐ 对 Cin、kh、kw 三个维度一起求和 —— 通道是一次吃掉全部
out[:, :, i, j] = np.tensordot(patch, w, axes=([1, 2, 3], [1, 2, 3]))
return out + (b.reshape(1, -1, 1, 1) if b is not None else 0)
np.random.seed(0)
x = np.random.randn(2, 3, 8, 8)
w = np.random.randn(4, 3, 3, 3)
b = np.random.randn(4)
out = conv2d(x, w, b, stride=2, pad=1)
print("输出形状", out.shape, " 参数量", w.size + b.size)
import torch
import torch.nn.functional as F
ref = F.conv2d(torch.tensor(x), torch.tensor(w), torch.tensor(b), stride=2, padding=1)
print("与 F.conv2d 最大误差:", np.abs(out - ref.numpy()).max())
输出形状 (2, 4, 4, 4) 参数量 112
与 F.conv2d 最大误差: 0.0
⚠️ 最容易错:
- ⭐ 输出尺寸公式:$\left\lfloor\dfrac{H+2p-k}{s}\right\rfloor+1$。上面这个例子 $\lfloor(8+2-3)/2\rfloor+1=4$。记不住就用
padding = kernel_size // 2配stride=1,尺寸不变。 np.pad只补 H/W。把 batch 和通道维也补了是最常见的低级错,形状不会立刻报错但结果全乱。- 参数量 $= k\times k\times C_{in}\times C_{out}\ (+\,C_{out}$ 个 bias$)$,上面是 $3\times3\times3\times4+4=112$,和跑出来一致。
- 追问「这到底是不是卷积」:⭐ 严格说不是,数学上的卷积要先把核翻转 180°,这里没翻转,所以它是互相关(cross-correlation)。所有深度学习框架都这么做——因为核是学出来的,翻不翻转只影响「学出来的核长什么样」,不影响表达能力。能主动说出这一条是加分项。
🔗 原理在:12 · CNN(多通道怎么回事、参数量、感受野)
8️⃣ K-means
这段在干什么:① 归到最近的中心 ② 中心移到自己那群点的重心 ③ 重复到不动。两步交替,就这么简单。
import numpy as np
def kmeans(X, k, iters=100, seed=0):
rng = np.random.default_rng(seed)
C = X[rng.choice(len(X), k, replace=False)].copy() # ⭐ 从样本里挑初始中心
lab = np.zeros(len(X), int)
for _ in range(iters):
d = ((X[:, None, :] - C[None, :, :]) ** 2).sum(-1) # (n,k) 广播算距离
lab = d.argmin(1) # ① 归到最近的中心
newC = np.array([X[lab == j].mean(0) if (lab == j).any() else C[j]
for j in range(k)]) # ② 移到重心(⭐ 处理空簇)
if np.allclose(newC, C): # ⭐ 中心不动 = 收敛
break
C = newC
return lab, C, ((X - C[lab]) ** 2).sum() # 第三个返回值是惯性
rng = np.random.default_rng(0)
X = np.vstack([rng.normal(m, 0.6, (150, 2)) for m in ([0, 0], [4, 4], [0, 5])])
best = min((kmeans(X, 3, seed=s) for s in range(10)), key=lambda r: r[2]) # ⭐ n_init=10
print(f"惯性 = {best[2]:.1f} 每簇样本数 = {np.bincount(best[0])}")
from sklearn.cluster import KMeans
print(f"sklearn 惯性 = {KMeans(3, n_init=10, random_state=0).fit(X).inertia_:.1f}")
惯性 = 303.1 每簇样本数 = [149 150 151]
sklearn 惯性 = 303.1
⚠️ 最容易错:
- 初始中心要从样本里挑,别在空间里随机撒。随机撒很容易撒到没有点的地方,那个簇一开始就是空的。
- ⭐ 必须处理空簇(上面那个
if (lab == j).any() else C[j])。不处理的话X[lab==j].mean(0)对空数组求均值会返回nan,然后所有距离都变nan,整个算法静默地烂掉——不报错,只是结果不对。 - 收敛判据用「中心不再变」(
np.allclose),比用「标签不再变」数值上更稳。 - 追问:复杂度 $O(n\cdot k\cdot d\cdot \text{iters})$;损失函数就是那个惯性(簇内平方和),两步交替各自都不会让它上升,所以一定收敛——但只保证收敛到局部最优,这就是要跑
n_init=10次取最好的原因。
🔗 原理在:06 · 无监督:聚类与降维(K-Means 的五个坑、为什么只能找球形簇)
9️⃣ AUC 的秩和实现(O(n log n))
这段在干什么:AUC 的定义是「随机取一个正样本一个负样本,正的排在前面的概率」。⭐ 这个定义本身就是一个算法——不用画 ROC 曲线,排个序数秩就行。
import numpy as np
def auc(y, score):
y, score = np.asarray(y), np.asarray(score)
order = np.argsort(score, kind="mergesort") # ⭐ O(n log n),瓶颈就这一行
ranks = np.empty(len(y), float)
ranks[order] = np.arange(1, len(y) + 1) # ⭐ 秩从 1 开始
s = score[order]
i = 0
while i < len(s): # ⭐ 分数相同的必须取平均秩
j = i
while j + 1 < len(s) and s[j + 1] == s[i]:
j += 1
if j > i:
ranks[order[i:j + 1]] = (i + j + 2) / 2
i = j + 1
P, N = int((y == 1).sum()), int((y == 0).sum())
return (ranks[y == 1].sum() - P * (P + 1) / 2) / (P * N) # ⭐ 秩和公式
rng = np.random.default_rng(0)
y = rng.integers(0, 2, 2000)
score = np.round(rng.normal(y * 1.2, 1.0), 1) # 四舍五入,制造大量并列
print(f"我的 AUC {auc(y, score):.6f}")
from sklearn.metrics import roc_auc_score
print(f"sklearn {roc_auc_score(y, score):.6f}")
print(f"分数整体 ×100 再 +5 之后 {auc(y, score * 100 + 5):.6f}")
我的 AUC 0.807631
sklearn 0.807631
分数整体 ×100 再 +5 之后 0.807631
⚠️ 最容易错:
- ⭐ 并列分数必须取平均秩。这不是学术洁癖:模型输出一旦被四舍五入、或者用了树模型(大量样本落进同一个叶子拿到完全相同的分数),并列就是常态,不处理算出来的 AUC 是错的。上面那个例子故意
round(..., 1)就是为了触发它。 - 秩从 1 开始不是 0,公式里的 $\frac{P(P+1)}{2}$ 是「$P$ 个正样本全排在最前面时的秩和」,减掉它才是「赢了多少对」。
- 复杂度 $O(n\log n)$,瓶颈在排序——「指出复杂度」是这道题的标配追问。
- ⭐ 最经典的追问:对预测分数做单调变换,AUC 会变吗? 不会(实测 ×100 再 +5,还是 0.807631)。因为 AUC 只看排序。推论:两个模型 AUC 完全一样,概率校准可能天差地别——所以要发概率给下游用的时候,AUC 好不代表能直接用。
🔗 原理在:05 · 评估与过拟合(AUC 的两种等价定义 —— ROC 曲线下面积 / 随机一正一负排对的概率、以及什么时候必须改看 PR-AUC)
🔟 逻辑回归 + 梯度下降(纯 numpy)
这段在干什么:把第 3 章那句「交叉熵配 Sigmoid,梯度正好是(预测 − 真实)」写成代码,闭环。
import numpy as np
def fit_logreg(X, y, lr=0.5, epochs=2000, l2=0.0):
n, d = X.shape
w, b = np.zeros(d), 0.0 # ⭐ 逻辑回归可以零初始化(神经网络不行)
for _ in range(epochs):
p = 1 / (1 + np.exp(-(X @ w + b))) # sigmoid
# ⭐ 梯度正好是 (预测 − 真实):第 3 章那个经典考点
gw = X.T @ (p - y) / n + l2 * w # ⭐ 别忘了 /n
gb = (p - y).mean() # ⭐ 偏置不加 L2
w -= lr * gw
b -= lr * gb
return w, b
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
X, y = load_breast_cancer(return_X_y=True)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=.3, random_state=42, stratify=y)
sc = StandardScaler().fit(Xtr) # ⭐ 必须标准化,且只 fit 训练集
Xtr, Xte = sc.transform(Xtr), sc.transform(Xte)
w, b = fit_logreg(Xtr, ytr)
print(f"手写逻辑回归 测试集准确率 {((Xte @ w + b > 0) == yte).mean():.1%}")
from sklearn.linear_model import LogisticRegression
print(f"sklearn 测试集准确率 "
f"{LogisticRegression(max_iter=5000).fit(Xtr, ytr).score(Xte, yte):.1%}")
手写逻辑回归 测试集准确率 98.2%
sklearn 测试集准确率 98.8%
⚠️ 最容易错:
- 忘了
/n。梯度会随 batch 大小线性变化,换个数据量就要重调学习率。 - 忘了标准化,或者用全量数据
fitscaler。前者让梯度下降走「之」字形,收敛极慢;后者是数据泄漏——scaler 只能 fit 训练集。 - 偏置
b不要加 L2。惩罚截距没有道理(它不控制模型对输入的敏感度),而且会让模型没法拟合类别先验。 - ⚠️
np.exp(-z)在 $z$ 很负时会溢出报 warning。标准化 + 零初始化下不会碰到,但真要上生产,写成分段的数值稳定版:$z\ge0$ 时用 $1/(1+e^{-z})$,$z<0$ 时用 $e^{z}/(1+e^{z})$。 - 追问「为什么可以零初始化」:逻辑回归只有一层、没有对称性可破,梯度 $X^\top(p-y)$ 对每个权重都不同。⭐ 神经网络不行——全零会让同一层所有神经元梯度完全相同、永远学成一样的东西。
🔗 原理在:03 · 线性模型(为什么用交叉熵、为什么必须标准化、L1/L2 的区别)
📋 一页速查表
| # | 题目 | 一句话记住 | 讲原理的那一章 |
|---|---|---|---|
| 1 | Softmax + 交叉熵 | 减 max 防溢出;反向就是 $p-\text{onehot}(y)$ | 03 / 08 |
| 2 | 缩放点积注意力 | 掩码填 -inf;除的是 $\sqrt{d_k}$ 不是 $\sqrt{d_{model}}$ |
14 |
| 3 | 多头注意力 | 先 view 再 transpose;最后别漏输出投影 |
14 |
| 4 | LayerNorm | 只在最后一维统计;有偏方差;eps 在 sqrt 里 |
10 |
| 5 | BatchNorm | 归一化用有偏,running_var 存无偏;必须有 eval 分支 |
10 |
| 6 | Dropout | inverted:训练时就除 $1-p$,推理零成本 | 10 |
| 7 | 卷积前向 | $\lfloor(H+2p-k)/s\rfloor+1$;通道一次吃掉全部 | 12 |
| 8 | K-means | 必须处理空簇,否则静默出 nan |
06 |
| 9 | AUC | 并列取平均秩;$O(n\log n)$;只看排序,缩放不变 | 05 |
| 10 | 逻辑回归 | 梯度就是 $X^\top(p-y)/n$;标准化只 fit 训练集 | 03 |
🔗 还想再深一层
| 去哪 | 为什么要去那里 |
|---|---|
| 18 · 挑战项目 A:手搓 mini-torch | ⭐ 这份附录里的反向传播都是手写的。想知道框架怎么自动求出来,去那里花 6–8 小时把 autograd 搓一遍——_backward 闭包 + 拓扑排序,一共不到 100 行 |
| 附录 B · 代码速查 | 真要跑实验、真要上线,用那边的模板。这边的实现只是拆开给你看的 |
| 11 · 训练调试手册 | 你手写的层塞进网络之后训不动时,去那里按顺序排查(先做 10 样本过拟合检查) |
| 04b · XGBoost 的推导 | 树模型这边没收手撕题——因为面试通常只让你推公式(XGBoost 的叶子权重 $w^*=-G/(H+\lambda)$ 和分裂增益),不让你手写建树。那一章有完整的六步推导 |
| Kaggle 12 · 检测后处理与框融合 | ⭐ IoU 和 NMS 这两道高频手撕题,这里故意不收。不是站内没有,是本板块从头到尾不讲目标检测——收录判据只有一条「正文已经把原理讲透了、只缺代码形态」,这两道不满足前半句,收进来就成了没有上文的孤题。想拿这两道,先去那一章把 IoU 阈值怎么定、NMS 和 Soft-NMS/WBF 的差别搞清楚,代码是顺带的 |
👉 回到首页