
简介基于用户行为与内容的个性化新闻推荐系统毕业设计项目内置协同过滤推荐算法面向计算机相关专业学生、教师及企业开发者可直接用于毕业设计、课程设计、大作业或阶段立项演示。代码经过测试运行、功能可用也可按需在现有结构上扩展具有较强的可操作性与学习参考价值。资源包共含2000个文件压缩后约21.31MB主要文件涉及HTML页面、JavaScript脚本、PHP后端逻辑、CSS样式、JSON数据等还包含xxtea加密相关C源码、Bootstrap前端组件、DHP数据文件与部署脚本内容覆盖前端展示、后端接口和推荐算法模块目录组织清晰便于分段查阅。已有189人学习下载适合个性化新闻推荐、协同过滤算法等方向的课设与毕设参考。除可运行代码外附带README等文档帮助快速启动项目并理解配置流程可作为完整课题方案直接使用或二次开发。1. 新闻推荐不是电商推荐先理解这三处不同刚拿到“新闻推荐系统”这个题目时很多人的第一反应是套用电商推荐那套矩阵分解模型但新闻场景会给出三个反直觉的结论第一新闻的时效性极强昨天的爆款文章今天就是垃圾推荐池的“物品”生命周期按小时计算而不是按年第二用户兴趣漂移快读者的偏好粒度往往在“科技-互联网-人工智能”这种三级类目上才稳定具体到单篇文章则几乎无规律可循第三行为数据极度稀疏大部分用户一天只点开三五条新闻不可能像电商那样积累几十上百个加购行为。因此围绕“基于用户行为和内容的个性化新闻推荐系统”这个方案核心不是把某个算法调到极致而是解决三个问题如何把用户行为转成可计算的特征如何把新闻内容转成可匹配的向量以及如何用协同过滤在“用户-新闻”这个稀疏矩阵上做召回与排序。这也是本篇文章要讲的主线先搭数据层再做画像然后实现 UserCF 与 ItemCF 双路召回最后用可复现的指标和参数调优方法把系统从“能跑”拉到“能用”。适合正在做毕业设计、想系统入门推荐系统或在小团队里从零搭一套内容分发 MVP 的开发者阅读。2. 用户行为数据如何组织从埋点到特征表的完整设计2.1 行为表的字段设计与事件定义任何推荐系统的上限都由数据质量决定而数据质量的第一个落点就是行为表设计。新闻场景中需要记录的最小行为集包括曝光、点击、停留、收藏、分享、负反馈六类其中曝光和负反馈最容易被忽略但它们恰恰是协同过滤召回和排序阶段最关键的信号。下面是一张适合单机 MySQL 或 PostgreSQL 存储的用户行为表结构CREATE TABLE user_behavior ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(64) NOT NULL, news_id BIGINT NOT NULL, behavior_type TINYINT NOT NULL COMMENT 1-曝光 2-点击 3-停留 4-收藏 5-分享 6-负反馈, stay_seconds INT DEFAULT 0 COMMENT 停留时长点击事件必填, from_channel VARCHAR(32) DEFAULT COMMENT 推荐位ID如 home_hot / news_detail_recommend, device_type VARCHAR(16) DEFAULT COMMENT app / h5 / pc, create_time DATETIME NOT NULL, KEY idx_user_time (user_id, create_time), KEY idx_news_time (news_id, create_time) );这张表的每个字段都有明确用途behavior_type区分行为强度点击和收藏不能按同一权重处理from_channel用于区分推荐位和自然流量评估算法效果时必须知道一条行为到底是不是推荐系统带来的device_type看似无关实际上新闻阅读的场景差异极大PC 端停留时长天然比移动端长不做归一化会污染画像。2.2 行为权重归一化让停留时长与点击可比推荐模型通常把用户对物品的兴趣表达成一个分值新闻场景的常见做法是把行为映射到 01 或 010 的权重区间。我一般会采用“事件基础分 时长修正”的策略而不是直接用原始停留秒数原因很简单一篇 3000 字的深度报道和一个 30 秒短视频的停留时长天然不在同一个量纲上。行为类型基础权重附加规则曝光0仅用于负样本构造曝光后 30 秒内无点击可作为弱负样本点击1.0不足 3 秒的点击视为误触权重降为 0.1停留1.03.0以该新闻类目平均停留时长为基线做归一化收藏4.0不与停留时长叠加分享5.0分享是强社交信号权重最高负反馈-5.0不感兴趣、减少此类推荐直接降权这里有一个容易踩的坑不要把停留时长和点击事件分别建模。客户端上报逻辑通常是“进入详情页时上报点击退出时上报停留”两条日志的news_id和user_id相同但停留时长落在点击那双日志里。处理时应该按user_id news_id session_id将两条事件合并为一条行为否则行为表里会出现大量重复记录。合并后的完整行为表可以再加一列behavior_weight DOUBLE DEFAULT 0后续所有画像计算直接读这一列。2.3 新闻内容表给文章维护一份可计算的元数据协同过滤本身不需要内容字段但标题里的“基于内容”决定了内容表必须能支撑画像构建和冷启动。新闻内容表建议独立维护通过news_id与行为表关联CREATE TABLE news_meta ( news_id BIGINT PRIMARY KEY, title VARCHAR(255) NOT NULL, content MEDIUMTEXT NOT NULL, category VARCHAR(32) NOT NULL COMMENT 一级类目如 tech / finance / sports, sub_category VARCHAR(32) DEFAULT COMMENT 二级类目如 ai / stock / basketball, keywords VARCHAR(512) DEFAULT COMMENT 逗号分隔的关键词由NLP模块离线抽取, publish_time DATETIME NOT NULL, source VARCHAR(64) DEFAULT COMMENT 来源站点或作者, status TINYINT DEFAULT 1 COMMENT 0-下线 1-在线 );keywords字段是内容画像的入口。毕业设计规模下不需要上 BERT 向量用 jieba 分词后统计 TF-IDF取每篇新闻 Top 20 关键词即可满足需求。publish_time不只用于展示后面 ItemCF 召回时要做时间衰减——新闻推荐系统里一篇 5 天前的文章即使相似度极高也不应该排进前三。2.4 展示日志与负反馈协同过滤最容易漏掉的字段很多实现把用户行为等同于点击和收藏但新闻推荐的用户意图弱误触比例高没有曝光数据就无法区分“用户看了但没兴趣”和“用户压根没看到”。常见的做法是为每个推荐位单独建一张曝光日志表记录每次请求返回的新闻列表CREATE TABLE impression_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(64) NOT NULL, news_id BIGINT NOT NULL, position INT NOT NULL COMMENT 新闻在推荐流中的位置从1开始, request_id VARCHAR(64) NOT NULL COMMENT 一次推荐请求的ID, create_time DATETIME NOT NULL );有了这张表就能在离线评估阶段回答“推荐系统给用户展示了什么”而不是只回答“用户点了什么”。同时position字段直接给排序模型提供了位置偏置信息——用户点第一位的概率天然高于第五位如果不把位置纳入 bias 修正离线评估指标会虚高。3. 构建可计算的用户画像与内容画像3.1 内容侧从新闻正文到关键词权重内容画像是后续一切算法的基础。给每篇新闻维护一个“类目-关键词-权重”三元组权重由两部分构成TF-IDF 计算出的词重要性以及类目本身的置信度。类目置信度可以用一个简单规则标题命中类目词典的词加 0.3正文首段首句命中再加 0.2。核心代码如下import jieba import jieba.analyse from collections import defaultdict def build_news_profile(news_row, top_k20): # 合并标题和正文标题权重乘3 text news_row[title] * 3 news_row[content] # 基于TF-IDF抽取关键词jieba默认已过滤停用词 tags jieba.analyse.extract_tags( text, topKtop_k, withWeightTrue ) profile { news_id: news_row[news_id], category: news_row[category], sub_category: news_row[sub_category], keywords: [(word, round(weight, 4)) for word, weight in tags], # publish_time 保留原始datetime后续做时间衰减 publish_time: news_row[publish_time] } return profilejieba.analyse.extract_tags返回的是经 TF-IDF 归一化后的权重范围通常在 00.1 之间直接用即可。注意这里的title * 3是字符串重复拼接目的是在分词时让标题中的词获得更高词频而不是简单地对权重乘 3。sub_category单独保留因为新闻推荐里跨二级类目的推荐质量很差召回阶段应尽量在同类目内做候选扩展。3.2 用户侧用行为加权的兴趣向量用户画像的本质是把行为表里的”行为“翻译成”偏好“。我一般会先按行为类型把behavior_weight准备好再用时间衰减函数把近期行为放大、远期行为缩小。半衰期设为 7 天即一周前的行为权重衰减到当前的一半import math import pandas as pd from collections import defaultdict def build_user_profile(behavior_df, news_profile_df, half_life7): # 将行为表与新闻画像按 news_id 做拼接 merged behavior_df.merge( news_profile_df, onnews_id, howinner ) # 计算每条行为的时间衰减系数 merged[time_decay] merged[create_time].apply( lambda ts: math.exp(-ts_days(ts) / half_life) ) user_profile defaultdict(float) for row in merged.itertuples(): # 关键词权重 行为权重 x 关键词tfidf x 时间衰减 for word, tfidf in row.keywords: delta row.behavior_weight * tfidf * row.time_decay user_profile[word] delta # 归一化避免高活跃用户向量范数过大 norm math.sqrt(sum(v**2 for v in user_profile.values())) for word in user_profile: user_profile[word] / norm return user_profile这段代码有两个参数要重点说明。half_life控制历史行为的影响半径数值越小兴趣漂移越快新闻场景下 7 天是合理起点做热点新闻或快讯时可以调到 3。behavior_weight来自第 2 节的权重表切不可直接使用未归一化的停留秒数否则一篇长文阅读就能淹没用户其他所有偏好。3.3 画像的更新策略与存储用户画像可以离线批量更新也可以在用户产生新行为时增量更新。毕业设计场景建议一天跑一次离线任务存储到 Redis Hash 或 MySQL JSON 字段中均可。实际生产里更推荐把用户画像拆成两层长期兴趣画像一周更新一次和短期兴趣画像实时增量更新推荐请求时合并两者作为最终输入。这里有一个经验值新闻推荐中短期信号对效果的影响通常远大于长期信号因为用户当天关心的内容与他三天前看的内容往往没有强相关性。因此召回时可以给短期行为更高的权重系数比如用户当天点击过的新闻直接膨胀 2 倍权重再参与画像更新。4. 协同过滤双路召回UserCF 与 ItemCF 的落地实现4.1 为什么新闻场景需要两路召回同时跑协同过滤在新闻推荐中并非最优算法但它依然是入职推荐系统的基础技能也是标题点名的核心。纯 ItemCF 的问题是覆盖窄用户的兴趣容易被锁死在已有行为覆盖的类目内纯 UserCF 的问题在于新闻池更新极快线下的用户相似度矩阵对当天新发布的文章完全无感知。所以这里不讨论单模型的“最优解”而是给出项目中最稳定的一套组合ItemCF 负责“读了 A 的人还读了 B”的发现式召回UserCF 负责“和你相似的人在看什么”的热点补充两路召回合并后再做过滤和排序。4.2 ItemCF基于物品相似度的实时推荐先看代码实现。以下实现遵循标准 ItemCF 三步构建用户-物品倒排表计算物品共现矩阵生成相似度矩阵并召回。import math from collections import defaultdict def itemcf_recall(user_items, user_id, top_n20, k10): user_items: dict {user_id: set(news_id)} # 第一步统计物品被多少用户交互过 item_user_cnt defaultdict(int) for uid, items in user_items.items(): for item in items: item_user_cnt[item] 1 # 第二步计算物品共现矩阵 item_sim defaultdict(dict) for uid, items in user_items.items(): for it_a in items: for it_b in items: if it_a it_b: continue item_sim[it_a][it_b] item_sim[it_a].get(it_b, 0) 1 # 第三步余弦相似度 活跃用户惩罚(IUF) item_sim_final {} for it_a, related_items in item_sim.items(): item_sim_final[it_a] {} for it_b, cnt in related_items.items(): # 余弦相似度公式 sim cnt / math.sqrt(item_user_cnt[it_a] * item_user_cnt[it_b]) # 活跃用户惩罚交互超过50个物品的用户对共现贡献降权 item_sim_final[it_a][it_b] sim # 召回取用户最近交互的k个物品作为种子 seed_items list(user_items.get(user_id, set()))[:k] scores defaultdict(float) for seed_item in seed_items: for cand_item, sim in item_sim_final.get(seed_item, {}).items(): if cand_item in user_items.get(user_id, set()): continue scores[cand_item] sim # 按得分排序返回 TopN ranked sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n] return ranked代码中值得注意的细节有两个。第一seed_items取用户最近交互的 k 个物品而不是全部物品因为新闻行为有强时序性三天前点击的新闻作为种子召回出来的“相似文章”大概率已经过时。第二IUF 惩罚注释提到了但没有真正实现实际生产中可以在共现累加时除以math.log(1 len(items))降低超级活跃用户对相似度的垄断。上面的代码适合在小数据集上验证流程、跑通业务逻辑数据量上到百万级后应把共现矩阵放到 Spark 或 Flink 里计算。4.3 UserCF面向新用户和新文章的互补通道UserCF 的工程实现与 ItemCF 完全对称先建物品-用户倒排表再算用户间相似度最后根据相似用户的兴趣做加权和。与 ItemCF 相比UserCF 的优势在于对新文章友好——只要一个相似用户点击了新文章新文章就能立刻进入候选集同时 UserCF 更适合新闻这种“群体兴趣速变”的场景因为它的输出天然偏向多人同时在读的热门内容。def usercf_recall(user_items, user_id, top_n20, k10): # 第一步物品-用户倒排表 item_users defaultdict(set) for uid, items in user_items.items(): for item in items: item_users[item].add(uid) # 第二步计算用户相似度 user_sim defaultdict(dict) for item, users in item_users.items(): for u_a in users: for u_b in users: if u_a u_b: continue user_sim[u_a][u_b] user_sim[u_a].get(u_b, 0) 1 # 第三步相似度归一化并召回 if user_id not in user_sim: return [] sim_users sorted( user_sim[user_id].items(), keylambda x: x[1], reverseTrue )[:k] scores defaultdict(float) for sim_user, sim_score in sim_users: for item in user_items.get(sim_user, set()): if item in user_items.get(user_id, set()): continue # 得分 用户相似度 x 行为权重这里统一按1处理 scores[item] sim_score return sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n]UserCF 两个参数需要格外留意sim_score这里直接用了共现次数更严谨的做法是除以math.sqrt(len(user_items[u_a]) * len(user_items[u_b]))做余弦归一化否则活跃用户的“相似性”虚高k值建议在 1030 之间网格搜索过小则召回结果随机性太大过大会把相似度很低的用户的兴趣也混进来。4.4 双路融合与过滤规则两路召回拿到候选集合后不能简单做并集就结束。新闻推荐必须加两层过滤一是用户已读过滤避免重复推荐同一篇文章或内容几乎相同文章的洗稿版本后者在新闻场景极其常见用标题的字符相似度阈值兜底二是时间衰减过滤发布日期超过 T 天的新闻降权甚至直接剔除。融合打分公式可以简单直接final_score 1.2 * itemcf_score usercf_score freshness_bonusfreshness_bonus是一条按新闻发布时长递减的加分项例如3 * exp(-age_days / 2)。不同推荐位对融合系数的偏好不同首页信息流适合放大 ItemCF 的权重热点频道则适合将 UserCF 权重提高。这里还要留意冷启动物品的兜底没有任何行为的新文章在召回和精排都拿不到机会需要在融合阶段直接保留一定比例的候选位给“最新发布”的新闻常见的比例是留给纯时间排序的新文章 10%20% 的流量位这是新闻系统兼顾时效与个性的关键手段。5. 评估、调参与冷启动一套可复现的推荐质量验证方法5.1 离线指标怎么算才不骗自己推荐系统离线评估最常见的错误是“用全量行为训练、同一批行为测试”过拟合到用户已读序列上指标虚高但线上无效果。正确做法是按时间分割以用户最近 7 天行为作为测试集之前行为作为训练集。指标上只关注四个指标计算方式新闻场景的含义RecallK测试集中用户点过的新闻有多少出现在召回 TopK 中召回覆盖率反映系统“有没有把用户想要的找出来”PrecisionKTopK 中用户真实点击的比例排序精度反映“推荐位有没有被浪费”NDCGK按用户点击的真实位置计算折扣累计增益排序质量命中位置越靠前得分越高覆盖率被推荐到的新闻数 / 全量新闻数池子利用程度防止系统只推爆款NDCG 的计算建议自己写一遍能加深对“位置敏感”的理解同时完成后可以直接用于后续对比实验import math def ndcg_at_k(y_true, y_score, k10): # y_true: {news_id: 是否点击(0/1)} # y_score: {news_id: 模型得分} ranked sorted(y_score.items(), keylambda x: x[1], reverseTrue)[:k] dcg 0.0 for idx, (news_id, _) in enumerate(ranked): rel y_true.get(news_id, 0) dcg (2**rel - 1) / math.log2(idx 2) # 取理想排序下的DCG作为分母 ideal_rels sorted(y_true.values(), reverseTrue)[:k] idcg sum((2**r - 1) / math.log2(i 2) for i, r in enumerate(ideal_rels)) return dcg / idcg if idcg 0 else 0.05.2 三个必调的协同过滤参数K 近邻数ItemCF 和 UserCF 的k都建议从 10 开始按 5 的步长搜索到 30。K 过小候选池窄、结果随机波动大K 过大低相关物品被混入Precision 下降明显。时间衰减半衰期第 3 节里的half_life对离线指标影响最直接。用 3/5/7/14 四档去对比 NDCG我遇到的情况通常是 5 天左右效果最好热点频道甚至可以用 1 天。相似度阈值ItemCF 中过滤掉相似度低于 0.01 的边可以显著降低计算量和在线响应延迟而推荐指标几乎不变。这个阈值建议做成配置项而不是写死在代码里。5.3 冷启动的兜底召回任何新闻推荐系统上线初期都会遇到新用户零行为的问题。此时协同过滤完全失效需要走规则兜底一级类目热榜兜底 地域/时段规则 内容画像关键词匹配。具体实现可以单独拉一个“默认推荐”分支当用户行为数少于 5 条时直接返回SELECT * FROM news_meta WHERE status1 ORDER BY publish_time DESC LIMIT 20加上按类目聚合的点击热度排序在用户积累到足够行为后再切换至协同过滤通道。5.4 行为回放验证法不依赖线上 AB 的推荐质量自检最后给一个毕业设计和生产环境都能用的验证技巧行为回放。取用户昨天点击过的 Top 10 新闻把这些新闻的 ID 作为“事实答案”然后重新跑一遍今天的推荐流程统计这 10 篇新闻有没有出现在召回候选集里以及出现在第几位。连续运行一周计算“回放命中率”如果命中率低于 30%说明偏好信号没有被算法有效利用需要检查时间衰减是否太狠、种子物品是否选择过少。这个方法的优势在于完全基于离线日志不依赖线上流量单机脚本即可执行却能提前暴露“算法是否真的在读用户行为”这一根本问题。脚本里对每条点击新闻的记录格式保持为(user_id, news_id, predict_rank)统计时直接按用户聚合即可。本文还有配套的精品资源点击获取