
简介这是一套完整的图书推荐系统毕业设计资源基于Django框架开发使用MySQL存储豆瓣图书与评分数据核心算法为基于商品的协同过滤Item-based CF。项目代码结构清晰包含Python源码、数据文件、HTML模板、CSS样式及说明文档压缩包共52个文件约797KB其中Python脚本约20个覆盖模型、视图、算法与配置模块可直接运行或二次开发。资源适合计算机相关专业学生用于课程设计、毕业设计或算法学习也可作为推荐系统入门实践项目。作者已在答辩中获得较高评价运行稳定性有保障。目前已有78人浏览下载如果你在环境配置、数据库导入或算法原理方面有疑问下载后可联系作者获取远程指导。1. 基于商品的协同过滤图书推荐系统这套代码能直接当毕设吗我拿到这套「Python 基于商品的协同过滤算法实现图书推荐系统」压缩包时第一反应是它的结构非常典型的毕设产物Django 写 Web 壳豆瓣图书评分数据当原料基于商品ItemCF的协同过滤算法在中间算推荐。项目不是空壳演示源码、uid_score_bid数据集、README 文档都齐全适合正在做推荐系统毕设、课程设计或者想从零看一个推荐算法怎么被塞进可访问网站里的新手。对想快速搭图书推荐 demo 的从业者来说这也是能直接改的骨架。下面从算法、部署、验证到踩坑把它拆开讲。2. ItemCF 算法核心评分矩阵、相似度矩阵与 TopN 推荐的代码路径资源里最容易被忽略的文件是basedUserCF.py。名字写着 UserCF但按项目标题和数据表结构看它做的是基于商品的协同过滤也就是 ItemCF。这一章把这条算法链路完整拆开你读完就知道用这份数据到底该怎么算物品相似度、怎么生成推荐列表。2.1 为什么图书场景选 ItemCF 而不是 UserCF协同过滤分两派UserCF 找“和我口味相似的人”ItemCF 找“和我读过书相似的书”。图书推荐场景里用户数量通常远大于物品数量UserCF 需要实时计算用户-用户相似度用户一旦增长矩阵膨胀得很快而 ItemCF 的物品相似度矩阵可以离线算好线上只做查表打分。图书本身还有一个特性品类稳定、长尾明显。《三体》和《球状闪电》的相似关系不会因为新增几个用户就剧烈变化所以 ItemCF 有天然的解释性优势——推荐结果可以说成“看过这本书的人也看了那本”答辩时比较好讲故事。新评分对 ItemCF 的影响是局部的只更新涉及物品的相似度行这对离线定时重算来说非常友好。2.2 原始评分数据长什么样先说 uid_score_bid项目里的数据文件叫uid_score_bid从名字看就是三列用户 ID、评分、图书 ID。常见的格式是 tab 分隔、没有表头第一行长这样import pandas as pd # 按 tab 分隔读入手动指定列名 df pd.read_csv(uid_score_bid, sep\t, names[uid, score, bid]) print(df.head()) print(df.shape)读取后先看df.shape和df.dtypes。这里有个常见坑bid和uid到底是字符串还是数字会影响后面 groupby 的结果。稳妥做法是让uid、bid保留原始类型只把score转成 float。如果发现同一对(uid, bid)出现多行说明采集时同一用户多次打分先做去重再往下走。# 同一用户对同一本书可能有多条评分记录取最大值作为最终偏好 df_dedup df.groupby([uid, bid], as_indexFalse)[score].max() print(df_dedup.shape)这里取max的逻辑是用户多次打分可能来自不同批次爬取最高分代表他愿意给的最高评价这也是协同过滤里最常用的去重策略之一。2.3 构造用户-物品评分矩阵pivot_table 的两个细节接下来把三列数据变成标准的评分矩阵行是用户、列是图书、值是评分。没有评分的位置填 00 表示“没有交互”。# 生成用户-物品评分矩阵行索引是 uid列名是 bid user_item df_dedup.pivot_table(indexuid, columnsbid, valuesscore) # 空缺位置补 0sim 计算时用 user_item user_item.fillna(0) print(user_item.shape) # 行数 用户数列数 图书数pivot_table的默认聚合函数是mean但前面已经做过max去重这里每个格子只有一个值不会有冲突。fillna(0)让矩阵变成稠密的二维数组后面算相似度可以直接做矩阵乘法代价是内存占用变大如果图书有上万本二维数组会膨胀后面避坑章会专门讲。2.4 物品相似度计算余弦相似度的两种工程写法物品相似度最常用的度量是余弦相似度把每本书被所有用户的评分当成一个向量两个向量的夹角越小说明评分模式越接近。这里给出一种不依赖 sklearn、只用 numpy 的写法方便你改代码时理解每一步在干什么。import numpy as np # user_item.T 把矩阵转成「物品 x 用户」每一行是一本书 item_vec user_item.T.values # 对每一行做 L2 归一化归一化后行向量点积就是余弦相似度 norm np.linalg.norm(item_vec, axis1, keepdimsTrue) norm[norm 0] 1e-6 # 零向量保护避免除 0 item_vec item_vec / norm # 物品相似度矩阵sim[i][j] 表示物品 i 和物品 j 的余弦相似度 sim item_vec item_vec.T # 自己对相似度置 0避免推荐列表被原书自己刷屏 np.fill_diagonal(sim, 0)逻辑说明norm是每本书评分向量的模长归一化后item_vec item_vec.T的结果就是余弦相似度矩阵因为归一化向量的点积等于夹角余弦。np.fill_diagonal(sim, 0)很关键不置 0 的话一本书和它自己的相似度是 1推荐时永远排在第一位。如果数据量大可以把sim转成scipy.sparse.csr_matrix只保留每行相似度最高的前几十个邻居能省大量内存。2.5 从相似度矩阵到推荐列表完整函数与参数含义物品相似度算完推荐就是一次矩阵乘法的事。对目标用户把他对所有书的评分向量乘以相似度矩阵得到每本书的预测分再排除已经读过的书取 TopN。def recommend(uid, user_item, sim, top_k10): if uid not in user_item.index: return [] # 新用户没有评分记录返回空 user_row user_item.loc[uid].values # 预测分 用户评分向量 x 物品相似度矩阵 scores user_row sim result pd.Series(scores, indexuser_item.columns) # 排除已经读过的书不然推荐列表全是用户看过的 rated user_item.loc[uid][user_item.loc[uid] 0].index result result.drop(indexrated) return result.sort_values(ascendingFalse).head(top_k)逻辑说明user_row sim的本质是加权求和——用户对书 A 打了 5 分A 和书 B 的相似度是 0.6那 B 从 A 这里拿到 3 分贡献所有贡献累加就是 B 的预测分。top_k控制返回条数毕设演示调到 510 都合理。rated的排除逻辑依赖前面fillna(0)的设定0 表示没读过 0恰好选出真实评分。2.6 Django 项目里文件怎么分工项目代码分三块bookRecommend/是 Django 主项目管 settings、urls、wsgilogin/是登录应用管会话校验templates/下有login.html、index.html、see.html三个页面分别对应登录页、推荐首页、图书详情/相似推荐页。models.py里定义图书、用户、评分表结构views.py负责把recommend()的结果塞进模板渲染。这里有个值得吐槽的点算法文件名basedUserCF.py从命名看是 UserCF但实际逻辑是按物品算相似度属于典型的“文件名和实现不符”。你读代码时不要被文件名带偏以pivot_table和相似度矩阵的计算方向为准。3. 把项目跑起来Django MySQL 环境配置与三步部署很多同学解压后直接python manage.py runserver然后被一串报错卡住。这一章按“环境选型 → 改配置 → 导数据 → 启动验证”的顺序走照着做基本能冒烟通过。3.1 环境版本怎么选Python、Django、MySQL 连接器这类老项目中常见.pyc文件说明作者是在特定 Python 版本下跑通后打包的但.pyc跟你的解释器版本不兼容不要拿它当运行依据。依赖方面根目录没有requirements.txt所以要手动装。我的建议组合如下依赖建议版本说明Python3.6 ~ 3.8兼顾老代码兼容性和新语法支持Django2.2.x对老url()写法兼容好Django 4 已移除MySQL5.7 或 8.08.0 需要额外处理认证插件PyMySQL最新让 Django 通过install_as_MySQLdb()连 MySQLpandas / numpy最新数据处理和相似度计算装 PyMySQL 的原因很实际Django 默认用 MySQLdb 连接 MySQL而 MySQLdb 在 Python 3 下编译麻烦PyMySQL 是纯 Python 实现的兼容替代。pip install django2.2.28 pymysql pandas numpy scipy安装完在bookRecommend/__init__.py里加两行让 Django 用 PyMySQL 冒充 MySQLdbimport pymysql pymysql.install_as_MySQLdb()这里的逻辑是Django 的 MySQL 后端导入MySQLdb模块PyMySQL 通过install_as_MySQLdb()把自己注册到sys.modules里Django 就察觉不到区别了。不加这两行启动时会直接报ModuleNotFoundError: No module named MySQLdb。3.2 settings.py 数据库配置改成你自己的 MySQL 账号打开bookRecommend/settings.py把DATABASES这段替换成你自己的连接信息DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: book_recommend, # 数据库名需要先建 USER: root, # 改成你的 MySQL 用户名 PASSWORD: your_password, # 改成你的密码 HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, }, } }utf8mb4必须显式指定它决定连接层的中文编码。如果只建库时用 utf8mb4 但连接层不带 charset中文评分和书名照样乱码。3.3 建库、导数据、迁移、启动先在 MySQL 里建库然后写一个独立脚本把uid_score_bid灌进评分表最后 makemigrations/migrate/runserver。mysql -uroot -p -e CREATE DATABASE book_recommend DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;建库指定DEFAULT CHARACTER SET utf8mb4这一步是为了让新建的表默认用 utf8mb4避免后续建表时继承数据库默认 latin1。评分数据导入我一般用 pandas 的to_sql一步到位from sqlalchemy import create_engine import pandas as pd df pd.read_csv(uid_score_bid, sep\t, names[uid, score, bid]) engine create_engine(mysqlpymysql://root:your_password127.0.0.1:3306/book_recommend?charsetutf8mb4) # if_existsappend 追加写入chunksize 分批避免一次写入过大 df.to_sql(book_recommend_rating, engine, if_existsappend, indexFalse, chunksize5000)chunksize5000表示每 5000 行提交一次评分数据几万行时不会让数据库连接超时。如果你看到django.db.utils.ProgrammingError大概率是表结构字段名和 CSV 列名对不上对照models.py里的模型字段改names参数即可。然后执行 Django 迁移并启动python manage.py makemigrations python manage.py migrate python manage.py runserver 0.0.0.0:8000makemigrations根据 models 生成迁移文件migrate把表结构写进数据库。跑完runserver看到Starting development server at http://0.0.0.0:8000/就说明 Web 层通了。3.4 从 login.html 到 index.html登录后能看到什么打开http://127.0.0.1:8000/先跳login.html输入测试账号密码后进入index.html。首页会展示推荐列表每条推荐包含书名和预测分。点任意一本进see.html通常会显示这本书的基本信息和“相似图书”列表这个相似列表就是直接查sim矩阵对应行得到的。如果你登录后页面空白先看两点第一模板里是否用了{{ user }}这类变量但 views 没传第二settings.py的INSTALLED_APPS是否包含login应用。老项目常见INSTALLED_APPS漏配导致模板标签{% url %}反向解析失败。4. 推荐结果验证用 80/20 划分量化命中率再上答辩项目跑通只是第一步。答辩时老师最常问的是“你怎么证明推荐是有效的”这一章给一套可复现的离线验证方法用数据说话。4.1 手动验证挑一个用户看推荐的解释性先随机抽一个评分记录多的用户打印他读过的书和推荐列表人工看题材是否有关联。这是最粗糙但也有用的 sanity check。sample_uid user_item.index[10] # 任取一个用户 rated_books user_item.loc[sample_uid] rated_books rated_books[rated_books 0] print(用户读过的书, rated_books.index[:10]) rec_list recommend(sample_uid, user_item, sim, top_k10) print(推荐的 10 本书, rec_list.index[:10])这一步看的是“解释性”。如果用户读的书是《三体》推荐列表出来全是《C 程序设计》说明物品相似度没有按预期工作问题很可能出在相似度矩阵的行列方向搞反了——sim应该是物品×物品而不是用户×用户。4.2 离线评估随机遮 20% 评分算 TopN 命中率手动验证过关后用留一法评估。把评分数据随机分成训练集 80% 和测试集 20%用训练集构建物品相似度对测试集里的评估。from sklearn.model_selection import train_test_split # 按用户分层切分保证测试集用户分布均匀 train, test train_test_split(df_dedup, test_size0.2, random_state42) # 用训练集重新构造评分矩阵和相似度矩阵 user_item_train train.pivot_table(indexuid, columnsbid, valuesscore).fillna(0) sim_train ... # 复用 2.4 节的归一化点积写法 hit 0 total 0 for uid in test[uid].unique(): rec recommend(uid, user_item_train, sim_train, top_k10) true_bids set(test[test[uid] uid][bid]) if len(true_bids) 0 or len(rec) 0: continue hit len(set(rec) true_bids) total len(true_bids) print(Top10 命中率 %.2f%% % (hit / total * 100))逻辑说明hit是推荐列表命中真实读过的书的总数total是测试集里真实读过的书总数两者相除就是命中率。random_state42固定随机种子保证每次跑结果一致答辩时不会出现这次 12%、下次 30% 的尴尬。命中率数值本身没有绝对标准图书长尾数据下 Top10 命中率在 5%15% 都算正常重要的是趋势调整top_k从 5 到 20命中率应该单调上升或持平这个趋势比单个数值更能说明算法有效性。4.3 把结果写进答辩命中率与覆盖率一起说只讲命中率容易被追问“你是不是只在热门书上命中”。再补一个覆盖率指标说明推荐列表不是集中在少数热门书上。all_recs set() for uid in user_item_train.index: rec recommend(uid, user_item_train, sim_train, top_k10) all_recs.update(rec) coverage len(all_recs) / user_item_train.shape[1] * 100 print(推荐覆盖率 %.2f%% % coverage)覆盖率表示推荐列表覆盖了多少不同图书覆盖率太低说明矩阵太稀疏或 top_k 太小。答辩时把两个指标放一起命中率证明“推得准”覆盖率证明“推得广”一准一广比单指标有说服力得多。5. 避坑手册跑这个项目最容易踩的 5 个坑与排查思路这类老代码的坑基本集中在版本、数据库、编码、内存这几块。下面都是实际跑的时候高频出现的。5.1 坑一Django 4.x 跑旧项目urls.py 直接翻车现象执行python manage.py runserver或migrate时抛NameError: name url is not defined或者TypeError: url() missing 1 required positional argument: view。原因老项目urls.py用的是django.conf.urls.url()Django 2.0 开始推荐path()Django 4.0 直接移除了url()兼容层。项目里.pyc暗示作者用的老版本 Django你在新环境装最新 Django 必炸。解决固定装django2.2.28。如果不想降版本把urls.py里所有url(r^xxx, view)改成re_path(r^xxx, view)也能过但正则部分要检查语法。我的习惯是毕设项目一律锁 Django 2.2省事且稳定。5.2 坑二MySQL 8 连不上报 caching_sha2_password现象启动后页面报Authentication plugin caching_sha2_password cannot be loaded或者连接时直接Access denied for user rootlocalhost。原因MySQL 8.0 默认认证插件是caching_sha2_password老 MySQLdb 和部分 PyMySQL 版本不支持该插件。项目 settings 里配的 ENGINE 是老写法没有经过 PyMySQL 的适配。解决确认bookRecommend/__init__.py里写了pymysql.install_as_MySQLdb()如果已写仍报错升级 PyMySQL 到最新或者在 MySQL 里把用户认证改回老插件ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY your_password;。改完后重启 MySQL 服务再试。5.3 坑三页面书名全是问号中文乱码现象浏览器里书名、作者显示为???或一堆乱码方块但数据库里查询是正常的。原因三层编码不一致——建库时用了 latin1连接时没带 charset模板渲染时又用了系统默认编码。最常见的是 MySQL 库是 latin1Django 连接没指定 utf8mb4数据入库时就丢了。解决三步全部改掉。第一步建库语句带上DEFAULT CHARACTER SET utf8mb4第二步 settings 里OPTIONS加charset: utf8mb4第三步对已有表执行ALTER TABLE 表名 CONVERT TO CHARACTER SET utf8mb4;。改完重启 runserver乱码一般在页面刷新后消失。如果还乱检查templates里meta charsetutf-8是否存在。5.4 坑四相似度矩阵计算到一半进程被系统杀掉现象运行相似度计算脚本时终端直接输出Killed或者电脑风扇狂转内存占满。原因物品数量上万时np.ndarray版的sim矩阵是 n×n 的稠密浮点数组。假设有 2 万本书就是 4 亿个 float64约 3.2 GB 内存直接撑爆。解决用scipy.sparse.csr_matrix代替稠密数组或者只保留每本书相似度最高的前 50 个邻居把矩阵变成稀疏的。实际推荐时用稀疏矩阵的top_k截断推荐效果几乎不变内存从 GB 级降到几十 MB。我在 2.4 节后面提到的稀疏化就是在这一步落地。5.5 坑五评分数据有重复记录推荐结果不稳定现象同一用户同一本书出现多次两次运行推荐结果不一样或者pivot_table报Index contains duplicate entries。原因数据采集时同一评分被爬了多次或者合并多份数据时没有去重。pivot_table遇到重复(uid, bid)索引默认抛异常即使不抛聚合函数不同结果也不同。解决先groupby([uid, bid], as_indexFalse)[score].max()去重再进pivot_table。如果数据带时间戳按时间取最后一条更合理但当前uid_score_bid只有三列取 max 即可。5.6 排查思路按顺序快速定位问题遇到跑不起来的场景我一般按这个顺序查先看 Python 和 Django 版本是否符合 3.1 节建议再看__init__.py里 PyMySQL 是否生效然后查数据库字符集最后看数据是否去重。版本问题占 40%连接问题占 30%编码和数据问题各占 15%。把这四关过了项目基本能稳定跑。6. 进阶把固定相似度矩阵改成“最近行为加权”的增量推荐前面实现的 ItemCF 有个明显短板sim矩阵是离线算好的新产生的评分不参与计算用户今天刚给一本书打了高分推荐结果明天才可能变。对一个演示项目来说够用但如果你想把推荐效果再往前推一步可以在不改算法框架的前提下引入“最近行为加权”。思路是把推荐分数拆成两部分离线 ItemCF 的预测分加上用户最近 7 天新评分带来的即时加权分。具体做法是给评分加时间衰减权重——越新的评分权重越大。假设评分数据里有时间字段import datetime def recency_weight(days_ago, half_life7.0): # 每过半衰期权重降一半 return 0.5 ** (days_ago / half_life)把recency_weight乘到用户评分向量上再和相似度矩阵做乘法相当于“最近读的书影响力更大”。实现时只需要在recommend()函数里把user_row从原始评分换成加权评分def recommend_with_recency(uid, user_item, sim, rating_time, top_k10, half_life7.0): user_row user_item.loc[uid].values.copy() now datetime.datetime.now() for idx, (bid, score) in enumerate(user_item.loc[uid].items()): if score 0 or bid not in rating_time: continue days_ago (now - rating_time[bid]).days user_row[idx] * recency_weight(max(days_ago, 0), half_life) scores user_row sim result pd.Series(scores, indexuser_item.columns) rated user_item.loc[uid][user_item.loc[uid] 0].index return result.drop(indexrated).sort_values(ascendingFalse).head(top_k)逻辑说明半衰期half_life7.0表示 7 天前的评分权重降为原来的一半14 天前降为四分之一。这个参数控制时间的敏感程度——演示环境下设置 7 天比较直观业务系统里用 30 天更平滑。rating_time是{bid: 评分时间}的映射数据里没有时间列就先不启用这段逻辑等采集到带时间戳的数据再接入。从那以后我每次接到这种带.pyc的老项目第一件事就是先锁 Python 和 Django 版本再动数据库而且改动前一定保留原始评分文件不动方便回归测试。环境先固定数据先备份再谈跑通。这套流程也建议你保留希望这篇拆解帮到你。本文还有配套的精品资源点击获取