简介:本资源是一套基于Python协同过滤算法的新闻推荐系统毕业设计项目,适合计算机、软件工程等专业学生用于课程设计、论文参考或求职作品整理。项目从数据抓取与清洗、用户行为建模,到相似度计算与Top-N推荐均有覆盖,并给出基于用户与基于物品两种协同过滤实现思路。压缩包共146个文件,包含Java、Scala、Python后端逻辑及Vue前端页面,配套XML配置、SQL脚本、properties配置文件与parquet数据文件等,层级分明,便于直接阅读和二次开发。整体大小约577KB,目前已有353人学习下载。对于希望理解推荐系统落地流程的开发者,该资源可提供一套可运行的工程骨架,同时通过Dockerfile与YAML环境配置降低部署门槛,有助于快速复现实验并进行算法效果评估。
1. 新闻推荐系统用协同过滤:同样是 Python 代码,为什么电商能跑通、新闻会翻车
如果你在毕业设计里选了“基于 Python 协同过滤算法的新闻推荐系统”,大概率是看中了协同过滤“不需要理解内容、只靠用户行为”的简洁性。这个判断方向是对的:用户点击、收藏、阅读时长这类行为数据确实比新闻正文好处理得多,也更容易在答辩时讲清楚推荐链路。但新闻场景有一个电商和电影没有的硬约束——物品生命周期极短。昨天的爆款新闻今天就是旧闻,一个基于“用户喜欢过什么就推荐什么”的模型如果只做静态相似度计算,推荐结果会迅速被时间淘汰。这就引出了这套系统的核心矛盾:协同过滤算法负责捕捉用户兴趣,但新闻的时效性要求你必须额外处理物品冷启动、兴趣漂移和时间衰减。本文不打算复述协同过滤公式,而是给出一个能落地、能跑通、能答辩的完整方案:数据怎么造、UserCF 和 ItemCF 怎么选、相似度矩阵怎么处理新闻的时间特性、本地项目怎么一步步构建并验证效果。
这套方案适合两类人。一是毕设选题锁定推荐系统的在校生,需要一份能自己敲出来、能解释清楚每个参数的业务代码;二是刚接触推荐工程的开发者,想看看最简单的协同过滤在新闻数据上会踩哪些坑、怎么用最少的人力把离线模型变成在线接口。我会把可运行的 Python 代码、每个模块的参数含义、以及调试时的关键日志位置都写出来,你照着敲就能复现。
2. 数据和召回设计:新闻推荐的第一层漏斗,行为日志决定协同过滤的上限
2.1 为什么新闻推荐要先做“行为日志”而不是直接找公开数据集
很多人在毕设开头会去爬新闻数据,然后对着 pandas 发愁:只有文章列表,没有用户行为,协同过滤根本无从下手。协同过滤的输入是“用户-物品”交互矩阵,没有交互数据,算法就是空转。所以做这个题目,第一步不是写算法,而是定义行为日志结构。
我一般建议自己造一份贴近真实场景的模拟日志。好处有三个:一是可控,你能精确制造“热门新闻”“长尾新闻”“新用户”“老用户”四类样本,方便验证算法行为;二是能复现,答辩时老师问“数据哪来的”,你可以理直气壮说“基于真实业务抽象出的仿真日志”;三是避坑,公开数据集的物品 ID 往往是商品而非新闻,字段语义对不上。模拟日志的核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| user_id | str | 用户唯一标识,建议格式 U0001 |
| news_id | str | 新闻唯一标识,建议格式 N0001,包含发布时间信息 |
| behavior_type | int | 1 曝光 / 2 点击 / 3 收藏 / 4 分享,推荐训练只用 2 和 3 |
| behavior_time | str | 行为发生时间,精确到分钟,用于时间衰减计算 |
| news_publish_time | str | 新闻发布时间,用于计算新闻年龄 |
用 Python 写一个日志生成脚本,控制在 100 行以内,能让后续所有模块都有稳定输入。
import random import pandas as pd from datetime import datetime, timedelta random.seed(42) def generate_news(n_news=500): news_list = [] base_time = datetime(2024, 5, 1) # 模拟从5月1日开始发布新闻 for i in range(n_news): publish_time = base_time + timedelta(hours=random.randint(0, 24 * 30)) news_list.append({ "news_id": f"N{i:04d}", "publish_ts": publish_time.strftime("%Y-%m-%d %H:%M:%S"), # 模拟新闻热度:20%的新闻占据80%的点击 "hot_rank": 1 if random.random() < 0.2 else 0 }) return pd.DataFrame(news_list) def generate_logs(news_df, n_users=2000, n_logs=50000): logs = [] user_ids = [f"U{i:04d}" for i in range(n_users)] base_time = datetime(2024, 6, 1) for _ in range(n_logs): user_id = random.choice(user_ids) # 80%的概率从热门新闻里选,模拟马太效应 hot_pool = news_df[news_df["hot_rank"] == 1] normal_pool = news_df[news_df["hot_rank"] == 0] if random.random() < 0.8 and len(hot_pool) > 0: news = hot_pool.sample(1).iloc[0] else: news = normal_pool.sample(1).iloc[0] behavior_time = base_time - timedelta(minutes=random.randint(0, 24 * 60 * 30)) logs.append({ "user_id": user_id, "news_id": news["news_id"], "behavior_type": random.choices([1, 2, 3, 4], weights=[50, 30, 15, 5])[0], "behavior_time": behavior_time.strftime("%Y-%m-%d %H:%M:%S"), "news_publish_time": news["publish_ts"], "hot_rank": news["hot_rank"] }) return pd.DataFrame(logs) news_df = generate_news(500) log_df = generate_logs(news_df) log_df.to_csv("user_behavior_log.csv", index=False)这段代码里的hot_rank是关键参数。20% 热门新闻占据 80% 点击,这是新闻阅读的典型马太分布,也是之后 ItemCF 需要做热门惩罚的根源。behavior_type的权重比例同样有讲究:曝光最多、点击其次、收藏和分享稀少,符合真实漏斗。你用这个数据集跑完整个系统后,会发现“热门新闻相似度虚高”的问题,那就是hot_rank埋下的雷,后面会专门解决。
2.2 协同过滤的输入矩阵:从行为日志到“用户-新闻”评分矩阵
日志生成后不能直接喂给算法。协同过滤的经典输入是评分矩阵,行是用户、列是新闻,值是行为权重。但新闻场景里“评分”不是用户主动打的星级,而是对行为类型的加权换算。常见做法是:曝光计 0 分、点击计 1 分、收藏计 3 分、分享计 5 分。换算后做数据透视,得到稀疏矩阵。
import pandas as pd import numpy as np log_df = pd.read_csv("user_behavior_log.csv") behavior_weight = {1: 0, 2: 1, 3: 3, 4: 5} log_df["score"] = log_df["behavior_type"].map(behavior_weight) # 只保留有正反馈的行为 valid_logs = log_df[log_df["score"] > 0] # 做时间衰减:越久远的行为权重越低,半衰期设为7天 import numpy as np from datetime import datetime REF_TIME = datetime(2024, 6, 1) valid_logs["behavior_dt"] = pd.to_datetime(valid_logs["behavior_time"]) valid_logs["age_days"] = (REF_TIME - valid_logs["behavior_dt"]).dt.total_seconds() / 86400 valid_logs["decay_score"] = valid_logs["score"] * np.power(0.5, valid_logs["age_days"] / 7) rating_matrix = valid_logs.pivot_table( index="user_id", columns="news_id", values="decay_score", aggfunc="sum" ).fillna(0) print(rating_matrix.shape) print(rating_matrix.sparsity) # 可以自己算非零元素比例时间衰减半衰期7天是最需要调的业务参数。新闻的生命周期通常在 24 到 72 小时,如果没有衰减,用户一个月前的点击和今天的点击权重一样,模型会给你推用户早就不关心的旧闻。半衰期设得越短,系统对新闻时效越敏感,但对“长周期兴趣”不敏感。我习惯先设 7 天跑一版,再对比设 3 天的推荐结果差异。
这一步做完,你就有了一个标准的协同过滤输入矩阵。需要留意的是aggfunc="sum"——同一个用户对同一篇新闻可能有多条行为,sum 会把点击加收藏的分数累加,符合直觉。如果你发现矩阵只有不到 1% 的非零元素,不用慌,新闻推荐的数据稀疏度通常比电商更严重,用户看的新闻本来就少。
2.3 UserCF 还是 ItemCF:新闻场景的选型逻辑
协同过滤分成基于用户(UserCF)和基于物品(ItemCF)两条路线,选型不能凭喜好。先记住一个结论:新闻推荐项目优先用 ItemCF,除非你的系统有很强的社交属性。为什么?
UserCF 的思路是找“和我兴趣相似的用户”,把他们看过的新闻推荐给我。这在兴趣长尾、以“关注人”为核心的场景里有效,比如微博。但新闻场景有几个硬伤:用户行为稀疏,两个用户共同点击过的新闻少,相似度计算不稳定;新闻更新快,用户向量变化剧烈,昨天相似的人今天可能完全不同;更重要的是,UserCF 推荐结果偏向热门,冷门新闻几乎没有机会被推出去。ItemCF 的思路是“和我看过的那篇新闻相似的新闻”,它更稳定,一篇新闻的相似物品不会频繁变化,也更容易在推荐时做一些业务规则干预,比如强制剔除 24 小时前的新闻。
但 ItemCF 在新闻场景也有两个必须处理的转折点:一是新闻都是短生命周期的,不能拿一个月前的同现数据算相似度,必须加时间窗口;二是热门新闻会被过度推荐,需要做热门惩罚。这两个问题是本文后面代码的核心,也是你答辩时能拿出来讲的亮点。
3. 实现 ItemCF 并引入时间窗口:把 Python 协同过滤从教材公式改造成新闻可用
3.1 基础相似度计算:同现矩阵的朴素实现
先把教材上的 ItemCF 写出来,再逐步加新闻约束。朴素 ItemCF 分两步:统计两篇新闻被同一用户点击的次数,得到同现矩阵;用余弦相似度把同现次数归一化到 0-1 区间。Python 实现如下。
from collections import defaultdict import math def build_item_similarity(rating_matrix): """输入评分矩阵,输出物品相似度字典 {news_id: {other_news: sim}}""" # 第一步:统计每个用户点击的新闻列表 user_items = {} for user_id, row in rating_matrix.iterrows(): items = set(row[row > 0].index) # 只取有正反馈的新闻 if len(items) > 1: user_items[user_id] = items # 第二步:统计同现次数 co_occur = defaultdict(lambda: defaultdict(int)) item_click_count = defaultdict(int) for user_id, items in user_items.items(): for news_id in items: item_click_count[news_id] += 1 for other_id in items: if other_id != news_id: co_occur[news_id][other_id] += 1 # 第三步:余弦相似度归一化 item_sim = {} for news_id, related_items in co_occur.items(): item_sim[news_id] = {} norm = math.sqrt(item_click_count[news_id]) # 分母的一部分 for other_id, count in related_items.items(): # 余弦公式:count / sqrt(click_i * click_j) denominator = norm * math.sqrt(item_click_count[other_id]) if denominator > 0: sim = count / denominator item_sim[news_id][other_id] = sim return item_sim这个版本跑出来的结果有一个明显问题:sim对热门新闻虚高。新闻 A 被 1000 人看过,新闻 B 被 800 人看过,A 和 B 的同现值会被余弦公式放得很大,即便它们只是因为都排在首页而被动获得的点击。这种假相似度在新闻场景是致命的,因为它会把所有冷门新闻都推向热门新闻的阴影里。要解决这个问题,得引入两个新闻场景专属的修正项。
3.2 新闻场景的修正项:热门惩罚和时间窗口
第一个修正项是流行度惩罚。常见做法是给热门新闻的权重加一个衰减因子,公式调整为:
sim(i, j) = sum(decay_score) / (sqrt(click_i * click_j) + alpha * hot_rank_i * hot_rank_j)这里的hot_rank来自第一节日志里的字段,alpha控制惩罚强度,一般取 0.2 到 0.5。当 i 是热门新闻时,分母变大,相似度被压低。第二个修正项是时间窗口——只统计近 3 天内的同现行为,超出窗口的行为不计入同现矩阵。这相当于给相似度计算加了一个滑动窗口过滤器,保证“今天的新闻只和今天的新闻相似”。
from collections import defaultdict import math import pandas as pd def build_news_item_similarity(log_df, time_window_days=3, alpha=0.3): """ 带时间窗口和热门惩罚的 ItemCF 相似度计算 log_df: 必须包含 user_id, news_id, score, behavior_time 列 """ # 过滤出时间窗口内的行为 latest_time = pd.to_datetime(log_df["behavior_time"]).max() window_start = latest_time - pd.Timedelta(days=time_window_days) windowed_logs = log_df[pd.to_datetime(log_df["behavior_time"]) >= window_start] # 用户-新闻映射 user_items = defaultdict(set) item_click_count = defaultdict(int) item_hot_rank = {} for _, row in windowed_logs.iterrows(): if row["score"] <= 0: continue user_items[row["user_id"]].add(row["news_id"]) item_click_count[row["news_id"]] += 1 item_hot_rank[row["news_id"]] = row.get("hot_rank", 0) # 同现矩阵 co_occur = defaultdict(lambda: defaultdict(float)) for user_id, items in user_items.items(): for news_id in items: for other_id in items: if other_id != news_id: co_occur[news_id][other_id] += 1 # 相似度 + 热门惩罚 item_sim = defaultdict(dict) for news_id, related in co_occur.items(): norm_i = math.sqrt(item_click_count[news_id]) hot_penalty_i = alpha * item_hot_rank.get(news_id, 0) for other_id, co_count in related.items(): norm_j = math.sqrt(item_click_count[other_id]) hot_penalty_j = alpha * item_hot_rank.get(other_id, 0) denominator = norm_i * norm_j + hot_penalty_i + hot_penalty_j if denominator > 0: sim = co_count / denominator if sim > 0.01: # 过滤噪声相似度 item_sim[news_id][other_id] = sim return item_sim参数说明:time_window_days是新闻场景最重要的旋钮,设 3 天表示只利用三天内的行为计算相似度,能保证推荐结果是“本周热闻”而不是“本月旧闻”;alpha是热门惩罚强度,设 0.3 表示对热门新闻的相似度压低三成。sim > 0.01的过滤条件是我踩坑加上的——不设阈值时,两篇只有一次同现的冷门新闻会拿到 0.5 以上的相似度,产生一堆无意义的推荐对。你可以把阈值从 0.01 微调到 0.05,观察推荐列表中被推荐新闻的平均点击率变化。
3.3 给目标用户生成 Top-N 推荐:从相似度到可展示列表
相似度矩阵构建完成后,推荐生成就是查表排序。目标用户看过新闻 S,就从item_sim[S]里取相似度最高的新闻,去掉已经看过的,按分数排序返回。但让推荐结果一眼看上去“像回事”,还需要加两个过滤规则:新闻时效过滤和重复来源过滤。
def recommend_for_user(user_id, rating_matrix, item_sim, top_n=10): """基于用户历史点击生成推荐""" # 用户看过的新闻 user_history = set(rating_matrix.loc[user_id][rating_matrix.loc[user_id] > 0].index) # 候选池:从每篇看过的新闻的相似新闻中收集 candidate_scores = defaultdict(float) for news_id in user_history: if news_id not in item_sim: continue for other_id, sim in item_sim[news_id].items(): if other_id in user_history: continue # 以用户对news_id的评分为权重,累加相似度 user_score = rating_matrix.loc[user_id, news_id] candidate_scores[other_id] += sim * user_score # 按分数排序取top_n ranked = sorted(candidate_scores.items(), key=lambda x: x[1], reverse=True)[:top_n] return [news_id for news_id, score in ranked]注意sim * user_score这一层加权:用户对自己看过的新闻评分越高,该新闻的相似新闻越靠前。这模拟了“喜欢程度越高,越希望看到类似内容”的直觉。但这个函数有个待优化点——它没有计算推荐分数的新鲜度惩罚。一篇 20 天前的新闻即使和用户历史高度相似,也不应该进推荐列表。把新闻发布时间纳入排序是下一章的事,也需要你决定离线计算和在线发布的边界。
4. 从离线相似度到在线推荐:把 Python 脚本改造成可持续运行的推荐服务
4.1 相似度矩阵预计算:为什么不能每次请求都跑一遍 ItemCF
很多初稿代码会把相似度计算和推荐生成写进同一个请求函数,用户每次刷新都重算所有新闻的两两相似度。这在 500 篇新闻的 demo 里看不出问题,但一旦数据量上千、用户行为上万,响应时间会从毫秒级劣化到秒级,答辩演示时一卡,印象分直接掉。协同过滤的标准工程做法是“离线计算、在线读取”:定时任务算出相似度矩阵,存入内存或数据库,推荐接口只做查表和排序。
import json import pickle from datetime import datetime def save_similarity_matrix(item_sim, filepath="item_sim_cache.pkl"): """把相似度矩阵持久化,供在线服务加载""" with open(filepath, "wb") as f: pickle.dump(dict(item_sim), f) def load_similarity_matrix(filepath="item_sim_cache.pkl"): with open(filepath, "rb") as f: return pickle.load(f) # 定时任务入口:每天凌晨3点跑一次 def daily_update(): log_df = pd.read_csv("user_behavior_log.csv") log_df["score"] = log_df["behavior_type"].map({1: 0, 2: 1, 3: 3, 4: 5}) log_df = log_df[log_df["score"] > 0] item_sim = build_news_item_similarity(log_df, time_window_days=3, alpha=0.3) save_similarity_matrix(item_sim) print(f"[{datetime.now()}] similarity matrix updated, {len(item_sim)} items")你可以在命令行用python -c "from daily_update import daily_update; daily_update()"手动触发,也可以挂到系统 crontab。重点是理解这个拆分:相似度矩阵属于中间结果,对新闻可保留半天到一天,对用户没有个性化,没必要实时计算。把耗时操作移出请求链路,是推荐系统性能优化的第一课。
4.2 在线推荐接口:用 Flask 暴露一个最小推荐服务
新闻推荐的在线部分做两件事:读预计算的相似度矩阵,根据用户 ID 查其历史行为并生成推荐列表。用 Flask 写一个极简接口,方便你在本地验证,也方便答辩时演示 HTTP 请求。
# app.py from flask import Flask, jsonify, request import pandas as pd from collections import defaultdict import pickle app = Flask(__name__) # 全局加载相似度矩阵和评分矩阵,避免每次请求都读文件 item_sim = pickle.load(open("item_sim_cache.pkl", "rb")) rating_matrix = pd.read_csv("rating_matrix.csv", index_col=0) @app.route("/api/recommend", methods=["GET"]) def recommend(): user_id = request.args.get("user_id", "") if not user_id or user_id not in rating_matrix.index: return jsonify({"code": 404, "msg": "user not found", "data": []}) user_history = set(rating_matrix.loc[user_id][rating_matrix.loc[user_id] > 0].index) candidate_scores = defaultdict(float) for news_id in user_history: if news_id not in item_sim: continue user_score = rating_matrix.loc[user_id, news_id] for other_id, sim in item_sim[news_id].items(): if other_id not in user_history: candidate_scores[other_id] += sim * user_score # 过滤掉发布时间超过48小时的新闻(需要在日志里带publish_time) ranked = sorted(candidate_scores.items(), key=lambda x: x[1], reverse=True)[:10] result = [{"news_id": nid, "score": round(score, 4)} for nid, score in ranked] return jsonify({"code": 200, "user_id": user_id, "data": result}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)启动服务后,浏览器或命令行访问http://127.0.0.1:5000/api/recommend?user_id=U0001,就能返回这个用户的个性化推荐。rating_matrix.csv文件需要在日常更新任务里一并导出,保证用户最新行为能反映到推荐接口。这里有一个取舍:接口读的是内存中的item_sim和rating_matrix,这意味着代码变更后需要重启服务才能生效。开发调试可以开debug=True,但答辩演示请务必关闭。
4.3 冷启动用户和冷启动新闻:推荐系统绕不开的两个兜底策略
ItemCF 对老用户有很好的效果,但新用户没有任何行为记录,查不到相似度;新新闻没有任何用户点击,也无法进入同现矩阵。这两类冷启动在新闻场景比电商更棘手,因为新闻每天都有新增。我的做法是用三层兜底:新用户先推热门新闻;新新闻先在热门新闻的相似候选中补充“最新发布”队列;再配合用户主动选择的兴趣标签,把标签匹配的新闻加权混入推荐列表。
def recommend_with_cold_start(user_id, rating_matrix, item_sim, news_df, top_n=10): if user_id not in rating_matrix.index: # 新用户:推荐近24小时热度最高的新闻 cold_start_pool = news_df[ (pd.to_datetime(news_df["publish_ts"]) > pd.Timestamp.now() - pd.Timedelta(hours=24)) ].sort_values("hot_rank", ascending=False) return cold_start_pool["news_id"].head(top_n).tolist() # 老用户:正常ItemCF + 混入最新新闻 rec_list = recommend_for_user(user_id, rating_matrix, item_sim, top_n=int(top_n * 0.8)) recent_news = news_df[ (pd.to_datetime(news_df["publish_ts"]) > pd.Timestamp.now() - pd.Timedelta(hours=12)) ]["news_id"].tolist() # 把最新新闻插到推荐列表的2, 4, 6位置 result = [] rec_iter = iter(rec_list) for i in range(top_n): if i % 2 == 1 and recent_news: result.append(recent_news.pop(0)) else: try: result.append(next(rec_iter)) except StopIteration: break return result这个插队策略看起来粗暴,但符合新闻产品的直觉:推荐结果不能全是旧闻,要让新内容有曝光机会。参数top_n * 0.8表示个性化结果占八成,两成留给新内容。你可以把比例调成 7:3 或 9:1,通过观察推荐列表的点击率来定,没有固定最优解。
5. 避坑与排查:新闻推荐系统最常见的 5 个翻车现场
5.1 “推荐全是爆款,没有个性化”:评分归一化出问题
现象:不同用户调用推荐接口,返回的 Top-10 新闻高度重叠,几乎都是全站热门。
原因:原始行为分数直接用behavior_type的权重值,没有做用户级归一化。一个读了 50 篇新闻的重度用户的分数天然比只读 3 篇的轻度用户高,导致 ItemCF 的加权累加结果偏向“重度用户喜欢的内容”。另一层原因是热门新闻的同现次数天然很大,相似度矩阵被热门新闻主导。
解决:把用户行为分数做最大最小归一化,让每个用户的分数向量长度一致;同时在相似度计算里把alpha调大,比如从 0.3 调到 0.6,加大对热门新闻的惩罚。不要一次性两个参数都改,不然无法判断是谁起的作用。
5.2 相似度矩阵全是 0:pivot_table 的默认填充值在捣乱
现象:item_sim构建完成后,打印发现大部分新闻的相似度列表为空。
原因:pivot_table生成评分矩阵时,fillna(0)没有对列名做对齐。如果训练日志里某些新闻只在窗口外出现过,它们的列会在过滤窗口行为后被整体丢弃,而iterrows()遍历时又可能拿到空行。常见新手错误是直接对rating_matrix做row > 0判断,忽略了NaN。
解决:pivot 之前先log_df = log_df.dropna(subset=["user_id", "news_id"]);构建相似度时用row.fillna(0)再判断。这个坑最气人的地方是代码不报错,只是安静地返回空结果,排查时优先打印len(user_items)和len(item_click_count)两个中间量。
5.3 推荐结果里全是旧闻,今天发布的新闻永远出不来
现象:推荐列表稳定,但点进去的新闻都是 5 天前的,最近两天发布的新内容完全没有曝光。
原因:ItemCF 的相似度完全来自行为同现,新新闻没有行为,没有同现;同时时间窗口淘汰了旧行为,进一步压缩了新新闻的历史数据来源。这个坑是算法设计的盲区,不是代码 bug。
解决:在推荐生成阶段混入新内容队列,正如 4.3 节所示。另一种常见做法是“时间衰减 + 时间提升”双机制:相似度计算时做时间衰减(越旧的行为权重越低),排序时给新发布的新闻加一个时间提升分(发布越新,分数加成越高)。建议在recommend_for_user的分数后面乘一个time_boost = 1 + exp(-age/24),让 24 小时内的新闻获得最高加成。
5.4 用户相似度出现 1.0 的“完美相似”:行为次数太少导致的假象
现象:某两篇冷门新闻的相似度是 1.0,但业务上看起来毫不相关。
原因:当两篇新闻只被同一个用户点击过,同现次数为 1,且各自点击次数都是 1 时,余弦相似度计算结果就是 1.0。这是小样本的统计假象,不是真实相似。
解决:相似度计算时增加最低同现阈值min_co_occur=2,或者对低于阈值的相似度直接置 0。我在代码里用if co_count < 2: sim = 0一行挡住,比调alpha更精准。毕设里写清楚这个处理方式,是加分项。
5.5 A/B 测试做不出来:离线指标和在线指标对不上
现象:离线评测显示 AUC 提升 5%,上线后点击率反而下降。
原因:协同过滤的离线指标(如准确率、召回率)是在“已有点击行为”上评测的,但推荐系统改变的是用户的“可见范围”。大量新推荐用户没看过,所以没点击,不代表推荐不好——只是 A/B 测试时间太短,没有覆盖用户发现新内容的过程。
解决:毕设不要求真上线,但你可以做一个时间序列回测:取第 1-7 天数据训练,第 8-10 天数据评测,观察“推荐曝光后是否带来新点击”。同时记录“推荐列表的新闻平均年龄”,如果推荐列表平均年龄小于全站新闻平均年龄,说明时效性在起作用。这三个指标一起看,比单看准确率靠谱。
6. 验证推荐质量:离线指标之外的三个可操作检查,以及我最后留下的一个习惯
你以为写完算法和接口就是终点,其实答辩时真正经得起追问的是“怎么验证它有效”。离线评测我建议做两个经典指标:准确率(推荐列表里被用户点击的比例)和召回率(用户点击过的新闻里被推荐覆盖的比例)。但你更需要的是三个能肉眼判断的技术检查,它们能帮你快速定位算法是否正确。
第一,检查推荐列表的多样性。取同一个用户两天前的推荐和今天的推荐,计算 Jaccard 相似度,如果超过 0.7,说明推荐结果几乎没有变化,兴趣漂移没有起作用。第二,检查相似度矩阵的热门惩罚是否生效。随机抽一篇热门新闻和一篇冷门新闻,打印它们的相似度前 5 名,热门新闻的相似度均值应该明显低于冷门新闻。第三,检查冷启动渠道是否真实曝光。给一个新建空用户发推荐请求,结果应该全是最新新闻,而不是报错空列表。
def verify_recall_quality(user_id, rec_list, held_out_logs): """ 简易离线验证:推荐列表对用户实际点击的覆盖情况 held_out_logs: 训练窗口之后新产生的用户行为日志 """ actual_clicked = set( held_out_logs[held_out_logs["user_id"] == user_id]["news_id"] ) rec_set = set(rec_list) if not actual_clicked: return 0.0 recall = len(rec_set & actual_clicked) / len(actual_clicked) precision = len(rec_set & actual_clicked) / len(rec_set) return precision, recall这个函数用起来很简单:训练时留出最后 2 天的日志不参与相似度计算,推荐生成后用这 2 天的真实点击做验证。如果 precision 在 5% 以下,别急着改算法,先去看看推荐列表里是否混入了大量热门旧闻——大概率是热门惩罚没生效。我在调通第一版时,precision 只有 2%,排查后发现是hot_rank字段在日志生成脚本里没有传给相似度计算函数,导致惩罚项恒为 0。这种低级错误靠看代码很难发现,但一打印相似度前几名的hot_rank就原形毕露。
最后说一个我留下的习惯。在我自己做的新闻推荐项目里,每次跑完离线评测,我会把“相似度最高的 5 对新闻”打印成一张表,肉眼扫一遍。这比任何指标都直观,因为指标会骗人,但“科技新闻和娱乐新闻相似度 0.9”这种结果一眼就能看出数据或权重配比有问题。你拿这个习惯去检查 ItemCF 结果,能省下大量调参时间。希望帮到你。
本文还有配套的精品资源,点击获取