🏠 总目录📚 本教程 附录C · 手撕代码速查
📑 本页目录(点开跳转)

附录 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

⚠️ 最容易错

🔗 原理在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

⚠️ 最容易错

🔗 原理在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

⚠️ 最容易错(这三条是这道题的全部考点):

🔗 原理在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

⚠️ 最容易错

🔗 原理在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

⚠️ 最容易错

🔗 原理在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

⚠️ 最容易错

🔗 原理在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

⚠️ 最容易错

🔗 原理在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

⚠️ 最容易错

🔗 原理在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

⚠️ 最容易错

🔗 原理在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%

⚠️ 最容易错

🔗 原理在03 · 线性模型(为什么用交叉熵、为什么必须标准化、L1/L2 的区别)


📋 一页速查表

# 题目 一句话记住 讲原理的那一章
1 Softmax + 交叉熵 减 max 防溢出;反向就是 $p-\text{onehot}(y)$ 03 / 08
2 缩放点积注意力 掩码填 -inf;除的是 $\sqrt{d_k}$ 不是 $\sqrt{d_{model}}$ 14
3 多头注意力 viewtranspose;最后别漏输出投影 14
4 LayerNorm 只在最后一维统计;有偏方差;epssqrt 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 的差别搞清楚,代码是顺带的

👉 回到首页

打卡记录保存在你的浏览器里,首页能看到总进度