简介:基于用户行为与内容的个性化新闻推荐系统毕业设计项目,内置协同过滤推荐算法,面向计算机相关专业学生、教师及企业开发者,可直接用于毕业设计、课程设计、大作业或阶段立项演示。代码经过测试运行、功能可用,也可按需在现有结构上扩展,具有较强的可操作性与学习参考价值。资源包共含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 行为权重归一化:让停留时长与点击可比
推荐模型通常把用户对物品的兴趣表达成一个分值,新闻场景的常见做法是把行为映射到 0~1 或 0~10 的权重区间。我一般会采用“事件基础分 + 时长修正”的策略而不是直接用原始停留秒数,原因很简单:一篇 3000 字的深度报道和一个 30 秒短视频的停留时长天然不在同一个量纲上。
| 行为类型 | 基础权重 | 附加规则 |
|---|---|---|
| 曝光 | 0(仅用于负样本构造) | 曝光后 30 秒内无点击,可作为弱负样本 |
| 点击 | 1.0 | 不足 3 秒的点击视为误触,权重降为 0.1 |
| 停留 | 1.0~3.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_k=20): # 合并标题和正文,标题权重乘3 text = news_row['title'] * 3 + news_row['content'] # 基于TF-IDF抽取关键词,jieba默认已过滤停用词 tags = jieba.analyse.extract_tags( text, topK=top_k, withWeight=True ) 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 归一化后的权重,范围通常在 0~0.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_life=7): # 将行为表与新闻画像按 news_id 做拼接 merged = behavior_df.merge( news_profile_df, on='news_id', how='inner' ) # 计算每条行为的时间衰减系数 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_n=20, k=10): """ 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(), key=lambda x: x[1], reverse=True)[: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_n=20, k=10): # 第一步:物品->用户倒排表 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(), key=lambda x: x[1], reverse=True )[: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(), key=lambda x: x[1], reverse=True)[:top_n]UserCF 两个参数需要格外留意:sim_score这里直接用了共现次数,更严谨的做法是除以math.sqrt(len(user_items[u_a]) * len(user_items[u_b]))做余弦归一化,否则活跃用户的“相似性”虚高;k值建议在 10~30 之间网格搜索,过小则召回结果随机性太大,过大会把相似度很低的用户的兴趣也混进来。
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 天行为作为测试集,之前行为作为训练集。指标上只关注四个:
| 指标 | 计算方式 | 新闻场景的含义 |
|---|---|---|
| Recall@K | 测试集中用户点过的新闻有多少出现在召回 TopK 中 | 召回覆盖率,反映系统“有没有把用户想要的找出来” |
| Precision@K | TopK 中用户真实点击的比例 | 排序精度,反映“推荐位有没有被浪费” |
| NDCG@K | 按用户点击的真实位置计算折扣累计增益 | 排序质量,命中位置越靠前得分越高 |
| 覆盖率 | 被推荐到的新闻数 / 全量新闻数 | 池子利用程度,防止系统只推爆款 |
NDCG 的计算建议自己写一遍,能加深对“位置敏感”的理解,同时完成后可以直接用于后续对比实验:
import math def ndcg_at_k(y_true, y_score, k=10): # y_true: {news_id: 是否点击(0/1)} # y_score: {news_id: 模型得分} ranked = sorted(y_score.items(), key=lambda x: x[1], reverse=True)[: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(), reverse=True)[: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 status=1 ORDER BY publish_time DESC LIMIT 20加上按类目聚合的点击热度排序,在用户积累到足够行为后再切换至协同过滤通道。
5.4 行为回放验证法:不依赖线上 AB 的推荐质量自检
最后给一个毕业设计和生产环境都能用的验证技巧:行为回放。取用户昨天点击过的 Top 10 新闻,把这些新闻的 ID 作为“事实答案”,然后重新跑一遍今天的推荐流程,统计这 10 篇新闻有没有出现在召回候选集里,以及出现在第几位。连续运行一周,计算“回放命中率”,如果命中率低于 30%,说明偏好信号没有被算法有效利用,需要检查时间衰减是否太狠、种子物品是否选择过少。这个方法的优势在于完全基于离线日志,不依赖线上流量,单机脚本即可执行,却能提前暴露“算法是否真的在读用户行为”这一根本问题。脚本里对每条点击新闻的记录格式保持为(user_id, news_id, predict_rank),统计时直接按用户聚合即可。
本文还有配套的精品资源,点击获取