简介:推荐系统是缓解信息过载的核心技术,其价值在于从海量物品中精准匹配用户需求。协同过滤通过挖掘用户行为模式实现个性化推荐,但面临新物品无行为记录的冷启动问题;文本相似度则利用物品内容特征构建画像,能有效弥补行为数据的缺失。将两者结合,采用双通道召回与加权融合排序,可在冷启动阶段用内容相似度兜底,在行为数据充足时用协同过滤提升精度。这一思路在图书推荐场景中尤为适用:新书入库、新用户注册等场景均能获得稳定推荐效果。基于Python实现的图书推荐系统源码,正是融合了用户行为协同过滤与图书文本相似度,完整展示了从向量化、相似度计算到混合排序的工程落地流程,适合开发者快速搭建可演示的推荐闭环。
1. 图书推荐系统为什么需要协同过滤和文本相似度夹击
把“猜你喜欢”从一句口号变成可复现的代码,最常见的数据缺口不是算法不够新,而是冷启动。新书入库没有点击量,新用户注册没有历史行为,纯协同过滤会直接失效;反过来只做文本相似度,又会让热门书永远霸榜。这套Python实现的图书推荐系统源码,选择的是“用户行为协同过滤 + 图书文本相似度”双通道方案:文本相似度解决物品冷启动,协同过滤解决用户个性化,两者在召回阶段先并行,在排序阶段再融合。适合正在做课程设计、毕业设计,或者想在本地搭建一个可演示的推荐业务闭环的开发者。下文所有代码和参数都基于该源码的业务模块拆解,不依赖外部大型框架,用的也是Python生态里最常见的库。
2. 用户冷启动:基于文本相似度的图书内容画像
2.1 图书信息的向量化与TF-IDF加权
图书详情页里能用的文本字段通常包括书名、作者、简介、分类、出版社。如果直接拼接成字符串再算相似度,分词噪声会很大。常见做法是先对文本做去停用词和分词,再用TF-IDF把每一本书转成向量。TF-IDF的精髓在于:某个词在一本书里出现频率高,但在整个图书语料里很少出现,这个词就更能代表这本书的主题。
源码里图书模块包含“根据分类展示”“搜索图书”“图书详情展示”,这些功能共用同一份文本向量。实施时我用到的字段权重大概是:书名 3.0,简介 1.0,分类 2.0,作者 0.5。作者权重低是因为同名作者可能写完全不同主题的书,而分类权重要拉高,因为用户对类别的感知最直接。分词工具用 jieba,停用词表覆盖常见虚词和“本书”“内容简介”这类页面噪音词。
2.1.1 向量化流程与参数选择
下面是核心的向量构造代码,适用于把数据库里图书表导出成 CSV 后进行离线计算:
import jieba import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer STOP_WORDS = {'本书', '内容', '简介', '以及', '可以', '一个', '我们', '他们'} def load_books(csv_path): df = pd.read_csv(csv_path) # 合并文本字段,书名和分类加大权重 df['weighted_text'] = ( df['book_name'].apply(lambda x: (x + ' ') * 3) + df['category'] + ' ' + df['intro'] + ' ' + df['author'] ) return df def tokenize_with_stopwords(text): words = jieba.lcut(text) return ' '.join([w for w in words if w.strip() and w not in STOP_WORDS]) df = load_books('books.csv') vectorizer = TfidfVectorizer(tokenizer=tokenize_with_stopwords, ngram_range=(1, 2)) tfidf_matrix = vectorizer.fit_transform(df['weighted_text'])这段代码做了三件事:首先把书名重复三次,变相提高权重;然后把分类、简介、作者拼接成带权重文本;最后用 TfidfVectorizer 生成稀疏矩阵。ngram_range=(1, 2)可以把“数据挖掘”这类双字词也纳入特征,避免单纯单字切分丢语义。
2.1.2 相似度矩阵的存储方式
得到 tfidf_matrix 后,直接计算两两余弦相似度会得到 n x n 稠密矩阵,一万本书就是 1 亿个浮点数,内存扛不住。源码里推荐的是用 scipy 的 k 近邻搜索,只保留每本书最相似的 TopK 本:
from sklearn.metrics.pairwise import cosine_similarity from scipy.sparse import csr_matrix import numpy as np def build_similarity_topk(tfidf_matrix, k=10): # 归一化后做矩阵乘法,余弦距离等价于归一化后的内积 normed = tfidf_matrix / np.sqrt(tfidf_matrix.multiply(tfidf_matrix).sum(axis=1)) sim = normed @ normed.T sim = sim.toarray() if hasattr(sim, 'toarray') else sim result = {} for i in range(sim.shape[0]): # 排除自己,取前k个相似图书 idx = np.argsort(-sim[i])[1:k+1] result[i] = [(int(j), float(sim[i][j])) for j in idx if sim[i][j] > 0.05] return result这里先做 L2 归一化再矩阵相乘,是余弦相似度的高效替代方案。阈值 0.05 用来过滤几乎无关的边,TopK 取 10 意味着每本书只保留 10 个近邻。注意argsort后跳过索引 0 自己,如果出现某本书和所有书相似度过低,保留空列表即可,下游推荐时自然忽略。
2.2 用相似度实现“被推荐图书展示”
源码前台有个“被推荐图书展示”区块,入口就是上面生成的 result 字典。当用户打开一本书详情,后端取这本书的相似图书列表,再和运营手动推荐的书籍做一次并集去重。排序时不能只按相似度降序,我会额外乘一个热度系数log(浏览量 + 1) / 5,避免冷门书相似度虚高。原因是简介文本可能大量雷同,比如“经典必读”这类套话,导致相似度失真。
具体查询伪代码如下:
def get_related_books(book_id, sim_dict, hot_map, limit=8): related = sim_dict.get(book_id, []) scored = [] for bid, score in related: if bid in hot_map: # 相似度为主,热度做微调 adjusted = score * 0.85 + min(hot_map[bid] / 10000, 0.15) else: adjusted = score * 0.85 scored.append((bid, adjusted)) scored.sort(key=lambda x: -x[1]) return [bid for bid, _ in scored[:limit]]这个公式是一个工程折衷:相似度占比 85%,热度占比 15%。热度上限封顶 0.15,防止超级大热门冲掉真正的语义相关。如果你想做中性内容,可以直接去掉热度项,纯按相似度排。
3. 行为数据变现:协同过滤召回与评分预测
3.1 UserCF与ItemCF的选择逻辑
有了阅读、收藏、点赞、评论、历史记录这些行为后,就可以上协同过滤。这套源码里既有“用户历史数据推荐的图书”,也有“查看个人点赞、收藏、评论的书籍”,说明行为数据是分类型的。我在拆包时看到登录用户会在浏览图书时产生隐性行为(打开详情),显性行为包括点赞、收藏、评论、评分。
UserCF 适合用户少、物品多、兴趣变化快的场景;ItemCF 适合物品少、用户多、兴趣稳定的场景。图书属于后者——图书数量远小于用户数,且用户喜欢某本书的意图比较持久。源码混合了两种:离线批处理用 ItemCF 生成候选,在线实时模块用 UserCF 补足用户刚发生的点击行为。下表是选型对比:
| 维度 | UserCF | ItemCF |
|---|---|---|
| 计算规模 | 受用户数量影响,用户多则矩阵爆炸 | 受图书数量影响,适合图书库规模中等 |
| 实时性 | 新行为立即可见,能反映兴趣漂移 | 要等到物品相似度更新后才生效 |
| 可解释性 | “和你相似的人也在看” | “看过这本的人还在看” |
| 冷门物品 | 容易被忽略 | 相似度高的冷门书也有机会 |
3.2 评分矩阵构建与ItemCF核心代码
源码里没有显式的 1-5 星评分按钮,所以我把“评分”隐式化:浏览 1 分、点赞 2 分、收藏 3 分、评论 4 分。构造评分矩阵时,每个用户对每本书的总分是这些行为分值的累加,然后减去用户平均分做中心化,消掉不同用户打分尺度的差异。
import pandas as pd import numpy as np from sklearn.metrics.pairwise import cosine_similarity # user_id, book_id, behavior 组成行为流 df = pd.read_csv('user_behavior.csv') behavior_score = {'view': 1, 'like': 2, 'favorite': 3, 'comment': 4} df['score'] = df['behavior'].map(behavior_score) # 累加后中心化 pivot = df.pivot_table(index='user_id', columns='book_id', values='score', aggfunc='sum', fill_value=0) mean_user = pivot.mean(axis=1) centered = pivot.sub(mean_user, axis=0) # 图书相似度:用每本书的评分列做余弦相似度 book_sim = cosine_similarity(centered.T) np.fill_diagonal(book_sim, 0) book_sim_df = pd.DataFrame(book_sim, index=pivot.columns, columns=pivot.columns)这里故意把行为分值映射写成字典,是为了让你能调参。比如你认为“收藏”的意图远强于“点赞”,可以把收藏调到 4、点赞降到 1。中心化处理是协同过滤中容易忽略的细节:不做中心化,爱给高分和只给低分的用户会被同等对待,相似度计算会产生偏差。
3.2.1 给用户召回图书候选
基于物品相似度矩阵,推荐分数是用户历史Rating过的物品的加权和。公式常见做法是R(u, i) = sum(score(u, j) * sim(i, j)) / sum(|sim(i, j)|),除法是为了防止热门历史物品主导结果。
def recommend_for_user(uid, pivot, book_sim_df, top_n=10): user_rated = pivot.loc[uid] rated_books = user_rated[user_rated > 0].index.tolist() if not rated_books: return [] # 留给文本相似度兜底 scores = {} for book in rated_books: w = user_rated[book] sim_bucket = book_sim_df[book].drop(index=rated_books) # 排除已读 for cand, sim_val in sim_bucket.items(): if sim_val <= 0: continue scores[cand] = scores.get(cand, 0) + w * sim_val # 归一化 for cand in scores: hist_w = sum([user_rated[b] for b in rated_books]) scores[cand] /= hist_w ranked = sorted(scores.items(), key=lambda x: -x[1])[:top_n] return [book_id for book_id, _ in ranked]注意drop(index=rated_books)是工程上的关键一步——不给用户推荐他已经看过的书。归一化分母用历史行为总分,而非单纯的行为数,因为加权分数会随历史量级膨胀。如果用户只点了一本书,那本书的相似图书会被放大,这是 ItemCF 的固有特性,需要通过第 5 章的阈值调优来缓解。
3.3 混合推荐的权重融合与排序
源码的“系统根据用户历史数据推荐的图书”最终展示,需要把文本相似度和协同过滤结果合并。我采用线性加权,具体比例为协同过滤 0.6、文本相似度 0.4。在实际运行中这个比例需要看数据集,协同过滤冷启动时文本相似度占比要升到 0.7。
def hybrid_rank(user_id, content_scores, cf_scores, alpha=0.6): all_ids = set(content_scores.keys()) | set(cf_scores.keys()) merged = {} for bid in all_ids: score = 0.0 if bid in cf_scores: score += alpha * (cf_scores[bid] / max(cf_scores.values())) if bid in content_scores: score += (1 - alpha) * (content_scores[bid] / max(content_scores.values())) merged[bid] = score return sorted(merged.items(), key=lambda x: -x[1])这段代码先把两种分数各自归一化到 0~1 区间,再按 alpha 加权相加。如果不归一化,协同过滤的浮点数往往比相似度小几个数量级,加权就失去意义。这也是常见误用:直接把原始分数相加,导致某一通道永远占优。
4. 前后端模块落地:从登录注册到后台管理
4.1 用户模块与等级体系对推荐的影响
源码里用户有三种等级:普通用户、会员、认证作者。积分和等级不只是装饰,它们直接影响推荐权重:会员行为分值在协同过滤中乘以 1.5,认证作者的书籍在文本相似度中有额外的曝光加权。登录注册模块采用 Django 自带认证机制时,用户表要注意扩展积分字段和等级字段。
4.1.1 用户注册与登录的密码处理
实际开发中不要存明文密码,用 Django 的make_password或者 werkzeug 的generate_password_hash都行。源码中注册流程我在本地复现时精简为:
from werkzeug.security import generate_password_hash, check_password_hash # 注册时 hashed_pwd = generate_password_hash(password, method='pbkdf2:sha256', salt_length=16) # 登录时 if check_password_hash(user.password_hash, input_pwd): # 更新登录时间、积分 +1 user.points += 1 user.last_login_at = datetime.now() db.commit()pbkdf2是当前成本可控且被广泛接受的方案,salt_length保持 16 字节足够。登录后建议把用户 ID 放入 session,不要存完整对象。积分加 1 的规则要和推荐计算部分解耦,否则每次登录都触发一次矩阵更新。
4.2 图书管理:批量导入、上下架与审核
后台图书管理支持批量导入 CSV、编辑、删除、上下架、审核。批量导入最容易出错的是编码和字段对齐。我通常用 pandas 读取并做校验,再逐条写入数据库。下架的逻辑不是删除,而是把status字段改为 0,推荐查询时强制过滤status=1。
-- 图书状态:1 上架,0 下架,2 待审核 SELECT book_id, book_name, category, intro, author FROM books WHERE status = 1 AND category = %s ORDER BY view_count DESC LIMIT 50;4.2.1 批量导入的校验流程
import pandas as pd def import_books(file_path): df = pd.read_csv(file_path, dtype={'isbn': str}) required = ['book_name', 'author', 'category', 'intro'] for col in required: if col not in df.columns: raise ValueError(f'缺少必填列: {col}') # 去重和校验 df = df.drop_duplicates(subset=['isbn'], keep='first') df = df[df['book_name'].notna() & df['book_name'].str.strip().ne('')] for _, row in df.iterrows(): create_book( name=row['book_name'].strip(), author=row['author'].strip(), category=row['category'].strip(), intro=row.get('intro', ''), status=1 if row.get('status') == '上架' else 0 )这里isbn用字符串读取是为了防止长数字被科学计数法截断。重复 ISBN 默认保留第一条,如果你想保留最新数据可以改成keep='last'。批量导入后必须调用一次第 2 章的文本向量更新,否则新书不会出现在推荐里——这是源码里最容易漏掉的步骤。
4.3 评论、点赞、收藏与历史续读
用户可以在图书详情页评论、修改评论,也可以点赞和收藏。这些行为都会写入用户行为日志表,成为协同过滤的输入。历史阅读记录用last_read_at和read_progress(百分比)两个字段存储,用户下次进入详情页时,前端根据进度唤起“续读”提示。
评论模块最重要是防重复提交。前端重选提交按钮是基础,后端还要做唯一约束:
class Comment(db.Model): __table_args__ = ( db.UniqueConstraint('user_id', 'book_id', name='uix_user_book_comment'), ) id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) book_id = db.Column(db.Integer, db.ForeignKey('book.id')) content = db.Column(db.Text, nullable=False) created_at = db.Column(db.DateTime, default=datetime.now)这样设计保证一个用户对一本书只能有一条评论,后续修改走 UPDATE。如果产品要求允许多次评论,把唯一约束去掉,但要在查询时取最新一条。
4.4 后台统计与数据备份恢复
系统管理模块包含运行日志、数据备份恢复、统计图。备份恢复最稳妥的方式是数据库本身的功能,而不是在应用层做快照。以 MySQL 为例,常见做法是每天凌晨定时mysqldump,恢复时只需要source文件。日志模块则要把“推荐结果日志”单独记录——用户看到哪些推荐、点击了哪个,这是评估推荐系统的唯一事实来源。
# 备份全部数据(排除缓存表) mysqldump -u root -p --databases book_recommend --ignore-table=book_recommend.cache_tb > backup_$(date +%Y%m%d_%H%M%S).sql # 恢复 mysql -u root -p < backup_20250623_120000.sql统计模块的“时间段内新增图书和新增用户”用一条 SQL 就能完成:
SELECT DATE(created_at) AS day, COUNT(DISTINCT book_id) AS new_books, COUNT(DISTINCT user_id) AS new_users FROM audit_log WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY DATE(created_at) ORDER BY day;这里audit_log是源码里记录新增操作的统一表,用 DISTINCT 防止同一本书多次编辑导致重复计数。展示给管理员时,前端用 ECharts 折线图即可满足。
5. 推荐系统的参数调优与线上验证技巧
5.1 相似度阈值、K值和融合权重的调参顺序
不要一上来就瞎调 alpha。我调这套源码的顺序是:先定文本相似度 TopK,再定协同过滤行为分值,最后才动混合权重。具体做法是——取一个已知图书的相似列表,人为标出“明显相关”和“明显无关”,用精确率衡量。比如取 500 本书,每本检查 Top10 里真正的相关书占比,这比看单个案例可靠。
K 值对结果影响如下表,以 2 万本图书规模估算:
| K 值 | 召回变化 | 精度变化 | 计算耗时 |
|---|---|---|---|
| 5 | 低,候选少 | 高,噪声少 | 最快 |
| 10 | 中等 | 中等 | 快 |
| 20 | 较高 | 下降明显 | 中等 |
| 50 | 最高 | 严重下降 | 慢 |
源码默认 TopK=10,我的经验是:图书简介长度小于 50 字时,建议增大到 15,利用更多文本特征;简介都很长时,10 以内更安全,否则长简介图书会霸占相似列表。
5.2 离线评估与A/B测试的最小验证方案
离线评估采用简单的留一法:从用户历史行为里随机隐藏 10% 的收藏记录,用剩余 90% 训练,看隐藏的收藏是否出现在推荐 TopN 中。命中率即recall@10,这个指标已经能说明问题。
import random def evaluate(data_loader, recommend_fn, hidden_ratio=0.1, topn=10): hits = 0 total = 0 for user in data_loader.users(): history = data_loader.get_history(user) if len(history) < 2: continue hidden = random.sample(history, max(1, int(len(history) * hidden_ratio))) train = [b for b in history if b not in hidden] recs = recommend_fn(user, train, topn=topn) hits += len(set(recs) & set(hidden)) total += len(hidden) return hits / total if total else 0注意recommend_fn的入参需要改成接收训练行为列表,不能内部去读全量数据库,否则测试会数据泄漏。线上 A/B 测试则更简单:把用户按用户 ID 哈希分流到对照组(纯协同过滤)和实验组(混合方案),比较 CTR 和次日回访率。样本量达到每日 1 万请求时,跑一周基本能判断差异是否显著。
最后一个小技巧:把推荐结果落库。每次用户请求推荐时,将推荐列表和当时用的参数版本写入recommend_log,排错时直接回放当时的参数,不用猜测是哪次配置改动导致了线上效果回落。这一点在源码的日志模块里预留了接口,补上后你这套系统的可持续维护性就能超过大多数课程设计项目。
本文还有配套的精品资源,点击获取