Python图书推荐系统实战:协同过滤与文本相似度混合推荐

发布时间:2026/10/14 0:28:56
Python图书推荐系统实战:协同过滤与文本相似度混合推荐
简介面向图书推荐系统课程设计或毕业设计的Python完整项目源码基于协同过滤与文本相似度实现个性化推荐覆盖前台用户与后台管理两大块。包体内共1828个文件约218.71MB以jpg截图与png图片为主辅以py核心代码、html页面、css/js前端样式、csv数据与xml配置便于对照界面与逻辑调试。功能上实现用户注册登录、图书热度排行与分类展示、搜索与详情、推荐图书展示支持评论、点赞、收藏以及公告管理、用户等级与积分、历史阅读记录续读等后台含图书上下架批量导入、用户等级审核、作者认证、日志备份及多维度数据统计。此外还包含管理员操作界面与过程截图便于快速定位问题。目前已有1625人学习下载适合正在实现推荐算法或搭建图书管理系统的人群参考可直接运行并在此基础上扩展。1. 为什么值得下这个图书推荐系统不是单个算法是完整闭环这套 Python 实现图书推荐系统并不是只丢给你一个协同过滤函数就完事而是把用户、图书、评论、后台管理全部串起来的完整 Web 项目。它解决的是课程项目最常见的“算法能跑但产品不能看”的问题前端有热度排行、分类、搜索、图书详情、推荐位后台有图书批量导入、上下架、作者认证审核、用户等级编辑与数据统计。适合两类人一是在做毕业设计或课程设计需要能演示、能答辩的完整系统二是想从零搭一个推荐系统 Demo但不想重复造数据表和管理后台的开发者。下面从算法原理说到跑源码的坑全程可复现。2. 双路推荐协同过滤与文本相似度怎么配合2.1 用户协同过滤找口味相近的人基于用户的协同过滤User-Based Collaborative Filtering核心是“物以类聚人以群分”。假设用户 A 和用户 B 都读完了《三体》和《球状闪电》A 还收藏了《流浪地球》系统就会认为 B 和 A 的口味接近把《流浪地球》推到 B 的推荐列表里。这里不需要知道书的具体内容只需要用户和图书之间的交互记录评论、点赞、收藏、阅读时长都可以作为“隐式评分”。实际源码里交互数据通常会整理成一个字典结构user_id - {book_id: score}。score 可以是有无交互的 0/1也可以是点赞 1、收藏 2、评论 3 这样的加权分值。下面的代码就是最朴素的双重循环相似度计算from collections import defaultdict def user_similarity(interactions): # interactions: dict, user_id - {book_id: score} # 返回值 sim[u1][u2] 表示 u1 和 u2 的口味相似度 user_items {uid: set(items.keys()) for uid, items in interactions.items()} sim defaultdict(dict) for u1 in user_items: for u2 in user_items: if u1 u2: continue common user_items[u1] user_items[u2] if not common: continue # 余弦相似度交集大小 / 两个集合模长的乘积 norm1 len(user_items[u1]) ** 0.5 norm2 len(user_items[u2]) ** 0.5 sim[u1][u2] len(common) / (norm1 * norm2) return sim这段代码的逻辑很直白先算出每个用户交互过的图书 ID 集合然后两两比对。common是两个人共同读过的书数量越多相似度越高。norm1和norm2是集合模长用来抵消“读得多的人天然容易和别人有交集”的偏差。参数上要注意interactions的 score 如果不为 1那么上面这种集合取交集的方式会丢失权重信息。更稳妥的做法是直接用向量内积除以模长积比如用numpy把每个用户表示成一个稀疏向量再做余弦相似度。数据量大时双重循环是 O(N²)会非常慢。我在实际项目里会先用sklearn.metrics.pairwise.cosine_similarity或者numpy矩阵运算替代源码里为了可读性通常保留循环写法但也只适合几百个用户的演示场景。得到相似度后为目标用户找 Top-K 个最像的邻居再把这些邻居读过且目标用户没读过的书按相似度加权聚合生成推荐列表。这一步才是协同过滤真正产出的地方。2.2 文本相似度用书的内容找替代协同过滤有一个天生短板新书刚上架没有任何用户行为数据它就永远是“被遗忘的角落”。这时需要内容推荐兜底。文本相似度做的事情是把图书的标题、作者、分类、简介拼成一段文本用 TF-IDF 转成向量再计算书与书之间的余弦相似度。对一本冷门书在详情页下方展示“喜欢这本书的人也看了这些书”本质就是文本相似度推荐。下面是这个模块最常见的写法from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def build_book_sim_matrix(books): # books: list[dict]每个 dict 至少包含 id, title, category, description corpus [] for book in books: text .join([ book.get(title, ), book.get(category, ), book.get(description, ) ]) corpus.append(text) vectorizer TfidfVectorizer( token_patternr[\u4e00-\u9fa5a-zA-Z0-9], # 只保留中文、英文、数字 min_df1, max_df0.6 ) tfidf_matrix vectorizer.fit_transform(corpus) sim_matrix cosine_similarity(tfidf_matrix) return sim_matrix, vectorizer这个代码的关键在token_pattern。默认的正则会把中文整段当一个词导致“三体”和“三体全集”完全不相似。我一般会先用jieba.lcut做分词再把分词结果重新用空格拼接成 corpus最后交给TfidfVectorizer效果会好非常多。min_df1表示只要在文档里出现过一次的词就保留max_df0.6表示出现在超过 60% 文档里的词会被丢弃这类词通常是“的、了、是”之类的停用词或高频泛义词。注意cosine_similarity返回的矩阵很大N 本书就是 N×N。演示项目几百本书没问题上万本就得改成“预先计算 Top-N 相似书”存到数据库而不是每次请求都现算。2.3 混合策略得分归一化与加权两条推荐路径产出的分数尺度完全不同协同过滤的得分可能是相似度加权累加的浮点数范围不固定文本相似度的余弦值天然落在 [0, 1]如果 TF-IDF 不为负。直接把两个分数相加文本相似度会被协同过滤淹没。因此在源码里通常会对文本相似度先做 min-max 归一化再做加权融合。import numpy as np def hybrid_score(cf_score, ts_score_arr, alpha0.6): # alpha 是协同过滤的权重文本相似度权重为 1-alpha ts_min ts_score_arr.min() ts_max ts_score_arr.max() ts_norm (ts_score_arr - ts_min) / (ts_max - ts_min 1e-8) return alpha * cf_score (1 - alpha) * ts_norm参数alpha0.6表示更信任用户行为适合老用户、行为记录多的场景新用户冷启动时我会把它降到 0.30.4让文本内容多说话。1e-8是防止所有文本相似度相同时除数为 0。还有一个容易忽略的细节混合推荐前两条路径要过滤掉已经被用户看过或收藏过的书。否则推荐列表里反复出现用户已经读过的书演示时会显得特别不“智能”。常见做法是在最终列表里剔除用户交互记录里的 book_id这个逻辑可以在 SQL 层做也可以在内存里用 set 差集做。到这里双路推荐的骨架就清楚了协同过滤解决“和我像的人读了什么”文本相似度解决“这本书内容的近亲有哪些”最后的归一化加权把两者揉成一个可排序的分数。理解了这三层后面走读代码时就不会迷路。3. 把源码跑起来环境、初始化与后台数据准备3.1 依赖与 Python 版本选择这套源码是基于 Flask 写的 Web 应用前端用了 Bootstrap 系列的 CSS。导入图书用的 CSV 解析、文本相似度用的 scikit-learn都是独立模块。我建议先用 Python 3.9 或 3.10 跑不要直接上 3.12 或 3.13因为部分依赖的 wheel 在旧源码里没适配。依赖清单大致长这样Flask2.2.5 Flask-SQLAlchemy3.0.5 PyMySQL1.1.0 scikit-learn1.3.2 pandas2.0.3 jieba0.42.1 Werkzeug2.3.7安装时建议用虚拟环境python -m venv venv source venv/bin/activate # Windows 上执行 venv\Scripts\activate pip install -r requirements.txt注意PyMySQL记得在入口文件里写import pymysql; pymysql.install_as_MySQLdb()否则 Flask-SQLAlchemy 连接 MySQL 时会报找不到MySQLdb的错误。这是 Flask 项目最常见的启动翻车点之一后面避坑章节还会重点提。3.2 初始化数据库、创建管理员源码包里一般会带init_db.py和create_admin.py如果没有自己看一眼模型文件也能很快补出来。初始化命令通常是python init_db.py python create_admin.py --username admin --password 123456 python app.pyinit_db.py内部做的事其实只有三步读取 config 里的数据库连接、调用db.create_all()创建表、写入默认的图书分类数据。我在实际操作时建议先手动确认 MySQL 里已经建了库例如CREATE DATABASE book_reco DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;否则create_all会因为数据库不存在而静默失败或者创建出 latin1 字符集的表。create_admin.py的密码参数只适合本地测试。源码里如果用werkzeug.security.generate_password_hash存密码那么明文密码不会出现在数据库表里这属于及格线水平。如果发现源码里直接存明文那就自己改成哈希否则内网演示都不安心。3.3 导入图书数据和封面系统跑起来之后后台需要数据才能展示排行和搜索。源码通常提供一个import_books.py或后台批量导入入口支持 CSV。CSV 的列一般有title, author, category, description, cover_url。我整理过一个最小可用的导入脚本import csv from models import Book, db def import_books_from_csv(csv_path): # 不存在的分类自动创建存在的图书按标题去重 with open(csv_path, encodingutf-8-sig) as f: reader csv.DictReader(f) count 0 for row in reader: title row.get(title, ).strip() if not title: continue if Book.query.filter_by(titletitle).first(): continue book Book( titletitle, authorrow.get(author, ).strip(), categoryrow.get(category, ).strip(), descriptionrow.get(description, ).strip(), cover_urlrow.get(cover_url, ).strip() ) db.session.add(book) count 1 db.session.commit() return count if __name__ __main__: imported import_books_from_csv(books.csv) print(f成功导入 {imported} 本图书)这段代码的encodingutf-8-sig很关键用 Excel 导出的 CSV 会带 BOM 头如果不指定这个编码第一列标题会变成\ufefftitle直接导致列名不匹配。if Book.query.filter_by(titletitle).first()是去重图书名重复在真实线上系统里很常见宁可丢也不要重复导。封面图这一块建议直接存 URL 而不是上传本地文件。源码里如果自带cover_url字段只需要把图片放到 static 目录然后在后台批量更新路径即可。如果图片没显示先看浏览器 Console 里的 404 路径多半是static路径前缀没有对齐。3.4 后台管理图书上下架与用户等级后台路由一般在/admin下登录管理员后可操作图书管理、用户管理、系统管理。图书管理最重要的字段是is_active或status控制图书是否在前台展示。批量导入的图书默认可能是上架状态调试时建议先批量下架几本再在前台验证搜索与详情页面避免新人一上来看到大量脏数据。用户等级模块里普通用户、会员、认证作者是三个等级后台可以手动修改。认证作者提交后需要管理员审核。这里有一个容易忽略的关联逻辑认证作者创建图书后后台图书列表里要能按作者过滤否则审核员会在一堆书里找不到提交待审的那本。源码里如果没做过滤自己加一个query.filter(Book.author_id uid)就能解决。系统管理里有运行日志、数据备份恢复、统计图表。备份恢复通常在 SQLite 或 MySQL 之间切换时特别有用。演示答辩前记得做一次备份并把备份文件下载到本地防止现场误操作把数据清掉。4. 核心代码走读注册登录、图书展示、评论收藏4.1 用户注册登录与 session用户模块是整套系统的地基。源码里注册视图一般是/register登录是/login成功后会写入session[user_id]。用原始 Flask 写流程非常直白from werkzeug.security import generate_password_hash, check_password_hash from flask import session, request, redirect, url_for, flash app.route(/register, methods[GET, POST]) def register(): if request.method POST: username request.form.get(username, ).strip() password request.form.get(password, ) if len(username) 3 or len(password) 6: flash(用户名至少3位密码至少6位) return redirect(url_for(register)) if User.query.filter_by(usernameusername).first(): flash(用户名已存在) return redirect(url_for(register)) user User( usernameusername, level普通用户, points0 ) user.password_hash generate_password_hash(password) db.session.add(user) db.session.commit() session[user_id] user.id session[username] user.username return redirect(url_for(index)) return render_template(register.html)密码不是明文存到password字段而是用generate_password_hash生成哈希校验时用check_password_hash(user.password_hash, password)。这是安全底线。源码里如果发现直接存user.password password我建议立刻改成上面的写法否则后台一查库就能看到全部账号密码答辩时被问到安全设计会很尴尬。登录之后需要登录才能访问的接口一般用一个装饰器统一校验from functools import wraps def login_required(view): wraps(view) def wrapper(*args, **kwargs): if user_id not in session: flash(请先登录) return redirect(url_for(login)) return view(*args, **kwargs) return wrapper这个装饰器保护的是页面级路由。Ajax 接口点赞、收藏不能返回重定向而应该返回 JSON 状态码401否则前端 fetch 会拿到一段 HTML 而不是 JSON解析直接报错。这一点很多课程项目没处理好。4.2 图书排行、分类、搜索图书模块的核心是列表页。热度排行一般用read_count字段降序排列每打开一次详情页就给这个字段加 1。分类展示用category精确过滤。搜索是模糊匹配源码里如果用 Flask-SQLAlchemy最简单的写法是Book.title.contains(keyword)。一个典型的列表视图如下app.route(/books) login_required def book_list(): category request.args.get(category, , typestr) keyword request.args.get(q, , typestr).strip() page request.args.get(page, 1, typeint) query Book.query.filter(Book.is_active True) if category: query query.filter(Book.category category) if keyword: query query.filter( db.or_(Book.title.contains(keyword), Book.author.contains(keyword)) ) # 先按热度排再按上架时间排 pagination query.order_by( Book.read_count.desc(), Book.created_at.desc() ).paginate(pagepage, per_page12) return render_template(books.html, paginationpagination)参数说明typestr和typeint是 Flask 在参数类型错误时直接返回 400 的兜底策略db.or_让关键词可以同时命中书名和作者is_active True保证下架书不会出现在搜索和排行里这是后台管理的边界。搜索这里有一个常见体验问题关键词是空字符串时会用%当 LIKE 条件返回所有结果。所以代码里先.strip()再判断空关键词直接不拼查询条件。日志里如果出现“搜索关键词空但 SQL 还是查了全表”多半就是这个原因。详情页除了展示图书信息还会调用第 2 章的混合推荐函数生成“相关图书”列表。源码如果没做这个可以在详情页模板里手动渲染一个similar_books变量从文本相似度矩阵取 Top 5。4.3 评论、点赞、收藏与个人中心评论模块包含展示、发布、修改。图书详情页应该传所有已审核评论表单提交评论时要把book_id和user_id都写入。评论修改我见过两种实现一种是在评论旁边放“编辑”按钮原地变成表单另一种是跳转到独立编辑页。源码里如果是前者注意只能让评论作者本人看到编辑按钮。点赞和收藏在源码里通常是同一种交互模式的两种数据表点赞表存user_id, book_id, created_at收藏表多一个状态字段方便取消。用 Flask 写 JSON 接口时我推荐这种写法app.route(/book/int:book_id/favorite, methods[POST]) login_required def toggle_favorite(book_id): book Book.query.get_or_404(book_id) fav Favorite.query.filter_by( user_idsession[user_id], book_idbook.id ).first() if fav: db.session.delete(fav) db.session.commit() return {code: 0, msg: 已取消收藏, book_id: book.id} fav Favorite(user_idsession[user_id], book_idbook.id) db.session.add(fav) db.session.commit() return {code: 1, msg: 已收藏, book_id: book.id}get_or_404让图书不存在时直接返回 404 页面返回的code字段用 0 和 1 表示取消与成功前端拿到后切换按钮样式。很多课程项目会在点赞接口里返回整页 HTML 再跳转这种交互在演示时显得非常笨重不推荐。个人中心是这些功能汇总的地方用户基本信息、等级、积分、推荐图书、点赞收藏评论过的书、阅读记录续读。阅读记录实现方式是每次访问详情页在history表里 upsert 一条(user_id, book_id, updated_at)续读就是取出最后一条。积分则会根据用户行为增加注册送 100 分评论 5 分收藏 2 分签到 1 分。积分数值直接影响用户等级是否可升级源码里通常在modify_points方法里统一处理避免散落各处导致积分对不上。5. 避坑与排查跑源码最容易翻车的五个点5.1 Token 失效登录态与 Ajax 请求现象前端页面能打开但点击收藏、点赞按钮接口一直返回 401 或重定向登录页浏览器 Console 里能看到请求被跳转到/login页面。原因Ajax 请求默认不带fetch(..., {credentials: include})浏览器的 SameSite 策略也可能拦截 session 传递。如果用XMLHttpRequest且后端设置了跨域相关配置session 更容易丢。解决前端统一在 fetch 里加上credentials: include后端把SESSION_COOKIE_SAMESITE设为Lax并检查SESSION_COOKIE_HTTPONLY不必关闭。如果前后端端口不同还要在 Flask 配置里设置CORS支持允许携带凭证。调通之后用浏览器无痕窗口再点一次收藏确认不再跳登录页。5.2 中文乱码数据库字符集与 HTML 编码现象注册中文用户名成功后数据库里显示一串问号或页面显示乱码。原因MySQL 建表时默认字符集是latin1应用连接串也没有指定字符集。Flask 与 MySQL 之间传输中文时编码不一致直接变成问号。解决重新建库指定utf8mb4连接串写成mysqlpymysql://user:passhost/dbname?charsetutf8mb4同时在 HTML 模板的head里写meta charsetutf-8。数据库和代码层面全部对齐后乱码立刻消失。已经写入的脏数据只能删除重导。5.3 相似度全为 0文本预处理现象文本相似度推荐位置是空的或者每本书都推同一批热门书。原因TfidfVectorizer的默认token_pattern对中文无效整句被切成一个 token不同书名之间 token 几乎不相同余弦相似度全部接近 0。解决在生成 corpus 前用jieba.lcut分词或者把token_pattern改为支持中文的正则。我常用的配置是token_patternr[\u4e00-\u9fa5a-zA-Z0-9]并加入自定义停用词表过滤“这本、一部、讲述”之类的高频泛义词。改完重新构建相似度矩阵再看详情页相关图书就有明显变化。5.4 浏览器缓存样式更新不生效现象改完 CSS 后刷新页面界面还是旧样式强制 CtrlF5 才恢复。这不是后端逻辑问题但演示时非常容易卡住。原因浏览器把静态资源缓存住了Flask 的静态文件路径没带版本参数。解决在模板里把静态资源引用改成{{ url_for(static, filenamecss/style.css, v1.0) }}每次改版换v参数。开发时也可以临时关闭浏览器缓存。这个坑和推荐算法无关但不注意会浪费大量排查时间。5.5 文件路径Windows 与 Linux 的坑现象本地 Windows 上源码跑得好好的部署到 Linux 服务器后导入 CSV 或读取封面图时路径报错导出备份文件找不到目录。原因硬编码了\路径分隔符或者用了绝对路径。解决用pathlib.Path统一处理路径不建议手写os.path.join以外的字符串拼接。数据库文件和数据备份路径都改成相对当前项目目录的config配置。导入 CSV 时用Path(csv_path).resolve()转换避免相对路径在不同工作目录下解析不一致。6. 进阶验证推荐质量怎么评估与调优6.1 离线评估留一法与准确率召回率推荐系统调参不能靠肉眼感觉。源码跑通后我会先做一次留一法离线评估把用户最新一次交互的图书作为“真相”用之前的交互生成推荐看这条真相有没有落在推荐列表前 N 位。评估脚本很短def evaluate_recall_topn(recall_fn, user_history, ground_truth, top_n5): # recall_fn 返回推荐书 id 列表user_history 是用户在切割前已有的交互 hit 0 total 0 for uid, truth_book in ground_truth: pred_books recall_fn(uid, user_history[uid])[:top_n] if truth_book in pred_books: hit 1 total 1 return hit / total if total else 0top_n5表示看前 5 个推荐位。连续跑几组不同alpha参数把命中率列表拉出来对比就能找到当前数据集下的较优权重比在页面上反复刷新直观得多。6.2 冷启动新书新用户如何处理新用户没有行为数据协同过滤算不出邻居。这时先把权重切到文本相似度同时按热度榜兜底。新书没有评论和点赞就只靠内容特征和它所在分类的默认推荐位。源码里如果没做冷启动分支最简单的做法是在推荐入口判断用户历史交互数量小于 5 时跳过协同过滤直接返回文本相似度结果加上热门榜。6.3 一次调优记录从梯度到收敛我在某一次测试中发现把alpha从 0.8 降到 0.6 后详情页相关推荐点击率提升明显但首页推荐却变差了。原因是首页用户行为充足协同过滤更准详情页用户大多是想看同类书的“内容型读者”文本相似度更符合意图。于是我把混合权重改成按页面类型分别设置首页alpha0.7详情页alpha0.4。从那以后我每次改推荐逻辑都强制走一遍离线评估再加一次线上对照不再凭感觉调参希望帮到你。本文还有配套的精品资源点击获取