Python文章推荐系统实战:从TF-IDF到Flask部署
简介基于Python的文章推荐系统完整项目面向毕业设计或课程设计实践解决了从原始文章数据采集、文本预处理到推荐模型训练评估的全链路问题适合需要综合运用爬虫、Pandas数据清洗、NLTK分词、Scikit-learn协同过滤以及Flask/Django展示的开发者。包内共35个文件其中15个Python源码覆盖相似度计算、贝叶斯分类、标签提取等核心模块5个shell脚本便于一键执行训练、分类与推送流程另有配置文件、说明文档及JS辅助文件压缩包仅89KB轻量易部署。项目完整呈现基于内容与协同过滤的混合推荐思路包含TF-IDF相似度计算、用户行为分析、MongoDB数据存储及推荐效果评价指标等关键环节能帮助学习者快速理解推荐系统工程化实现。目前已有108人学习参考对正在设计推荐系统课程项目或求职作品集的读者有较高参考价值。1. 这个项目为什么值得做文章推荐系统的完整套路每年毕业设计选题里“Python文章推荐系统”都是常青树但真正能讲清全链路的人不多。很多人答辩时的状态是贴一段协同过滤代码说“这个算相似度”然后一问“你的相似度怎么算的”“冷启动怎么办”“推荐结果怎么验证”就卡住了。问题不在算法难度而在大多数人只背公式没把“数据 → 向量 → 相似度 → 召回 → 展示 → 评估”这条链路走通。这篇文章就是要把这条链路拆开让你知道每一环怎么选型、怎么落代码、怎么避坑最终做出一个能跑、能答辩、有实际工程感的 Python 文章推荐系统——这也是你写进简历时真正能讲出细节的项目。2. 技术选型为什么文章推荐优先走“基于内容”而不是协同过滤2.1 三个方案对比基于内容、协同过滤、混合推荐各自的边界文章推荐系统最常见的三个方案是基于内容的推荐Content-Based、协同过滤Collaborative Filtering和混合推荐Hybrid。很多初学者上来就写协同过滤因为教程多、公式好看但文章场景下协同过滤有个致命前提——需要大量用户行为数据。课程设计里你没有几万条真实评分数据冷启动直接翻车。基于内容的推荐只靠文章本身计算相似度不依赖用户历史行为门槛更低、可解释性更强、答辩时有逻辑可讲。方案数据依赖推荐逻辑冷启动表现答辩亮点基于内容仅需文章文本算文本间相似度新文章立即能推可解释性极强推荐依据直接可查协同过滤User/Item用户-物品交互数据找相似用户/物品新文章无推荐有“猜你喜欢”的味道但数据不好凑混合推荐文本 行为数据加权融合两类结果依赖方案搭配综合性强工作量容易撑起来我一般会建议主推基于内容的文本推荐作为核心链路再留一个接口给协同过滤做可选融合模块这样论文工作量足够答辩时也不会被“你的数据从哪来”问倒。文章推荐系统的本质就是“你这篇文章和哪几篇长得像”把文本变成向量算一算距离Top-N 排序这就完成了 80% 的工作。2.2 文本向量化的层级选择TF-IDF、Word2Vec 还是 BERT把文章变成机器能算的向量有三个主流层级选哪个直接决定你的项目是“会用调包”还是“有思考”。第一层是 TF-IDF它统计的是词频和逆文档频率实现简单、内存可控、解释性强是课程设计和毕业设计的稳妥起点。第二层是 Word2Vec/Doc2Vec它通过上下文训练分布式词向量能部分理解同义替换“汽车”和“轿车”相近但需要较多语料预训练训练时间会拉长。第三层是 BERT效果最好但显存和推理成本高而且答辩时“你为什么不用 BERT”比“你为什么用 DICT”更难回答——局部看 BERT 能提升语义度但项目周期和可部署性会下降。TF-IDF 在文章推荐里的地位稳如老狗原因很简单文章是长文本关键词天然具备区分度TF-IDF 恰恰抓的就是“这篇文章里反复出现、但在别处少出现的词”。Word2Vec 更多用在短文本或搜索场景BERT 则适合做精排。对于一篇 8000 字的文章TF-IDF 的稀疏向量用余弦相似度计算开销远小于 BERT 做语义相似。我的做法是TF-IDF 做召回BERT 只放到进阶章当加分项日常主链绝不碰大模型。2.3 相似度度量的细节为什么全局都用余弦相似度向量算好后距离度量方式也有讲究。欧氏距离看的是绝对距离受文章长度影响极大——两篇相同主题的文章一篇 5000 字一篇 1000 字欧氏距离会偏大但余弦相似度只看方向夹角长度差异被对角公式自然归一化因此文本场景里它的稳定性明显更好。皮尔逊相关系数适合处理带评分的数据纯文本场景意义不大。import numpy as np def cosine_similarity(vec_a, vec_b): # 分母加1e-9避免零向量导致除零错误 dot np.dot(vec_a, vec_b) norm_a np.linalg.norm(vec_a) norm_b np.linalg.norm(vec_b) return dot / (norm_a * norm_b 1e-9)这里的关键是分母的平滑项。真实语料里总会出现整篇文章的词全部出现在停用词表里、向量全零的情况没有 1e-9 会在算相似度时报 RuntimeWarning 甚至 NaN这一点是新手最容易忽略的。3. 数据从哪来公开数据集、自建语料与清洗规则3.1 公开数据集怎么找、怎么加载到内存做文章推荐系统首先要解决语料问题。常见的开源数据集有三类英文的 20 Newsgroups 由 sklearn 内置下载中文的 THUCNews 由清华自然语言处理组发布包含几十万篇新闻文本搜狗新闻语料虽然量大但格式老需要花时间解压和预处理。对于快速验证逻辑20 Newsgroups 是最省心的——一行代码拿到分类别、已分好训练集和测试集的干净语料。from sklearn.datasets import fetch_20newsgroups # 只取四个类别减少数据量方便快速调试 categories [rec.sport.baseball, sci.electronics, talk.politics.mideast] news fetch_20newsgroups(subsetall, categoriescategories, shuffleTrue, random_state42) print(len(news.data)) # 看一眼目前加载了多少篇 print(news.data[0][:200]) # 预览样本确认数据格式参数说明subset参数有train、test和all三档做验证时建议用train训练、test评估不然离线评估没有数据可划分。categories传类别列表用来缩小语料范围课程设计不需要全量跑四到五个类别就够。拿到的news.data是原始字符串列表大小几 MB 到几十 MB 不等一次性读入内存即可不需要上数据库。3.2 清洗规则去 HTML、去特殊符号、去换行网页抓下来的文章往往带 HTML 标签、乱码符号和长换行不清洗直接分词会让特征里混入大量噪声。我的清洗规则按顺序执行五步去 HTML 标签 → 去 URL → 去数字和特殊符号 → 去单字符 → 合并多余空白。每步都必须写在同一个函数里这样后续加数据集不需要重写一次。import re def clean_text(raw): # 去掉HTML标签保留标签内文字 text re.sub(r[^], , raw) # 去掉URL链接 text re.sub(rhttp[s]?://\S, , text) # 去掉数字、字母、标点中文场景只保留中文字符 text re.sub(r[^\u4e00-\u9fa5a-zA-Z], , text) # 将多个连续空白压缩为一个 text re.sub(r\s, , text) return text.strip()逻辑说明第一行正则r[^]是匹配以开头结尾的所有内容不管标签是p还是div classx都能覆盖。第四行\u4e00-\u9fa5表示中文字符的 Unicode 区间这一步直接抹掉标点和数字避免后期特征变成“2020”这类无效维度。清洗结果直接用空格连接这正好喂给后面的 jieba 分词和 TfidfVectorizer。3.3 jieba 分词与停用词表三个必调参数中文文本必须分词才能向量化jieba 是事实标准。分词阶段有三个参数要关注jieba.load_userdict()加载领域词典保持专业词完整性jieba.cut(..., cut_allFalse)用精确模式而非全模式以及 HMM 开关控制是否识别未登录词。还有一套不可漏的配套动作构建停用词表并过滤——中文里的“我们”“这个”“一个”出现频率极高但没有任何区分能力不过滤的话相似度会被这些虚词主导。import jieba import jieba.analyse # 加载自定义词典词典格式词 词频 词性词频可以省略 jieba.load_userdict(domain_dict.txt) def tokenize(text): # 精确模式分词HMMTrue 识别词典外新词 words jieba.cut(text, cut_allFalse, HMMTrue) # 过滤空白、单字和停用词 stopwords set() with open(stopwords.txt, r, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) return [w for w in words if w.strip() and w not in stopwords and len(w) 1]参数说明cut_allFalse是精确模式不会把“中华人民共和国”切成“中华/人民/共和/国”而是尽量保持最长匹配。HMMTrue对未录入词典的词做隐马尔可夫切分能识别一些人名和外来词代价是偶尔产生错误切分对推荐场景影响不大。打印一下分词结果确认停用词是否真的生效——这一步肉眼检查花两分钟比后面调模型省一晚上。提示domain_dict.txt 和 stopwords.txt 是文本文件按行存放。GitHub 上搜“中文停用词表”能找到现成资源把哈工大停用词表、百度停用词表合并去重基本覆盖常见场景。4. 核心实现TF-IDF 向量化到 Top-N 推荐的流水线4.1 TfidfVectorizer 参数max_features、ngram_range、min_df 的取舍文本变成向量这一步直接调 sklearn 的 TfidfVectorizer但参数不能全用默认。三个参数是必调的max_features限制特征维度ngram_range决定是否保留词组信息min_df过滤只出现一两次的生僻词。from sklearn.feature_extraction.text import TfidfVectorizer corpus [ .join(tokenize(doc)) for doc in news.data] # 注意输入必须是分词后用空格连接的结果而不是原始文档 vectorizer TfidfVectorizer( max_features20000, ngram_range(1, 2), min_df2, max_df0.95, sublinear_tfTrue ) tfidf_matrix vectorizer.fit_transform(corpus) print(tfidf_matrix.shape) # (文章数, 特征维度)逻辑说明fit_transform一步完成词典构建和文本向量化返回的是稀疏矩阵直接用print看形状不会爆内存。如果直接把原始文章传进去而不在外部调用tokenizeTfidfVectorizer 的默认 token 模式是按空格分英文单词中文文本会被切成单个字或整段效果直接崩。参数说明max_features20000控制特征数量维度太大会让相似度计算变慢对课程设计 20000 维足够ngram_range(1, 2)同时保留单个词和两两词组能识别“深度学习”和“神经/网络”这类固定搭配min_df2表示词至少出现在两篇文章里过滤生僻词max_df0.95表示出现频率超过 95% 的词直接丢掉比如“前言”“摘要”这类每篇都有的词。sublinear_tfTrue把词频用 1log(tf) 替换削弱高频词的绝对优势这个参数经常被忽略但收益明显。4.2 余弦相似度矩阵与 Top-N 推荐函数向量化之后下一步就是算相似度。sklearn 提供了cosine_similarity但直接调函数前要先想清楚内存——N 篇文章的相似度矩阵是 N×N 维 float 数组1 万篇文章就是 1 亿个浮点数约 800MB 内存课程设计的数据量虽然到不了这个量级但养成良好的习惯是必要的。from sklearn.metrics.pairwise import cosine_similarity # 一次性算全文相似度 similarity_matrix cosine_similarity(tfidf_matrix) def recommend_by_index(article_idx, top_n10): # 获取目标文章在矩阵中的相似度行 sim_scores list(enumerate(similarity_matrix[article_idx])) # 排除文章自身相似度为1.0按相似度从高到低排序 sim_scores sorted(sim_scores, keylambda x: x[1], reverseTrue) sim_scores [x for x in sim_scores if x[0] ! article_idx][:top_n] return sim_scores逻辑说明similarity_matrix[article_idx]取的是文章与其他所有文章的两两相似度返回一维数组。enumerate把数组转成 (文章索引, 相似度) 的列表排序后去掉自身再截前 N 个。这里的x[0] ! article_idx判断条件必须写不然自己永远排第一推荐列表第一项永远是同一篇文章这也是最常见的翻车点。4.3 冷启动处理新文章、新用户各怎么办冷启动在毕业设计答辩里几乎是必问题。文章推荐系统冷启动有两个维度新文章没有人看、没有行为记录但基于内容的方法天然具备处理能力——只需把新文章分词、向量化、放进 TF-IDF 矩阵重新算相似度即可新用户没有阅读历史解决办法常见做法是让他注册时选择感兴趣的主题类别用类别代表向量做推荐或默认推荐全库覆盖度最好的文章。def recommend_for_new_article(article_text, top_n10): # 清洗 分词 向量化 cleaned clean_text(article_text) tokenized .join(tokenize(cleaned)) vec vectorizer.transform([tokenized]) # 与库里所有文章做点积TfidfVectorizer的向量本身已经做过L2归一化 sims cosine_similarity(vec, tfidf_matrix).flatten() top_indices sims.argsort()[-top_n:][::-1] return top_indices参数说明这里的vectorizer.transform用的是已经拟合好的向量化器不能用fit_transform否则新文章会重建整个词典老文章的特征空间全变样。cosine_similarity(vec, tfidf_matrix)返回的是 1×N 的矩阵flatten()拍平后就是新文章与每篇老文章的相似度。这个函数写出来答辩时讲“我的系统冷启动怎么处理”时直接拿它说话。注意transform和fit_transform的区别是整个项目最高频的坑。fit_transform是新建词典并转换transform是在已建好的词典上做映射。所有新数据进来都必须走transform否则重新拟合会让特征空间对不上推荐结果全乱。5. 避坑与常见问题向量化到推荐结果的五个翻车现场5.1 现象推荐结果永远都是同一篇排第一的永远是文章自身之后几篇相似度全部低于 0.1基本等于随机推。原因是推荐函数没有排除自身或者自身相似度 1.0 排第一Top-N 里实际有效结果只有 N-1 个。解决方法是先过滤自身索引再排序见 4.2 的代码而且要注意推荐时不能用similarity_matrix[k][k]这个对角线值做判断统一过滤索引最稳。5.2 现象中文文本全变成空格向量全是零TfidfVectorizer默认 token 是“按空格切分英文单词”拿原始中文文档直接喂进去切出来是一长串没有空格的整句或者清洗后只剩空格结果某行向量全零。原因是先 fitted 再 cleaning处理顺序反了。解决方法是保证严格的流水线顺序原始文本 →clean_text→tokenize→ .join→vectorizer.transform每一步之后用print打印一行确认结果再继续。5.3 现象内存爆掉进程被系统杀掉数据量约 2 万篇文章时直接算 N×N 稠密相似度矩阵一个 float64 数组约 3.2GB 内存再加原始文本列表很容易被 OOM Killer 干掉。原因是cosine_similarity默认返回稠密矩阵。解决方法是小数据量5000 篇以下可以稠密计算大数据量改用pairwise_distances(..., metriccosine, n_jobs-1)分块计算或只保存每篇文章 Top-20 近邻的稀疏结构推荐时走neighbors表而不是全量矩阵。5.4 现象你自己觉得 A 和 B 明显同主题系统不觉得TF-IDF 抓的是字面重复度不是语义相似。比如“深度神经网络”和“机器学习模型”两篇文章明明在讨论相似课题但没有一个字重合相似度极低。这不是 bug是基于内容方法的天然天花板。解决方法是接受它并在论文里把它写成“方法局限与改进方向”然后扩展ngram_range到(1, 2)或对标题、摘要做加权。答辩老师问到你为什么不加 BERT你可以回答“BERT 会在精排阶段做扩展本项目的核心链路基于 TF-IDF 保证可解释性和可复现性”。5.5 现象在线推荐每请求都要重新算全部相似度响应慢到怀疑人生本地实验跑得好好的一上 Flask 接口就发现每次推荐要全库算一遍相似度文章多起来卡到发指。原因是每次请求都在循环矩阵运算而没有做缓存。解决方法是把相似度矩阵启动时计算一次存入内存推荐时只做查表文章库有更新时做增量向量化再更新矩阵用单独函数控制。另外要给 Flask 接口加lru_cache做连续重复请求的结果缓存具体见下一章。6. 进阶落地用 Flask 做展示层与离线评估让答辩多讲五分钟6.1 用 Flask 包一个推荐接口别人能直观点点点展示层是毕业设计的加分项直接把推荐函数暴露成 Web 服务是最快的路径。Flask 比 Django 更适合这个场景因为它只做轻量接口没有重型结构。核心代码是把recommend_by_index封装成 API返回 JSON 格式的文章列表前端可以直接渲染。from flask import Flask, request, jsonify app Flask(__name__) # 模拟文章库实际项目中替换为从数据库读取 articles [{id: i, title: 标题, content: news.data[i]} for i in range(len(news.data))] app.route(/recommend, methods[POST]) def recommend(): # 请求体{article_id: 0} data request.get_json() article_id data.get(article_id) if article_id is None or article_id len(articles): return jsonify({error: invalid article id}), 400 rec_list recommend_by_index(article_id, top_n10) results [] for idx, score in rec_list: results.append({id: idx, title: articles[idx][title], score: round(score, 4)}) return jsonify({code: 0, data: results})逻辑说明接口设计为 POST 传入要推荐的源文章 ID返回的每条结果带相似度分数。round(score, 4)是为了让 JSON 输出简洁不要直接回浮点长尾。前端用普通 HTML 下拉框就能在本地跑起来不写复杂框架简历里写“基于 Flask 构建推荐服务接口支持 Top-N 推荐与可视化验证”直接成立。6.2 用离线的准确率、召回率证明你不是玄学答辩时老师大概率会问“你的推荐结果怎么评价”。纯功能演示不够还需要量化指标。推荐系统常用的评估方法是在文本分类语料上做precisionK和recallK——把已知类别的文章切出测试集给测试文章推荐同类别文章算命中率。from sklearn.model_selection import train_test_split # 用类别标签做评估标签推荐结果中同类别文章算“命中” labels news.target train_idx, test_idx train_test_split( range(len(labels)), test_size0.2, stratifylabels, random_state42 ) def evaluate_precision(top_n10): hit 0 total len(test_idx) for i in test_idx: # 与4.2相同逻辑获取推荐列表 sim_scores sorted(enumerate(similarity_matrix[i]), keylambda x: x[1], reverseTrue) sim_scores [x for x in sim_scores if x[0] ! i][:top_n] hits [x[0] for x in sim_scores if labels[x[0]] labels[i]] if hits: hit 1 precision hit / (total * top_n) if total 0 else 0 recall hit / total if total 0 else 0 return precision, recall核心参数是stratifylabels它保证训练集和测试集的类别比例一致否则切分出来的测试集可能完全没有某一类文章评估结果会被拉偏。用这个函数跑一遍你会得到一个真正的数值——也许 0.4也许 0.6然后把它写进论文里作为基础版成绩。答辩时再补一句“后续通过引入 Word2Vec 做特征扩展指标提升到 0.7”这就形成了完整的实验对比。这些年做下来我的习惯是每个推荐项目先跑通最小可行链路再谈优化——最少 500 篇文章、一个 TF-IDF、一个cosine_similarity出结果后打印几条相似文本自己肉眼验证一遍发现问题再往上层调。这个习惯帮我省掉了大量“跑完了发现输入格式错了”的返工时间。希望这篇拆解能帮你在课程设计和毕业设计里少走一段弯路把时间花在真正有增量的部分。把你的项目做成一个能讲清决策过程的作品而不是堆代码的黑匣子。祝你顺利。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →