DSSM双塔模型实战:基于MovieLens 1M的深度召回全流程解析
简介基于MovieLens 1M数据集展开的DSSM深度召回实验资源包面向推荐系统方向的学生、研究者及入门工程师完整覆盖了数据预处理、特征工程、模型训练、效果评估与结果分析等实验步骤。压缩包内共10个文件包含三个数据文件、三个Python训练与辅助脚本、一个Jupyter实践笔记、一个训练好的模型权重、一个说明文档与一个Markdown笔记整体约10.36兆字节。实验中利用用户年龄、性别、职业及电影类别、年代、评分等多维度特征作为输入训练DSSM模型将用户与电影映射至共同的低维语义空间以实现快速准确的召回。资源附有完整的模型训练代码、注释清晰的Notebook和已保存的模型权重可直接运行测试或继续调参便于理解DSSM的结构、损失函数及训练细节。资料还涉及避免过拟合的正则化思路适合用于课程作业、毕设实验或作为深度召回方向的基线对比。目前已有163人学习适合希望结合公开数据集动手实践并验证算法效果的开发者。1. 从语义匹配看DSSM为什么拿movieLen1M做深度召回实验不少做推荐的新手第一次听到“深度召回”会以为是一个复杂的图模型或序列模型其实DSSM双塔才是入门最扎实的方案。在movieLen1M这个只有百万级评分的经典小数据集上双塔的整套训练流程能在一台普通笔记本上跑完。DSSM把用户和物品分别编码成两个向量匹配分直接由向量内积给出训练完先把物品向量全量离线算好线上只算用户向量再走近似最近邻。下面以“基于movieLen1M数据集的DSSM深度召回实验”为线索把数据清洗、双塔搭建、损失设计和召回指标串起来讲适合想验证深度召回效果的人照着复现。2. movieLen1M数据准备评分转交互样本、负采样与特征构建拿到DSSM.zip之后除非压缩包已经带了处理好的pkl文件否则第一步必然是把原始MovieLens 1M读进来。原始数据包含三份采用::分隔的文件ratings.dat、users.dat、movies.dat。ratings.dat是最关键的交互来源字段依次为用户ID、电影ID、评分1~5和时间戳users.dat带用户人口学属性movies.dat带标题和电影类型。大多数实验包直接用ratings.dat因为DSSM至少需要用户ID和电影ID两个信号额外特征是否有效要单独做消融。下面这段读取代码可以帮你快速确定压缩包里的数据是原始格式还是预处理格式同时顺手把评分降到隐式交互。import pandas as pd ratings pd.read_csv( ml-1m/ratings.dat, sep::, names[user_id, movie_id, rating, timestamp], enginepython, ) thresh 4 interactions ratings[ratings[rating] thresh].copy() print(保留评分%d的交互数: %d % (thresh, len(interactions))) print(用户数:, interactions[user_id].nunique()) print(电影数:, interactions[movie_id].nunique())这串代码里enginepython要特别留意MovieLens 1M的分隔符是双冒号pandas的C解析器不能直接处理这种连续分隔符不指定enginepython会读进一个空列。评分阈值选择4比较常见选5会让正样本过少选1到3则把用户只是“看过但不喜欢”的交互也算成正样本双塔会学习到大量噪音。如果实验目标是排序而不是召回可以保留原始评分做pairwise loss但DSSM召回实验通常只需要隐式反馈。2.1 显式评分转隐式交互后ID必须重映射MovieLens 1M的用户ID和电影ID是从1开始的连续整数但中间有空缺不能直接拿来做Embedding查表。常见做法是重新映射到0到N-1让Embedding的vocab_size与实际数量完全一致。user_codes, user_ids pd.factorize(interactions[user_id]) item_codes, item_ids pd.factorize(interactions[movie_id]) interactions[user_idx] user_codes interactions[item_idx] item_codes print(映射后用户ID范围 [%d, %d] % (user_codes.min(), user_codes.max())) print(映射后电影ID范围 [%d, %d] % (item_codes.min(), item_codes.max()))pd.factorize返回的是从0开始的自增整数正好满足Embedding要求。如果你自己写映射字典要保证训练、验证、测试三个集合共用同一套映射否则训练完的向量在评测阶段索引对不上。这里也顺便确认交互矩阵的规模按上述过滤后大约能保留58万条正样本用户数约6000电影数约3600稀疏度在2%左右这个大小对于DSSM训练来说非常轻。2.2 按时间戳切分训练与评估避免随机切分带来的时间穿越随机把交互拆成训练集和测试集看起来没问题但你的模型可能在训练时见过某个用户未来的行为。在线推荐系统遵循时间顺序离线实验也应如此。我一般的处理方式是先对每个用户按timestamp排序把最后一次交互留作测试其余作为训练正样本并参与负采样。interactions interactions.sort_values(timestamp) train_list [] test_list [] for user, group in interactions.groupby(user_idx): train_list.append(group.iloc[:-1]) test_list.append(group.iloc[-1:]) train pd.concat(train_list).reset_index(dropTrue) test pd.concat(test_list).reset_index(dropTrue)这个切分确保测试正样本一定不在训练集合中。需要注意MovieLens 1M里存在同一个用户只看过一部电影的情况iloc[:-1]会得到空组需要在进入模型前过滤掉这类用户否则训练数据里会出现没有正样本的用户优化器会把他们的表示往所有负样本方向推。2.3 负采样策略对比全库随机、batch内负采样与曝光未点击召回模型的训练要求每个正样本配备至少一个负样本。MovieLens 1M没有曝光数据拿不到用户看到了却没点击的样本所以主流做法只剩全库随机和batch内随机两种。两种都可以减轻流行度偏差但语义完全不同。策略负样本来源负样本质量训练吞吐实现复杂度全库随机负采样从用户未交互的电影里随机抽K个容易到“一眼假”模型学不到细粒度差异高可离线预生成低batch内负采样当前batch里其他正样本物品有一定难度包含热门物品高无需额外存储中曝光未点击线上日志中的真实负反馈质量最高最难低依赖日志高电影评分数据集里随机负样本绝大多数是用户完全没兴趣的电影网络很快就能把它们和正样本区分开损失降得很快但向量空间没有拉开不同电影之间的区分度。batch内负采样把同batch的其他正样本当成负例等于一个物品要同时给多个用户当负样本梯度计算量只增加了一个矩阵乘法因此实验包基本都会采用它。具体训练实现会在第4章展开。3. DSSM双塔结构与Embedding层实现编码器、相似度函数与温度系数DSSM原名用于语义匹配结构上是两座完全独立的深度网络一座把用户特征编码成向量另一座把物品特征编码成向量。用户和物品不必有相同的特征空间只需最终向量维度一致然后用点积或余弦相似度做匹配。双塔的最大优势是物品塔可以离线批量推理为在线检索预留了足够大的优化空间代价是两侧塔不交互特征交叉完全靠向量内积完成表达能力弱于单塔交互模型。推荐场景用DSSM做召回看重的是它能在全量物品上快速近邻搜索而不是为了刷精度。3.1 用户塔和物品塔分别吃什么特征MovieLens 1M给用户的原始特征有性别、年龄、职业和邮编电影有标题、类型。这里有个常见误区不是所有特征都值得进塔。像邮编、标题这种高基数特征如果没有专项Embedding反而会稀释ID向量的学习性别、职业等低基数特征则可以作为补充输入。下面代码只用了用户ID和电影ID先跑通最小路径如果后面要加特征只需要修改塔的输入部分。import torch import torch.nn as nn class UserTower(nn.Module): def __init__(self, num_users, embed_dim, hidden_dim, output_dim): super().__init__() self.embedding nn.Embedding(num_users, embed_dim) self.mlp nn.Sequential( nn.Linear(embed_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, output_dim), ) def forward(self, user_ids): x self.embedding(user_ids) return torch.nn.functional.normalize(self.mlp(x), p2, dim-1)物品塔代码几乎一样只是把num_users换成num_items。两个塔的参数各自独立这就是“双”的含义。Embedding维度控制在16到64之间hidden_dim取64或128output_dim取32或64对6000用户和4000电影的数据量输出维度过高不会有明显增益反而让后续检索的内存和距离计算成本上升。最后一层L2归一化是DSSM常见的做法它能让点积结果等价于余弦相似度从而在向量检索阶段直接使用内积距离。3.2 从one-hot到低维Embedding避免万物都做One-Hot双塔里出现过不少“把one-hot向量接一层全连接”的实现本质上和Embedding是同一件事但后者省内存且查表效率高。用PyTorch的nn.Embedding是最直接的路径不用真的构造几万维的稀疏向量。class ItemTower(nn.Module): def __init__(self, num_items, embed_dim, hidden_dim, output_dim): super().__init__() self.embedding nn.Embedding(num_items, embed_dim) self.mlp nn.Sequential( nn.Linear(embed_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, output_dim), ) def forward(self, item_ids): x self.embedding(item_ids) return torch.nn.functional.normalize(self.mlp(x), p2, dim-1)如果你想把电影类型加进来类型是离散的短逗号列表可以从movies.dat拆出多hot编码然后接一个全连接层映射到和Embedding相同的维度再与ID向量拼接或相加。拼接会让输入维度变大相加则要求两种特征向量维度一致实验包大多用拼接代价是参数数量增加。对于movieLen1M这种小数据加类型特征通常只能涨零点几个点的Recall不必一开始就做。3.3 相似度函数选内积还是余弦温度系数的实际作用两个塔输出同为D维向量匹配分数可以用内积sum(u*v)也可以除以向量长度得到余弦相似度。因为塔的最后已经做了L2归一化两者完全相同。温度系数则混入logits上把匹配分除以T后再接softmax。T越小softmax分布越尖锐负样本中与用户比较相似的物品会获得更大的梯度T越大所有负样本梯度趋向均匀。这个性质直接影响在batch内负采样下的训练难度。下表整理了三种选择常见配置以中间一行为主。相似度配置logits公式训练特点检索阶段是否需同样处理纯内积u·v向量模长与方向同时影响分数热门物品容易被推远不需要余弦相似度u·v/(u带温度余弦cos(u,v)/T控制难负样本梯度调参有红利温度可忽略单调变换需要注意温度对排序是单调的在检索阶段乘不乘T不会改变TopK的顺序但会影响训练时梯度在各负样本间的分配。很多实验坑都来自这里训练时用带T的softmax评估时却用了不一致的相似度定义导致向量空间和检索索引不对齐。4. 训练DSSM召回模型batch内负采样、损失函数与收敛检查DSSM的训练核心不是模型本身而是负样本从哪里来。MovieLens 1M规模小最实用的做法是batch内负采样取一个batch的用户和物品用户塔和物品塔各输出一组向量然后算出一个B×B的相似度矩阵。矩阵的第i行表示第i个用户与这个batch里所有物品的匹配分对角线恰好是第i个用户的正样本其他位置都是负样本。这样不需要额外存储负样本还能让同一个物品在多个用户那里承担负例角色。4.1 一次forward构建全batch的logits矩阵用交叉熵训练双塔目标是对每一个用户从batch内的B个物品中把正确的那个挑出来。实现上把用户向量和物品向量做矩阵乘法得到B×Blogits再除以温度系数最后用CrossEntropyLoss让对角线上的得分最高。def dssm_batch_loss(user_ids, item_ids, user_tower, item_tower, temperature0.1): user_vec user_tower(user_ids) # (B, D) item_vec item_tower(item_ids) # (B, D) logits torch.matmul(user_vec, item_vec.T) / temperature labels torch.arange(user_ids.size(0), deviceuser_ids.device) loss torch.nn.functional.cross_entropy(logits, labels) return loss这里只有三行核心计算却包含两个容易出问题的细节。第一logits矩阵的行是用户、列是物品矩阵乘法用用户向量乘以物品向量的转置方向的拼接方式绝不能反否则对角线就不是正样本第二labels直接取0到B-1的索引因为正样本永远在每行的对角线上不需要额外传label。温度写进logits而不是损失外部的权重为的是让softmax输入维持在一个合理量级默认0.1对MovieLens 1M是个比较保守的起点。4.2 训练主循环数据集、优化器与学习率数据加载用PyTorch的TensorDataset即可训练前把用户ID和物品ID都转成Tensor并放到GPU上。如果压缩包里自带数据管道也需要确认它是否每次迭代都重取样因为固定batch里的负样本会在训练过程中反复出现容易导致过拟合。from torch.utils.data import DataLoader, TensorDataset train_dataset TensorDataset( torch.tensor(train[user_idx].values), torch.tensor(train[item_idx].values), ) train_loader DataLoader(train_dataset, batch_size256, shuffleTrue) user_tower UserTower(num_users, embed_dim32, hidden_dim64, output_dim32) item_tower ItemTower(num_items, embed_dim32, hidden_dim64, output_dim32) opt torch.optim.Adam( list(user_tower.parameters()) list(item_tower.parameters()), lr1e-3, ) user_tower.train() item_tower.train() for epoch in range(20): epoch_loss 0.0 for u, i in train_loader: opt.zero_grad() loss dssm_batch_loss(u, i, user_tower, item_tower) loss.backward() opt.step() epoch_loss loss.item() print(epoch, epoch, loss, epoch_loss / len(train_loader))shuffleTrue会让每个batch的组合不断变化batch内负样本也随之变化等于时刻在给模型出新的考题。学习率1e-3配合Adam通常能让损失在3个epoch内快速下降到接近初始值的1/10如果超过5个epoch损失还没有下降趋势优先去查数据里是不是混入了空ID或重复样本而不是调网络宽度。4.3 三个必调参数batch_size、向量维度、temperature这三个参数的相互影响远大于单独调节。表格里给出的是在MovieLens 1M上比较合理的搜索范围。参数推荐范围数值过大数值过小batch_size256~1024每个batch负样本充足但显存和计算量增长负样本太少模型容易把正样本当成离群点output_dim32~128向量空间容量大检索开销高小数据过拟合维度太低不同物品容易挤在一起temperature0.05~0.2梯度趋于平均难负样本被淹没训练不稳定损失容易跳到NaNbatch_size在这里不只是优化器的一个参数它直接决定负样本数量因此batch越大双塔学得越稳。output_dim则受MovieLens物品基数限制4000个电影用128维已经非常奢侈可以先用32维跑通再逐步放大观察召回提升。temperature和batch_size还会互相作用batch越小要让softmax有足够的区分度温度往往需要取得更低。4.4 损失下降但召回不涨损失不降但召回在涨的排查损失下降但Recall10没有任何变化通常说明负样本太简单模型只要学会“用户自己的东西”就能把loss压得很低而向量空间的结构和物品分布保持一致。此时可以把temperature调低或者改用更难的负采样如只在同一个batch的物品Top里挖负样例。另一种情况是损失在高位震荡但召回持续提升多半由train和test一致性差引起比如测试时没有把训练交互过的物品剔除指标被“记忆”赚走偏移。建议训练时每3个epoch导出一份物品向量跑一次第5章的轻量评估观察曲线比死盯loss更有意义。5. 召回效果评估Hit Rate、RecallK与几种常见错误算法模型训练完了不能只看交叉熵损失召回效果的评估要回到“从所有电影里给我TopK我把正样本排在前面了吗”这个问题上。MovieLens 1M有4000部电影全量距离计算只需要一次矩阵乘法因此可以跳过近似最近邻直接算精确TopK。评估目标通常用Hit RateK和RecallK两者定义略有差别不要混用。Hit RateK是“有多少个用户的测试正样本出现在TopK中占比多少”RecallK是所有测试正样本中有多少被召回。由于留一法每个用户只有一个测试正样本Hit RateK和RecallK会得到相同数值。5.1 留一法每个用户只保留一条新交互做评估第2章做过时间切分这里再把测试集合的目标统一成“每个用户一条测试样本”。如果某个用户被分到了多条时间戳完全相同的评分保留其中任意一条否则一个用户出现多个测试正样本时“命中TopK”的判断会变得含糊指标也会被重复计数。hit_holder test.groupby(user_idx, as_indexFalse).first() test_user_ids hit_holder[user_idx].values test_item_ids hit_holder[item_idx].valuesfirst()在这种情况下只负责去重不用真正排序因为第2章已经按时间排过序。如果你想换一种更粗粒度的评估也可以让每个用户保留最后5条但这样每个用户就有多个测试目标计算RecallK时需要分母不同建议还是从一条样本起步。5.2 用矩阵乘法计算全量相似度并输出TopK电影数量只有4000直接把测试用户向量与全部物品向量做矩阵乘法得到U×N_items的分数矩阵。生成匿名评估前必须把用户训练数据里出现过的物品分数设为-inf否则模型只需记住用户看过的电影就能拿到看似不错的召回完全没有泛化意义。import numpy as np all_item_vecs np.array([ item_tower(torch.tensor([i])).detach().numpy()[0] for i in range(num_items) ]) all_item_vecs all_item_vecs / np.linalg.norm(all_item_vecs, axis1, keepdimsTrue) user_vecs user_tower(torch.tensor(test_user_ids)).detach().numpy() user_vecs user_vecs / np.linalg.norm(user_vecs, axis1, keepdimsTrue) scores user_vecs all_item_vecs.T for u, user_id in enumerate(test_user_ids): train_items train[train[user_idx] user_id][item_idx].values scores[u, train_items] -np.inf top_k_idx np.argsort(-scores, axis1)[:, :10] hits sum((top_k_idx[i] test_item_ids[i]).any() for i in range(len(test_user_ids))) print(Hit Rate10:, hits / len(test_user_ids))这段代码里有一个刻意的小设计矩阵乘法前手动对向量做L2归一化。塔的最后一层已经归一化过一次这里再做一次是为了覆盖“导出向量后被改动”或“用了第三方工具重新序列化”的场景保证相似度计算口径一致。train_items被置为-inf后这些历史交互不可能再出现在TopK里从而让召回更接近真实在线候选集。5.3 算RecallK时最常踩的三个坑第一个坑是没有排除训练集中已交互物品导致指标虚高两到三个点第二个坑是把测试正样本同时当成负样本使用比如在batch内负采样时物品塔把测试物品也嵌入到batch中让模型在训练时就见过测试物品的信息评估时自然容易命中第三个坑是直接使用sklearn.metrics的recall_score它默认衡量的是多分类或二分类标签不适用于这种“一个用户一个正样本”的检索场景。指标分子分母MovieLens留一法取值Hit RateK测试正样本在TopK里的用户数测试用户数和RecallK相同RecallK被召回的测试正样本数全部测试正样本数做留一随机抽样后两者相等PrecisionKTopK中命中数K衡量排在前面的可靠性评估脚本最好在每轮训练结束后自动输出Hit RateK和当前物品向量的L2范数分布后者可以快速校验是否出现向量退化为全零点的情况。一旦发现某个维度的值全部接近0优先排查Embedding初始化或梯度数值问题而不是急着换模型结构。6. DSSM向量检索阶段的验证技巧归一化、温度参数与全量索引的衔接训练评估通过后最后一步是把模型落到实际的向量检索阶段。DSSM的在线流程不需要用户和物品特征拼接在一起算分数而是提前用物品塔把所有物品向量抽出来建索引线上只计算用户向量再在索引里做最近邻搜索。这里的坑常常出现在“训练侧的相似度”和“检索侧的距离度量”不一致上。6.1 温度参数只影响训练检索时不要重复乘进距离带温度余弦在训练时控制梯度分配但温度是单调变换不影响每个用户对物品的排序。检索侧只需保持向量归一化方式一致温度值不需要带进FAISS或numpy的TopK搜索里。如果代码里有人把得分除以温度后再传给排序逻辑结果不会变但会白白增加一次数值转换也会让日志里的分数和训练阶段的logits对不上。6.2 用numpy核对FAISS索引的召回一致性向量库只有4000个物品更稳妥的验证方式是拿numpy手算精确TopK再和FAISS索引的返回结果做交集比对。这里给一个基于numpy的对照脚本适合拿训练完的导出向量直接在实验包output目录下运行。import numpy as np import faiss item_vecs np.load(output/item_vecs.npy).astype(float32) faiss.normalize_L2(item_vecs) index faiss.IndexFlatIP(item_vecs.shape[1]) index.add(item_vecs) user_vecs np.load(output/user_vecs.npy).astype(float32) faiss.normalize_L2(user_vecs) _, faiss_top index.search(user_vecs[:1000], 10) manual_scores user_vecs[:1000] item_vecs.T manual_top np.argsort(-manual_scores, axis1)[:, :10] consistent 0 for i in range(len(user_vecs[:1000])): if set(faiss_top[i]) set(manual_top[i]): consistent 1 print(FAISS与numpy TopK一致率:, consistent / len(user_vecs[:1000]))这里用IndexFlatIP而不是IndexFlatL2是因为输出向量已经做归一化内积顺序等价于余弦相似度而L2在归一化后才会等价于余弦但容易出现索引内部精度差异。中文类实验包常写的“FAISS用内积还是欧氏距离”争论本质上都落在训练时是否对向量做了L2归一化。如果一致率不是100%先复查两个入口一是导出向量时是否又做了一次归一化二是导入FAISS前是否误用了float16导致精度折损。对MovieLens 1M这种规模还可以在检索脚本里加一个断言确保物品向量矩阵的非零行数量等于电影数量防止某个异常用户或物品ID溢出到索引里。日常跑实验我倾向把这段核对脚本挂在每个epoch的evaluation之后只要发现FAISS和numpy顺序不一致就立刻停掉后续实验。它不消耗多少时间却能把“训练看起来很好但线上召回变差”的排查窗口从小时级压缩到分钟级。可以把温度值也写进模型配置在评估阶段读出来用于复算训练时的logits避免团队里不同人改口径。本文还有配套的精品资源点击获取