
简介这是一份基于循环神经网络RNN的推荐系统实现代码包适合有一定Python基础、正在学习深度学习与推荐系统交叉领域的开发者。项目采用Jupyter Notebook组织实验流程完整覆盖从数据预处理、RNN/LSTM/GRU模型搭建到训练、评估与预测生成的典型环节。资源包共12个文件其中8个Python脚本承担核心逻辑如数据加载、模型定义、训练与评估1个ipynb笔记本用于交互式演示另有说明文档、配置与许可证文件整体仅20KB轻量紧凑。已有159人学习下载便于快速参考DREAM框架的代码结构。通过阅读源码和运行笔记本可掌握用序列行为数据构建推荐模型的关键步骤包括如何处理用户历史序列、设计RNN变体网络、划分训练验证集并调优超参数适合作为课程设计或论文复现的起点。1. 基于 RNN 的推荐模型到底解决什么问题一个行为序列的预测问题一个用户在电商 App 里留下了一串真实行为浏览 A 商品、搜索 B 关键词、把 C 加进购物车、最后下单 D。下一个该推什么传统协同过滤把行为当成一个可以任意交换的集合丢失了先后顺序基于 RNN 的推荐模型则是把这串行为当作时间序列用循环神经网络学习“看完这些之后下一步最可能做什么”。这是“基于 rnn 的推荐模型”这个名字背后的核心诉求也是它和矩阵分解、DeepFM 这类模型最大的区别。这类模型非常适合会话推荐、短视频推荐、购物车预测、以及交易类场景的下一步行为预测。它不要求你维护一个长期用户画像只需要最近十几次行为就能预测下一个物品。正因为这样Python 和 Jupyter Notebook 在这个项目里显得特别合适你要做大量交互式实验、看 tensor 形状、调 batch size、画 loss 曲线脚本式开发反而拖慢节奏。这篇笔记会从环境搭建、序列数据构造、GRU 模型训练写到验证评估和踩坑边界读者按步骤走就能把模型跑起来而不是停在公式推导上。2. 先把运行环境焊死Python、PyTorch 与 Jupyter Notebook 的组合2.1 为什么用 Jupyter Notebook 而不是纯脚本做推荐实验我很少拿一个很大的 .py 文件直接做这类实验。原因有三个第一RNN 模型的 tensor 形状很容易记错Notebook 可以每个 cell 单独执行并打印中间结果不用整段重跑第二序列推荐的迭代节奏是“改一个参数 - 看一个曲线 - 再改”天然适合单元格执行第三把模型定义、训练过程、损失曲线和预测样例放在同一个文档里两星期后复盘还能看清当时为什么这么改。Jupyter Notebook 的默认保存路径一般会落在用户主目录我习惯先建好项目文件夹再从项目根目录启动 jupyter这样数据文件用相对路径就能找到不会出现“明明数据在桌面代码却跑不出来”的低级问题。如果你拿到的是一个带 .ipynb 文件的 zip 包解压时务必保留 notebook 和数据文件的相对目录结构不要把 notebook 单独拖出去。你要是更习惯命令行批量任务最后可以用 jupyter nbconvert 把 notebook 导出成 py 脚本但实验阶段我不建议这么做。2.2 依赖安装的最小集合与版本选择下面这段是我在一台干净机器上装依赖的全过程。PyTorch 的安装方式比较特殊官方安装页会根据操作系统、CUDA 版本生成不同的命令我这里给的是 CPU 版的做法小模型完全够用。# 建一个独立虚拟环境避免把系统 Python 弄乱 python -m venv .venv # Windows 激活命令是 .venv\Scripts\activateLinux/macOS 用下面这行 source .venv/bin/activate # 先升级 pip再装数据处理和可视化相关的库 pip install --upgrade pip pip install numpy pandas scikit-learn matplotlib jupyterlab # 安装 CPU 版 torch只跑 GRU 这种小模型内存 32G 的普通笔记本足够 pip install torch参数说明我用 venv 而不是 conda原因是这个项目依赖少、环境轻。torch 默认会拉带 CUDA 的版本如果机器没有 NVIDIA 显卡也能装只是会多占几个 G 磁盘空间所以建议在安装时按 PyTorch 官网的 cpu 版本命令来。jupyterlab 和 notebook 二选一前者界面更好。scikit-learn 不是必须的但后面做评估指标时会用到先装上不亏。装完后用python -c import torch; print(torch.__version__)验证导入是否成功看到版本号就说明环境通了。2.3 固定随机种子训练可复现的第一道工序RNN 推荐模型不固定随机种子时同一个训练代码每次跑出来的 hit10 会有两三个百分点的抖动。这不是模型写错了而是初始化、Dropout 和数据加载 shuffle 的共同作用。做参数对比实验时这种抖动很容易掩盖真实差异所以我在所有实验代码开头都会固定随机源。import os import random import numpy as np import torch def set_seed(seed42): os.environ[PYTHONHASHSEED] str(seed) random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)逻辑说明PYTHONHASHSEED影响 Python 字符串哈希的随机性数据里如果用到set或字典会涉及torch.backends.cudnn.deterministic True让 cuDNN 选择确定性算法代价是可能比默认模式慢一点但对小模型影响不大。benchmark False则是关掉自动调优确保同一台机器上每次结果一致。注意一个隐藏坑DataLoader 里如果用了shuffleTrue最好也传入一个固定的torch.Generator因为 DataLoader 的随机源独立于torch.manual_seed。2.4 数据文件组织与读入别忽略编码和路径项目里的数据通常是 csv 或 json 格式建议按“数据文件放在 data 目录、notebook 放在根目录”的方式组织。读取带中文的 csv 时最容易翻车的是编码Windows 下另存的 csv 经常是 GBK直接用pd.read_csv(data/behavior.csv)会报 UnicodeDecodeError。import pandas as pd # 先用 utf-8 读失败后回退到 gbk覆盖绝大多数中文字符集场景 try: df pd.read_csv(data/behavior.csv, encodingutf-8) except UnicodeDecodeError: df pd.read_csv(data/behavior.csv, encodinggbk) print(df.head()) print(df.dtypes)参数说明encodinggbk是中文 Windows 环境的常见编码如果你在 Mac 或 Linux 上拿到同事传的文件优先试 utf-8。读完之后顺手打印 dtype确认三列必不可少session_id 或 user_id、item_id、timestamp。缺时间列的话后面会话切分没法做这是数据层面最容易忽视的问题。我见过不止一次拿到的 zip 包里没有时间戳最终只能用固定窗口暴力截断模型效果大打折扣。3. 把行为序列变成模型输入序列切分、滑窗采样与 padding3.1 会话切分为什么固定长度截断不够构造序列样本前先想清楚一个关键问题什么算一个序列。如果直接拿用户全部行为拼成一个超长序列再按固定长度截断会把两个不相关时间段的行为硬凑到一起。比如用户周一搜了“手机壳”周五又打开 App 停留了十分钟这两段行为中间没有线性关系强行放进一个序列只会给模型加噪声。常见做法是按会话切分同一 session_id 内的行为属于一个序列或者按“相邻行为时间间隔超过 30 分钟”断成新会话。时间间隔阈值是个超参数电商场景我一般取 30 分钟视频场景取 5-10 分钟因为视频站点用户在短时间内频繁切换内容间隔太大容易把不同意图的行为混在一起。序列切分粒度直接决定了样本质量这一步做不好后面换什么模型都救不回来。import pandas as pd def split_sessions(df, gap_minutes30): df df.sort_values([user_id, timestamp]).reset_index(dropTrue) df[ts_diff] df.groupby(user_id)[timestamp].diff() new_session (df[ts_diff].isna()) | (df[ts_diff] gap_minutes * 60) df[session_id] new_session.cumsum() return df df split_sessions(df, gap_minutes30) print(df[[user_id, item_id, timestamp, session_id]].head(10))逻辑说明按 user_id 排序后diff()计算同一用户相邻行为的时间差gap_minutes * 60把分钟换算成秒因为 timestamp 通常存 Unix 秒。new_session.cumsum()给每个新会话起点分配递增编号属于同一会话的行共享同一个 session_id。这个函数没有做窗口滑动的实际转换它只是把行为数据整理成模型可用的序列结构。3.2 滑动窗口构造输入输出对有了 session 之后接下来的目标是生成“前 N 个物品 - 第 N1 个物品”的训练样本。序列长度可以短到只用最近几次行为因为 GRU 这类模型本身能记住前面的信息窗口设太长反而增加计算量。我通常设max_len10也就是最多看最近 10 次行为超过的丢弃更早的部分。def make_samples(seq, max_len10): samples [] for i in range(1, len(seq)): input_seq seq[max(0, i - max_len):i] target seq[i] samples.append((input_seq, target)) return samples sample_seq [101, 102, 103, 104] for x, y in make_samples(sample_seq, max_len10): print(x, -, y)参数说明max_len是滑窗宽度同时也是后面 RNN 输入的最大时间步。小于max_len的样本需要做 padding等于max_len时直接取最近一段目标永远是当前时间步的下一个 item。这里故意用短序列举例实际运行中你会看到[101] - 102[101,102] - 103这样的递进关系模型是在学“看到前面的行为猜下一个”而不是学“填空”。3.3 Dataset 与 collate_fnpadding 和真实长度的统一处理PyTorch 的 Dataset 只是负责返回单个样本真正麻烦的是把不同长度的输入凑成一个 batch。RNN 要求一个 batch 内的序列长度一致所以必须 padding。padding 值我固定用 0并要求模型 Embedding 层padding_idx0这样模型会直接把 padding 位置映射成全零向量不会学到无意义的信息。import torch from torch.utils.data import Dataset from torch.nn.utils.rnn import pad_sequence class SessionDataset(Dataset): def __init__(self, sequences, max_len10): self.samples [] for seq in sequences: self.samples.extend(make_samples(seq, max_lenmax_len)) def __len__(self): return len(self.samples) def __getitem__(self, idx): x, y self.samples[idx] return torch.tensor(x, dtypetorch.long), torch.tensor(y, dtypetorch.long) def collate_fn(batch): xs, ys zip(*batch) lens torch.tensor([len(x) for x in xs], dtypetorch.long) padded pad_sequence(xs, batch_firstTrue, padding_value0) return padded, lens, torch.tensor(ys, dtypetorch.long)逻辑说明__getitem__返回原始序列和标签不在这里做 padding。collate_fn在每次取 batch 时执行先把长度信息收集到lens再用pad_sequence把短序列补到 batch 内最长长度padding_value0和 Embedding 的padding_idx对应。这样做的原因是 PyTorch 的 pad 是按 batch 实时计算的不是把全数据集 pad 成固定长度再进模型内存上省很多。3.4 物品 ID 编码从原始 item_id 到模型输入的映射RNN 推荐模型的 Embedding 层要求输入是连续整数。原始 item_id 往往是字符串必须先编码成 1 到 N 的整数其中 0 保留给 padding所以合法 id 从 1 开始。如果 item_id 有很多长尾我一般只保留出现次数前 N 的 item低频 item 统一映射到 0不行0 已经是 padding 了更好的做法是单独留一个unknownid比如 1padding 用 0。from collections import Counter def encode_items(df, min_count5): item_counts Counter(df[item_id]) item2id {pad: 0, unknown: 1} for item, cnt in item_counts.items(): if cnt min_count: item2id[item] len(item2id) df[item_idx] df[item_id].map( lambda x: item2id.get(x, item2id[unknown]) ) return df, item2id df, item2id encode_items(df, min_count5) print(物品总数含 padding 和 unknown, len(item2id))参数说明min_count5是低频物品过滤阈值设太大丢掉大量行为设太小模型容易见过稀有的偶然行为然后过拟合。我调了几次之后发现交互少于 5 次的 item 在会话推荐里基本是噪声过滤掉反而让 hit10 更平稳。注意映射到unknown而不是直接丢掉这条行为因为用户点击低频物品本身也是一个信号只是不参与 Embedding 学习。4. 实现并训练一个轻量 GRU 推荐模型结构选择与训练细节4.1 为什么选 GRU 而不是 LSTMRNN 家族的常用选择是 LSTM 和 GRU。在推荐场景里我几乎默认用 GRU原因很直接GRU 只有两个门参数量比 LSTM 少四分之一训练更快效果却不会差。会话推荐的行为序列通常只有几十步不是长文本LSTM 的长期记忆优势体现不出来反而更容易在小数据上过拟合。特别是项目标题的定位是 Python Jupyter Notebook 的工程实践意味着要快速迭代GRU 的收敛速度对实验效率帮助很大。GRU 的另一个好处是隐藏状态初始化要求低。LSTM 需要同时初始化 cell state 和 hidden state处理不好容易让早期时间步的输出震荡GRU 只有一个状态用零向量初始化就很稳。这也符合“少即是多”的工程原则模型简单好复现出问题好排查。4.2 模型代码Embedding、GRU 层和输出层模型的核心结构是三段Embedding 把 item id 映射成向量GRU 读取向量序列全连接层把最后一步隐藏状态映射成所有 item 的分数。输出层大小等于物品数量所以如果物品上百万这一步会变成瓶颈需要用负采样或 sampled softmax 优化小数据集直接全量输出即可。import torch.nn as nn class GRURec(nn.Module): def __init__(self, num_items, embedding_dim64, hidden_size128, num_layers2, dropout0.3): super().__init__() self.emb nn.Embedding(num_items, embedding_dim, padding_idx0) self.rnn nn.GRU( input_sizeembedding_dim, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0 ) self.head nn.Linear(hidden_size, num_items) def forward(self, x, lens): embedded self.emb(x) out, _ self.rnn(embedded) batch_size x.size(0) last_hidden out[torch.arange(batch_size), lens - 1] logits self.head(last_hidden) return logits参数说明embedding_dim64是物品向量的维度数据集小就用 32-64物品多可以加到 128。hidden_size128决定 GRU 的记忆容量太小学不到行为模式太大容易过拟合且更吃内存推荐先 128 起步。num_layers2是层数超过两层梯度衰减明显收益有限大部分会话级任务一至两层足够。dropout0.3只对多层 GRU 的层间输出生效单层时 dropout 直接置 0这是 PyTorch 的实现细节别设了 dropout 就以为单层也起作用。4.3 训练循环的核心逻辑训练循环和普通分类模型类似但需要多处理一件事取 GRU 输出中每个样本的有效时间步。因为 padding 后的序列长度不同最后一步隐藏状态不能简单取out[:, -1, :]因为-1可能对应 padding 位置。正确做法是用lens - 1索引到每个样本真实最后一个有效步。import torch import torch.nn as nn device torch.device(cuda if torch.cuda.is_available() else cpu) model GRURec(num_itemslen(item2id)).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(20): model.train() total_loss 0.0 for padded, lens, y in train_loader: padded, lens, y padded.to(device), lens.to(device), y.to(device) logits model(padded, lens) loss criterion(logits, y) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() print(fepoch {epoch1} loss{total_loss / len(train_loader):.4f})参数说明lr1e-3是 Adam 的常用初始学习率数据量小可以适当降到 5e-4。clip_grad_norm_是 RNN 训练的必要手段不裁剪梯度时长序列的反向传播容易出现梯度爆炸loss 跳成 NaN 是常见后果。max_norm1.0是个保守值我习惯先设 1.0loss 震荡时降到 0.5 或 0.25比调学习率更直接。训练窗口长度是动态的batch 内最长序列决定 GRU 步数所以 batch size 主要影响 GPU/内存占用不影响模型语义。4.4 训练参数的调整顺序一开始不需要同时调一堆参数。我的习惯是先保持batch_size64、lr1e-3、hidden_size128跑一轮观察 loss 是否下降。loss 能降到合理范围再调结构参数loss 完全不动先看数据有没有切对再看是不是 class imbalance 太严重。batch size 对 RNN 推荐模型的影响比普通分类大序列样本长度不一取太大容易让短样本的 padding 占比太高有效信息的密度被稀释。一个小技巧是训练前按序列长度排序分组每个 batch 内长度相近padding 浪费小训练速度能有可感知的提升。这步不是必须的但值得知道。我见过有人在 32G 内存的机器上把 batch size 拉到 256结果内存没爆只是慢因为 Jupyter Notebook 里上一次训练留下的中间变量没释放重启 kernel 反而最有效。5. 避坑指南RNN 推荐模型从训练到推理的常见问题5.1 数据路径和编码把程序卡在第一行现象在 Jupyter Notebook 里运行pd.read_csv直接报FileNotFoundError或UnicodeDecodeError。原因notebook 的当前工作目录不是 notebook 文件所在目录而是你启动 Jupyter 的位置Windows 上另存的 csv 默认是 GBK 编码。解决不用复杂路径把 csv 放到项目根目录的 data 文件夹用相对路径data/behavior.csv读取。启动 Jupyter 前先cd到项目根目录这一点和热词“jupyter notebook 默认保存路径”有关很多人是直接在桌面启动再到处找路径。编码问题按上一章的 try/except 处理utf-8 失败就回退 gbk。5.2 训练到一半 loss 变成 NaN现象前几个 epoch 正常某次迭代 loss 突然变成 NaN后面全部是 NaN。原因最常见的是学习率过大导致梯度爆炸其次是序列里出现 0 或负数 id 混合padding 和真实 item 冲突最后是 Embedding 层的padding_idx没传padding 向量也被更新产生异常梯度。解决先把 lr 降到 5e-4 或 3e-4同时加上梯度裁剪。检查 item id 是否都大于等于 10 号位只留给 padding。确认模型定义里nn.Embedding(..., padding_idx0)写对了这行代码最容易漏。5.3 预测出来的下一项总是热门物品现象推荐列表 top10 里全是全局热门 item用户自己的个性化行为几乎没体现在结果里。原因推荐场景的item分布高度长尾热门 item 出现在训练样本里的次数非常多模型收敛到输出热门 item 的局部最优。RNN 看得到用户历史但被热门 item 的高频信号压制了。解决训练时对目标 item 做负采样。常见做法是把全量输出层改成 sampled softmax或者更直接的在每个 batch 里随机采样若干未出现在该序列中的 item 作为负样本参与 loss 计算。数据侧可以对热门 item 做降采样但别降太狠否则模型会忽视真实的全局流行度。5.4 训练很慢GPU 利用率低内存却涨得快现象打开任务管理器发现 GPU 占用只有 20%训练一个 epoch 要很久同时笔记本内存被吃满。原因padding 使用率太低batch 内序列长度差距大GRU 被迫跑满最长序列步数大量 time step 处理的是无意义的 padding token。内存问题则往往是上一个训练循环的大 tensor 还留在 notebook 的 kernel 里没释放。解决按序列长度排序再组 batch每个 batch 内长度基本接近。如果还慢把num_workers调小甚至设为 0小数据集上多进程加载反而有序列化开销。Notebook 里训练完一个大模型直接用del model加gc.collect()清理缓存。5.5 离线评估指标很高线上效果却完全对不上现象验证集上 hit10 到了 0.5一上线点击率几乎没涨。原因离线数据按随机划分 train/validation导致验证集里出现和训练集同时期的行为模型见过相近分布更本质的问题是 Next Item 预测的评估方式没有考虑曝光空间模型在全体 item 里选 top10而线上只从几百个候选里排序答案空间不一样。解决按时间切分做离线评估用最后一段时间的行为做验证集绝不随机切。评估时把候选集限定为该 session 中确实出现过的 item 作为参考或者至少单独报告全量候选上的指标别只看随机划分的漂亮数字。习惯上我会同时保留“随机划分”和“时间划分”两套结果作为对比。6. 最后一步用 hit10 和 mrr10 验证模型并盯住 loss 之外的东西验证一个序列推荐模型能不能用我最看重两个指标hit10 和 mrr10。hit 看“正确答案在不在前十”mrr 看“正确答案排得多靠前”两个一起看才不会误会模型。只追求 hit模型会把相关但不精准的 item 排前面mrr 会教你做人。def evaluate(model, data_loader, k10): model.eval() hits, mrrs, total 0.0, 0.0, 0 with torch.no_grad(): for padded, lens, y in data_loader: padded, lens, y padded.to(device), lens.to(device), y.to(device) logits model(padded, lens) topk logits.topk(k).indices.cpu().tolist() for i, target in enumerate(y.cpu().tolist()): pred_list topk[i] if target in pred_list: hits 1 mrrs 1.0 / (pred_list.index(target) 1) total 1 return hits / total, mrrs / total hit, mrr evaluate(model, val_loader) print(fhit10{hit:.4f} mrr10{mrr:.4f})逻辑说明topk(k)返回分数最高的 k 个物品索引预测列表里找到目标 item 就给 hit 加 1MRR 是“第一个正确结果排名的倒数”排在第一位得 1.0排在第十位得 0.1这能有效识别“猜中但是排得很靠后”的水模型。评估时务必要在torch.no_grad()下跑否则又额外占用内存。另一个我踩过多次的坑是只看训练 loss 就下结论。RNN 推荐模型的训练 loss 可以降到很低但验证指标不动这通常是过拟合了或者模型只是在背训练序列里的高频片段。我现在养成的习惯是每次训练都跑同一个已经跑过的 baseline用同一份验证集对比拿到的指标变化超过一个百分点才认为改动有效。这个习惯帮我挡住过很多次“感觉上有效果、实际白忙一场”的调整。可视化上我会把每个 epoch 的 loss 和验证 hit 画在同一张图里直觉上能看出“loss 还在降但 hit 已经停了”就是过拟合的典型信号。最后提醒一句如果你在一台普通笔记本上做这个实验别急着上 128 维 embedding 和三层的 GRU先跑通再调大毕竟算力省着点用调试心态也会更稳。希望帮到你。本文还有配套的精品资源点击获取