简介:这份资源是一套基于Python的个性化阅读推荐系统完整项目实例,面向具备Python基础、熟悉Web开发与机器学习入门知识的开发者及计算机专业学生,帮助其从零理解推荐系统全链路实现。内容围绕用户画像建模、内容语义分析、协同过滤与内容过滤的混合推荐算法展开,并覆盖实时反馈、多目标优化、数据库设计与GUI界面等模块,可应用于在线教育、新闻资讯、数字图书馆等精准分发场景。资源包共1个docx文件,约77KB,以图文与代码详解形式呈现系统架构、核心算法、数据生成、API接口规范及前后端实现,目录按项目背景、挑战方案、模型架构、代码示例等分层组织,便于按模块检索学习。目前已有68人学习。读者可据此掌握用户兴趣动态建模、内容特征提取与混合推荐落地思路,并获得可二次开发或教学演示的完整参考方案。
1. 从零搭一套基于 Python 的个性化阅读推荐系统:用户画像加内容语义到底怎么落地
很多人第一次听到「推荐系统」这四个字,脑子里浮现的是大厂那套动辄上百台机器的召回排序流水线,于是还没动手就先劝退。但如果你只是想做一套能跑在自己笔记本上、能给几百上千个用户做个性化阅读推荐的系统,事情远没有那么玄乎。这套基于 Python 的个性化阅读推荐系统,核心就两件事:一是把用户是谁、爱看什么刻画成结构化的用户画像,二是把文章讲了什么、属于哪个主题抽成内容语义向量,然后让两者在同一个空间里算相似度。它解决的是「用户打开 App 只看到一堆无关文章」这个最朴素的问题,适合有 Python 基础、懂一点 MySQL、想做一个完整可演示项目的学生和初中级工程师。整套东西用 Python 加 MySQL 就能撑起来,不需要 GPU,不需要分布式,一台普通开发机足够。下面我按真实做项目的顺序,把选型、建库、画像、语义、融合、排错一路讲透,你照着抄就能复现。
2. 技术选型与整体架构:为什么是 Python + MySQL 而不是别的组合
2.1 推荐系统三条主流路线,为什么选「画像 + 语义」混合
推荐系统落地无非三条路:协同过滤、内容推荐、混合推荐。协同过滤靠「和你相似的人喜欢什么」来推,冷启动阶段用户行为稀疏时基本瘫痪;纯内容推荐靠「和你读过的文章相似的文章」来推,能解决冷启动,但容易越推越窄,用户被困在信息茧房里。这套系统选的是混合路线:用户画像负责刻画「你是谁」,内容语义负责刻画「文章是什么」,两者加权融合出最终推荐分。
为什么这么选?因为阅读类场景有个特点——新用户多、新文章多,纯协同过滤根本转不动。而用户画像可以靠注册信息、兴趣标签、历史点击快速建立,内容语义可以靠文章标题正文直接抽取,两条腿走路,冷启动和多样性都能兼顾。这也是目前中小型推荐项目最常见、最稳的落地方式。
具体到算法,画像侧用标签权重加时间衰减,语义侧用 TF-IDF 或轻量句向量做文本表示,融合层用加权线性组合。整套逻辑不依赖深度学习框架,纯 Python 加 scikit-learn 就能跑通,部署成本极低。
2.2 整体架构分层与数据流
系统分四层,从上到下依次是:
| 层级 | 职责 | 关键技术 |
|---|---|---|
| 数据层 | 存用户、文章、行为、画像 | MySQL 8.0 |
| 计算层 | 画像构建、语义抽取、相似度计算 | Python + scikit-learn + jieba |
| 服务层 | 对外提供推荐接口 | Flask |
| 展示层 | 阅读列表、兴趣管理界面 | Flask 模板 / 简易 GUI |
数据流是这样的:用户行为(点击、收藏、读完)写入 MySQL 的行为表,画像模块定时或实时读取行为,更新用户画像表;文章入库时,语义模块抽取标题正文的语义向量,存进文章语义表;推荐请求进来时,服务层同时拉取该用户的画像向量和候选文章的语义向量,算融合分,排序返回 Top-N。
这套架构的好处是每一层都能单独替换。比如你后面想上 BERT 句向量,只改语义模块,其他层不动;想换 Redis 缓存,只改服务层。对新手来说,分层清晰意味着排错时能快速定位是哪一层出的问题。
2.3 环境准备:Python 与 MySQL 的安装配置要点
先把地基打好。Python 建议 3.9 以上,MySQL 建议 8.0。Windows 用户去 python 官网下载安装包,安装时务必勾选「Add Python to PATH」,否则后面命令行敲 python 会提示找不到命令,这是新手翻车率最高的一步。MySQL 用官方安装包或压缩包都行,装完记得把 bin 目录加进环境变量。
装完先验证:
python --version mysql --version两条命令都能正常输出版本号,才算环境通了。如果 MySQL 报error 2002 (HY000): can't connect to local MySQL server through socket,八成是服务没启动,Windows 去服务管理器启动 MySQL 服务,Linux 用systemctl start mysqld。
Python 依赖装这几个就够:
pip install flask pymysql scikit-learn jieba numpy pandas提示:如果你用 PyCharm 或 VSCode,记得把解释器指到你装依赖的那个 Python 环境,否则会出现「命令行能跑、IDE 里报 ModuleNotFoundError」的经典问题。
3. 数据库设计:用户、文章、行为、画像四张核心表怎么建
3.1 建库建表 SQL 与字段设计理由
数据库是整个系统的黑匣子,表设计错了后面全是血泪。核心四张表:用户表、文章表、行为表、画像表。先建库:
CREATE DATABASE reading_rec DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE reading_rec;用户表存基础信息和注册时选的兴趣标签:
CREATE TABLE users ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, interest_tags VARCHAR(255) DEFAULT '', -- 注册时选的兴趣,逗号分隔 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;文章表存正文和分类,正文是语义抽取的原料:
CREATE TABLE articles ( article_id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, content TEXT NOT NULL, category VARCHAR(50) DEFAULT '', publish_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;行为表记录用户对文章的动作,是画像更新的数据源:
CREATE TABLE behaviors ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, article_id INT NOT NULL, action_type TINYINT NOT NULL, -- 1点击 2收藏 3读完 action_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_article (article_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;画像表存每个用户在各标签上的权重,是推荐时直接读的:
CREATE TABLE user_profiles ( user_id INT NOT NULL, tag VARCHAR(50) NOT NULL, weight FLOAT DEFAULT 0, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, tag) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段设计有几个讲究。行为表的action_type用 TINYINT 而不是字符串,省空间且比较快;画像表用(user_id, tag)联合主键,保证一个用户一个标签只有一行,更新时直接ON DUPLICATE KEY UPDATE就行,不用先查再插。文章正文用 TEXT,因为阅读类文章动辄几千字,VARCHAR 不够用。
3.2 用 Python 封装数据库连接与连接池
不要在每个函数里裸写pymysql.connect,那样连接泄漏是迟早的事。封装一个连接工具:
import pymysql from dbutils.pooled_db import PooledDB POOL = PooledDB( creator=pymysql, maxconnections=10, # 最大连接数 mincached=2, # 启动时预建的空闲连接 host='localhost', port=3306, user='root', password='your_password', database='reading_rec', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) def get_conn(): return POOL.connection()maxconnections按你并发量调,本地开发 10 足够;cursorclass设成 DictCursor,查询结果直接是字典,后面取字段不用记下标,可读性高很多。用连接池而不是每次新建连接,是因为 MySQL 建连接的开销不小,高频推荐请求下裸连会明显拖慢响应。
注意:连接用完一定要
conn.close(),连接池的 close 是归还不是真关闭,但你不还,池子很快耗尽,报「too many connections」。
4. 用户画像构建:从行为日志到标签权重向量
4.1 画像的数学表达与权重衰减设计
用户画像本质是一个稀疏向量:profile = {tag1: w1, tag2: w2, ...},每个 tag 是文章分类或关键词,w 是用户对该 tag 的偏好强度。权重怎么算?最朴素的是行为计数,但那样三个月前的一次点击和昨天的一次读完权重一样,不合理。所以引入时间衰减:
w = base_score * exp(-λ * Δt)base_score是行为基础分(点击 1、收藏 3、读完 5),Δt是行为距今天数,λ是衰减系数,一般取 0.05 到 0.1。λ 越大,越只看近期行为;λ 越小,越看长期兴趣。阅读场景我一般取 0.07,兼顾新鲜度和稳定性。
这个公式的意义在于:同样是读完,昨天的行为贡献接近满分,一个月前的行为贡献衰减到原来的十分之一左右。这样画像能跟着用户兴趣漂移走,不会一直推他半年前爱看的东西。
4.2 画像更新脚本:从 behaviors 表聚合到 user_profiles
下面这段是画像更新的核心,读行为、算权重、写画像:
import math from datetime import datetime from db import get_conn BASE_SCORE = {1: 1.0, 2: 3.0, 3: 5.0} # 点击/收藏/读完 LAMBDA = 0.07 def update_profile(user_id): conn = get_conn() try: with conn.cursor() as cur: # 关联行为表和文章表,拿到每次行为对应的文章分类 cur.execute(""" SELECT b.action_type, b.action_time, a.category FROM behaviors b JOIN articles a ON b.article_id = a.article_id WHERE b.user_id = %s """, (user_id,)) rows = cur.fetchall() tag_weight = {} now = datetime.now() for r in rows: delta_days = (now - r['action_time']).days score = BASE_SCORE.get(r['action_type'], 1.0) * math.exp(-LAMBDA * delta_days) tag = r['category'] or 'unknown' tag_weight[tag] = tag_weight.get(tag, 0) + score # 归一化,避免高频用户权重整体偏大 total = sum(tag_weight.values()) or 1 with conn.cursor() as cur: for tag, w in tag_weight.items(): cur.execute(""" INSERT INTO user_profiles (user_id, tag, weight) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE weight = VALUES(weight) """, (user_id, tag, w / total)) conn.commit() finally: conn.close()逻辑说明:先 JOIN 拿到行为和分类,再逐条算衰减分累加,最后归一化写库。归一化这步很多人会漏,导致活跃用户画像权重整体偏大,融合时压过语义分。ON DUPLICATE KEY UPDATE保证重复更新不报错,直接覆盖旧权重。
参数说明:BASE_SCORE按你的业务调,如果收藏比读完更能代表兴趣,就把收藏调高;LAMBDA控制记忆长度,想更灵敏就调大。跑的时候可以先用一个测试用户验证,打印tag_weight看分布是否合理。
4.3 冷启动:新用户没有行为时画像怎么给
新用户 behaviors 表是空的,画像算出来也是空的,推荐就退化成随机。解决办法是把注册时选的interest_tags作为初始画像,给每个标签一个中等权重,比如 0.5,然后随着真实行为积累逐步被覆盖。这样新用户一进来就有像样的推荐,不至于看到一堆无关内容。
5. 内容语义建模:把文章标题正文变成可计算的向量
5.1 中文分词与 TF-IDF 向量化
内容语义这步的目标是把一篇文章变成一串数字,让「两篇文章像不像」变成「两个向量近不近」。中文第一步是分词,用 jieba:
import jieba from sklearn.feature_extraction.text import TfidfVectorizer def build_tfidf(articles): # articles: [(article_id, title, content), ...] corpus = [] for _, title, content in articles: # 标题权重高,重复三次相当于加权 text = (title + '。') * 3 + content corpus.append(' '.join(jieba.cut(text))) vectorizer = TfidfVectorizer(max_features=5000, stop_words=['的', '了', '是', '在']) matrix = vectorizer.fit_transform(corpus) return vectorizer, matrix逻辑说明:先把标题重复三次再拼正文,这是内容推荐里常用的「标题加权」技巧,因为标题往往最能代表文章主题。max_features=5000限制词表大小,防止维度爆炸;停用词表过滤掉高频无意义词。fit_transform返回的 matrix 是稀疏矩阵,每行对应一篇文章的 TF-IDF 向量。
参数说明:max_features按语料规模调,文章几千篇时 5000 够用,几万篇可以到 20000。停用词表建议用现成的中文停用词表,比手写几个词靠谱得多。
5.2 语义相似度计算与文章近邻表
有了向量,算相似度就是余弦相似度:
from sklearn.metrics.pairwise import cosine_similarity def similar_articles(matrix, article_id, top_n=10): sims = cosine_similarity(matrix[article_id], matrix).flatten() # 排除自己 idx = sims.argsort()[::-1][1:top_n+1] return [(int(i), float(sims[i])) for i in idx]cosine_similarity算的是向量夹角余弦,值越接近 1 越相似。argsort()[::-1]是降序排列取前 N,[1:top_n+1]跳过自己。这个函数在推荐时用来找「和用户读过的文章语义相近的文章」。
如果文章量大,每次实时算全量相似度会慢,常见做法是离线预计算每篇文章的 Top-K 近邻,存一张article_similar表,推荐时直接查表。这是典型的空间换时间。
5.3 语义向量落库与增量更新
TF-IDF 向量维度高且稀疏,直接存 MySQL 不划算。常见做法是只存「文章 ID 到近邻文章 ID 及相似度」的映射,向量本身在内存里维护,服务启动时加载。文章新增时,重新 fit 一次向量器并更新近邻表。如果文章更新频繁,可以只对新文章算与存量文章的相似度,增量插入,避免全量重算。
注意:TF-IDF 的词表是全局的,新增文章后如果重新 fit,老文章的向量也会变,所以要么定期全量重建,要么固定词表只做 transform。新手容易在这里踩坑,导致相似度结果前后不一致。
6. 融合推荐与接口实现:画像分和语义分怎么加权
6.1 融合打分公式与权重调参
最终推荐分是画像分和语义分的加权和:
final_score = α * profile_score + (1 - α) * semantic_scoreprofile_score是候选文章分类在用户画像里的权重,semantic_score是候选文章与用户历史读过文章的最大语义相似度。α 控制两者比重,阅读场景我一般从 0.6 起步,偏向画像,因为画像更稳定;如果发现推荐太单一,把 α 降到 0.4,让语义分多带点新鲜内容进来。
调 α 没有理论最优,只能靠离线评估。做法是留出一部分行为做测试集,看不同 α 下推荐命中率,选最高的那个。别拍脑袋定,也别一次调太多参数,一次只动一个。
6.2 Flask 推荐接口与候选集召回
推荐接口的逻辑是:召回候选、打分、排序、返回。
from flask import Flask, request, jsonify from db import get_conn app = Flask(__name__) @app.route('/recommend') def recommend(): user_id = int(request.args.get('user_id', 0)) top_n = int(request.args.get('top_n', 10)) conn = get_conn() try: with conn.cursor() as cur: # 1. 取用户画像 cur.execute("SELECT tag, weight FROM user_profiles WHERE user_id=%s", (user_id,)) profile = {r['tag']: r['weight'] for r in cur.fetchall()} # 2. 召回候选:取最近文章做候选池 cur.execute("SELECT article_id, category FROM articles ORDER BY publish_time DESC LIMIT 500") candidates = cur.fetchall() scored = [] for c in candidates: p_score = profile.get(c['category'], 0) # 语义分这里简化为分类匹配,实际接第5章的相似度 s_score = 1.0 if c['category'] in profile else 0.0 final = 0.6 * p_score + 0.4 * s_score scored.append((c['article_id'], final)) scored.sort(key=lambda x: x[1], reverse=True) return jsonify([{'article_id': a, 'score': round(s, 4)} for a, s in scored[:top_n]]) finally: conn.close() if __name__ == '__main__': app.run(debug=True, port=5000)逻辑说明:先查画像,再召回最近 500 篇做候选池,逐篇算融合分,排序取 Top-N。候选池用「最近发布」是一种简单召回,实际可以换成「语义近邻召回 + 热门召回」多路合并。debug=True只在开发用,上线要关掉。
参数说明:top_n默认 10,前端可传;候选池 500 是经验值,太小推荐不够多样,太大响应变慢。融合权重 0.6/0.4 就是上面说的 α。
6.3 推荐结果去重与多样性控制
直接按分数排序容易出现「十篇全是同一分类」的情况,用户会觉得单调。加一层多样性控制:同一分类最多返回 3 篇,超出的往后排。实现上在排序后遍历,用计数器限制每类数量。这个改动很小,但体验提升明显,是性价比很高的一步。
7. 避坑与排查:这套系统最容易翻车的五个地方
7.1 中文乱码:数据库、连接、Python 三处编码不一致
现象:文章标题存进去变成问号或乱码。原因:MySQL 库、表、连接、Python 文件编码有一处不是 utf8mb4。解决:建库建表都指定utf8mb4,连接参数加charset='utf8mb4',Python 文件头声明编码,三处对齐。特别注意 emoji 和生僻字必须用 utf8mb4 而不是 utf8。
7.2 画像权重不更新:ON DUPLICATE KEY 没生效
现象:用户行为变了,推荐结果纹丝不动。原因:画像表主键没设成(user_id, tag)联合主键,ON DUPLICATE KEY UPDATE匹配不到,插入了重复行,查询时取到旧行。解决:确认联合主键存在,或者改用先 DELETE 再 INSERT 的写法。排查时直接SELECT * FROM user_profiles WHERE user_id=x看有没有重复 tag。
7.3 推荐全是热门:候选池召回太单一
现象:所有用户推荐结果高度雷同,都是那几篇爆款。原因:候选池只按发布时间或热度召回,没有个性化召回。解决:加一路「基于用户历史文章语义近邻」的召回,和热门召回合并去重,让候选池本身就带个性化。
7.4 接口越来越慢:连接池耗尽或全表扫描
现象:推荐接口响应从几十毫秒涨到几秒。原因:连接没归还导致池子耗尽,或者 behaviors 表没建索引导致全表扫描。解决:检查代码里conn.close()是否都在 finally 里;给 behaviors 的 user_id、article_id 建索引;用EXPLAIN看慢查询执行计划。
7.5 冷启动用户推荐为空:画像表没初始化
现象:新注册用户推荐列表空白。原因:画像表没有该用户记录,profile 字典为空,所有候选分都是 0。解决:注册时用 interest_tags 初始化画像,或在推荐接口里判断画像为空时回退到热门推荐。
8. 进阶技巧:用离线评估把推荐效果量化出来
做到这里系统能跑了,但「效果好不好」不能靠感觉。我一般会加一个离线评估脚本,用留一法验证:把每个用户最后一条读完行为藏起来,用之前的行为生成推荐,看藏起来的那篇有没有出现在 Top-N 里,命中率就是 Hit Rate。
def evaluate(hit_n=10): conn = get_conn() with conn.cursor() as cur: cur.execute("SELECT user_id, article_id FROM behaviors WHERE action_type=3") records = cur.fetchall() # 按用户分组,最后一条做测试 from collections import defaultdict user_hist = defaultdict(list) for r in records: user_hist[r['user_id']].append(r['article_id']) hit, total = 0, 0 for uid, arts in user_hist.items(): if len(arts) < 2: continue test = arts[-1] recs = get_recommend_ids(uid, hit_n) # 复用推荐逻辑 total += 1 if test in recs: hit += 1 print(f"HitRate@{hit_n} = {hit/total:.4f}")这个脚本的价值在于,你调 α、调 λ、换分词方案时,有个客观数字告诉你到底变好还是变坏。我踩过的最大坑就是凭感觉调参,改了半天以为变好了,一评估反而降了。有了 Hit Rate,每次改动前后跑一遍,涨了就留,跌了就回滚,比任何直觉都靠谱。
参数说明:hit_n就是推荐条数,一般和线上一致;样本太少的用户(行为少于 2 条)跳过,否则噪声大。评估集和训练集要按时间切分,别随机切,否则会数据泄漏,评估结果虚高。
我现在的习惯是:任何推荐相关的改动,先跑离线评估,数字不涨就不上线。这套基于 Python 的个性化阅读推荐系统,从建库到画像到语义到融合,每一层都能单独优化,而离线评估就是那根标尺。希望帮到你。
本文还有配套的精品资源,点击获取