简介:一份基于Python深度学习的新闻推荐系统毕业设计源码,面向计算机相关专业学生与从业者,适合用于毕业设计、期末课程设计或推荐系统实战入门,帮助你快速掌握新闻推荐场景下的深度学习应用与Web系统集成方法。压缩包共444个文件、约8.44MB,内容涵盖Python核心源码、HTML/CSS/JS前端页面、PHP服务端接口、模型持久化文件(joblib/hdf5)以及配置说明文档等,从数据准备、模型训练、推荐逻辑到前端交互形成了完整可运行的项目闭环,便于直接部署演示或二次开发。目前已有1235人学习下载,该项目为个人原创毕业设计且评审分达95分以上,经作者严格调试确保稳定运行;读者不仅能获得一套可复用的推荐系统模板,还能学习到新闻内容特征处理、用户行为建模、深度学习排序策略等关键细节,同时通过清晰的目录结构和注释快速定位各模块,为独立完成毕设或课程大作业提供有力支撑。
1. 拿到深度学习新闻推荐系统源码之后:先别急着解压跑通
把一个命名成“基于python的深度学习的新闻推荐系统源码(毕业设计)”的 zip 包交到你手上,最常见的翻车方式不是模型训不出来,而是解压后对着十几个 Python 文件不知道从哪一步开始。这类毕业设计源码的典型构成是:数据处理脚本、召回模型、排序模型、离线评估和一个小型展示界面。它要解决的问题很直接——给用户从大量新闻里挑出他可能感兴趣的 TopN 条,核心是用户兴趣建模和新闻冷启动。
很多同学拿到包后的第一反应是“先装依赖、跑通 demo”,但我的建议相反:先别急着跑。先花半小时把目录结构走一遍,搞清楚每份脚本在数据流里处于哪个位置。这篇文章会按一线落地的方式,把这个系统从选型、数据、特征、模型训练到坑位排查拆开讲清楚。如果你正在做毕业设计,或者刚入行推荐想找个练手方向,照着这个思路走,比直接改别人的代码要省力得多。
2. 系统架构与数据流:双塔召回加深度排序为什么是毕业设计的安全牌
新闻推荐和电商推荐有一个本质差异:新闻的时效性极强,一条热点新闻的生命周期可能只有几个小时,用户兴趣也随时在漂移。你不可能像卖商品那样把 item 特征固化下来。所以新闻推荐里最常见的工程方案是“召回 + 排序”两段式结构:召回阶段从几十万篇新闻里粗筛出几百个候选,排序阶段用深度模型对候选做精排。这个结构既能在线上保证延迟,又能让每个模块单独迭代,对毕业设计来说也是最容易讲清楚、最容易答辩的结构。
2.1 选型理由:从协同过滤到双塔召回,新闻场景的必然选择
传统的协同过滤在新闻场景里有两个硬伤:一是 user-item 共现矩阵极度稀疏,大多数用户只点击过几十条新闻,算不出可靠的相似度;二是冷启动问题,新新闻没有行为记录,协同过滤永远推不出去。所以新闻推荐基本不会把协同过滤当主力,而是用内容向量加行为序列来做用户兴趣表示。
常见做法是双塔召回结构,也叫 DSSM 风格的用户塔和新闻塔。用户塔输入用户行为序列、统计特征,输出一个用户向量;新闻塔输入新闻标题、正文、类目,输出一个新闻向量。训练时让点击过的 pair 在向量空间里靠近,没点击过的远离。线上使用时新闻向量可以提前算好灌入向量索引,用户向量实时计算,一次内积检索就能拿到候选集。这个方案对毕设来说是个安全牌:模块边界清楚、训练稳定、可视化也好解释。
排序阶段则用深度 CTR 模型,输入用户向量、新闻向量和交叉特征,输出点击概率。整体上,召回保证“把对的新闻找回来”,排序保证“把最对的新闻排在最前面”。这两部分合起来才是一个完整的新闻推荐系统,而 zip 包里的所有代码基本都围绕这条链路展开。
2.2 数据流与样本构造:用户行为日志如何变成模型输入
拿到源码后,第一步是确认数据流。一套完整的训练数据包括三张表:用户行为日志、新闻内容表、用户画像表。日志表里最关键的四列是 user_id、news_id、behavior 和 timestamp。很多毕设源码不会直接给你原始数据,而是给一个处理好的 CSV,但你要明白这些 CSV 是怎么来的,因为答辩时老师大概率会问。
我一般会先写一个样本构造脚本,把点击日志转成模型可用的正负样本。新闻推荐里负样本的构造方式直接决定模型能不能收敛。随机采样是底线方案,更好的做法是从“曝光未点击”里采负样本。但很多公开日志里没有曝光数据,那就只能全局随机采样,这时候要把正负样本比例控制在 1:4 左右,否则模型会严重偏向负类。
import pandas as pd import numpy as np def build_train_samples(log_path, item_path, neg_ratio=4, min_click=3): # 读取用户行为日志:user_id, news_id, behavior, timestamp logs = pd.read_csv(log_path) logs = logs[logs["behavior"] == "click"].drop_duplicates( ["user_id", "news_id", "timestamp"] ) # 过滤行为数过少的用户,避免训练样本太稀疏 cnt = logs.groupby("user_id")["news_id"].nunique() valid_users = cnt[cnt >= min_click].index logs = logs[logs["user_id"].isin(valid_users)].copy() logs.sort_values(["user_id", "timestamp"], inplace=True) # 点击过的当作正样本 logs["label"] = 1 pos = logs[["user_id", "news_id", "label"]] # 负样本:随机采样用户没点过的新闻,比例由 neg_ratio 控制 item_pool = pd.read_csv(item_path)["news_id"].unique() neg_list = [] for uid, group in pos.groupby("user_id"): clicked = set(group["news_id"]) candidates = [n for n in item_pool if n not in clicked] if len(candidates) >= neg_ratio: neg_list.append(pd.DataFrame({ "user_id": uid, "news_id": np.random.choice(candidates, neg_ratio, replace=False), "label": 0 })) neg = pd.concat(neg_list, ignore_index=True) return pd.concat([pos, neg], ignore_index=True)这段代码的核心逻辑是先按用户去重并过滤行为过少的用户,再做负采样。neg_ratio是正负样本比例,毕设项目里建议调成 4,也就是一份正样本配四份负样本。min_click控制每个用户的最少点击次数,过滤掉只点过一次的用户,这类用户的行为序列太短,学不出稳定的兴趣表示。注意这里负采样用的是全量新闻池随机采样,实际效果和曝光负样本有差距,但如果日志里没有曝光字段,这已经是最稳妥的选择。
数据做好后,下一步就是把新闻文本转成向量。这个环节处理得好不好,直接决定双塔召回的上限。
3. 新闻文本与用户特征工程:从 jieba 分词到行为序列向量
新闻推荐里文本是核心信号。一条新闻的标题往往就决定了用户点不点它,所以特征工程的第一个重点是把标题和正文变成稠密向量。另一个重点是用户行为序列——用户最近点过的新闻列表,代表他的短期兴趣。这两块特征会分别进入新闻塔和用户塔,特征质量不够,模型结构再花哨也救不回来。
3.1 标题和正文的向量化:word2vec 训练与序列 padding
处理新闻文本的第一步是分词。中文场景下 jieba 是默认选择,但有个细节容易被忽略:jieba 的默认词典对新闻里的网络新词、人名、缩写覆盖很差。常见做法是加载一个额外的自定义词典,把新闻类目里的高频实体词加进去。分词之后必须去停用词,否则“的”“了”“在”这类词会把向量平均池化的结果冲淡。
分词完成后,词向量可以选择现成的预训练词向量,也可以用自己的新闻语料训练 word2vec。我建议毕设项目里自己训练一个轻量版 word2vec,原因有二:一是公开新闻语料和你手头的新闻数据集领域不完全匹配,二是自己训练的词表可以直接覆盖你数据里的专有名词。向量维度设 128 就够用,窗口设 5,min_count 设 3,低频词直接丢弃。
import jieba import numpy as np from gensim.models import Word2Vec STOP_WORDS = set(open("data/stopwords.txt", encoding="utf-8").read().split()) def tokenize(text): # 分词并过滤停用词与空白字符 return [w for w in jieba.cut(text) if w.strip() and w not in STOP_WORDS] def train_w2v(sentences_path, vec_dim=128, window=5, min_count=3): # 按行读取新闻文本,逐行分词后训练 word2vec sentences = [] with open(sentences_path, encoding="utf-8") as f: for line in f: sentences.append(tokenize(line.strip())) model = Word2Vec(sentences, vector_size=vec_dim, window=window, min_count=min_count, workers=4, sg=1) return model def title_embedding(title, model, dim=128): # 标题向量 = 所有词向量的平均池化 words = tokenize(title) vecs = [model.wv[w] for w in words if w in model.wv] if not vecs: return np.zeros(dim) return np.mean(vecs, axis=0)这里sg=1表示用 skip-gram 训练,对低频词更友好,适合新闻这种关键词稀疏的场景。title_embedding里做的是平均池化,这也是最容易踩坑的点:如果一条标题的所有词都不在词表里,函数会返回全零向量。一个全零的新闻向量进入双塔后,模型会把它当成“无信息”样本,导致这条新闻永远得不到推荐机会。所以词表的 min_count 不要设太高,宁留噪声,不留空向量。
3.2 用户行为序列的构造:多兴趣与时间衰减
用户兴趣不是静止的。用户昨天看体育新闻,今天可能因为突发事件转向时政。所以构造用户向量时,必须对行为序列做时间衰减——越近的点击,权重越大。常见做法是取用户最近 50 条点击新闻,按时间衰减系数加权平均,衰减系数通常取 0.95 到 0.99 之间。
另一个细节是行为序列的去重。用户可能在十分钟内反复点击同一条新闻,如果不做去重,这条新闻的向量会在用户兴趣里被放大好几倍。我的做法是同一个 session 内只保留一次点击,session 切分按 30 分钟无操作为边界。处理完这些之后,用户侧输入就是一个 128 维的稠密向量。
def build_user_vec(user_seqs, news_vec_map, max_len=50, decay=0.98): # user_seqs: {user_id: [news_id, ...]}, news_vec_map: {news_id: 128维向量} user_vecs = {} for uid, seq in user_seqs.items(): seq = seq[-max_len:] # 只保留最近 max_len 条行为 if not seq: user_vecs[uid] = np.zeros(128) continue # 时间衰减权重:越靠近当前时间的点击权重越大 w = np.array([decay ** (len(seq) - i) for i in range(len(seq))]) w = w / w.sum() vecs = np.array([news_vec_map[nid] for nid in seq if nid in news_vec_map]) if len(vecs) == 0: user_vecs[uid] = np.zeros(128) else: user_vecs[uid] = (vecs * w[:, None]).sum(axis=0) return user_vecs这段代码的核心在权重计算:decay ** (len(seq) - i)让最近的点击拿到最大权重,最早的点击权重指数级衰减。max_len设 50 是因为超过 50 条历史行为后,更早的信息对当前兴趣几乎没有贡献。实际调参时你会发现,decay从 0.98 调到 0.95,对短期热点新闻的推荐效果有明显改善,但长期兴趣类新闻的覆盖率会下降。这个参数是用户兴趣“短期 vs 长期”的调节旋钮,答辩时可以拿它当分析点。
4. 模型训练与召回排序:双塔加 CTR 排序的落地代码
特征准备好之后,就进入整个毕业设计最核心的部分:模型训练。我强烈建议把模型拆成两个独立的文件——召回模型和排序模型。召回模型用双塔结构产出候选集,排序模型在候选集上做精排。两个模型分开训练、分开评估,出了问题也容易定位。很多 zip 源码喜欢把两个模型揉在一个脚本里,看起来省事,但调试时你会后悔。
4.1 双塔召回训练与候选集生成
双塔召回的结构本身不复杂:用户塔吃用户行为向量和统计特征,新闻塔吃标题向量和类目 embedding,两个塔各自过几层全连接,最终都输出 64 维向量,用向量内积算相似度。训练时用 binary crossentropy 作为损失函数,正样本的相似度往 1 拉,负样本的相似度往 0 压。
这里有个容易被忽视的细节:双塔输出的向量必须做 L2 归一化。如果不做归一化,内积的大小会被向量模长主导,热门新闻的向量模长普遍偏大,模型会倾向于推荐热门内容,而不是用户真正感兴趣的内容。归一化之后内积等价于余弦相似度,模长信息被去掉,个性化效果会明显提升。
import tensorflow as tf from tensorflow.keras import layers, Model USER_DIM = 128 # 用户侧输入维度,即用户向量的维度 NEWS_DIM = 128 # 新闻侧输入维度,即标题向量的维度 EMB_DIM = 64 # 双塔最终输出的向量维度 def build_tower(input_dim, tower_name): inp = layers.Input(shape=(input_dim,), name=tower_name + "_input") x = layers.Dense(256, activation="relu")(inp) x = layers.Dropout(0.2)(x) x = layers.Dense(128, activation="relu")(x) x = layers.Dense(EMB_DIM, activation=None)(x) x = layers.Lambda(lambda v: tf.math.l2_normalize(v, axis=1))(x) return Model(inp, x, name=tower_name) user_tower = build_tower(USER_DIM, "user_tower") news_tower = build_tower(NEWS_DIM, "news_tower") user_in = layers.Input(shape=(USER_DIM,)) news_in = layers.Input(shape=(NEWS_DIM,)) u_vec = user_tower(user_in) n_vec = news_tower(news_in) score = layers.Dot(axes=1, normalize=True)([u_vec, n_vec]) model = Model([user_in, news_in], score) model.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss="binary_crossentropy", metrics=["AUC"])训练完成后,新闻塔要对全量新闻跑一遍,拿到所有新闻的向量,然后用 faiss 建索引。faiss 是向量检索的事实标准,用 CPU 跑几十万条新闻的检索也只需要几十毫秒。线上推荐时,先用用户塔算用户向量,再去 faiss 索引里搜 TopK 个候选。这里的 TopK 一般设 300 到 500,太小会漏掉长尾兴趣,太大会给排序模型增加负担。
import faiss # 全量新闻向量建索引,IndexFlatIP 表示内积检索 news_emb = news_tower.predict(news_feature_all) # shape: [N, EMB_DIM] index = faiss.IndexFlatIP(EMB_DIM) index.add(news_emb.astype("float32")) # 用户向量检索 Top500 候选 user_emb = user_tower.predict(user_feature_batch) # shape: [B, EMB_DIM] scores, idx = index.search(user_emb.astype("float32"), 500)注意IndexFlatIP是暴力检索,数据量超过百万时建议换成IndexIVFFlat或IndexHNSWFlat。毕设场景下几十万条新闻用IndexFlatIP完全够用,而且实现最简单,不容易出 bug。
4.2 深度排序模型与核心参数调节
召回拿到 500 条候选后,排序模型的任务是从中挑出最可能被点击的 TopN 条。排序模型的输入特征是用户向量、新闻向量、余弦相似度、类目匹配度、新闻热度等。模型结构不需要太复杂,一个三层 MLP 就能达到不错的效果。
排序模型最容易遇到的问题不是结构,而是特征泄漏。举例来说,如果把“这条新闻是否被用户点击过”的直接统计特征放进排序特征里,离线 AUC 会高得离谱,但上线后特征拿不到,模型直接崩。我见过某开发者的毕设源码里排序特征包含“用户过去点击该类目的次数”,这类特征不叫泄漏,它本身就是用户兴趣特征,可以保留。真正的泄漏是“用户对该新闻的点击次数”这种,训练时有、线上没有的特征。
def build_sort_model(feat_dim): inp = layers.Input(shape=(feat_dim,)) x = layers.Dense(128, activation="relu")(inp) x = layers.BatchNormalization()(x) x = layers.Dropout(0.2)(x) x = layers.Dense(64, activation="relu")(x) x = layers.Dense(1, activation="sigmoid")(x) model = Model(inp, x) model.compile(optimizer=tf.keras.optimizers.Adam(5e-4), loss="binary_crossentropy", metrics=["AUC"]) return model排序模型训练时,负样本要从召回的候选集里采,而不是从全量新闻里随机采。原因很简单:排序模型只需要学会在候选集内分好坏,不需要学会区分“用户感兴趣的新闻”和“用户完全不感兴趣的新闻”。如果你用随机新闻当排序负样本,模型会走捷径,AUC 虚高,但线上效果惨不忍睹。这个坑在毕设答辩时经常被问到,你能答上来就是加分项。
双塔召回和排序模型的几个关键参数,我按踩坑后的常用值列在下面,你可以根据自己的数据集微调。
| 参数 | 召回模型常用值 | 排序模型常用值 | 调节方向 |
|---|---|---|---|
| embedding 维度 | 64 | 64 | 维度越大表达力越强,但训练数据少时容易过拟合 |
| 学习率 | 1e-3 | 5e-4 | 排序模型特征更复杂,学习率要更低 |
| batch size | 256 | 128 | 显存不足时优先降 batch size |
| Dropout | 0.2 | 0.2 | 过拟合明显时调到 0.3 |
| 训练轮数 | 5~8 | 5~8 | 用验证集 AUC 判断早停 |
5. 毕业设计源码落地的 5 个常见坑:从导入报错到模型不收敛
这一章是血泪经验汇总。我帮人排查过不少新闻推荐源码,发现很多问题不是模型设计不行,而是卡在一些很基础的坑上。每一条我都按“现象、原因、解决”来写,你遇到类似报错时可以直接对照。
5.1 分词报错:缺少自定义词典与停用词表
现象:运行预处理脚本时,jieba 报错找不到dict.txt,或者停用词表路径指向一个不存在的文件,程序直接崩溃。
原因:zip 包里的代码把词典和停用词表写成了绝对路径,比如/home/user/data/stopwords.txt,换到你的机器上路径就失效了。还有的情况是源码打包时只打了.py文件,资源文件漏掉了。
解决:强烈建议把所有资源路径改成相对路径,用os.path.join拼接,以项目根目录为基准。资源文件缺失时,不要硬编码一个路径,而是在代码里处理异常,提示用户放置文件的位置。我一般会在项目里建一个data/目录,里面放停用词表、自定义词典、原始日志,路径统一用项目根目录动态拼接。
5.2 排序模型预测全为“0”或“1”:正负样本比例失衡
现象:排序模型训练完,对测试集的预测概率几乎全部小于 0.1 或全部大于 0.9,AUC 却很高,但推荐结果明显不合理。
原因:典型的正负样本比例失衡。如果负样本是随机从全量新闻里采的,模型很容易找到一个“廉价特征”把正负样本分开,导致预测概率集中在两端。另外,如果负样本数远大于正样本数,模型会默认所有样本都是负类。
解决:先确认正负样本比例,建议控制在 1:4 以内。然后看负样本来源:排序模型的负样本必须从召回候选集里采,不能从全量新闻里随机采。你可以打印一批预测概率的分布,如果呈 U 型,说明特征里存在泄漏或样本构造有问题。
5.3 CPU 训练卡死:embedding 维度与 batch size 设置不当
现象:训练开始后,内存持续上涨,几个小时后还在第一个 epoch,CPU 占用 100%,看起来像死机了。
原因:很多源码把新闻 ID 和用户 ID 直接塞进 Embedding 层,词表几万,embedding 维度设成 256 甚至 512,参数量爆炸。再加上 batch size 设成 1024,CPU 训练时每个 batch 的计算量巨大。
解决:第一种做法是放弃原始 ID embedding,改用我们已经算好的新闻向量和用户向量,也就是前文提到的双塔结构,输入维度只有 128,参数量小了几个数量级。如果坚持用 ID embedding,词表截断到 5 万以内,embedding 维度 64,batch size 降到 128。训练时密切观察内存变化,内存只涨不降,说明有数据加载泄漏。
5.4 新发布的新闻永远推荐不出去:冷启动被忽略
现象:模型上线后,新发布的新闻没有任何曝光,所有推荐结果都是老新闻。
原因:召回阶段的新新闻向量是算出来了,但用户行为序列里从来没有它,向量相似度普遍偏低,根本进不了 TopK。排序阶段更严重——新新闻没有点击历史,热度特征是 0,排序模型会给它打很低的分数。
解决:两步走。召回阶段加一个“类目热门兜底”通道,每个用户的 TopK 里有 10% 到 20% 的候选来自他感兴趣类目的热门新新闻。排序阶段给新新闻一个“新鲜度先验分”,比如发布时间越近,基础分越高。毕设里不需要做得太复杂,但必须让新新闻有机会曝光,否则用户刷到的永远是旧闻,体验很差。
5.5 离线 AUC 高但线上效果差:数据集划分方式不对
现象:离线评估 AUC 达到 0.9 以上,看起来模型很优秀,但上线后点击率却不如一个简单的热门榜。
原因:数据集划分时用了随机打散,而不是按时间划分。随机划分会导致同一条新闻的样本同时出现在训练集和测试集里,模型在测试集上“见过”这批新闻,AUC 虚高。这不是模型好,是数据泄漏。
解决:必须按时间划分数据集。比如取前 80% 时间段的日志当训练集,后 20% 当测试集。如果日志里有用户 id,还要保证训练集和测试集的用户不重叠,否则模型记住了用户 id 的向量,测试时遇到同一批用户效果虚高。按时间划分后,AUC 可能会掉到 0.75 左右,这个数字才是接近线上真实水平的值。
6. 上线前的最后一公里:离线评估、增量更新与最小 API 封装
模型训练完不等于项目完事,你还需要一个可验证、可展示的闭环。毕设答辩时,老师不只看模型结构,更看你怎么评估、怎么上线。我习惯把最后一步做成三件事:评估脚本、增量训练、一个最小可用的推荐接口,三件事都不复杂,但能让整个项目完整度上一个台阶。
先写一个评估脚本。排序模型用 AUC,召回模型用 Recall@K,这两个指标是推荐系统评估的地基。注意 Recall@K 在召回阶段算,K 取 10 或 50,看你的 TopK 设置。
from sklearn.metrics import roc_auc_score def evaluate(gold_list, pred_list): # gold_list: 真实标签,pred_list: 模型预测的点击概率 auc = roc_auc_score(gold_list, pred_list) # Recall@10: 每个用户的推荐列表里是否包含真正点击的新闻 hits = 0 for user_recs, pos_item in zip(cand_recs, true_items): if pos_item in user_recs[:10]: hits += 1 recall = hits / len(true_items) return auc, recall增量训练这块,新闻推荐和商品推荐不同,热点更替太快,不可能一套模型跑一个月。我的做法是保留训练好的 word2vec 词表,每天或每周用新增日志做增量训练。word2vec 的min_alpha设小一点,比如 0.0001,避免增量训练把原有词向量冲散。双塔模型同样用增量日志继续训练几个 epoch,保存检查点。注意负采样池也要跟着更新,否则新新闻仍然没有曝光机会。
最后封装一个最小 API,用 Flask 暴露一个/recommend接口,接收 user_id,返回推荐新闻列表。这个接口不用做得很复杂,能打通链路就好。
from flask import Flask, request, jsonify app = Flask(__name__) @app.route("/recommend", methods=["GET"]) def recommend(): uid = request.args.get("user_id") u_vec = load_user_vec(uid) # 读取或实时计算用户向量 rec_ids = search(u_vec, top_k=10) # faiss 召回 + 排序模型打分 return jsonify({"user_id": uid, "news_list": rec_ids})这个最小 API 的价值在于让整个系统从“脚本集合”变成“可演示的推荐系统”。你可以在答辩现场输入一个用户 ID,看到实时返回的推荐结果,比贴几张训练曲线有说服力得多。
我自己的习惯是,每次训练前先打印一个 batch 的输入 shape 和前向输出,确认数据流没问题再开完整训练,这个习惯帮我省下了无数个小时的等待。遇到 AUC 虚高时,先怀疑数据集划分,再怀疑特征泄漏,最后才怀疑模型结构。希望帮到你。
本文还有配套的精品资源,点击获取