写推荐系统学习笔记已经到第十篇,这周我把《动手学深度学习》里的Softmax回归和MLP感知机放在一起,从零实现了一遍。很多朋友学推荐系统,上来就看DeepFM、DCN、MMoE,结果卡在多层神经网络上。其实你只要先把Softmax回归和MLP从零实现一遍,后面很多精排模型都只是在这两层结构上做文章。这篇我会用可运行的Python代码,把数学公式、前向传播、反向传播梯度推导全部走一遍,重点讲softmax如何求导、交叉熵实现、ReLU激活函数和BP训练过程,不依赖框架的自动求导,适合已经会了一点线性回归、想动手搭神经网络的读者。
我会用Fashion-MNIST图像数据来当“实验品”,但最终会把这些模型迁移到推荐系统场景里。你可能觉得奇怪:图像分类和推荐系统八竿子打不着?其实两者的核心都是“从特征到概率”的映射,只不过推荐系统里的特征从像素换成了用户和物品的embedding。先把最基础的东西写明白,后面看Wide&Deep、DeepFM的时候,你会觉得只是往这个骨架里塞更多结构而已。
1. 为什么推荐系统要先啃Softmax和MLP
1.1 从推荐任务中抽象出分类问题
推荐系统里很多任务本质上都是分类问题。“用户会不会点击这个商品”是二分类,“用户对商品可能产生哪几种行为”是多分类,“从一万个候选物品里挑出最可能的10个”则是带top-k截断的多分类问题。Softmax回归做的事情,就是把模型输出的线性得分转换成一个概率分布,让每个类别的概率都大于0、加起来等于1,然后取argmax或者top-k。
很多人以为softmax只是个“输出层函数”,其实它同时是一个很简朴的线性模型。推荐系统里的召回阶段,经常把用户和物品映射成向量,再在全量物品库上做softmax多分类,从中拿到每个物品的概率。所以理解Softmax回归是不是只在图像分类里有用?不是,它是推荐系统“判别式模型”的基本功。先把这个基本功搞扎实,后面接触采样softmax、NCE损失这些近似方法时,你会更容易明白它们为什么存在。
1.2 Softmax回归和逻辑回归、MLP的关系
Softmax回归可以看成逻辑回归在多分类上的推广。当类别数只有2时,它和逻辑回归虽然参数形式略有不同,但决策边界是一样的。从结构上看,Softmax回归就是没有隐藏层的线性模型:
输入向量 x → 线性变换 xW+b → softmax → 类别概率
MLP则是在输入和输出之间加入了隐藏层,并且在每个隐藏层后面接了非线性激活函数。如果隐藏层个数为0,MLP就退化成Softmax回归。所以标题把这两者放在一起,不是随手凑数,而是同一个框架从简单到复杂的自然延伸。
说到“MLP和BP-ANN是什么关系”:MLP是模型结构,BP是训练神经网络的反向传播算法。BP-ANN通常指使用BP算法训练的前馈神经网络,可以粗暴地理解成“带反向传播训练的MLP”。模型是骨架,训练算法是灵魂,两者配合才是一个完整可用的神经网络。
1.3 从零实现才会真正理解反向传播
用PyTorch、TensorFlow实现一个模型很快,net = nn.Sequential(...),然后loss.backward(),几行代码结束。但我强烈建议至少在入门阶段手写一次反向传播。原因很简单:如果你不知道梯度是从哪一层传回来的,遇到loss变成NaN、权重不更新、梯度爆炸这些问题时,就只能瞎试。
我学softmax怎么求导时踩过坑,光看公式觉得自己懂了,一写代码就懵。后来发现,只要把“softmax+交叉熵”合并求导,结果会简化成 p - y 这个漂亮的形式,也就是“预测概率减去真实one-hot标签”。这个结论在Softmax回归和MLP里都用得上。自己动手把矩阵形状、求和方向理一遍,后面看注意力机制的梯度、看推荐模型里的多任务梯度累加,都会有底气得多。
2. 环境准备、数据构造与softmax求导基础
2.1 Ubuntu/Debian环境下的Python环境配置
我这次实验在Ubuntu上跑的,Debian系系统也完全适用。如果你的机器没有GPU,也完全不用慌,Fashion-MNIST这种规模的数据集,CPU跑每个epoch一般也就几十秒到一两分钟。我习惯这样配置环境:
sudo apt update sudo apt install python3-venv python3-pip python3 -m venv ~/dl_env source ~/dl_env/bin/activate pip install torch torchvision numpy matplotlib为什么不直接用系统的pip装到全局?因为系统Python环境很容易被乱七八糟的包污染,尤其以后在不同项目之间切换时极其痛苦。虚拟环境是Python项目的基本隔离手段,这一点和推荐系统项目里的环境隔离理念一样——线上环境越干净,排查问题越容易。
如果你不想装GPU版本,pip install torch之前也可以按CPU版本安装,安装体积小很多。装完之后验证一下:
python -c "import torch; print(torch.__version__)"能正常输出版本号,环境就算通了。除了torch用来加载数据,全文的模型实现我只用NumPy,所以即使你只装numpy也能手动造数据跑通,torch只是让数据加载方便一点。
2.2 Fashion-MNIST数据集读取与数据预处理
Fashion-MNIST比手写数字MNIST更有意思,它是10类服装图片:T恤、裤子、套头衫、裙子、外套、凉鞋、衬衫、运动鞋、包和短靴。每张图都是28×28的灰度图。这个数据集的雏形是“果冻卷”式的现代玩具,线性模型在上面刚刚好不会太强也不会太弱,特别适合用来观察Softmax回归和MLP的差异。
加载代码很直接:
import torch import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader batch_size = 256 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_data = torchvision.datasets.FashionMNIST( root='./data', train=True, download=True, transform=transform) test_data = torchvision.datasets.FashionMNIST( root='./data', train=False, download=True, transform=transform) train_loader = DataLoader(train_data, batch_size=batch_size, shuffle=True, num_workers=2) test_loader = DataLoader(test_data, batch_size=batch_size, shuffle=False)像这种28×28的图像,神经网络不能直接吃二维矩阵,至少需要把它展平成784维向量。我在真正训练时,会对每个batch做这一步:
X = batch[0].reshape(batch_size, -1).numpy() y = batch[1].numpy()像素值在ToTensor之后会从0~255变成0~1,再用Normalize变成-1~1。为什么要归一化?因为数值范围太大会让梯度更新不稳定,尤其是像softmax里的指数运算,输入一大直接就溢出了。把数据限制在差不多的尺度,训练过程会稳很多。
2.3 softmax函数实现与求导公式
先给出最常写的softmax前向实现:
import numpy as np def softmax(z): z = z - np.max(z, axis=1, keepdims=True) exp_z = np.exp(z) return exp_z / np.sum(exp_z, axis=1, keepdims=True)这里减掉每行的最大值,是为了防止 exp(100) 这种溢出。减去一个常数不会改变softmax的结果,因为分子分母同时除以了exp(max),多一个常数只会同时缩放到同样倍数。
softmax本身是很好懂的,难点在求导。设输入向量 z,输出概率 p_k,softmax的雅可比矩阵为:
∂p_k / ∂z_j = p_k (δ_kj - p_j)
也就是对角线位置的导数是 p_k(1-p_k),非对角线是 -p_k p_j。如果损失函数是交叉熵:
L = -Σ_k y_k log p_k
其中 y 是one-hot标签,那么最终对 z_j 的导数会化简成:
∂L / ∂z_j = p_j - y_j
这个结果就是“预测概率减真实标签”。在代码里,你完全不需要手工构造那个k×k的雅可比矩阵,一行grad = prob - one_hot(y)就够了。很多从零实现教程不解释这一步,导致读者看到反向传播代码时总觉得“为什么突然就减了”,其实这就是链式法则加交叉熵化简后的结果。
3. 从零实现Softmax回归:模型定义、梯度手写与训练结果
3.1 模型定义与参数初始化
Softmax回归的输入维度是784,输出维度是10个类别。参数主要包括:
- W:形状是(784, 10)的权重矩阵
- b:形状是(10,)的偏置向量
初始化我用的是均值为0、标准差为0.01的高斯分布:
num_inputs = 784 num_outputs = 10 W = np.random.normal(0, 0.01, (num_inputs, num_outputs)) b = np.zeros(num_outputs)为什么不把W也初始化为0?在线性层加softmax的场景下,零初始化导致每个输出神经元的梯度方向完全一样,多个隐藏单元会变得对称,永远学不出差异化特征。虽然在单层softmax里没那么致命,但我建议尽早养成“对称破缺”的习惯。后面到了MLP,这个问题会更突出。
还需要把整数标签转成one-hot向量,方便做梯度计算。如果用NumPy实现,可以这样:
def one_hot(y, num_classes=10): return np.eye(num_classes)[y]3.2 前向传播、交叉熵损失与准确率
前向传播就是线性变换加softmax。给定一个batch的X,形状是(m, 784),那么:
logits = X @ W + b probs = softmax(logits)交叉熵损失定义是每个样本正确类别的概率取负对数,再求平均。实现时为了避免probs中出现0导致log下溢,我用了一个小保护:
def cross_entropy(probs, y): m = y.shape[0] log_probs = -np.log(probs[np.arange(m), y] + 1e-12) return log_probs.sum() / m这里的1e-12是一个很小的下界保护。如果某个概率在浮点数运算中被算成了0,log(0)会得到-inf,整个训练就废了。加上这个clip不是完美方案,但至少能让训练过程稳定,后面我会在调试篇再展开讲。
准确率也很简单:
def accuracy(probs, y): preds = np.argmax(probs, axis=1) return float((preds == y).mean())3.3 一个batch的反向传播与SGD更新
训练时,每个batch的反向传播套路是这样的:
def train_softmax_epoch(X, y, lr=0.1): global W, b m = X.shape[0] logits = X @ W + b probs = softmax(logits) y_onehot = one_hot(y) # softmax + cross entropy 的合并梯度 grad_output = (probs - y_onehot) / m grad_W = X.T @ grad_output grad_b = grad_output.sum(axis=0) W -= lr * grad_W b -= lr * grad_b注意我除以了m,因为前面交叉熵损失是取平均的,梯度的量纲和样本数没有关系,这样设置学习率时不用管batch_size是多少。grad_W的形状正好是(784, 10),grad_b的形状是(10,),和参数一一对应。
完整训练循环无外乎是遍历若干轮:
epochs = 10 lr = 0.1 for epoch in range(epochs): for X_batch, y_batch in train_loader: X_batch = X_batch.reshape(X_batch.shape[0], -1).numpy() y_batch = y_batch.numpy() train_softmax_epoch(X_batch, y_batch, lr)在测试集上算一下准确率,十个epoch跑下来,Softmax回归在Fashion-MNIST上通常能拿到0.83~0.85的准确率。这个数字不差,但也不算好,因为图像数据本身是非线性的。
3.4 在Fashion-MNIST上的训练结果与瓶颈分析
我自己的实跑记录是:训练集准确率大约0.84,测试集准确率大约0.84,训练准确率和测试准确率几乎持平,说明线性模型在这个任务上没有过拟合,是纯粹的“能力不够”。
这个瓶颈在哪?你可以想象784个像素点拼成的一张图片,线性模型只能学出一个高维空间里的线性决策边界。比如区分“衬衫”和“套头衫”,很多时候差别集中在局部纹理、袖口形状这些特征组合上,线性模型很难捕捉这类组合信息。你可以在训练结束后把W权重画出来,每个类别对应一维模板,会发现它更像粗糙的“平均图”,而不是理解到“衣领”“口袋”这些抽象概念。
这对推荐系统的意义非常直接:如果只用线性模型做精排,效果上限基本由特征工程决定。你做一个特征“用户是否点击过同类商品”,线性模型就能用上;但如果你只是把一堆原始行为id丢进去,线性模型学不出“两个特征协同产生新含义”的能力。所以当你发现LR效果不够时,第一时间想到的升级方向就是引入非线性模型,这正是MLP的用武之地。
4. 从零实现MLP感知机模型:ReLU、反向传播与效果对比
4.1 MLP结构和激活函数:为什么隐藏层要接ReLU
MLP的结构就是在输入和输出之间插入了隐藏层。这次我设计一个单隐藏层MLP:
- 输入层:784维
- 隐藏层:256个神经元,激活函数用ReLU
- 输出层:10个神经元,最后接softmax
为什么隐藏层必须接激活函数?我们做个简单推理:如果 W1 是输入到隐藏层的权重,W2 是隐藏层到输出的权重,那么 W2(W1x + b1) + b2 这个整体仍然是一个线性函数,两层线性变换完全可以合并成一层线性变换,拟合能力没有任何提升。所以没有非线性的“多层”只是个纸老虎。
ReLU的定义是 f(x)=max(0,x),它计算快,还能缓解梯度消失。Sigmoid在两端的导数接近0,深层网络里梯度连乘几次就变成几乎为0的数,权重更新非常慢。ReLU在正半轴导数恒为1,等于给反向传播开了一条高速公路。推荐系统里Dense特征经过MLP时,ReLU同样是我的首选激活函数。
4.2 梯度传播的关键:从输出层到输入层的链式法则
MLP的前向传播比Softmax回归多了一个隐藏层:
def relu(x): return np.maximum(0, x) def forward(X, W1, b1, W2, b2): h1 = relu(X @ W1 + b1) logits = h1 @ W2 + b2 return h1, logits反向传播时,输出层的梯度还是(probs - y_onehot) / m,这不难。真正的难点在于把梯度从输出层传回隐藏层再传回输入层。手写代码如下:
def backward(X, h1, logits, y, W2): m = X.shape[0] probs = softmax(logits) y_onehot = one_hot(y) dz2 = (probs - y_onehot) / m dW2 = h1.T @ dz2 db2 = dz2.sum(axis=0) dh1 = dz2 @ W2.T dz1 = dh1 * (h1 > 0) dW1 = X.T @ dz1 db1 = dz1.sum(axis=0) return dW1, db1, dW2, db2这里最需要理解的是dh1 = dz2 @ W2.T,意思是输出层的梯度要先乘以W2的转置,才能映射回隐藏层空间。然后dz1 = dh1 * (h1 > 0)是ReLU的反向传播:正区间梯度直接透传,非正区间梯度置为0。如果你换用其他激活函数,比如Sigmoid,这里就要改成dh1 * h1 * (1 - h1),但ReLU让所有负输入的梯度直接消失,简化了计算。
你也可以注意到,反向传播里的矩阵乘法和前向传播的方向正好镜像:前向是 X @ W1,反向是 dh1 = dz2 @ W2.T,W2在前向里是(hidden, output),反向时转置成(output, hidden)。我写代码时反复对过形状,因为形状一错,训练就会立刻崩掉。这个部分值得你在纸上自己画一遍计算图。
4.3 完整训练代码与效果对比
MLP的参数多了不少,初始化也需要更讲究。ReLU一般配He初始化,也就是标准差等于 sqrt(2 / 输入神经元数量) 的高斯分布:
import math hidden_size = 256 W1 = np.random.normal(0, math.sqrt(2 / num_inputs), (num_inputs, hidden_size)) b1 = np.zeros(hidden_size) W2 = np.random.normal(0, math.sqrt(2 / hidden_size), (hidden_size, num_outputs)) b2 = np.zeros(num_outputs)为什么不继续用0.01?因为ReLU会把一半神经元置0,如果初始权重太小,每层信号的方差会被压缩,越往深层信号越弱;He初始化是为了让每层输出的方差基本保持在一个可控范围。这里不需要彻底理解“方差保持”的完整数学,只需要记住一个结论:激活函数是ReLU,优先He;激活函数是Sigmoid或者Tanh,优先Xavier。
训练循环和Softmax回归几乎一样,只不过前进一次、计算梯度、更新参数要同时更新四组参数:
epochs = 10 lr = 0.1 for epoch in range(epochs): for X_batch, y_batch in train_loader: X_batch = X_batch.reshape(X_batch.shape[0], -1).numpy() y_batch = y_batch.numpy() h1, logits = forward(X_batch, W1, b1, W2, b2) dW1, db1, dW2, db2 = backward(X_batch, h1, logits, y_batch, W2) W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2我实测在相同超参数下,MLP在Fashion-MNIST测试集上大约能达到0.87~0.89的准确率,比Softmax回归高了三到五个点。这个提升完全来自隐藏层加ReLU带来的非线性表达能力。训练过程中你会看到训练准确率比测试准确率高一点,这是正常的,因为模型开始“记住”训练集的一部分规律,但还没有到严重过拟合的地步。
两个模型最直观的对比:
| 模型 | 参数量 | 非线性能力 | Fashion-MNIST测试准确率 | 主要瓶颈 |
|---|---|---|---|---|
| Softmax回归 | 约7850 | 无 | 约0.84 | 线性决策边界,无法捕捉特征组合 |
| 单隐藏层MLP | 约203530 | 有(ReLU) | 约0.88 | 只能感知空间局部,缺少卷积结构 |
在推荐系统里,同样的现象经常出现:LR能做到0.70的AUC,换成MLP之后可能涨到0.73,但再往上走就需要更复杂的特征交叉结构或者序列模型。MLP不是万能的,但它是你从线性模型往上走的第一块踏板。
4.4 MLP和BP-ANN的关系:模型结构 vs 训练算法
我前文说过“MLP和BP-ANN是什么关系”这个热词,这里展开细聊。MLP指多层感知机,它描述的是一种网络结构:若干个全连接层,每层带激活函数。BP指反向传播算法,它解决“如何更新网络参数”的问题。一个MLP模型必须搭配优化算法才能训练,而BP就是其中最经典的一种。
BP-ANN这个词是早期文献里常见的叫法,意思是“使用反向传播算法的人工神经网络”。在那个年代,人们还没有这么多花哨的网络结构,说BP-ANN,基本就等于在说“一个用BP训练的MLP”。所以你可以理解为:MLP是“长什么样”,BP-ANN是“怎么训练出来”。两者不是对立关系,而是模型和算法的合作关系。
我今天手写的这个反向传播,就是最朴素的BP过程。它从最终的损失出发,逐层求出参数梯度,再用梯度下降更新参数。如果你将来去看PyTorch里的backward(),背后的原理完全一样,只不过框架帮我们自动算了链式法则,还换成了更高效的张量库。
5. 把Softmax回归和MLP迁移到推荐系统场景的3个方向
5.1 用Softmax多分类做推荐召回
推荐系统召回阶段会遇到这样一个问题:用户历史行为序列和候选物品集合都在,怎么判断用户最可能喜欢哪一批物品?常见的做法之一是把物品ID看成类别,用户特征经过编码后,在全量物品集合上做softmax多分类。训练时,用户点击的真实物品就是正样本,softmax会输出每个物品的概率。
这里有个现实麻烦:物品库可能动辄上亿,直接对所有物品计算softmax代价太高。所以工程上会引入负采样、采样softmax、NCE这类近似方案。但不管近似方案怎么变,梯度公式里那个p - y的核心逻辑不变:预测概率和真实标签之间的差就是梯度方向。理解了最原始的Softmax回归,再去看那些花哨的采样技巧,你会更容易接受“为什么sample的负样本也要算进分母”。
5.2 用MLP做CTR精排里的特征交互层
精排阶段最常用的监督信号是点击率CTR。如果只用LR,那模型等价于把所有特征做线性加权求和。但你心里的推荐逻辑没那么简单:用户喜欢“运动鞋”和“最近在看篮球鞋”这两个信号,单独看都没那么强,组合起来说明用户大概率想买鞋。这种特征组合逻辑,线性模型表达不了。
MLP就很适合做这个工作。把用户ID、物品ID、上下文特征分别做embedding,拼成一个长向量,然后丢给MLP。MLP的隐藏层每一层都在做特征的“非线性重组合”,后层会利用前层学到的中间表达。我今天从零实现的这个MLP,原封不动把输入换成推荐特征拼接向量,就是一个可用的CTR预估baseline。Wide&Deep那样的大模型,Deep部分就是这样一个MLP。
5.3 多任务推荐模型中共享底层
推荐系统里常常同时关心多个目标:点击率、收藏率、转化率、观看时长。与其为每个目标单独训练模型,不如共享一个底层特征抽取网络,上面分出多个任务输出。这种多任务架构的底层往往就是MLP。
从手写MLP的角度看,多任务模型最有趣的地方在梯度累加。底层共享参数会收到来自各个任务头传回来的梯度,最终更新时是这些梯度的总和。用我之前写的反向传播来看,当你有两个loss时,底层的 dW1 等于“任务1贡献的 dW1 + 任务2贡献的 dW1”。理解这一点后,再看MMoE里那些门控网络、梯度平衡技巧,就清楚多了:它们本质上都是在调整不同任务梯度的贡献比例,减少任务之间打架。
5.4 在推荐任务上先跑线性基线再上深度模型
我个人做推荐模型实验的习惯,是先跑一个逻辑回归或者Softmax回归,把数据清洗和评估流程走通,记录一个baseline。然后在这个baseline上增加MLP,观察提升幅度。很多线上问题并不是“深度模型没用”,而是baseline没做扎实,或者数据泄露严重导致深度模型看起来很强,上了线就崩。
从零实现这两个模型后,你会更容易理解超参数变化带来的效果波动:同样一个MLP,隐藏层128、256、512,学习率差一点,结果就区别明显。推荐系统实验里经常看到的“部分胜出”,往往也来自这种随机性和超参数敏感度。先有了线性baseline,你才能辨别深度模型的提升到底是真实的建模能力,还是训练过程中的噪声在起作用。
6. 从零实现过程中踩过的坑与调试建议
6.1 数值稳定性:softmax溢出和NaN
写softmax最容易踩的坑就是溢出。如果输入向量里有100、200这种大数,np.exp(100)会得到一个非常大的数,甚至警告溢出,结果为inf,最后算出来NaN。所以一定要在softmax内部先减去每行最大值。这是所有深度学习框架里都在做的事情,不是可有可无的优化。
交叉熵那边也要小心。如果probs里出现0,np.log(0)就是-inf,损失直接变成NaN。虽然softmax的结构理论上不会输出绝对的0,但浮点数在极端情况下可能会因为下溢变成0。我在交叉熵里加一个1e-12的小常数,能兜住底。如果训练过程中loss突然变成NaN,第一怀疑对象不是学习率,而是数值稳定性。
6.2 梯度检查:手写反向传播自检
手写反向传播最怕“写完了也不知道对不对”。一个非常有效的自检方式是数值梯度:用中心差分公式验证几个参数位置的梯度,和反向传播算出来的梯度对比。
对某个参数 θ,数值梯度近似是:
(f(θ + h) - f(θ - h)) / (2h)
如果反传梯度和数值梯度在大致同一数量级,就说明链式法则没写错。我在做MLP的时候,就靠这个方式找出过一次形状错误:dW1的方向写反了,导致模型虽然能跑,但loss长时间不下降。数值梯度检查不会占用太多代码量,但能在开发期帮你省下大把调参时间。
6.3 一个适合自己的实验节奏
从零实现Softmax回归和MLP,我不建议只盯着准确率。我更建议每一步都打印loss、训练集准确率、测试集准确率,观察它们的趋势。比如Softmax回归的loss会在前500个batch快速下降,后面变平;MLP如果初始学习率太大,loss可能会先降后突然飙升,这时候调低学习率往往就能解决。
我自己在做推荐系统相关实验时也有类似节奏:先小数据验证模型能跑通,再在完整数据上跑长训练;先不做花哨trick,等模型真的达到瓶颈再逐个加。这样定位问题会快很多。最后再分享一个小技巧:如果你在Ubuntu或Debian上做这类训练,记得给Python虚拟环境多留一点磁盘空间,Fashion-MNIST和torch下载的缓存加起来虽然不大,但torch本身有时会占两三个GB,提前安排好总比训练到一半发现磁盘满了要舒服。