ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用TF-IDF和Flask从零搭建新闻推荐系统实战

用TF-IDF和Flask从零搭建新闻推荐系统实战 推荐系统在多数人的直觉里已经被深度学习、排序模型、向量召回这些词包围了。但真到落地的时候我反而经常把TF-IDF这种“老古董”放在第一版。最近我用Flask从零到一搭了一套新闻推荐系统整体链路非常清晰对新闻语料做中文分词用TF-IDF特征提取算法把每篇文章转成高维文本向量再通过余弦相似度找到内容最相近的若干篇最后用Flask包一个HTTP接口对外提供服务。整套东西在一台普通开发机上就能跑起来几千篇新闻规模下效果完全能打而且每个环节的问题都能被清晰地解释。这篇文章适合两类人看一是Flask学完基础语法后想做个完整项目的人二是刚接触推荐系统、想搞清楚内容推荐底层逻辑的新手。我会把为什么选TF-IDF、数据从哪里来、中文分词怎么处理、相似度怎么算、接口怎么封装以及我实际踩过的坑全部写清楚。1. 我先说清楚为什么新闻推荐能用TF-IDF做基线推荐系统本质上是个匹配问题把“用户可能喜欢的东西”和“现存的内容”进行匹配。匹配的前提是“内容可计算”而TF-IDF就是让计算机“读懂”新闻的一种最朴素、最稳定的方式。很多人上来就学DSSM、双塔模型却忽略了文本理解才是推荐系统的地基。1.1 新闻场景和电商场景的推荐逻辑不一样拿电商商品推荐来对比商品有类目、品牌、价格、销量这些结构化字段用户行为数据密集协同过滤很容易找到“买了A的人还买了B”。但新闻是纯文本内容而且用户行为非常稀疏——一个人一天可能只点三五条新闻而且新闻的生命周期很短今天的热点明天就没人看了。协同过滤在新闻冷启动阶段几乎瘫痪新入库的文章没有点击数据就无法被推荐出去。新闻推荐更适合“以内容定相似”的思路给定一条新闻找出语料里“写的是同一件事”的其他新闻。这种基于内容相似度的匹配不依赖用户行为新闻一入库就能参与推荐天然规避了冷启动问题。1.2 TF-IDF在这套系统里到底起了什么作用TF-IDF全称是“词频-逆文档频率”它在推荐系统里扮演的角色很简单把一段自然语言文本变成一个向量让两条新闻的相似程度可以用数学来度量。拆开看TF是“这个词在当前文章里出现得多不多”IDF是“这个词在全部新闻里是否稀有”。两者相乘得到每个词的权重。比如“人工智能”在一篇新闻里出现8次而在整个语料里只有少数文章提到它这个词的TF-IDF权重就很高反过来“记者”“报道”这样的词出现再频繁会因为IDF很低而被压制。把一条新闻里所有词的权重按顺序排列成一个向量这篇新闻就有了固定的“坐标”。两条新闻的向量越接近内容就越相似。这套逻辑对推荐系统入门者来说比一上来就背神经网络的损失函数要好理解得多。1.3 这套baseline适合你的项目吗说实话TF-IDF方案不适合所有场景。但如果你满足以下条件它比上深度模型更划算语料规模在几万条以内单台机器可以完成计算业务目标是“相似内容推荐”而非“猜用户喜欢什么新话题”模型结果需要可解释想让运营或编辑知道“为什么推荐这篇文章”。我见过不少团队数据量就几千条非要去跑一个几十层的神经网络。训练出来的结果不一定比TF-IDF好还难调试、难维护。先从简单的baseline跑起来拿到一个可视化的、可向业务方解释的结果远比直接上豪华配置重要。2. 动手前的三件套数据、中文分词和相似度度量的选择写代码之前有三件事必须先定下来数据从哪来、中文文本怎么切成词、用什么公式算相似度。这三件事决定了整个项目的质量也是网上大多数教程一笔带过的地方。2.1 数据获取不要求大但要求干净做个人项目时很多人卡在第一步没有新闻数据。我的建议是不要一上来就追求百万级数据集几十条到几百条的干净样例就足以跑通流程。常见的数据来源有两种用公开的数据集做实验或者自己写脚本抓取RSS源。用RSS抓取时务必先看目标网站的robots约定控制请求频率仅用于个人学习别把别人服务器搞挂了。数据清洗也很重要。新闻文本里最常见的脏数据是重复内容和广告噪音。保存到本地时我习惯用一个元组结构存放news_items [ { news_id: 1, title: 中国人工智能产业规模持续扩大多家企业发布新模型, content: 正文内容, category: 科技 }, ]news_id必须是稳定的整数编号后面所有索引操作都依赖它。别小看这一步项目做到后面你就会发现ID和行号映射混乱是最大的坑之一。2.2 中文分词跳不过去直接用jieba英文文本天然用空格分隔单词但中文句子是连成一片的。“中国人工智能发展迅速”这句话计算机不知道是“中国/人工智能/发展/迅速”也不知道是“中国/人工/智能/发展/迅速”。所以在计算TF-IDF之前必须先分词。分词工具我选了jieba。虽然现在有很多更复杂的分词模型但jieba足够轻量效果在新闻场景下完全够用。核心用法就这么几行import jieba def chinese_tokenizer(text): words [w.strip() for w in jieba.lcut(text) if w.strip()] return wordsjieba.lcut返回一个分词列表顺手把空白字符过滤掉。后面把这个函数作为tokenizer参数传给TfidfVectorizer就能无缝衔接。2.3 相似度为什么首选余弦而不是欧氏距离两个向量之间的相似度常见选择有欧氏距离、曼哈顿距离、余弦相似度等。我做新闻相似推荐时直接锁定余弦相似度原因很实际新闻文本长短差异大一条标题型短讯可能只有几十个字一篇深度报道可能几千字。TF-IDF向量维度一样但向量的模长差异很大。余弦相似度只看两个向量之间的夹角不看模长这在文本场景里等价于“两个文本集中在哪些词上”而不是“两边总共用了多少词”。欧氏距离对文本长度太敏感非常容易被长文本带偏。做一个简单类比两个人在不同房间里各自追求不同方向的事业目标欧氏距离会觉得“他们离得远”但余弦相似度会觉得“他们方向一致”。新闻推荐要的是“方向一致”所以选余弦。3. 核心实现TF-IDF向量化与推荐函数的完整代码现在进入正题。项目主流程分三步构建TF-IDF矩阵、计算相似度矩阵、写推荐函数。我先贴一个完整可跑的建模脚本再逐段拆解里面的设计选择。3.1 用TfidfVectorizer加jieba完成中文向量化这里是项目最核心的代码也是我反复调整过的地方import jieba import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 示例语料真实项目请替换成你自己的新闻库 news_data [ {news_id: 1, title: 中国人工智能产业规模持续扩大多家企业发布新模型}, {news_id: 2, title: 人工智能在医疗影像识别中的应用取得新进展}, {news_id: 3, title: 足球联赛决赛精彩纷呈冠军球队捧杯庆祝}, {news_id: 4, title: 人工智能写作工具引发内容创作行业热议}, ] contents [item[title] for item in news_data] def chinese_tokenizer(text): words [w.strip() for w in jieba.lcut(text) if w.strip()] return words vectorizer TfidfVectorizer( tokenizerchinese_tokenizer, stop_words[的, 了, 是, 在, 和, 及, 等], min_df1, max_df0.8, ) tfidf_matrix vectorizer.fit_transform(contents) print(tfidf_matrix.shape) # (4, 若干特征词)有几个细节必须说明白。tokenizerchinese_tokenizer表示sklearn不再用默认的英文空格分词直接用我定义的中文分词函数。min_df1表示一个词至少要在1篇文档中出现才保留max_df0.8表示如果某个词在超过80%的文档里都出现就认为区分度太低直接丢弃。这两个参数能自动过滤掉一部分废话词。3.2 算相似度矩阵但要小心内存接下来用cosine_similarity计算两两之间的相似度similarity_matrix cosine_similarity(tfidf_matrix, tfidf_matrix)这个矩阵的形状是“新闻条数x新闻条数”。第i行第j列的值表示第i篇和第j篇新闻的余弦相似度取值范围在0到1之间对角线必然为1自己和自己完全相似。如果新闻数量只有几百条这个矩阵可以放心全量计算但如果到了几万条矩阵元素会膨胀到几亿个浮点数内存直接爆炸。这个问题我会在后面的坑里专门展开。3.3 推荐函数排序加排除自身有了相似度矩阵推荐逻辑就是“取某一行按分数从高到低排去掉自己取前N个”。我在项目里写成了这样def recommend(news_id, top_n5): scores list(enumerate(similarity_matrix[news_id])) scores.sort(keylambda x: x[1], reverseTrue) results [] for idx, score in scores[1: top_n 1]: results.append({ news_id: news_data[idx][news_id], title: news_data[idx][title], score: round(float(score), 4), }) return results注意for idx, score in scores[1: top_n 1]这个切片。因为排序后第一位必然是它自己相似度是1所以要从索引1开始跳过自己。这一步不处理的话推荐结果第一条永远是当前新闻本身属于低级但很常见的错误。4. 推荐接口的Flask封装与运行细节算法部分跑通之后下一步是让推荐能力可以被外部调用。Flask在这里的价值是轻量、零依赖、几十行代码就能起一个稳定的API服务。4.1 把建模脚本和Web服务分离很多人喜欢把所有代码塞在一个文件里小程序没关系但项目一旦涉及模型加载、数据更新就非常难受。我用的目录结构是news_recommender/ ├── build_model.py # 训练TF-IDF并保存模型文件 ├── app.py # Flask Web服务 ├── models/ │ ├── vectorizer.pkl │ ├── tfidf_matrix.pkl │ └── similarity_matrix.npy ├── data/ │ └── news.db └── requirements.txtbuild_model.py负责从数据库读取新闻、分词、向量化、计算相似度、保存模型文件。app.py只负责加载模型、处理请求、返回JSON。分离之后模型要更新就重跑一次build_model.py不用重启Web服务逻辑。4.2 Flask API怎么设计才顺手我的推荐接口设计如下from flask import Flask, request, jsonify import numpy as np import joblib app Flask(__name__) # 服务启动时一次性加载模型避免每个请求重复加载 vectorizer joblib.load(models/vectorizer.pkl) tfidf_matrix joblib.load(models/tfidf_matrix.pkl) similarity_matrix np.load(models/similarity_matrix.npy) news_list [] # 真实项目里从数据库读入 app.route(/api/recommend, methods[GET]) def recommend_api(): news_id request.args.get(news_id, typeint) top_n request.args.get(top_n, default5, typeint) if news_id is None: return jsonify({code: 1, message: 缺少news_id参数}), 400 if news_id 0 or news_id similarity_matrix.shape[0]: return jsonify({code: 1, message: news_id不存在}), 404 scores list(enumerate(similarity_matrix[news_id])) scores.sort(keylambda x: x[1], reverseTrue) results [] for idx, score in scores[1: top_n 1]: results.append({ news_id: int(news_list[idx][news_id]), title: news_list[idx][title], score: round(float(score), 4), }) return jsonify({code: 0, data: results}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)接口为什么用GET而不是POST因为这里的推荐请求是幂等的——同一个news_id和top_n永远返回相同结果传参也简单浏览器地址栏直接能测。若以后要传用户历史行为序列数据量大且复杂时再考虑POST。浏览器验证方式非常直接启动服务后访问这个地址就能看到JSON结果。http://127.0.0.1:5000/api/recommend?news_id1top_n34.3 部署前的模型序列化pkl和npy的选择模型文件用joblib存取是标准做法比pickle更高效。相似度矩阵是纯Numpy数组直接存成.npy加载速度快也不用考虑跨版本的兼容性问题。这里有个容易被忽略的细节joblib.load(models/tfidf_matrix.pkl)加载出来的对象是稀疏矩阵计算时最好保持稀疏格式别随手调用.toarray()转成稠密矩阵否则几百MB内存瞬间被吃光。只有在打印调试时我才会转稠密矩阵看一眼。5. 跑通之后我踩过的四个大坑代码跑起来只是开始。我把项目从“能演示”调到“能稳定用”的过程中踩了四个实实在在的坑每个都花了不少时间排查。这里完整复盘给你。5.1 停用词没处理干净推荐结果词不达意第一版我图省事停用词表只写了“的、了、是、在、和”。结果推荐出来的结果经常莫名其妙一篇写人工智能的新闻推荐的却是另一篇“记者在北京报道……”的新闻因为两篇都高频出现“记者”和“报道”这类词。这类词在每篇新闻里都有IDF本来就低但在样本量少的时候依然能混进高权重区间。解决方案是准备一份稍微像样的中文停用词表并且按新闻场景补充“记者”“报道”“昨日”“今天”“编辑”这类词。注意停用词表不是越多越好删过头会把一些有实际含义的词也过滤掉影响推荐细粒度。我建议先用默认表跑一版打印出每条新闻的top高频词看着不顺眼的再加进停用词表。5.2 全量相似度矩阵把内存吃爆了这是我吃过的最大教训。我用cosine_similarity(tfidf_matrix, tfidf_matrix)计算全矩阵几千条新闻时毫无感觉等把语料扩展到两万条发现进程直接把16G内存吃光机器卡死。原因很简单两万乘两万的矩阵每个浮点数占8字节大概是1.6万个浮点乘以粒度实际内存高达约3GB。这还没算稀疏矩阵转稠密时的峰值占用。最优解是不要预先计算全矩阵而是对单条推荐请求实时计算一行相似度def recommend_row(news_id, top_n): row_vector tfidf_matrix[news_id] sim_scores cosine_similarity(row_vector, tfidf_matrix).flatten() candidate_ids np.argsort(sim_scores)[::-1][1: top_n 1] return candidate_ids这样每个请求只算一行的相似度内存开销从N乘以N降到N在万级语料下完全可接受。做产品原型时我通常先用全矩阵是为了调试方便上线前再切换成按行实时计算。5.3 新新闻入库了模型还是看不到它业务方不断有新闻入库但推荐结果里永远没有新文章。因为这个TF-IDF模型是一次性训练的fit_transform确定了整个词表新文本如果不在当时的语料里就无法参与相似度计算。这不是代码bug而是方案本身的特性。解决思路有两个层面。低频更新场景下每天定时重跑一次build_model.py重新训练并更新模型文件。高频更新场景下要用增量方案新文章入库时直接用已经训练好的vectorizer.transform()把新文本转成向量再追加到tfidf_matrix中。注意这里只能用transform绝不能用fit_transform否则整个词表会重建前面所有向量都得跟着重算。new_tfidf vectorizer.transform([new_content]) tfidf_matrix np.vstack([tfidf_matrix.toarray(), new_tfidf.toarray()])但这里有个业务决策问题vectorizer本身也需要定期用全量语料重训练才能捕捉新的热点词。所以实践中一般是增量方法保证当天新闻能上线每天晚上再全量重建保持词表新鲜。5.4 news_id和矩阵行号错位推荐结果满盘皆输这个坑最隐蔽也最致命。数据库里的news_id是自增主键可能从1000开始而矩阵的行号是从0开始的数组索引。如果直接把数据库的news_id当作矩阵行号使用轻则推荐错几条重则数组越界崩服务。正确做法是先在内存里维护一个id_to_index映射字典所有查询先通过映射转换成矩阵行号返回结果时再反向映射回数据库ID。我在第一版就吃过这个亏推荐出来的“相关新闻”完全对不上号。排查下来发现问题不在算法而在ID映射关系上。这个教训也说明推荐系统里索引管理比算法本身更容易出bug。6. 怎么从“相似内容推荐”升级到“个性化推荐”到此为止系统能做的是“看到一篇新闻找到相似文章”。但如果要做“每个用户看到不同的推荐”还需要在相似内容之上叠加用户行为信息。这并不复杂TF-IDF框架还能继续复用。6.1 最简单的用户画像点击历史均值向量思路是把用户最近点击过的若干新闻向量取平均得到用户的“兴趣向量”再用这个向量和全库新闻计算余弦相似度def build_user_profile(clicked_ids, id_to_index): rows [id_to_index[i] for i in clicked_ids] profile_vector np.asarray(tfidf_matrix[rows].mean(axis0)).flatten() return profile_vector def recommend_for_user(profile_vector, top_n10): scores cosine_similarity([profile_vector], tfidf_matrix).flatten() top_indices np.argsort(scores)[::-1][:top_n] return top_indices这个方法的好处是几乎不增加复杂度一行mean就把用户历史行为编码进去了。缺点也很明显它假设用户兴趣是固定且平均的如果用户今天想看科技明天想看体育平均向量会变得含混不清。但这仍是“从内容推荐到个性化推荐”的最佳第一步因为逻辑简单随时可以叠加。6.2 用召回加精排的思路优化计算效率当语料量达到百万级即便是按行计算余弦相似度全量扫一遍也不划算。这时可以用两层结构召回层先用倒排索引等粗筛方式从全库挑出几百个候选再对候选做精确的TF-IDF余弦排序。倒排索引的朴素实现并不复杂每个词对应一个包含该词的新闻ID列表查询时取目标新闻的top权重词收集这些词的文档列表做并集得到候选集合。这样把计算量从“全库N”降到了“候选数量”。到了这一步基本上就是工业级推荐系统的雏形了召回负责快精排负责准。6.3 什么时候该放弃TF-IDF换语义模型TF-IDF有个天然缺陷它只能识别“词面相同”的内容识别不了“语义相似但不同词”的句子。比如“欧冠决赛”和“欧洲冠军联赛巅峰对决”词面几乎没有交集TF-IDF判定它们不相似但人一眼就知道说的是同一件事。所以当你的新闻语料里充满这种同义表达或者业务方明确要求“跨词面语义召回”时就该考虑用句向量模型加向量索引来替换或补充TF-IDF。不过请记住即使换了模型也不需要把TF-IDF这套链路推翻。用户的兴趣向量、倒排索引召回思路、Flask API封装全都原样保留。TF-IDF的价值在于它把推荐系统的完整骨架训练了一遍。我现在做任何推荐项目第一版都会先跑一套TF-IDF基线把推荐结果打印出来人工看几遍。这套流程能让我在十分钟之内判断语料质量、停用词配置和相似度逻辑是否合理。等基线结果稳定了再决定要不要往深度模型迁移。做技术选型时先有baseline再谈优化这条经验比任何模型都值钱。
返回列表