资讯动态

手写SoftMax与MLP:推荐系统深度学习基石

发布时间:2026/10/2 15:26:51 来源:尧图企业网站定制
这一篇我们动手把两个最基础、也是推荐系统里出场率最高的模型从头实现一遍SoftMax回归函数和MLP感知机模型也算把《动手学深度学习》系列里的关键一关补上。别看它们简单YouTube DNN、Deep Crossing、WideDeep这些经典推荐模型最底层都是这套东西。我是在刷这个系列时意识到跟着框架调用API很容易但要自己手推一遍梯度模型才真正变成自己的。这篇比较适合正在入门推荐系统、又想补深度学习基础的朋友。我会用纯NumPy实现尽量不借助任何高级深度学习框架数据加载可以用顺手的数据集接口但模型的前向、反向传播全部自己写。你会学到为什么SoftMax要和交叉熵绑定为什么MLP的非线性激活那么重要以及当推荐模型里的数值出现NaN时应该先从哪查起。这些都是我实际踩坑后返回来补基础的经验直接抄作业应该能省不少时间。1. 先想清楚推荐系统为什么要学这两个模型1.1 SoftMax在推荐里的真实身份推荐系统本质上是预测用户对物品的偏好落到排序或召回阶段经常要把问题看成一个多分类问题在候选物品集合中用户最可能选哪一个。SoftMax就是那个把原始得分变成概率的开关。它接收一个维度等于类别数的向量输出一个所有分量加起来等于1的概率分布。比如你在一个视频推荐模型里最后一层可能就是在海量视频ID上做SoftMax取出概率最高的那批视频当候选结果。工程上因为全量物品实在太多直接对全部类别做SoftMax会让计算量爆炸所以有负采样、分层SoftMax这些近似手段但无论怎么近似核心的数学结构都没变。换句话说把SoftMax的代码吃透后面理解采样逻辑会轻松很多。1.2 MLP是所有深度推荐模型的底座你把WideDeep拆开看Deep部分就是几层MLPDeep Crossing的用户特征和物品特征先转成Embedding拼起来之后交给残差层残差层本质上也是MLP的变体。MLP做的事可以粗浅地理解为特征之间的非线性交叉输入是用户特征和物品特征的拼接向量经过若干层隐藏层和激活函数输出可以是点击率也可以是评分。好多同学从LR直接跳到DIN一看注意力权重就懵了其实DIN最底层的特征交互网络还是MLP。所以不把MLP的梯度传播搞明白后面的注意力模型、特征交叉模型都像在盖空中楼阁。2. SoftMax回归从公式到代码2.1 模型定义和输入输出形状假设输入特征是d维向量x类别数是q。SoftMax回归先做一次线性变换z W^T x b其中W的形状是[d, q]b是[q]。然后对z做SoftMaxy_hat_i exp(z_i) / sum_j exp(z_j)。实际代码里我们通常一次性处理一批样本所以X的形状是[batch_size, d]W的形状是[d, q]z和y_hat都是[batch_size, q]。很多新手在实现时会搞混轴的方向axis1是类别轴一行样本的q个分数做归一化。最简单的检验办法是写完后打印y_hat.sum(axis1)每行都应该是1。这个检查我在调代码时几乎每次都会用。2.2 损失函数为什么要选交叉熵回归预测连续值常用均方误差分类预测概率分布则要用交叉熵。两个分布p和q的交叉熵是负的p_i乘log q_i求和。在SoftMax回归里真实标签是one-hot所以损失等价于真实类别对应预测概率的负对数。为什么不用MSE一方面分类目标和概率分布天然匹配另一方面也是更实在的原因SoftMax配上交叉熵最后的梯度形式极其干净等于预测概率减真实one-hot向量。如果用MSE作为损失需要多乘一长串SoftMax函数的雅可比矩阵不但算起来麻烦还很容易让梯度在概率接近0或1的时候变得非常小也就是梯度消失。所以你会看到几乎所有深度模型的分类输出层都用交叉熵这不是约定俗成而是有数学上的合理性。2.3 数值稳定先减max再用exp直接调np.exp很容易遇到数值问题。当z里某个分数比较大的时候exp(z)可能变成inf再一除就成了NaN。解决方法非常简单对每个样本先让z减去该样本的最大值再做exp和归一化。这个操作不会改变概率因为exp(z-c)和exp(z)只是缩放了一个常数倍分母也跟着缩放结果不变。代码里要保持维度一致X.shape是[batch_size, q]用keepdimsTrue才能让广播正常。下面这个函数就是我一直在用的稳定版SoftMax。import numpy as np def softmax(X): X X - np.max(X, axis1, keepdimsTrue) X_exp np.exp(X) return X_exp / np.sum(X_exp, axis1, keepdimsTrue)如果遇到非常极端的情况可以在分母上加一个1e-9之类的小浮点数做保护不过在稳定版里一般不需要。2.4 交叉熵损失实现损失函数实现的核心是取每个样本真实类别对应的预测概率。写代码时千万别对整行求和只取一个值。我习惯用一行索引y_hat[range(batch_size), y]。def cross_entropy(y_hat, y): batch_size y.shape[0] eps 1e-9 log_probs -np.log(y_hat[range(batch_size), y] eps) return np.mean(log_probs)如果预测概率接近0np.log会给出负无穷加一个极小量是常见的防御手段。实际训练时如果出现NaN往往就是这里没写稳定。3. 从零实现SoftMax回归的训练全过程3.1 数据准备拿一份多分类数据练手为了跟后面的推荐任务衔接我用Fashion-MNIST当练习数据集。它包含10个商品类别正好对应多分类。你可以用任何顺手的方式下载比如torchvision的FashionMNIST或者TensorFlow内置的接口拿来之后立刻转成numpy数组并归一化。我的常用姿势是这样的from torchvision import datasets, transforms train_set datasets.FashionMNIST( root./data, trainTrue, downloadTrue, transformtransforms.ToTensor() ) X_train np.array([img.flatten() for img, _ in train_set], dtypenp.float32) / 255.0 y_train np.array([label for _, label in train_set], dtypenp.int64)注意这里我把图片从28x28拉直成了784维向量并且除以255让像素值落到0到1之间。这一步对训练稳定性非常关键。如果你不想装torch也可以直接用sklearn.datasets.fetch_openml(nameFashion-MNIST)效果完全一样。数据下载好之后先看一眼X_train.shape和y_train里的类别数再往下走。我通常还会顺手打乱一次数据避免数据本身自带顺序影响训练。3.2 初始化参数SoftMax回归的参数是W和b。W不能用零初始化否则所有类别对应的输出会完全一样梯度也对称模型学不动。一般用小的正态分布随机数比如0.01倍的标准正态。b初始化为零向量没关系。def init_softmax_params(d, q): W np.random.normal(0, 0.01, (d, q)) b np.zeros(q) return W, b3.3 前向、反向、更新三步训练过程就是循环前向算概率算损失反向算梯度然后沿负梯度方向更新。SoftMax回归的梯度推导结果非常漂亮对W的梯度等于X转置乘以误差项对b的梯度等于误差项在样本维取均值。这里的误差项就是y_hat减去真实标签的one-hot。def softmax_grad(X, y_hat, y): batch_size len(y) y_onehot np.zeros_like(y_hat) y_onehot[range(batch_size), y] 1 error y_hat - y_onehot dW X.T error / batch_size db np.mean(error, axis0) return dW, db如果你对推导过程有兴趣可以这样想交叉熵对logits的导数是y_hat减one-hot而logits对W的导数就是X所以链式法则乘起来就是X.T和error相乘。这里的batch_size是样本数所以除一下是取平均梯度。很多框架里的交叉熵损失内部就是这么干的只不过帮我们把链式法则藏起来了。3.4 完整训练循环def train_softmax(X_train, y_train, lr0.1, epochs100, batch_size256): d, q X_train.shape[1], len(np.unique(y_train)) W, b init_softmax_params(d, q) n X_train.shape[0] for epoch in range(epochs): perm np.random.permutation(n) X_train, y_train X_train[perm], y_train[perm] for i in range(0, n, batch_size): X_batch X_train[i:i batch_size] y_batch y_train[i:i batch_size] z X_batch W b y_hat softmax(z) dW, db softmax_grad(X_batch, y_hat, y_batch) W - lr * dW b - lr * db if epoch % 10 0: y_hat softmax(X_train W b) loss cross_entropy(y_hat, y_train) acc np.mean(np.argmax(y_hat, axis1) y_train) print(fepoch {epoch}, loss {loss:.4f}, acc {acc:.4f}) return W, b我习惯每一个epoch开始前打乱一下样本顺序这样每个batch的分布更接近真实数据。lr0.1在归一化后的Fashion-MNIST上能正常收敛但这不是银弹如果输入特征范围很大lr要相应调小如果每个batch太小梯度噪声大lr也别太大。跑完一个完整epoch打印一次loss和准确率你会发现loss一直在降准确率也会缓慢爬升纯线性回归在Fashion-MNIST上大概能到7成多。这个成绩不算高因为图像分类本身有很强的非线性结构等着后面MLP来改进。3.5 一个容易踩的坑不标准化就训练在实际训练中最常遇到的坑就是数据不归一化。像素值0到255直接用z很容易跑到几十甚至几百SoftMax输出会变成几乎one-hot梯度虽然不小但也会让损失产生很大的动态范围训练曲线就像心电图。推荐系统的特征更是如此连续特征里有的量级是1有的是1万如果不做标准化模型会被大尺度特征带着走。所以每次动手前先检查特征列的min和max顺手做个归一化后面能省很多排查NaN的时间。4. 从零实现MLP感知机到多层感知机4.1 为什么单层不行SoftMax回归是线性模型在特征空间里只能画一个超平面做分割。推荐场景里这种线性假设太弱了比如“年轻用户喜欢科技产品但不喜欢游戏”这种条件关系单纯把特征加权求和是表达不出来的。单层感知机本质上也是线性分类器连异或问题都解决不了这就是当年感知机被称为“只能做基础运算”的数学原因。多层感知机增加了一个或多个隐藏层并在隐藏层后面接非线性激活函数相当于把特征空间先做一次非线性变换再在变换后的空间里分类。理论上只要有足够多的隐藏单元它可以逼近任意连续函数这也是推荐模型从LR升级到深度模型的核心动机。4.2 激活函数选ReLU而不是Sigmoid激活函数是MLP能变“非线性”的关键。常见的有sigmoid、tanh、ReLU。在推荐系统里我默认首选ReLU原因有三计算成本低正区间梯度恒等于1反向传播时梯度衰减要慢很多而sigmoid在两个极限端梯度都趋近0层数一深就传不动tanh好一点但同样存在饱和区。只有在最后一层输出概率时才用softmax或sigmoid中间隐藏层尽量别用。ReLU唯一的坑是输入为负时梯度为0神经元一旦在这个区域死掉就再难恢复所以后面才有LeakyReLU这些改进但在入门阶段ReLU足够用。def relu(X): return np.maximum(X, 0) def relu_grad(X): return (X 0).astype(np.float32)4.3 两层MLP的前向传播我们实现一个最简单的两层MLP输入层到隐藏层隐藏层到输出层。设输入维度d隐藏单元数h类别数q。第一层的参数是W1[d, h]和b1[h]第二层是W2[h, q]和b2[q]。前向传播分四步隐藏层线性变化H_pre XW1b1激活Hrelu(H_pre)输出层线性变化ZHW2b2最后概率y_hatsoftmax(Z)。W1和W2的初始化不能再用0.01这种“小随机数”了我推荐用He初始化W1用np.random.randn(d,h)乘以sqrt(2/d)W2同理乘以sqrt(2/h)。原因是ReLU会把一半神经元置零如果用Xavier那种小方差初始化深层网络的信号会层层收缩训练起来很慢。4.4 反向传播最容易写错的地方反向传播是这段最难也最值钱的部分。我们一共要算四个梯度dW1、db1、dW2、db2。推导思路很简单就是链式法则但写代码时维度匹配很容易翻车。第一步输出层误差项delta2。因为输出层用的是softmax交叉熵误差项直接就是y_hat减去真实标签的one-hot和SoftMax回归完全一致。 第二步算第二层参数梯度dW2 H.T delta2 / batch_sizedb2 mean(delta2, axis0)。 第三步把误差传回隐藏层。这一步要用隐藏层激活前的值H_pre来算relu的导数也就是delta1 (delta2 W2.T) * relu_grad(H_pre)。这里乘法是逐元素乘因为ReLU的导数是0或1。很多第一次写的人会拿H激活后的值去判断梯度结果方向不对其实激活后的H所有元素都非负ReLU梯度全都为1等于没做。 第四步算第一层参数梯度dW1 X.T delta1 / batch_sizedb1 mean(delta1, axis0)。def mlp_forward(X, W1, b1, W2, b2): H_pre X W1 b1 H relu(H_pre) Z H W2 b2 y_hat softmax(Z) cache (X, H_pre, H, W1, W2) return y_hat, cache def mlp_backward(y_hat, y, cache): X, H_pre, H, W1, W2 cache batch_size len(y) y_onehot np.zeros_like(y_hat) y_onehot[range(batch_size), y] 1 delta2 (y_hat - y_onehot) / batch_size dW2 H.T delta2 db2 np.mean(delta2, axis0) delta1 (delta2 W2.T) * relu_grad(H_pre) dW1 X.T delta1 db1 np.mean(delta1, axis0) return dW1, db1, dW2, db2注意我把除以batch_size放到了delta2里所以后面dW2和dW1不再除db用mean。其实怎么除都行关键是保持一致。验证梯度是否写错可以随便挑一个参数比如W1[0,0]把它加一个1e-6重新算loss再和用反向传播得到的梯度比较两者应该非常接近。我每次写完反向传播都会先做这个数值梯度检查至少能筛掉八成的维度错误。4.5 完整训练一个两层MLPdef init_mlp_params(d, h, q): W1 np.random.randn(d, h) * np.sqrt(2.0 / d) b1 np.zeros(h) W2 np.random.randn(h, q) * np.sqrt(2.0 / h) b2 np.zeros(q) return W1, b1, W2, b2 def train_mlp(X_train, y_train, hidden_size128, lr0.1, epochs50, batch_size256): d, q X_train.shape[1], len(np.unique(y_train)) W1, b1, W2, b2 init_mlp_params(d, hidden_size, q) n len(y_train) for epoch in range(epochs): perm np.random.permutation(n) X_train, y_train X_train[perm], y_train[perm] for i in range(0, n, batch_size): X_batch X_train[i:i batch_size] y_batch y_train[i:i batch_size] y_hat, cache mlp_forward(X_batch, W1, b1, W2, b2) dW1, db1, dW2, db2 mlp_backward(y_hat, y_batch, cache) W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2 if epoch % 5 0: y_hat, _ mlp_forward(X_train, W1, b1, W2, b2) loss cross_entropy(y_hat, y_train) acc np.mean(np.argmax(y_hat, axis1) y_train) print(fepoch {epoch}, loss {loss:.4f}, acc {acc:.4f}) return W1, b1, W2, b2hidden_size设128这个值对Fashion-MNIST够用。跑几十个epoch之后训练集准确率通常会明显超过SoftMax回归说明引入非线性和隐藏层确实在起效果。如果发现loss不降优先检查relu_grad里是不是把激活前和激活后搞混了。如果准确率一直不涨还可以试着把lr调小比如0.03。训练完这套代码你可以拿它和一个PyTorch写同样结构的MLP对比两者在相同超参数下loss下降轨迹应该高度一致差一点点是因为随机种子不同。4.6 从SGD到Adam为什么推荐系统里更常用优化器从零实现里我一直用的SGD因为它简单梯度更新逻辑清晰。但推荐系统的数据噪声通常很大特征很稀疏SGD对学习率非常敏感。工业界更常用Adam这种自适应优化算法它本质上就是给每个参数维护一个动量项和梯度平方的指数移动平均然后据此调整每次更新步长。理解了SGD之后去看Adam公式会发现只是多了一行累积量计算。如果你在后续实验里发现SGD调不收敛换成Adam通常能立刻缓解这不丢人因为数据特性决定了SGD在稀疏场景下确实不好调。5. 回到推荐系统把SoftMax和MLP用起来5.1 推荐经典的EmbeddingMLP范式前面用图像数据练手接下来得把这两个模型塞回推荐场景。推荐系统最典型的输入是离散特征用户ID、物品ID、品类、城市、年龄分桶。这些特征不能直接送给MLP得先做Embedding把每个ID映射成一个稠密向量。比如用户ID查表得到32维向量物品ID查表得到32维向量再把所有Embedding拼接成一个长的稠密向量后面接一个两层MLP最后输出预测分数或点击概率。这就是Deep Crossing、WideDeep深度部分的基本骨架。Embedding层本身就是一个可训练矩阵梯度从MLP传回Embedding的方式和从输出层传回W1的方式一模一样理解了刚才的反向传播你就不会再觉得Embedding是什么带魔法的东西。5.2 用SoftMax做多分类召回和负采样在召回阶段模型可以把用户历史行为和用户特征拼接成输入在候选物品集合上做多分类预测用户下一个可能要交互的物品。输出层就是一个大号的SoftMax类别数等于候选集大小。但实际产品中候选物品可能上百万全量计算SoftMax代价太高。工业界常用的负采样思路是每个batch只保留一个真实交互物品再随机抽几十个未交互的物品作为负样本只在这么一小批物品上计算SoftMax。这样训练时类别数从几百万降成几十但正样本的概率仍然被最大化负样本的概率被压低。你从零写的SoftMax代码到这里基本不用改只需要把W的列数从“全部物品”变成“采样物品数”。后续看YouTube DNN那篇经典论文时这一段就会特别亲切。5.3 MLP超参选择与训练稳定性在推荐模型里MLP的隐藏层大小和深度怎么定没有一个万能公式但有一些经验可以抄隐藏单元数一般取输入维度和输出维度的中间偏上方128、256、512都常见层数别堆太多1到3层在大部分推荐任务上已经够用再加层反而容易过拟合和难训练。激活函数默认ReLU输出层按任务决定回归用线性二分类用sigmoid多分类用softmax。初始化用He优化器我一般直接上Adam但学习率得比SGD小一个数量级比如0.001。数据方面数值型特征要做标准化类别型特征做embedding每个特征的缺失值单独弄一个“未知”桶别硬填0。这些看起来琐碎但实验差距往往就是从这里拉开的。5.4 常见问题速查表训练代码写完最怕就是遇到问题不知道往哪查。我把从零实现时最容易出现的几个症状和排查方向整理成了表照着顺序排查能少走很多弯路。症状可能原因优先排查方向loss变成NaN学习率过大 / 输入数据未归一化 / 代码里有log(0)先降lr再检查X里有没有NaN/Inf最后看softmax是否稳定评估准确率不涨反向传播写错 / 隐藏层激活梯度算错挑一个参数做数值梯度检查确认delta1用了激活前的值loss下降但acc不动类别不平衡 / 标签和概率维度不匹配检查每个类别样本数打印y_hat的argmax是否全是同一类训练时loss震荡特别厉害lr太大 / batch太小降lr增大batch必要时用AdamMLP输出对所有样本都一样ReLU把大量神经元杀死 / 隐藏层太小看relu_grad里非零比例换LeakyReLU或增大hidden_size参数更新后准确率反而下降数据没有打乱 / 特征尺度差异大每个epoch打乱数据做特征标准化这张表不神秘但都是我自己调代码时反复碰到过的问题。第一次跑通模型后不要急着上数据调参先拿一个小子集比如只取2000条训练看它能不能把训练集的loss降到很低。如果这么小的数据都过拟合不了那多半是代码有逻辑错误而不是超参数问题。6. 最后我自己的几点体会6.1 最值得投入时间去写一遍的是反向传播我最初跟着《动手学深度学习》做这一节时以为把SoftMax和MLP的代码写出来就算懂了然后直接跳到推荐模型源码结果被各种Embedding和采样绕得晕头转向。后来回头把反向传播手写了一遍才突然明白“梯度从输出端沿着计算图回流”是什么意思。如果让我给后来者一个建议不要用框架自动求导跑一遍就完事至少亲手推一次梯度再算一次数值梯度验证。这个过程会很烦但它是后续理解注意力机制、残差网络、极大似然估计这些概念的杠杆。6.2 一个小建议先在小数据集上跑通另一个小技巧是训练时把loss打印改成每5个epoch一条别每个batch都打印否则目光全被噪声带走了。先在小数据集上跑通再上全量能省去大量debug时间。我就是靠这个习惯在后面写更复杂的推荐模型时少踩了很多坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑