首页 / 资讯中心 / 文章详情

基于Python深度学习新闻推荐系统:召回精排与BiLSTM-Attention实战

基于Python深度学习新闻推荐系统:召回精排与BiLSTM-Attention实战 ★ FEATURED ARTICLE
简介这是一套基于Python的深度学习新闻推荐系统源码属于个人毕业设计完整项目面向计算机专业学生、从业者以及需要完成课程设计或期末大作业的读者解决新闻场景中的个性化推荐建模与系统展示问题。资源包共444个文件压缩后约8.44MB包含Python核心算法实现、PyCharm工程配置、PHP后端逻辑与预训练模型文件前端页面基于Bootstrap、Swiper、SweetAlert等组件构建样式、图标、交互脚本一应俱全整体覆盖数据、模型、接口与界面多个层级便于按模块查阅与调试。目前已有1235人学习浏览项目评审分达到95分以上经过严格调试解压后可直接运行可作为毕业设计审查演示的参考。内容涉及数据处理、推荐算法实现、系统集成等关键环节结构完整且目录分层清晰既可作为答辩展示的优质模板也有利于在此基础上进行二次开发和功能扩展。1. 基于 Python 的深度学习新闻推荐系统它到底解决了什么问题“基于 Python 的深度学习新闻推荐系统”这个标题背后是一条从新闻数据清洗、用户行为序列构造到深度模型训练、最终生成 Top-N 推荐结果的完整链路。新闻推荐和电商推荐有一个根本差异新闻的时效性极强昨天的头条今天再推就已经失去意义所以模型必须在“理解用户长期兴趣”和“跟随新闻热点变化”之间找到平衡。这套系统通常由召回、精排两层构成浅层逻辑用类别和热度兜底深层语义交给深度学习模型不靠手工特征硬扛。本文不堆论文里的花哨结构按实际能跑通的方案拆开讲数据怎么来、模型怎么选、参数怎么调、坑在哪。适合准备做推荐方向毕业设计、或者想用深度学习完整接触一回推荐流水线的同学新手能照着复现熟手可以拿来对比自己的边界取舍。2. 先把系统拆开召回、精排与数据流2.1 为什么新闻推荐必须走“召回 精排”两段而不是一个模型大包大揽很多第一次做推荐系统的人会想把所有新闻丢进一个深度模型直接输出用户最可能点的前十条不行吗从原理上能说通但从工程和效果上看纯端到端单模型会遇到两个问题一是候选集太大几万条新闻全部参与模型推理单次请求延迟会冲到几百毫秒以上二是用户兴趣和新闻文本都高度稀疏一个模型同时承担“找相关”和“排优劣”两件事训练信号容易被噪声淹没。我一般把系统拆成两段召回阶段先把候选从全量新闻里快速筛到 200500 条精排阶段再用深度学习模型对这批候选逐条打分排序。召回不追求精准只求别把用户可能感兴趣的新闻漏掉精排才是模型发挥语义理解能力的主战场。这样做还有一个额外好处召回逻辑可以随时替换升级今天是按分类和热度召回明天想加一路协同召回只需要改一个接口不影响精排模型重训。两段式结构对毕业设计同样友好答辩时你可以清晰说明每一段的输入输出和性能指标而不是含糊地讲“一个神经网络搞定了所有”。如果只有几千条样本强行训练一个端到端模型大部分参数根本得不到有效梯度效果反而不如先召回再精排的结构稳定。2.2 模型选型对比TextCNN、BiLSTM-Attention 与双塔结构的性价比精排模型是这套系统的核心。常见做法是先让模型分别理解“新闻文本语义”和“用户兴趣”再计算二者的匹配度。不同选型的差异主要体现在文本编码器上我把常用的三种方案放在下表里从情感、算力和工程难度三个角度对比模型核心思路训练速度语义捕捉能力部署成本适合场景TextCNN多尺寸卷积核提取局部 n-gram 特征快中等擅长关键词和短语低语料小、文本短的起步方案BiLSTM-Attention双向序列建模 注意力加权重要词位较慢较强能捕捉上下文长距离依赖中新闻标题、正文摘要等中长文本双塔结构DSSM 类用户塔和新闻塔分别编码余弦相似度打分中中上依赖负采样质量中高真正上线需要低延迟高并发的场景这个项目我推荐 BiLSTM-Attention新闻标题通常在 2060 个词之间BiLSTM 能利用前后文把关键实体和事件串起来注意力机制又会把“今天”“宣布”“暴涨”这类对判断用户是否点击影响较大的词位权重放大。TextCNN 虽然快但在长句上的综合表现偏弱双塔结构上线友好但毕业设计阶段要处理负采样和塔间交互调试成本不低。2.3 整体数据流与模块清单把系统看成一整条流水线起点是原始新闻表和用户点击日志终点是一个推荐服务接口。以我惯用的实现方式为例模块划分如下新闻采集入库 - 文本清洗与分词 - 训练词向量 - 新闻语义向量化 用户点击日志 - 行为序列构造 - 结合新闻向量生成用户兴趣向量 兴趣向量与候选新闻向量做匹配 - 精排打分 - Top-N 结果落库 - HTTP 接口返回其中新闻入库层负责把标题、正文、发布时间、分类字段整理成结构化表格文本预处理层负责繁体转简体、去掉乱码、分词、过滤停用词向量层负责把分词结果映射成模型可读的数值矩阵匹配层是 BiLSTM-Attention 模型的主体服务层用轻量框架包一个推荐查询接口即可。模块划分清楚之后你就能给每个模块定验收标准分词模块看切分结果的召回率向量模块看相似样本的余弦距离是否合理精排模型看验证集上的 AUC 和 PrecisionK。这比整体糊成一团更容易排查问题也更容易在论文里写出“每个环节独立评测”的结论。2.4 用户兴趣向量是怎么来的精排打分前还有一个关键设计用户侧信息如何表示。最朴素的做法是把用户最近点击过的新闻向量做平均池化作为用户兴趣向量稍好一点的做法是引入时间衰减七天内点击的新闻权重高一个月前的权重低再进一步就是让模型学习一个注意力权重自动判断历史点击记录里哪些对下一次点击更重要。我建议从时间衰减平均向量开始。原因很简单它不需要额外参数逻辑直观适合先跑通整体链路等离线指标验证通过后再改造成可学习的注意力版本。注意力版本虽然效果上限更高但需要小心训练不稳定一旦行为序列长度差异太大注意力权重可能收敛到只盯少数几篇新闻。用户兴趣向量和新闻向量最后在同一个向量空间里计算相似度这个相似度会作为精排打分的重要输入。一般还会把新闻的热度值、新鲜度、用户对来源的偏好拼进来做特征融合让打分不只依赖语义。3. 数据准备与预处理把新闻变成模型认识的数字3.1 新闻数据字段设计与最小可用采集方案跑通这个推荐系统最好用的不是完整工业级数据集而是一个字段规整的小规模语料。每条新闻至少需要四个字段新闻 ID、标题、正文摘要、分类如果要做个性化推荐还必须有用户点击行为记录。我建议把所有数据统一成两个 CSV 文件一个放新闻本身一个放点击行为。import pandas as pd news_df pd.DataFrame({ news_id: [1, 2], title: [某地发布新版交通规划, 新一代芯片流片成功], summary: [该规划聚焦地铁与公交接驳..., 芯片采用新架构功耗降低...], category: [城市, 科技] }) click_df pd.DataFrame({ user_id: [101, 101, 102], news_id: [1, 2, 2], click_time: [2025-01-02 10:00, 2025-01-02 11:30, 2025-01-03 09:15] }) news_df.to_csv(news.csv, indexFalse, encodingutf-8) click_df.to_csv(clicks.csv, indexFalse, encodingutf-8)这里把新闻和用户行为分开存符合大多数脱敏后的真实数据格式。采集阶段不用贪多几千条新闻、几百个用户的点击记录就能撑起整个流程数据量过小时模型容易过拟合反而会让你误以为框架有问题。字段命名也尽量保持与后续代码一致避免中途改列名引发连锁错误。3.2 中文文本预处理流水线分词、停用词与序列填充中文预处理有一个容易翻车的地方分词结果直接决定词表大小而词表大小决定嵌入层参数量。如果把“人工智能”这类词切碎成“人工”“智能”语义信息会丢失如果切词粒度太细OOV词表外词比例会飙升。我习惯的做法是使用常见的精确模式分词再配合一个通用中文停用词表把“的”“了”“并且”这类无信息量的词去掉。import jieba import re STOPWORDS set([的, 了, 在, 是, 我, 有, 和, 就, 不, 人, 都, 一]) def clean_text(text): text re.sub(r[\t\r\n], , text) text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) return text def cut_to_sequence(text, max_len64): text clean_text(text) words [ w.strip() for w in jieba.cut(text) if w.strip() and w.strip() not in STOPWORDS ] return words[:max_len]上面的函数里re.sub先过滤掉所有非中英文和数字的字符避免标题里残留的标点符号干扰分词。jieba.cut切词后再用停用词表过滤words[:max_len]是粗暴的长度截断超长正文只保留前 64 个词。真实业务里新闻标题通常 30 词以内正文摘要建议截到 128 词这个数字可以作为超参数调。序列截断要特别注意max_len设得太大短文本会被无意义的填充词拉长设得太小长文本后半段的转折和结论信息全部丢失。我的经验是起步阶段标题序列用 32摘要序列用 64观察注意力权重分布后再决定要不要加长。3.3 点击行为序列构造从原始日志到训练样本原始点击日志是按时间顺序平铺的模型训练需要把它重组成“用户最近点过什么、此刻会点什么”的形式。具体做法是把每个用户按时间排序的点击列表滑窗取前 N 条作为历史序列第 N1 条作为正样本。负样本一般用随机采样生成从用户没点过的新闻里抽同等数量。def build_samples(click_df, news_df, seq_len20, neg_per_pos4): users click_df.groupby(user_id)[news_id].apply(list).to_dict() all_news set(news_df[news_id]) samples [] for uid, clicked_list in users.items(): if len(clicked_list) seq_len 2: continue for i in range(seq_len, len(clicked_list)): hist clicked_list[i - seq_len:i] pos clicked_list[i] samples.append((uid, hist, pos, 1)) for _ in range(neg_per_pos): neg random.sample(list(all_news - set(clicked_list)), 1)[0] samples.append((uid, hist, neg, 0)) return samplesbuild_samples返回四个元素用户 ID、历史点击序列、候选新闻 ID、标签。正样本是用户真实点击的新闻负样本是从未点过的随机新闻。neg_per_pos控制正负样本比例一般取 4 左右负样本太多会让模型偏向输出低分太少则正样本容易被记住验证集上表现虚高。这里有个细节负样本采样时用了all_news - set(clicked_list)目的是避免把用户点过的新闻误标成负样本。真实场景中用户没点不代表不喜欢可能只是没曝光过但作为起步方案随机负采样足够让模型学到基本的兴趣匹配方向。等系统跑到后期再考虑用“曝光未点击”作为更真实的负样本来源。4. 模型构建与训练从词向量到推荐结果4.1 先训一个轻量词向量还是直接用预训练模型新闻文本进入神经网络之前必须变成稠密向量。这里有两种常见路径一种是直接用公开预训练词向量另一种是在自有新闻语料上训练一个轻量词向量。毕业设计场景下我强烈建议先用自有语料训练一个 100 维的 Word2Vec原因很现实新闻语料有很强的领域个性比如“再融资”“反垄断”“涨停”这些词在通用域向量里可能语义不充分自有语料训练能对齐项目里的上下文。from gensim.models import Word2Vec import jieba sentences [ [w for w in jieba.cut(title) if w.strip()] for title in news_df[title].tolist() ] w2v_model Word2Vec( sentencessentences, vector_size100, window5, min_count2, workers4, epochs10 ) w2v_model.save(news_w2v.model)vector_size100适合小语料window5表示每个词只看前后 5 个词范围内的共现关系min_count2让只在语料里出现一次的生僻词不进词表能显著减小词表规模。词表太大时嵌入层参数爆炸训练变慢且多数生僻词根本没有足够上下文学出有效向量。有人会问为什么不直接用预训练模型原因在于加载体积大、初始化慢且领域词覆盖不一定好自有语料训练出的词向量虽然整体水平弱一些但至少所有词都在词表内能避免后面在嵌入层查不到词导致的维度崩溃问题。等主链路跑通再替换成预训练向量锦上添花。4.2 BiLSTM-Attention 模型主体新闻编码器与打分模块模型的主体是把新闻文本编码成语义向量再与用户兴趣向量做匹配。下面的实现使用双向 LSTM 拿到每个时间步的隐状态然后通过一个可学习的注意力层把隐状态加权汇集成一条固定长度的向量。import torch import torch.nn as nn import torch.nn.functional as F class NewsEncoder(nn.Module): def __init__(self, vocab_size, embed_dim100, hidden_dim128): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue, bidirectionalTrue) self.attn nn.Linear(hidden_dim * 2, 1) def forward(self, token_ids, mask): emb self.embedding(token_ids) # [B, L, E] lstm_out, _ self.lstm(emb) # [B, L, 2H] attn_score self.attn(lstm_out).squeeze(-1) # [B, L] attn_score attn_score.masked_fill(mask 0, -1e9) attn_weight F.softmax(attn_score, dim-1) ctx torch.bmm(attn_weight.unsqueeze(1), lstm_out).squeeze(1) return ctxpadding_idx0是专门用来忽略填充位的masked_fill把填充位置的注意力分数压到负无穷这样 softmax 之后它们不会参与加权。torch.bmm完成注意力权重对隐状态的加权求和输出的ctx就是一条新闻的语义向量。这里有个自定义点注意力层用了一个线性层直接计算每个词的原始分数没有引入额外的维度变换算是收敛更快的一种简化设计。用户兴趣向量我倾向单独定义一个模块把历史点击新闻向量按注意力或平均池化成一条用户向量再与候选新闻向量求余弦相似度。为了让训练更稳定输出时不做纯余弦而是把余弦改成带缩放的点积后面接一个二分类头。4.3 训练主循环与关键参数学习率、批次大小与早停训练阶段常见的翻车点是学习率设置不当导致 loss 震荡以及训练轮次过多导致模型记住训练集。下面的训练循环里我同时加了梯度裁剪和早停逻辑这两个细节能解决多数不收敛问题。from torch.optim import Adam model NewsEncoder(vocab_sizelen(vocab)) optimizer Adam(model.parameters(), lr1e-3) loss_fn nn.BCEWithLogitsLoss() for epoch in range(20): model.train() total_loss 0.0 for batch in train_dataloader: hist_ids, cand_ids, labels batch optimizer.zero_grad() score model(hist_ids, cand_ids) loss loss_fn(score, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() print(fepoch {epoch} loss: {total_loss / len(train_dataloader):.4f}) val_auc evaluate(model, val_dataloader) if val_auc best_auc: best_auc val_auc torch.save(model.state_dict(), best_model.pt)Adam初始学习率1e-3对这类中小型网络是安全的起点clip_grad_norm_把梯度模长限制在 1.0防止个别异常信号把嵌入层参数冲坏每次验证集 AUC 创新高才保存权重这比“每轮都保存”更省心。早停条件没写在代码里但判断逻辑很简单连续三个 epoch 验证指标没有上升就终止训练避免纯靠轮数硬撑。训练日志不能只看 loss 下降一定要关注验证集 AUC如果 loss 下降但 AUC 不动说明模型在记忆训练样本而不是在学泛化特征这时候应该检查负样本是不是太简单或者模型容量是否过大。4.4 从打分结果到推荐输出Top-N 排序接口模型训练完线上部分要做的不是实时跑一遍全量新闻而是把每条新闻向量化结果缓存起来用户请求到来时只对候选集打分排序。我实现了一个很轻量的查询函数它接受用户历史序列为候选新闻列表批量打分然后按分数降序取前 N 条。def recommend_for_user(model, user_hist, cand_news_ids, news_emb_cache, top_k10): model.eval() with torch.no_grad(): user_vec model.encode_user(user_hist) scores [] for nid in cand_news_ids: news_vec news_emb_cache[nid] scores.append((nid, torch.cosine_similarity(user_vec, news_vec, dim0).item())) scores.sort(keylambda x: x[1], reverseTrue) return [nid for nid, _ in scores[:top_k]]news_emb_cache是一个从新闻 ID 到向量的字典训练结束后一次性计算好线上就不再走网络前向计算新闻向量。top_k是最终返回的推荐条数新闻场景一般取 10。这里的encode_user内部就是读取用户点击序列里每条新闻的向量做时间衰减平均池化。这个接口还暴露了一个优化点cand_news_ids越小推荐越快而它由召回阶段决定。所以精排模型质量固然重要召回范围也直接决定线上性能如果候选集压缩到 200 条以内单用户推荐耗时可以控制在 20 毫秒以内。5. 避坑/常见问题/排查训练与部署时踩过的五个真实坑5.1 词向量与模型词表对不上embedding 查不到词现象训练开始阶段nn.Embedding报索引越界或者某些新闻分词后全部变成[UNK]模型几乎学不到文本信息。原因Word2Vec 训练时设了min_count2词表被过滤过但构造模型词表时如果直接用了全部新闻分词结果就会出现词表内查不到对应向量的词。更隐蔽的是分词时加入的停用词过滤与词向量训练时不一致导致同一句话在训练和预测阶段被切得不一样。解决在构造词表时只保留“既在词向量里、又出现在语料中”的词并强制预留两个槽位0作为填充位1作为 OOV 位。所有不在词表里的词在送入模型前统一替换为 OOV 索引保证序列长度一致。vocab {pad: 0, oov: 1} for word in w2v_model.wv.index_to_key: if word not in vocab: vocab[word] len(vocab)这段代码在训练词向量之后、定义嵌入层之前执行。之后所有文本序列化函数都查同一个vocab查不到的返回oov这一步能彻底杜绝索引越界问题。5.2 序列填充位参与注意力导致模型被“空词”带偏现象可视化注意力权重时发现大量权重落在填充位置推荐结果和新闻关键词几乎无关。原因mask构造和padding_idx没配合好。padding_idx0只是让嵌入层的填充向量为全零但 LSTM 和注意力并不会自动忽略全零向量如果不把填充位置的注意力分数置为负无穷softmax 后这些位置仍有非零权重模型相当于在看一堆空词学兴趣。解决每个 batch 都要显式构造mask并把 mask 传进模型。mask的形状是[B, L]有效词位置为 1填充位置为 0在注意力层计算完成后masked_fill把填充位置的分数换成-1e9。这一步是新闻序列建模的标配不是可选项。mask (token_ids ! 0).long() attn_score attn_score.masked_fill(mask 0, -1e9)5.3 训练集过拟合、验证指标却原地踏步现象前几个 epoch 训练 loss 持续下降但验证集 AUC 在 0.55 附近波动怎么加大模型容量都没用。原因负样本采样太随意。随机负样本和用户真实兴趣的差异往往非常大模型很容易找到一个“几乎不费劲”的决策边界只要是随机选来的都是负样本、用户点过的都是正样本。它根本没有被迫去理解“为什么这条新闻相关”只是学到了用户历史里常见词的表面信号。解决增大负样本难度。把随机采样改成“随机选一批候选新闻再按正文和标题相似度挑出那些看起来相关、但用户实际没点过的做负样本”。这个操作稍微提升代码复杂度但对模型泛化能力有明显帮助。5.4 模型文件体积失控服务启动内存翻车现象训练完成后保存的模型参数文件超过 800MB本地加载测试时机器内存持续吃紧服务接口响应越来越慢。原因嵌入层词表太大。如果词表里有 80 万个词每个词 100 维仅嵌入层参数就有 8000 万个浮点数接近 320MB再加上 LSTM 两个方向的多组参数整体翻倍毫不意外。解决第一步是控制词表规模min_count从 2 调到 3 或 5第二步是训练完成后裁剪掉没有实际出现过向量的低频词槽位重建一个精简词表后重新保存模型第三步是新闻向量缓存不上内存盘启动时按需加载到 Redis 或内存数据库。毕业设计阶段做到前两步模型文件能缩小到 100MB 以内。5.5 分词不统一带来的特征漂移现象同一条新闻在训练时被切成“新一代/芯片/流片”上线后被切成“新一代/芯片/流片成功”用户兴趣向量完全对不上推荐结果不稳定。原因训练脚本和预测脚本分别调用了两次分词或者一方加了停用词过滤、另一方没加。文本预处理的任何不一致都会让模型看到的特征分布发生变化这在深度学习里属于隐蔽 bug。解决把分词、停用词过滤、序列化封装成一个统一函数全项目只引用这一个函数同时让词向量训练、模型训练、线上预测共用同一个vocab字典。这样从数据到特征再到推理的全流程都只有一套切分逻辑问题自然消除。6. 进阶验证从离线指标到冷启动的落地习惯精排模型训练完不能只看 loss 和 AUC还要用推荐场景特有的指标确认效果。最直接的是 PrecisionK给每个用户留下一段点击记录用模型对其余历史推荐 Top-K看被保留的新闻有没有出现在推荐列表里。代码量很小我习惯写成一个独立函数def precision_at_k(reco_list, heldout_set, k10): reco_top_k set(reco_list[:k]) hit len(reco_top_k heldout_set) return hit / k除此之外覆盖率值得关注也就是推荐结果里不同新闻的占比。如果模型永远只在少数热门新闻里打转说明向量空间缺乏区分度用户兴趣可能被热点完全淹没。覆盖率的计算方式是对全量用户推荐结果取并集除以新闻总量小于 10% 就该回头检查召回阶段的过滤条件。冷启动问题可以这样处理新用户没有行为序列就把用户向量初始化为全站新闻向量的均值推荐结果退化为整体热度排序随着用户产生一两次点击立刻用新点击序列替换默认向量让推荐逐渐个性化。新新闻则是先按类别进入召回池向量由模型在线生成不需要等待大量点击。我的习惯是每跑完一个实验记录三条信息数据集规模、训练轮数、验证集 PrecisionK。这样反复对比两三周才能看出参数调整的方向是否有效。推荐系统没有一劳永逸的参数只有不断按数据反馈修边界模型才会越来越可靠。希望这篇实战笔记能帮你在自己的项目里少走几个弯把深度学习新闻推荐系统真正跑顺跑稳。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站