真实面经题目 · 原创解析
手写单层 MLP 做回归或二分类时,如何实现 forward、loss、反向传播和参数更新?
这题考的是能否从零写出一个最小神经网络训练闭环:线性层、激活、任务损失、链式法则、梯度形状和参数更新。回答要同时覆盖回归和二分类,并能解释为什么回归常用 MSE,二分类常用 sigmoid + BCE,以及如何验证梯度和训练是否正确。
真实面经题目 · 原创解析
这题考的是能否从零写出一个最小神经网络训练闭环:线性层、激活、任务损失、链式法则、梯度形状和参数更新。回答要同时覆盖回归和二分类,并能解释为什么回归常用 MSE,二分类常用 sigmoid + BCE,以及如何验证梯度和训练是否正确。
手写单层 MLP 时,我会先明确结构:输入 X 形状是 N×D,隐藏层或输出层权重 W、偏置 b,前向先算 z=XW+b,再根据任务选择输出。严格说 MLP 通常至少有一层非线性隐藏层,但面试里的单层 MLP 常指一个线性层加可选激活;如果是回归,可以直接输出 y_hat=z,用 MSE loss;如果是二分类,可以输出 logit z,再过 sigmoid 得到概率 p,用 BCE loss。反向传播按链式法则从 loss 对输出的梯度开始。回归 MSE 下,若 loss=mean((y_hat-y)^2),则 dL/dz=2*(y_hat-y)/N;梯度 dW=X^T dZ,db=sum(dZ)。二分类 sigmoid+BCE 的组合会简化为 dL/dz=(p-y)/N,比先分别写 sigmoid 导数和 BCE 导数更稳定。更新就是 W -= lr*dW, b -= lr*db。完整回答还要补充初始化不能全零隐藏层,输入最好标准化,BCE 要做数值稳定,检查张量形状,训练时看 loss 是否下降,并用有限差分做梯度检查。
设 X 是 N×D 的 batch,y 是 N×1。单输出回归或二分类都可以用 W 形状 D×1、b 形状 1。若面试官要求真正的单隐层 MLP,可以扩展为 XW1+b1 经过 ReLU,再接 W2+b2;但如果题目说单层 MLP 手写回归/二分类,最小可讲清的是线性层加任务输出。关键是先把形状说清,后面梯度才不会乱。
回归任务常直接用线性输出 y_hat=XW+b。MSE 可以写成 mean((y_hat-y)^2),也可以带 1/2 系数简化梯度。MSE 适合连续值预测且对大误差更敏感;如果异常值很多,可以提到 MAE 或 Huber,但手写题优先把 MSE 的梯度写对。
二分类最好保留 logit z=XW+b,再通过 sigmoid 得到 p=1/(1+exp(-z))。损失用 BCE:-mean(y log p + (1-y) log(1-p))。实现时要 clip p 或用 logits 形式避免 log(0) 和 exp 溢出。面试中要强调标签 y 通常是 0/1,预测概率 p 用阈值 0.5 或按业务调阈值转成类别。
回归 MSE 的梯度从 dZ=2*(y_hat-y)/N 开始;二分类 sigmoid+BCE 合并后 dZ=(p-y)/N。然后线性层梯度都是 dW=X^T dZ,db=sum(dZ, axis=0)。如果有隐藏层,还要继续乘 W2^T 并乘激活函数导数,例如 ReLU 的导数是 z>0 时为 1,否则为 0。核心是每一步梯度形状都要和参数形状一致。
最基础更新是 SGD:W -= lr*dW,b -= lr*db。实际训练会用 mini-batch、学习率衰减、动量或 Adam,但手写题不要一开始堆优化器,先把梯度和更新闭环写正确。每轮训练前向、算 loss、反向、更新,循环若干 epoch,并监控训练集 loss 和验证集指标。
正确性可以从三方面验证:第一,打印各张量 shape,确保广播没有误伤;第二,小数据集上 loss 应该下降,线性可分二分类应能接近高准确率;第三,用有限差分对 W 的某个元素做 gradient check。常见故障是 sigmoid 溢出、BCE log(0)、忘记除以 batch size、db 维度错误、学习率太大导致 loss 爆炸、输入未归一化导致训练慢。
import numpy as np
class OneLayerMLP:
def __init__(self, in_dim, task="binary", seed=0):
rng = np.random.default_rng(seed)
self.W = rng.normal(0.0, 0.01, size=(in_dim, 1))
self.b = np.zeros((1,))
self.task = task
def forward(self, X):
z = X @ self.W + self.b
if self.task == "regression":
return z, {"X": X, "z": z, "y_hat": z}
p = 1.0 / (1.0 + np.exp(-np.clip(z, -50, 50)))
return p, {"X": X, "z": z, "p": p}
def loss_and_backward(self, cache, y):
X = cache["X"]
y = y.reshape(-1, 1)
n = X.shape[0]
if self.task == "regression":
y_hat = cache["y_hat"]
loss = np.mean((y_hat - y) ** 2)
dz = 2.0 * (y_hat - y) / n
else:
p = np.clip(cache["p"], 1e-12, 1.0 - 1e-12)
loss = -np.mean(y * np.log(p) + (1.0 - y) * np.log(1.0 - p))
dz = (p - y) / n
dW = X.T @ dz
db = dz.sum(axis=0)
return loss, dW, db
def step(self, dW, db, lr=0.1):
self.W -= lr * dW
self.b -= lr * db
# Training loop
model = OneLayerMLP(in_dim=3, task="binary")
X = np.array([[0.0, 1.0, 2.0], [1.0, 0.0, 1.0], [2.0, 1.0, 0.0]])
y = np.array([1, 0, 1])
for _ in range(200):
pred, cache = model.forward(X)
loss, dW, db = model.loss_and_backward(cache, y)
model.step(dW, db, lr=0.5)
print(loss, pred.ravel()) 二分类建模的是伯努利分布概率,BCE 对应最大似然,更符合概率输出;sigmoid+BCE 的梯度对 logit 是 p-y,优化更直接。MSE 也能训练,但在概率接近 0 或 1 时梯度形态不理想,分类收敛和校准通常不如 BCE。
设 h=ReLU(XW1+b1),z=hW2+b2。先按任务得到 dZ2,然后 dW2=h^T dZ2,db2=sum(dZ2)。再传回隐藏层 dH=dZ2 W2^T,dZ1=dH*(pre_hidden>0),最后 dW1=X^T dZ1,db1=sum(dZ1)。本质还是链式法则。
选 W 的一个元素,把它加 epsilon 和减 epsilon 分别算 loss,用 (loss_plus-loss_minus)/(2*epsilon) 近似数值梯度,再和反向传播算出的 dW 对应元素比较。误差很小说明梯度大概率正确;如果差很多,通常是 batch 平均、激活导数或 shape 广播出错。
常见原因包括学习率过大或过小,输入未标准化,标签形状或取值错误,梯度忘记除以 batch size,BCE 数值溢出,参数没有更新,或者模型表达能力不足。如果是单层线性模型,非线性不可分数据本来就无法拟合很好。