首页 / 资讯中心 / 文章详情

Python新闻推荐系统:TF-IDF与ItemCF融合排序实战

Python新闻推荐系统:TF-IDF与ItemCF融合排序实战 ★ FEATURED ARTICLE
简介面向新闻推荐系统初学者、课程设计及毕业设计实践者的Python项目资料围绕新闻推荐场景提供一套可运行的实现参考帮助理解从数据样例、模型参数到推荐结果输出的完整链路兼顾算法入门与项目复现需求。压缩包共181个文件约10.32MB以102个txt文本、35个py源码和32个pyc编译文件为主并包含p模型参数、csv数据样例、md说明文档及项目配置项便于按源码、参数、数据与说明分模块查阅。已有4077人学习下载具备一定参考热度。读者可从中获取新闻推荐系统的Python代码结构、概率模型参数文件与样例数据结合说明文档梳理运行思路观察模型参数如何参与预测流程适合用于推荐算法入门、课程作业复现和二次开发也可作为排错思路与目录组织方式的参考帮助快速定位核心模块并开展自己的实验。1. 从「按时间倒序」到「按兴趣排序」一套 Python 新闻推荐系统要解决什么问题「推荐位先按发布时间倒序查 20 条吧」——这句话在资讯类项目里几乎每次都能听到上线第一周没人抱怨第二周运营就会拿着后台数据来问头部几条的点击怎么还不如频道页。新闻是强时效、强长尾的品类热点两小时就过气长尾稿压在库里没人点同一个用户在体育和财经之间来回跳。纯时间序解决不了「给谁看什么」纯热榜又解决不了小众人群看什么。这套基于 Python 的新闻推荐系统补的是这两个极端之间那段把新闻标题摘要转成可计算的文本向量把点击收藏转成行为矩阵一路内容召回加一路协同过滤再用融合打分与多样性打散输出候选列表。它适合已经有一批新闻数据和点击日志、想把「猜你喜欢」从零搭起来的开发者。2. 拆开压缩包先看骨架目录分层、依赖与第一次跑通代码包最容易劝退人的不是算法是环境。很多下载下来的 Python 项目pip install -r requirements.txt跑完python app.py直接报一串找不到模块人还没看到推荐逻辑就先去修环境了。所以先花十分钟把结构看清、把依赖锁死再谈算法。2.1 目录分层从入库到出推荐的四段链路一个能改、能维护的推荐系统项目代码结构基本会顺着「数据层 → 算法层 → 任务层 → 接口层」切开。下面是比较典型的目录形态实际解压后文件名可能不同分层逻辑是一致的news-recsys/ ├── app/ # Web 层只做参数校验和组装返回 │ ├── api/ │ │ └── recommend.py # 推荐入口个性化 兜底两条路 │ ├── models/ # 表结构映射 │ └── __init__.py ├── rec/ # 算法层离线在线共用 │ ├── content.py # TF-IDF 内容召回 │ ├── cf.py # ItemCF 协同过滤 │ ├── rank.py # 加权融合 多样性打散 │ └── hot.py # 热度衰减兜底 ├── jobs/ # 离线任务 │ ├── build_index.py # 建文本向量索引 │ ├── build_sim.py # 算相似度矩阵 │ └── dump_recall.py # 召回结果写缓存 ├── conf/ │ └── config.py # 权重、阈值、TopK 全在这 ├── data/ # 新闻 jsonl、停用词表 └── requirements.txt这个分法最关键的一点是算法层被 Web 层和任务层同时引用而不是各写一套。我见过不少改崩的项目离线算相似度用一套代码在线兜底又抄一份简化版最后两边算出来的分数对不上排查一整天发现是分词停用词表不一致。把rec/单独拎出来在线只做「读缓存 拼装」是这套结构最值得保留的设计。另一个细节是conf/config.py。权重、阈值、TopK 这类东西必须集中别散落在代码里手写。上线后调参是常态权重散在十个文件里改一次就得搜一遍全局。2.2 依赖与环境三个版本号对不上的典型翻车点这类项目最常踩的是科学计算库的版本。numpy 2.x 和早期 scikit-learn 二进制不兼容装完 import 就报_ARRAY_API not foundpandas 2.x 又把一部分默认行为改了导致读 csv 时字段类型和预期不一致。稳妥做法是显式锁版本flask2.3.3 jieba0.42.1 scikit-learn1.3.0 numpy1.24.4 pandas2.0.3 scipy1.10.1 redis4.6.0 pymysql1.1.0 apscheduler3.10.4numpy和scipy必须成对看scipy 1.10 对应的 numpy 上限是 1.24装成 2.x 大概率编译失败。jieba是纯 Python 包没有编译依赖装不上基本是 pip 源问题不是代码问题。scikit-learn只用来做 TF-IDF 和余弦相似度不需要 GPU 版本别顺手装带 cupy 的分支。启动顺序也有讲究先起 MySQL 和 Redis再跑建表和灌数据脚本最后起 Web。顺序颠倒的话应用启动时会因为连不上库直接退出日志里只有一行Connection refused新手容易误以为是代码问题。# 1. 装依赖建议用虚拟环境 python -m venv venv source venv/bin/activate pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple # 2. 起依赖服务本地开发环境用容器最省事 docker run -d --name rec-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD123456 mysql:8.0 docker run -d --name rec-redis -p 6379:6379 redis:7-alpine # 3. 建表 灌样例数据 首次离线计算 python jobs/init_db.py python jobs/build_index.py python jobs/build_sim.py python jobs/dump_recall.py # 4. 起服务 python app.py --port 5000config.py里的数据库连接串和 Redis 地址要和上面两步保持一致尤其是别把127.0.0.1和localhost混用——在某些容器网络配置下这两个解析结果不同表现是宿主机能连、容器里连不上。提示dump_recall.py跑完如果 Redis 里rec:u:*全是空列表先别怀疑算法去看行为表有没有数据。多数情况是样例数据只灌了新闻、没灌点击。2.3 冷启动跑通建表、灌数据、拿到第一条推荐推荐系统的全部输入输出都压在两三张表上新闻表存内容行为表存反馈。表设计不用花哨字段够用就行-- 新闻元数据推荐结果最终要靠它回填标题、封面、发布时间 CREATE TABLE news ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, abstract VARCHAR(512) DEFAULT , channel VARCHAR(32) NOT NULL, -- 频道用于兜底召回 publish_time DATETIME NOT NULL, read_cnt INT DEFAULT 0, -- 累计阅读热度分的基础 KEY idx_channel_publish (channel, publish_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 用户行为隐式反馈同一用户对同一新闻只留一条最高权重 CREATE TABLE behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, news_id BIGINT NOT NULL, action TINYINT NOT NULL, -- 1 曝光 2 点击 3 阅读过半 4 收藏 ts DATETIME NOT NULL, UNIQUE KEY uk_user_news (user_id, news_id), KEY idx_news (news_id), KEY idx_ts (ts) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;action这一列是整个系统里最需要提前约定清楚的。隐式反馈没有显式打分权重得自己定我一般的取法是曝光 0.1、点击 1、阅读过半 3、收藏 5收藏和点击拉开五倍差距是因为收藏是明确的正反馈点击可能是误触。这套权重后面会直接进 ItemCF 的矩阵权重定得随意相似度就是噪声。灌数据之后用一条用户维度的查询确认行为链路通不通-- 看某个用户最近 30 天的有效行为确认数据真的进来了 SELECT b.user_id, b.news_id, b.action, b.ts, n.title FROM behavior b JOIN news n ON n.id b.news_id WHERE b.user_id 1001 AND b.ts DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY b.ts DESC LIMIT 20;如果这条查询有结果而推荐接口返回的还是兜底列表问题就落在dump_recall.py这一步要么相似度矩阵没算出来要么写缓存的 key 前缀和读取时对不上。key 前缀不一致是排查成本最高的那类问题日志不会报错接口也能返回只是永远走兜底。我在config.py里习惯把所有 key 前缀提成常量读写共用同一个变量就是为了堵这个坑。第一次跑通不要求推荐准只要求「个性化分支被命中」。可以在接口返回里加一个from_字段值分别是user、cf、hot一眼就能看出这次走的是哪条路。3. 推荐算法怎么选TF-IDF 内容召回与 ItemCF 各自管什么新闻推荐最容易犯的错是直接拿协同过滤当主力。电商场景里一个商品能活半年用户行为足够稠密新闻的寿命按天算今天推过的稿子明天就凉了用户对同一篇新闻也不会反复点。结果是 ItemCF 算出来的相似新闻里混着一堆已经过期的稿子。内容召回和协同过滤在新闻场景不是二选一是分工。3.1 内容召回TF-IDF 把标题摘要变成稀疏向量内容召回负责解决「新文章没人看过也能被推」的问题。核心是把文本转成向量再算向量之间的余弦相似度。文本表示的选择上TF-IDF 是性价比最高的一档不需要训练几十万条新闻几分钟就能建完索引效果在新闻这种关键词密集的短文本上也不差。import jieba from sklearn.feature_extraction.text import TfidfVectorizer # 停用词表里除了常规虚词建议加上记者报道编辑这类新闻稿套话 STOP set(open(data/stopwords.txt, encodingutf-8).read().split()) def tokenize(text: str) - list: 中文没空格先用 jieba 切词再过滤单字和停用词 words jieba.lcut(text) return [w for w in words if len(w) 1 and w not in STOP] def build_content_index(titles, abstracts): # 标题拼两遍做加权标题里的词更能代表主题比调内部参数直观 corpus [f{t} {t} {a} for t, a in zip(titles, abstracts)] vec TfidfVectorizer( tokenizertokenize, max_df0.6, # 超过 60% 的文档都出现的词丢掉多半是套话 min_df2, # 只出现一次的词不进词表压维度 max_features50000, # 词表上限控制内存 sublinear_tfTrue # 词频取对数长文不至于霸榜 ) mat vec.fit_transform(corpus) return vec, mat几个参数值得说一下max_df0.6是控制「套话词」的关键。新闻里「记者」「昨日」「获悉」这类词在所有稿子里都出现IDF 值极低但如果不卡掉它们会占据词表位置、增加内存也会让不同频道的文章相似度被无意义地拉高。min_df2是为了压维度。几十万条新闻的词表很容易冲到百万级卡掉只出现一次的词维度通常能砍掉一半以上对推荐效果几乎无影响。sublinear_tfTrue让词频走对数。长报道里某个关键词出现十次和出现两次权重差距不该是 5 倍。建完索引后相似度计算走sklearn.metrics.pairwise.linear_kernel一次拿一批新闻对全库打分比逐条循环快一个量级。实际用法是用户最近点击过的 N 篇新闻各取一篇分别召回 Top 20合并去重作为内容通道的候选集。N 一般取 5 到 10取太多会让候选集膨胀到几百条后面的融合排序扛不住。注意tokenize函数必须是模块级函数而不是闭包否则TfidfVectorizer在持久化后会因为缺少函数引用而加载失败。这个坑在把模型存成 pickle 时必现。3.2 协同过滤ItemCF 在新闻场景为什么会「过期」ItemCF 的思路是「看了 A 的人也看了 B」做的是行为共现。它比 UserCF 更适合新闻原因是新闻条目数量远大于用户数量级上的活跃用户而且用户兴趣漂移快UserCF 维护用户相似度的成本更高、稳定性更差。import numpy as np from scipy.sparse import csr_matrix from sklearn.metrics.pairwise import cosine_similarity def build_item_sim(user_ids, item_ids, weights): 输入行为三元组输出 item x item 相似度矩阵 uidx {u: i for i, u in enumerate(set(user_ids))} iidx {n: i for i, n in enumerate(set(item_ids))} rows [iidx[n] for n in item_ids] cols [uidx[u] for u in user_ids] # 权重先做 log 压缩1 次点击≈0.695 次收藏≈1.79避免重度用户主导 data np.log1p(weights) mat csr_matrix((data, (rows, cols)), shape(len(iidx), len(uidx))) sim cosine_similarity(mat) # 输出 item x item np.fill_diagonal(sim, 0) # 自己和自己的相似度清零防止推荐原文 return iidx, sim代码里有两处细节决定了这套 ItemCF 能不能用第一处是np.log1p(weights)。原始权重直接进矩阵的话一个收藏了 200 条新闻的活跃用户对每个 item 的贡献都是 5会主导所有相似度计算。log 压缩之后行为多的用户贡献增长变缓稀疏矩阵的数值分布也更稳。这是隐式反馈场景的常见做法不做这一步推荐结果会明显偏向那几个重度用户的口味。第二处是np.fill_diagonal(sim, 0)。不置零的后果是每篇新闻最相似的永远是自己召回阶段会把用户刚看过的那篇又推一遍。看过的稿子再推在新闻场景里是最伤体验的一种错误。但即便做完这两步ItemCF 在新闻场景仍有一个绕不过去的问题相似度矩阵的时效性。热门新闻的行为共现只在两三天内有效一个月前的共现关系基本没有参考价值。所以相似度矩阵必须高频重算或者至少在计算时按时间衰减行为权重——behavior.ts越旧权重乘一个越小的系数。我的处理方式是每天凌晨全量重算一次同时给行为表加时间窗口只取最近 30 天的数据参与计算。30 天是经验值窗口太短行为量不够相似度全是噪声窗口太长过期新闻混进来。3.3 融合排序三路打分加权与热度衰减内容召回和协同过滤各自出一份候选集热度榜作为第三路兜底三路合并后要统一到可比的分数区间再排序。不统一量纲直接相加谁的分数量级大谁说了算权重就白设了。import math from datetime import datetime def hot_score(read_cnt: int, publish_time: datetime, nowNone) - float: 热度分 阅读量对数 * 时间衰减衰减半衰期约 12 小时 now now or datetime.now() hours max((now - publish_time).total_seconds() / 3600, 0) decay math.exp(-0.0578 * hours) # ln2 / 12 ≈ 0.0578 return math.log1p(read_cnt) * decay def normalize(scores: dict) - dict: min-max 归一化把三路分数拉到 [0,1]缺省值给 0 if not scores: return {} lo, hi min(scores.values()), max(scores.values()) span hi - lo or 1.0 return {k: (v - lo) / span for k, v in scores.items()} def fuse(content, cf, hot, w(0.5, 0.3, 0.2)): 三路加权权重在 config.py 里可调 c, f, h normalize(content), normalize(cf), normalize(hot) keys set(c) | set(f) | set(h) return {k: w[0] * c.get(k, 0) w[1] * f.get(k, 0) w[2] * h.get(k, 0) for k in keys}半衰期取 12 小时不是拍脑袋。新闻的生命周期通常比这更短热点稿子 6 小时内吃掉大部分流量取 12 小时能保证热点在榜单上停留一天左右又不会让旧稿长期占位。如果业务偏资讯快讯可以把系数调到 0.1 以上半衰期缩到 7 小时。权重(0.5, 0.3, 0.2)是冷启动阶段的默认值内容召回占大头是因为新用户行为少、ItemCF 覆盖不到靠内容相似度还能推出东西。等行为数据攒够一般会把权重往(0.4, 0.4, 0.2)调。融合完还差最后一步多样性打散。同一事件经常有七八家媒体同时发稿标题不同、内容高度重合只按分数排序会连着推三四条同质新闻。用 MMR最大边际相关做重排在相关性和多样性之间做权衡def mmr_rerank(cands, sim_fn, topk10, lam0.7): cands: [{id:..., score:...}]sim_fn(a, b) 返回 0~1 的相似度 selected, rest [], list(cands) while rest and len(selected) topk: best, best_score None, -1e9 for c in rest: rel c[score] # 和已选结果的最大相似度越大代表越冗余 div max([sim_fn(c, s) for s in selected], default0.0) score lam * rel - (1 - lam) * div if score best_score: best, best_score c, score selected.append(best) rest.remove(best) return selectedlam0.7表示相关性权重 0.7、多样性权重 0.3。调到 0.9 以上基本等于没打散调到 0.5 以下会出现「为了不一样而推不相关内容」。这个值最好在离线评估里试三档再定别凭感觉。4. 工程落地表结构、缓存与接口的衔接方式算法跑通只是离线脚本里的一个数字真正上线要解决三件事数据存哪、接口怎么扛住并发、离线结果怎么送到线上。这三件事没处理算法效果再好也只能在 notebook 里看。4.1 存储分层MySQL 存元数据与行为Redis 存结果职责划分要清晰。MySQL 存需要持久化和关联查询的数据新闻元数据、用户行为、配置项。Redis 存高频读取、可重建的数据用户推荐列表、频道榜单、相似度矩阵。Key 模式类型内容过期时间写入方rec:u:{uid}List用户个性化推荐 id 列表2 小时离线任务rec:c:{cid}List频道兜底列表30 分钟离线任务hot:channel:{cid}ZSet频道热度榜score 为热度分不设定时覆盖热点任务sim:item:{nid}Hash单条新闻的相似 top5024 小时相似度任务过期时间的设定有讲究。用户推荐列表 2 小时过期是为了让行为更新能较快反映到推荐结果上又不至于每次请求都实时算。频道兜底列表 30 分钟是因为兜底逻辑本身变化慢短过期没有收益还会增加缓存穿透的概率。提示Redis 里不要存完整的新闻对象只存 id。标题、封面这类字段放 MySQL接口层批量查一次回填。存完整对象的问题是新闻标题被编辑改过一次之后缓存里的旧标题和正式站对不上而且 JSON 反序列化的开销比批量 SQL 高。同一条新闻的相似度矩阵单条动辄几十 KB全量塞 Redis 内存吃不消。实际存的是截断后的 Top 50并且只在召回计算时按需读取读完就释放。4.2 接口层/api/recommend 的入参、兜底与分页接口是唯一和前端打交道的地方要处理三件事参数校验、降级兜底、返回体稳定。from flask import Flask, request, jsonify import redis app Flask(__name__) r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def load_news(ids): 批量回填新闻详情顺序保持和入参一致 if not ids: return [] ph ,.join([%s] * len(ids)) rows db.query(fSELECT id,title,channel,publish_time FROM news WHERE id IN ({ph}), ids) m {str(row[id]): row for row in rows} return [m[i] for i in ids if i in m] app.route(/api/recommend) def recommend(): uid request.args.get(uid, ).strip() cid request.args.get(cid, ).strip() # 上限卡死 50防止外部把 size 调大拖垮数据库 size min(int(request.args.get(size, 10)), 50) # 路径一个性化召回命中缓存直接返回 if uid: ids r.lrange(frec:u:{uid}, 0, size - 1) if len(ids) size: return jsonify(code0, dataload_news(ids), from_user) # 路径二频道兜底走热度榜 ids r.zrevrange(fhot:channel:{cid}, 0, size - 1) if ids: return jsonify(code0, dataload_news(ids), from_hot) # 路径三再兜一层防止新频道冷启动返回空数组 rows db.query( SELECT id,title,channel,publish_time FROM news WHERE channel%s ORDER BY publish_time DESC LIMIT %s, (cid, size)) return jsonify(code0, datarows, from_latest)from_字段平时看不出价值出问题时是救命稻草。线上反馈「推荐不准」先看这个字段的分布如果hot占比 80%说明个性化召回基本失效别去调算法权重先查离线任务有没有正常产出。size卡上限这条也必须有。我见过一个接口被外部脚本循环调用每次传size10000单次请求拉了全表数据库连接池瞬间打满。这类问题在业务高峰期才会暴露加一行min()就能堵上。分页不能用offset在 Redis 列表上做lrange的偏移在列表被覆盖后会错位。常见做法是一次性把 Top 50 写进缓存分页用start/stop参数截取翻到第二页时如果缓存已被刷新重复或漏掉一两条是可接受的用户无感知。4.3 离线批与在线召回的衔接定时任务与增量更新离线任务的价值是让线上接口永远只做「读」不做「算」。任务节奏按数据变化频率排from apscheduler.schedulers.blocking import BlockingScheduler sched BlockingScheduler(timezoneAsia/Shanghai) sched.scheduled_job(cron, hour3, minute0) def daily_rebuild(): 全量重算文本索引 相似度矩阵放到凌晨低峰 rebuild_content_index() rebuild_item_sim(window_days30) # 只用近 30 天行为 dump_recall_to_redis() sched.scheduled_job(interval, minutes10) def tick_hot(): 热度榜高频刷新只算分数不碰相似度矩阵 refresh_hot_zset() sched.scheduled_job(interval, minutes30) def tick_user_recall(): 增量补齐给有新增行为的用户重算推荐列表 for uid in get_users_with_new_behavior(minutes30): write_user_recall(uid) sched.start()三档任务的耗时量级差很多。全量重算在百万级新闻上通常要十几分钟到半小时必须放凌晨热度榜只读news.read_cnt和publish_time做一次批量计算几十秒用户增量只处理近半小时有行为的用户通常几百到几千人几分钟内跑完。有一个容易忽略的衔接点dump_recall_to_redis覆盖缓存时如果直接del再rpush中间会有一段空窗期接口全部走兜底。改成先写一个新 key 再renamerename在 Redis 里是原子的能避免这个空窗。def write_user_recall(uid, ids): tmp frec:u:{uid}:tmp pipe r.pipeline() pipe.delete(tmp) if ids: pipe.rpush(tmp, *ids) pipe.expire(tmp, 7200) pipe.rename(tmp, frec:u:{uid}) pipe.execute()这个写法在 Redis 集群模式下要注意rename的两个 key 必须落在同一个 slot所以临时 key 用{uid}做 hash tag 会更稳。本地单机版不用管上集群就必须改。5. 排查与避坑五个上线后才会暴露的问题离线跑出来的推荐和线上用户看到的推荐经常不是一个东西。下面五条是这类项目里重复率最高的故障每条按现象、原因、解决写清楚。5.1 推荐结果千人一面所有人都收到同一批稿子现象换几个不同 user_id 调接口返回的新闻列表高度重合只有顺序不同。原因个性化召回没命中全部落到频道热度兜底。查日志发现from_字段 90% 以上是hot。继续往上追dump_recall.py的执行日志显示用户推荐列表只写了 200 条而用户表有 5 万行。解决先确认用户列表的生成逻辑是不是只处理了「近 7 天有点击」的用户很多项目的默认实现就是只给活跃用户出推荐沉默用户直接空。对沉默用户做行为扩展拿他最后一次点击的新闻走内容相似度召回去补。补完之后沉默用户的命中率能从个位数提到一半以上。5.2 新用户进来直接空列表前端渲染成一片空白现象刚注册的用户打开首页推荐模块什么都不显示控制台报data 为空。原因接口只实现了个性化分支用户没有行为记录时返回空数组没有降级路径。解决接口必须有三层兜底用户维度 → 频道热度 → 最新发布任何一层有结果就返回。另外前端要容忍空数组不要假定推荐位一定有内容。还有一个更隐蔽的情况用户有行为记录但都在 30 天窗口之外矩阵里查不到这个人表现和新用户一样兜底逻辑同样能覆盖。5.3 相似度矩阵越算越慢从 8 分钟涨到 2 小时现象刚上线时全量重算 8 分钟跑了两个月涨到 2 小时凌晨任务还没跑完早高峰已经开始。原因行为表没有做时间窗口过滤历史数据越积越多参与计算的用户-新闻对线性增长。另外cosine_similarity直接对全量 item 矩阵算内存和耗时都是平方级。解决两步。第一步给行为查询加ts DATE_SUB(NOW(), INTERVAL 30 DAY)只算近 30 天。第二步对相似度计算做分块每次只算 5000 条 item 对全量的相似度算完只保留 Top 50 写盘不要保留完整矩阵。分块之后内存峰值降一个数量级遇到百万级 item 也能跑。5.4 中文分词把关键词切碎相似新闻推不出来现象两篇讲同一件事的稿子标题一个是「某地暴雨预警升级」一个是「强降雨来袭 多地发布预警」内容召回算出来相似度不到 0.1。原因jieba 默认词典对方言缩略词、新词覆盖不足加上停用词表把「预警」「发布」这类高频词当套话滤掉了剩下的有效词太少。解决分词层面加自定义词典把业务里的领域词、机构简称、活动名称加进jieba.load_userdict停用词表不要一刀切「预警」「通报」这类词在特定频道是关键词要按频道维护不同的停用词表。另一个办法是给标题里的词加权前面代码里t t就是这个思路简单但有效。5.5 推荐列表里连着三条同一事件的新闻现象用户反馈「推荐的都是同一件事」点开看确实是三家媒体对同一发布会的报道标题不同、内容高度重合。原因融合排序只按分数排没做去重而内容召回按相似度出候选天然会把同质新闻排在一起。解决在融合之后加一层聚类去重。轻量做法是用标题的 TF-IDF 向量做相似度超过 0.85 视为同一事件同组只保留分数最高的一条再配合 3.3 节里的 MMR 重排。两个手段叠加列表多样性提升很明显代价是整体相关性会掉一点点lam取值要把这个折损考虑进去。6. 用离线评估把「感觉挺准」变成可比的数字推荐系统最难的不是搭起来是判断改动到底有没有变好。凭肉眼看几条推荐结果几乎一定会得出错误结论——你看到的恰好是算法表现最好的那几条。上线前必须有一套固定的离线评估每次改权重、改分词、改窗口都跑同一批数据看同一组指标。评估数据的构造方式要提前定死取最近 7 天的行为做训练最后 1 天的点击做测试集测试集里的新闻不能出现在训练集里否则指标虚高。下面是最小可用的一套指标def evaluate(recs: dict, truth: dict, k10, all_itemsNone): recs: {uid: [news_id,...]}truth: {uid: set(news_id)} users [u for u in truth if u in recs] if not users: return {} hit, prec, rec, covered 0, 0.0, 0.0, set() for u in users: r list(recs[u])[:k] covered.update(r) inter set(r) truth[u] hit len(inter) prec len(inter) / k rec len(inter) / max(len(truth[u]), 1) n len(users) out { precisionk: round(prec / n, 4), recallk: round(rec / n, 4), hit_users: hit, users: n, } if all_items: out[coverage] round(len(covered) / len(all_items), 4) return outprecisionk衡量推出来的东西有多少是用户真的会点的recallk衡量用户会点的东西有多少被推出来了。新闻场景里这两个指标天然偏低绝对值不重要重要的是同一份测试集上横向比较。coverage更容易被忽略——它算的是推荐结果覆盖了多少不同的新闻如果覆盖率长期低于 5%说明系统在反复推那几百条热门稿长尾内容根本没机会曝光。几个实操上的坑测试集里要过滤掉曝光行为只保留点击及以上同一个用户的行为要按时间排序用前 N 条训练、后 M 条测试随机切分会造成信息泄漏评估脚本的输出要带配置指纹把权重、窗口、分词版本一起记下来否则跑了几十组之后根本分不清哪组对应哪次改动。我一般的做法是维护一张评估记录表每改一次参数就追加一行版本内容权重CF 权重热度权重行为窗口precision10recall10coveragev10.50.30.230 天0.0810.1144.2%v20.40.40.230 天0.0930.1264.8%v30.40.40.214 天0.0970.1316.1%这张表比任何调参直觉都可靠。v3 把行为窗口从 30 天缩到 14 天三项指标全涨说明旧行为确实是噪声。这种结论靠看推荐结果永远得不出来。从那以后我每次动权重之前都会先把评估脚本跑一遍留基线改完再跑一遍做对照两次结果不一致就不合并。这个习惯帮我挡掉过好几次「感觉变好了、实际全线下跌」的改动。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站