简介:这份资源是面向高校计算机相关专业毕业设计的完整项目包,主题为Python+Vue基于协同过滤算法的图书推荐系统,适合需要完成推荐系统类毕设、希望掌握前后端分离开发与机器学习算法落地的学生参考。包内共346个文件,涵盖55个Vue组件、47个JavaScript脚本、32个Python源码、19个CSS样式、2个SQL建表脚本及若干图片与配置文件,压缩包约24.6MB,并附数据库文档与演示视频。系统围绕用户模块、图书模块、推荐算法模块和推荐结果展示模块展开,协同过滤部分涉及用户基于、物品基于及混合型三种思路,后端可用Flask或Django提供RESTful API,前端由Vue.js构建交互界面。数据库文档详细说明表结构、字段类型、索引与主外键关系,演示视频直观呈现系统操作流程。目前已有80人学习,适合作为毕设选题落地的完整参考方案。
1. 图书推荐系统为什么在毕业设计里反复被选中:从协同过滤到前后端分离
做过几年企业项目再回头看毕业设计选题,图书推荐系统几乎是每年都会被翻牌子的方向。原因不复杂:图书数据天然适合做推荐,用户对书的评分、借阅、收藏行为能直接转成协同过滤算法需要的用户-物品矩阵;同时这个题目又能把 Python 后端、Vue 前端、数据库设计、算法实现串成一条完整链路,工作量饱满又不至于失控。但真正让这个选题值得投入的,是它踩中了推荐系统最核心的工程问题——冷启动、稀疏矩阵、相似度计算、实时推荐与离线推荐的取舍。这些不是教科书上的名词,而是你写完代码跑起来之后,看着推荐结果全是同一本书或者干脆推不出来时,才会真正理解的东西。这篇笔记就按一线落地的思路,把「Python + Vue + 协同过滤 + 图书推荐」这条线从选型、建库、算法实现到前后端联调、避坑,完整走一遍。适合正在做毕业设计、想把这个题目做出工程质感而不是只交个 demo 的读者。
2. 协同过滤的两种路线与图书场景的选型:UserCF 还是 ItemCF
2.1 图书推荐里 UserCF 和 ItemCF 的本质差别
协同过滤分两大流派:基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)。UserCF 的思路是「和你口味相似的人还喜欢什么」,ItemCF 的思路是「喜欢这本书的人还喜欢什么」。放到图书场景里,这个差别会被放大。
图书有一个很明显的特征:长尾极长,热门书集中度高,但大量冷门书只有个位数评分。UserCF 依赖用户之间的相似度,当用户数量不大、评分矩阵稀疏时,用户相似度计算会非常不稳定——两个用户可能只因为共同评了一本热门书就被判定为高度相似,推荐结果会严重偏向热门书。ItemCF 则依赖物品之间的相似度,图书的相似关系相对稳定,一本《算法导论》和《数据结构与算法分析》的关联不会因为用户群体变化而剧烈波动。所以在图书推荐这个场景,ItemCF 通常是更稳的选择,尤其是数据量在几千到几万条评分级别时。
但 UserCF 也不是不能用。如果你的系统有社交属性,比如用户能关注别人、看到别人的书单,那 UserCF 的「相似用户推荐」在解释性上更强,用户更容易理解「因为你关注的人也在读」。毕业设计里常见做法是两者都实现,用开关切换,答辩时能讲清楚各自适用边界,这本身就是加分项。
2.2 相似度计算:余弦、皮尔逊和调整余弦怎么选
相似度是协同过滤的发动机。常见的有余弦相似度、皮尔逊相关系数、调整余弦相似度。图书评分通常是 1 到 5 的整数,用户打分习惯差异很大——有人习惯全打 4 分,有人只打 1 分和 5 分。皮尔逊相关系数会减去用户平均分,能部分消除这种打分偏置,在图书评分场景下往往比裸余弦更合理。
调整余弦相似度(Adjusted Cosine)则是从物品角度减去物品平均分,适合 ItemCF。实际落地时我一般会先算皮尔逊,如果发现推荐结果同质化严重,再切到调整余弦对比。下面这段代码是相似度计算的核心,用 Python 的 numpy 做矩阵运算,避免双重循环拖慢速度。
import numpy as np def pearson_similarity(matrix): """ matrix: 用户-物品评分矩阵,行是用户,列是物品,缺失值为 0 返回物品之间的皮尔逊相似度矩阵 """ # 转置成物品-用户,方便按物品算相似度 item_user = matrix.T n_items = item_user.shape[0] sim = np.zeros((n_items, n_items)) for i in range(n_items): for j in range(i + 1, n_items): vec_i = item_user[i] vec_j = item_user[j] # 只取两个物品都有评分的用户 mask = (vec_i > 0) & (vec_j > 0) if mask.sum() < 2: continue a = vec_i[mask] b = vec_j[mask] # 减去各自均值,消除打分偏置 a_mean = a.mean() b_mean = b.mean() num = np.sum((a - a_mean) * (b - b_mean)) den = np.sqrt(np.sum((a - a_mean) ** 2)) * np.sqrt(np.sum((b - b_mean) ** 2)) if den == 0: continue sim[i][j] = sim[j][i] = num / den return sim这段代码里mask.sum() < 2是个关键判断:共同评分用户少于 2 个时,皮尔逊相关系数没有统计意义,直接跳过比强行算出一个噪声值更安全。a_mean和b_mean分别减去的是当前物品在共同评分用户上的均值,这就是皮尔逊和裸余弦的区别所在。实际跑的时候如果物品数量上万,这个双重循环会明显变慢,可以先用稀疏矩阵过滤掉评分数量过少的物品,再对剩下的做相似度计算。
2.3 评分预测与 Top-N 推荐:从相似度到推荐列表
有了相似度矩阵,下一步是预测用户对未评分物品的分数,然后取 Top-N。ItemCF 的预测公式是:用用户已评分物品与目标物品的相似度做加权平均。这里有个工程细节——相似度不能直接用,通常要设一个阈值或者只取 Top-K 个最相似物品,否则长尾物品的噪声会污染预测。
def predict_rating(user_id, item_id, matrix, sim, k=20): """ 预测用户对某个物品的评分 k: 只取最相似的 k 个物品参与加权 """ user_ratings = matrix[user_id] rated_items = np.where(user_ratings > 0)[0] if len(rated_items) == 0: return 0 sims = sim[item_id][rated_items] # 取相似度最高的 k 个,且相似度必须为正 top_k_idx = np.argsort(sims)[::-1][:k] top_k_idx = [i for i in top_k_idx if sims[i] > 0] if not top_k_idx: return 0 numerator = 0 denominator = 0 for idx in top_k_idx: s = sims[idx] r = user_ratings[rated_items[idx]] numerator += s * r denominator += abs(s) return numerator / denominator if denominator != 0 else 0k=20是经验值,图书场景下通常 10 到 30 之间比较稳。sims[i] > 0这个过滤很重要,负相似度意味着「喜欢 A 的人往往不喜欢 B」,在推荐里直接加权会把分数拉低甚至推错,一般直接丢弃。预测出分数后,对用户所有未评分物品排序取前 N 个,就是推荐列表。实际系统里还会加一层过滤,比如已经借阅过的书不再推荐、库存为 0 的书不推。
3. 从零搭起图书推荐系统的数据层与后端:表结构、接口和算法服务
3.1 数据库表设计:用户、图书、评分、借阅四张核心表
图书推荐系统的数据层不复杂,但字段设计直接影响后面算法能不能跑通。核心四张表:用户表、图书表、评分表、借阅记录表。评分表是协同过滤的燃料,借阅记录可以作为隐式反馈补充。下面给出 MySQL 建表语句,字段类型和索引都按实际查询场景调过。
CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(128) NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `book` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL, `author` VARCHAR(100), `category` VARCHAR(50), `isbn` VARCHAR(20), `stock` INT DEFAULT 0, INDEX `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `rating` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `book_id` INT NOT NULL, `score` TINYINT NOT NULL COMMENT '1-5 分', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_book` (`user_id`, `book_id`), INDEX `idx_book` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `borrow` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `book_id` INT NOT NULL, `borrow_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `return_time` DATETIME, INDEX `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;rating表的uk_user_book唯一索引保证一个用户对一本书只有一条评分,避免重复评分把矩阵搞乱。idx_book是为了按物品查评分时走索引。borrow表没有加唯一约束,因为同一本书可以多次借还。实际做推荐时,评分数据是显式反馈,借阅数据可以转成隐式反馈——借了但没评分,可以给一个默认分比如 3 分,或者只用来做召回不做排序。
3.2 Flask 后端接口:推荐、评分、图书列表三条主线
后端用 Flask 足够轻量,毕业设计不需要上 Django 那么重。接口分三块:图书列表和详情、用户评分提交、推荐结果获取。推荐接口是核心,它要调用算法模块,把离线算好的相似度矩阵加载进来做在线预测。下面是一个精简的 Flask 应用结构。
from flask import Flask, request, jsonify from flask_cors import CORS import numpy as np import pymysql app = Flask(__name__) CORS(app) # 允许 Vue 前端跨域调用 # 全局缓存相似度矩阵和评分矩阵,实际项目可以放 Redis sim_matrix = None rating_matrix = None def load_data(): """从数据库加载评分数据,构建矩阵""" global sim_matrix, rating_matrix conn = pymysql.connect(host='localhost', user='root', password='123456', database='book_rec') cursor = conn.cursor() cursor.execute("SELECT user_id, book_id, score FROM rating") rows = cursor.fetchall() conn.close() if not rows: return max_user = max(r[0] for r in rows) + 1 max_book = max(r[1] for r in rows) + 1 rating_matrix = np.zeros((max_user, max_book)) for uid, bid, score in rows: rating_matrix[uid][bid] = score # 这里调用第 2 章的相似度函数 sim_matrix = pearson_similarity(rating_matrix) @app.route('/api/recommend/<int:user_id>') def recommend(user_id): if rating_matrix is None: load_data() if rating_matrix is None or user_id >= rating_matrix.shape[0]: return jsonify({'code': 1, 'msg': '无数据', 'data': []}) user_ratings = rating_matrix[user_id] rated = set(np.where(user_ratings > 0)[0]) scores = [] for bid in range(rating_matrix.shape[1]): if bid in rated: continue pred = predict_rating(user_id, bid, rating_matrix, sim_matrix) if pred > 0: scores.append((bid, pred)) scores.sort(key=lambda x: x[1], reverse=True) top_n = scores[:10] return jsonify({'code': 0, 'data': [{'book_id': b, 'score': round(s, 2)} for b, s in top_n]}) @app.route('/api/rating', methods=['POST']) def add_rating(): data = request.json conn = pymysql.connect(host='localhost', user='root', password='123456', database='book_rec') cursor = conn.cursor() cursor.execute( "INSERT INTO rating (user_id, book_id, score) VALUES (%s, %s, %s) " "ON DUPLICATE KEY UPDATE score = VALUES(score)", (data['user_id'], data['book_id'], data['score']) ) conn.commit() conn.close() # 评分更新后重新加载矩阵,实际项目应该异步做 load_data() return jsonify({'code': 0, 'msg': 'ok'}) if __name__ == '__main__': load_data() app.run(debug=True, port=5000)CORS(app)是前后端分离必须的,否则 Vue 跑在 8080 端口调 5000 会被浏览器拦。load_data里用max_user + 1和max_book + 1确定矩阵大小,前提是用户和图书 ID 从 0 或 1 连续分配,如果 ID 有空洞会浪费内存,实际项目可以用 ID 映射表压缩。ON DUPLICATE KEY UPDATE保证重复评分是更新而不是插入新行。评分后重新load_data在数据量大时会卡接口,生产环境应该用消息队列异步重算,毕业设计里数据量小可以接受。
3.3 离线计算与在线推荐的衔接:预计算相似度矩阵
协同过滤的相似度矩阵计算是 O(n²) 复杂度,物品上千时在线算根本来不及。标准做法是离线定时任务算好相似度矩阵,存到文件或 Redis,在线接口只做查表和加权。下面是一个离线计算脚本,用 crontab 每天凌晨跑一次。
import numpy as np import pymysql import pickle def offline_compute(): conn = pymysql.connect(host='localhost', user='root', password='123456', database='book_rec') cursor = conn.cursor() cursor.execute("SELECT user_id, book_id, score FROM rating") rows = cursor.fetchall() conn.close() max_user = max(r[0] for r in rows) + 1 max_book = max(r[1] for r in rows) + 1 matrix = np.zeros((max_user, max_book)) for uid, bid, score in rows: matrix[uid][bid] = score sim = pearson_similarity(matrix) # 持久化,在线服务直接加载 with open('sim_matrix.pkl', 'wb') as f: pickle.dump({'sim': sim, 'matrix': matrix}, f) print(f'相似度矩阵计算完成,物品数 {max_book}') if __name__ == '__main__': offline_compute()pickle存 numpy 数组比存数据库快得多,在线服务启动时pickle.load一次加载到内存。注意max_book如果很大,相似度矩阵是max_book × max_book的稠密矩阵,内存占用是平方级。图书数量超过 5000 时就要考虑稀疏存储或者只保留 Top-K 相似邻居,否则内存会爆。这是很多毕业设计跑着跑着内存溢出的根因。
4. Vue 前端怎么把推荐结果讲清楚:列表渲染、评分交互和状态管理
4.1 推荐列表组件:从接口到卡片渲染
Vue 前端核心是把后端返回的book_id和score渲染成用户能看懂的推荐卡片。因为推荐接口只返回 ID 和分数,前端还需要调图书详情接口补全书名、作者、封面。常见做法是后端推荐接口直接联表返回完整信息,减少前端请求次数。下面是一个 Vue 3 组合式 API 的推荐列表组件。
<template> <div class="recommend-list"> <h3>为你推荐</h3> <div v-if="loading" class="loading">加载中...</div> <div v-else class="cards"> <div v-for="item in books" :key="item.book_id" class="card"> <img :src="item.cover || defaultCover" alt="封面" /> <div class="info"> <h4>{{ item.title }}</h4> <p>{{ item.author }}</p> <span class="score">推荐指数 {{ item.score }}</span> <button @click="rate(item.book_id, 5)">五星好评</button> </div> </div> </div> </div> </template> <script setup> import { ref, onMounted } from 'vue' import axios from 'axios' const books = ref([]) const loading = ref(true) const defaultCover = '/static/default-cover.png' const userId = 1 // 实际项目从登录态取 const fetchRecommend = async () => { loading.value = true try { const res = await axios.get(`/api/recommend/${userId}`) if (res.data.code === 0) { // 推荐接口返回 book_id 和 score,这里再补全图书信息 const detailList = await Promise.all( res.data.data.map(item => axios.get(`/api/book/${item.book_id}`).then(r => ({ ...r.data.data, score: item.score })) ) ) books.value = detailList } } finally { loading.value = false } } const rate = async (bookId, score) => { await axios.post('/api/rating', { user_id: userId, book_id: bookId, score }) fetchRecommend() // 评分后刷新推荐 } onMounted(fetchRecommend) </script>Promise.all并发请求图书详情比串行快很多,但要注意如果推荐 10 本书就是 10 个请求,实际项目应该让后端推荐接口直接返回完整字段。rate之后调fetchRecommend刷新列表,这样用户能立刻看到评分对推荐结果的影响,答辩演示时效果很好。userId硬编码是毕业设计常见简化,实际应该从登录后存的 token 或 Vuex/Pinia 里取。
4.2 评分交互与用户反馈闭环
评分是协同过滤的数据来源,前端评分交互设计直接影响数据质量。常见做法是星级评分组件,鼠标悬停高亮、点击提交。但有个坑:用户可能反复修改评分,如果每次修改都触发后端重算相似度矩阵,接口会非常慢。合理做法是前端做防抖,或者后端只更新评分表,相似度矩阵按固定周期离线重算。
import { ref } from 'vue' import axios from 'axios' export function useRating(userId) { const submitting = ref(false) const submitRating = async (bookId, score) => { if (submitting.value) return submitting.value = true try { await axios.post('/api/rating', { user_id: userId, book_id: bookId, score: score }) return true } catch (e) { console.error('评分失败', e) return false } finally { submitting.value = false } } return { submitting, submitRating } }submitting锁防止用户快速连点造成重复提交。评分接口返回后不立即重算推荐,而是等用户刷新页面或手动点「刷新推荐」再拉新结果,这样后端压力小很多。如果要做实时推荐,可以把重算逻辑放到 Celery 异步任务里,接口只负责写库和触发任务。
4.3 用 Pinia 管理用户状态和推荐缓存
Vue 3 项目里用户登录态、推荐列表、评分记录这些跨组件状态,用 Pinia 管理比 props 层层传递干净得多。推荐列表缓存还能避免每次切页面都重新请求。
import { defineStore } from 'pinia' import axios from 'axios' export const useBookStore = defineStore('book', { state: () => ({ userId: null, recommendList: [], lastFetchTime: 0 }), actions: { async fetchRecommend(force = false) { const now = Date.now() // 5 分钟内不重复请求 if (!force && now - this.lastFetchTime < 5 * 60 * 1000 && this.recommendList.length) { return } const res = await axios.get(`/api/recommend/${this.userId}`) if (res.data.code === 0) { this.recommendList = res.data.data this.lastFetchTime = now } } } })lastFetchTime做简单缓存,5 分钟内切回推荐页不会重新请求。force参数用于用户主动刷新。这个模式在毕业设计里足够用,也方便答辩时讲「前端状态管理」这个点。
5. 避坑与排查:协同过滤图书推荐系统最常见的五个翻车现场
5.1 推荐结果全是热门书
现象:不管哪个用户,推荐列表前几名永远是那几本评分人数最多的书。原因:相似度计算时没有对热门物品做惩罚,热门书和几乎所有书都有共同评分用户,相似度虚高。解决:在相似度公式里除以log(1 + 物品评分人数)做热度惩罚,或者在召回阶段先过滤掉评分人数超过阈值的书。这是推荐系统里经典的流行度偏置问题,答辩时能讲清楚就是亮点。
5.2 新用户登录后推荐为空
现象:刚注册的用户,评分表里没有记录,推荐接口返回空列表。原因:协同过滤本质是基于历史行为的,没有行为就没有推荐。解决:冷启动用热门榜兜底,或者让新用户注册时选几个感兴趣的图书分类,用基于内容的推荐先撑过冷启动期。实际项目里常见做法是「热门 + 分类偏好」混合,等用户评分超过 5 条再切到协同过滤。
5.3 相似度矩阵内存溢出
现象:后端启动时加载相似度矩阵,图书数量到几千本时进程直接 OOM。原因:max_book × max_book的稠密矩阵,5000 本书就是 2500 万个 float,约 200MB,加上中间计算副本很容易爆。解决:只保留每个物品的 Top-K 相似邻居,用稀疏字典存储;或者用float32代替默认float64,内存直接减半。图书超过 1 万本时建议上 Faiss 或 Annoy 做近似最近邻。
5.4 评分数据更新后推荐不刷新
现象:用户提交评分后,推荐列表还是旧的。原因:相似度矩阵和评分矩阵是启动时加载到内存的,评分写库后没有重新加载。解决:评分接口写库后触发一次轻量重载,或者用定时任务每 10 分钟重算。如果要做实时,把重算逻辑放异步任务,接口立即返回,前端轮询或 WebSocket 通知刷新。毕业设计里定时重算最简单也最稳。
5.5 前后端跨域和端口配置踩坑
现象:Vue 前端调 Flask 接口报 CORS 错误,或者请求发到了错误端口。原因:开发环境 Vue 默认 8080,Flask 默认 5000,浏览器同源策略拦截。解决:Flask 端加flask-cors并CORS(app),或者 Vue 的vue.config.js里配devServer.proxy把/api代理到 5000。生产环境用 Nginx 反代,前端静态资源和后端接口同域,就不存在跨域问题。这个坑几乎每个前后端分离项目都会踩一次。
6. 让推荐结果可解释、可验证:离线评估指标与一个提升命中率的小技巧
推荐系统做完能跑只是第一步,答辩时老师一定会问「你怎么知道推荐得准」。这就需要离线评估。最常用的指标是命中率(Hit Rate)和 NDCG。做法是把用户评分数据按时间切分,前 80% 做训练,后 20% 做测试,看推荐列表里有多少命中了测试集中的书。
def hit_rate(test_data, recommend_dict, top_n=10): """ test_data: {user_id: set(book_id)} 测试集 recommend_dict: {user_id: [book_id]} 推荐结果 """ hits = 0 total = 0 for uid, true_books in test_data.items(): if uid not in recommend_dict: continue rec = recommend_dict[uid][:top_n] hits += len(set(rec) & true_books) total += len(true_books) return hits / total if total else 0这个指标算出来一般在 0.1 到 0.3 之间算正常,图书场景因为长尾严重,命中率不会太高。如果低于 0.05,大概率是相似度计算或者 Top-N 选取有问题。
一个提升命中率的实用技巧:在评分预测时,对用户已经评分过的物品相似度做时间衰减。用户三个月前打的 5 分和昨天打的 5 分,对当前推荐的价值不一样。给评分加一个exp(-λ * 天数差)的权重,λ 取 0.01 左右,能让推荐更贴近用户近期兴趣。这个改动很小,但在图书场景里往往能带来几个百分点的命中率提升。
我自己做这类系统最大的教训是:不要一上来就调算法参数,先把数据质量盯住。评分表里如果有大量用户随手打的 3 分、或者测试账号刷的假数据,再好的算法也救不回来。每次跑评估前先看一眼评分分布,比调参有用得多。希望帮到你。
本文还有配套的精品资源,点击获取