简介:面向新闻推荐系统初学者、课程设计及毕业设计实践者的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 found;pandas 2.x 又把一部分默认行为改了,导致读 csv 时字段类型和预期不一致。稳妥做法是显式锁版本:
flask==2.3.3 jieba==0.42.1 scikit-learn==1.3.0 numpy==1.24.4 pandas==2.0.3 scipy==1.10.1 redis==4.6.0 pymysql==1.1.0 apscheduler==3.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_PASSWORD=123456 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用户行为:隐式反馈,同一用户对同一新闻只留一条最高权重 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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', encoding='utf-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( tokenizer=tokenize, max_df=0.6, # 超过 60% 的文档都出现的词丢掉,多半是套话 min_df=2, # 只出现一次的词不进词表,压维度 max_features=50000, # 词表上限,控制内存 sublinear_tf=True # 词频取对数,长文不至于霸榜 ) mat = vec.fit_transform(corpus) return vec, mat几个参数值得说一下:
max_df=0.6是控制「套话词」的关键。新闻里「记者」「昨日」「获悉」这类词在所有稿子里都出现,IDF 值极低,但如果不卡掉,它们会占据词表位置、增加内存,也会让不同频道的文章相似度被无意义地拉高。min_df=2是为了压维度。几十万条新闻的词表很容易冲到百万级,卡掉只出现一次的词,维度通常能砍掉一半以上,对推荐效果几乎无影响。sublinear_tf=True让词频走对数。长报道里某个关键词出现十次和出现两次,权重差距不该是 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.69,5 次收藏≈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, now=None) -> 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, topk=10, lam=0.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], default=0.0) score = lam * rel - (1 - lam) * div if score > best_score: best, best_score = c, score selected.append(best) rest.remove(best) return selectedlam=0.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 | 单条新闻的相似 top50 | 24 小时 | 相似度任务 |
过期时间的设定有讲究。用户推荐列表 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(host='127.0.0.1', port=6379, decode_responses=True) def load_news(ids): """批量回填新闻详情,顺序保持和入参一致""" if not ids: return [] ph = ','.join(['%s'] * len(ids)) rows = db.query(f'SELECT 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(f'rec:u:{uid}', 0, size - 1) if len(ids) >= size: return jsonify(code=0, data=load_news(ids), from_='user') # 路径二:频道兜底,走热度榜 ids = r.zrevrange(f'hot:channel:{cid}', 0, size - 1) if ids: return jsonify(code=0, data=load_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(code=0, data=rows, from_='latest')from_字段平时看不出价值,出问题时是救命稻草。线上反馈「推荐不准」,先看这个字段的分布:如果hot占比 80%,说明个性化召回基本失效,别去调算法权重,先查离线任务有没有正常产出。
size卡上限这条也必须有。我见过一个接口被外部脚本循环调用,每次传size=10000,单次请求拉了全表,数据库连接池瞬间打满。这类问题在业务高峰期才会暴露,加一行min()就能堵上。
分页不能用offset在 Redis 列表上做,lrange的偏移在列表被覆盖后会错位。常见做法是一次性把 Top 50 写进缓存,分页用start/stop参数截取,翻到第二页时如果缓存已被刷新,重复或漏掉一两条是可接受的,用户无感知。
4.3 离线批与在线召回的衔接:定时任务与增量更新
离线任务的价值是让线上接口永远只做「读」,不做「算」。任务节奏按数据变化频率排:
from apscheduler.schedulers.blocking import BlockingScheduler sched = BlockingScheduler(timezone='Asia/Shanghai') @sched.scheduled_job('cron', hour=3, minute=0) def daily_rebuild(): """全量重算:文本索引 + 相似度矩阵,放到凌晨低峰""" rebuild_content_index() rebuild_item_sim(window_days=30) # 只用近 30 天行为 dump_recall_to_redis() @sched.scheduled_job('interval', minutes=10) def tick_hot(): """热度榜高频刷新:只算分数,不碰相似度矩阵""" refresh_hot_zset() @sched.scheduled_job('interval', minutes=30) def tick_user_recall(): """增量补齐:给有新增行为的用户重算推荐列表""" for uid in get_users_with_new_behavior(minutes=30): write_user_recall(uid) sched.start()三档任务的耗时量级差很多。全量重算在百万级新闻上通常要十几分钟到半小时,必须放凌晨;热度榜只读news.read_cnt和publish_time做一次批量计算,几十秒;用户增量只处理近半小时有行为的用户,通常几百到几千人,几分钟内跑完。
有一个容易忽略的衔接点:dump_recall_to_redis覆盖缓存时,如果直接del再rpush,中间会有一段空窗期,接口全部走兜底。改成先写一个新 key 再rename,rename在 Redis 里是原子的,能避免这个空窗。
def write_user_recall(uid, ids): tmp = f'rec:u:{uid}:tmp' pipe = r.pipeline() pipe.delete(tmp) if ids: pipe.rpush(tmp, *ids) pipe.expire(tmp, 7200) pipe.rename(tmp, f'rec: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, k=10, all_items=None): """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 = { 'precision@k': round(prec / n, 4), 'recall@k': round(rec / n, 4), 'hit_users': hit, 'users': n, } if all_items: out['coverage'] = round(len(covered) / len(all_items), 4) return outprecision@k衡量推出来的东西有多少是用户真的会点的,recall@k衡量用户会点的东西有多少被推出来了。新闻场景里这两个指标天然偏低,绝对值不重要,重要的是同一份测试集上横向比较。coverage更容易被忽略——它算的是推荐结果覆盖了多少不同的新闻,如果覆盖率长期低于 5%,说明系统在反复推那几百条热门稿,长尾内容根本没机会曝光。
几个实操上的坑:测试集里要过滤掉曝光行为,只保留点击及以上;同一个用户的行为要按时间排序,用前 N 条训练、后 M 条测试,随机切分会造成信息泄漏;评估脚本的输出要带配置指纹,把权重、窗口、分词版本一起记下来,否则跑了几十组之后根本分不清哪组对应哪次改动。
我一般的做法是维护一张评估记录表,每改一次参数就追加一行:
| 版本 | 内容权重 | CF 权重 | 热度权重 | 行为窗口 | precision@10 | recall@10 | coverage |
|---|---|---|---|---|---|---|---|
| v1 | 0.5 | 0.3 | 0.2 | 30 天 | 0.081 | 0.114 | 4.2% |
| v2 | 0.4 | 0.4 | 0.2 | 30 天 | 0.093 | 0.126 | 4.8% |
| v3 | 0.4 | 0.4 | 0.2 | 14 天 | 0.097 | 0.131 | 6.1% |
这张表比任何调参直觉都可靠。v3 把行为窗口从 30 天缩到 14 天,三项指标全涨,说明旧行为确实是噪声。这种结论靠看推荐结果永远得不出来。
从那以后我每次动权重之前,都会先把评估脚本跑一遍留基线,改完再跑一遍做对照,两次结果不一致就不合并。这个习惯帮我挡掉过好几次「感觉变好了、实际全线下跌」的改动。希望帮到你。
本文还有配套的精品资源,点击获取