news 2026/9/14 5:21:46

新闻推荐系统实战:协同过滤双路召回与用户画像构建

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
新闻推荐系统实战:协同过滤双路召回与用户画像构建

简介:基于用户行为与内容的个性化新闻推荐系统毕业设计项目,内置协同过滤推荐算法,面向计算机相关专业学生、教师及企业开发者,可直接用于毕业设计、课程设计、大作业或阶段立项演示。代码经过测试运行、功能可用,也可按需在现有结构上扩展,具有较强的可操作性与学习参考价值。资源包共含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_iduser_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 profile

jieba.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_bonus

freshness_bonus是一条按新闻发布时长递减的加分项,例如3 * exp(-age_days / 2)。不同推荐位对融合系数的偏好不同:首页信息流适合放大 ItemCF 的权重,热点频道则适合将 UserCF 权重提高。这里还要留意冷启动物品的兜底,没有任何行为的新文章在召回和精排都拿不到机会,需要在融合阶段直接保留一定比例的候选位给“最新发布”的新闻,常见的比例是留给纯时间排序的新文章 10%~20% 的流量位,这是新闻系统兼顾时效与个性的关键手段。

5. 评估、调参与冷启动:一套可复现的推荐质量验证方法

5.1 离线指标怎么算才不骗自己

推荐系统离线评估最常见的错误是“用全量行为训练、同一批行为测试”,过拟合到用户已读序列上,指标虚高但线上无效果。正确做法是按时间分割:以用户最近 7 天行为作为测试集,之前行为作为训练集。指标上只关注四个:

指标计算方式新闻场景的含义
Recall@K测试集中用户点过的新闻有多少出现在召回 TopK 中召回覆盖率,反映系统“有没有把用户想要的找出来”
Precision@KTopK 中用户真实点击的比例排序精度,反映“推荐位有没有被浪费”
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.0

5.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),统计时直接按用户聚合即可。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 5:21:41

SSRFScanner:从设计到实战,构建高效SSRF检测扫描器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 5:19:34

HiFox vs Jira:AI智能体如何夺取开发工作流主权

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 5:18:47

解析ThreeUI开源组件库:Open Core模式与社区贡献实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 5:18:42

NPU加速CV任务:Ops-CV算子库优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 5:15:50

基于Spring Boot+Vue的养老院管理系统毕设设计与实现

简介:基于JavaSpringBootVueMySQL的养老院管理系统完整源码包,主要面向计算机相关专业学生的毕业设计、课程设计与期末大作业,解决养老院日常运营中的数字化管理问题,系统功能完善、操作便捷。系统覆盖老人信息管理、员工管理、财…

作者头像 李华