简介一套面向毕业设计场景的购物网站商品推荐系统覆盖电影、音乐、图书等多类物品推荐以协同过滤算法为技术核心。实现中利用 Word2Vec/Doc2Vec 对物品标签建模以缓解冷启动问题并结合 User-based CF 与 Item-based CF 完成相似用户/物品的 TOP-N 推荐适合推荐系统相关毕业设计选题参考。资源共包含 1160 个文件压缩包约 25.98MB。其中 java/jsp 为后端业务代码html/css/js 承担前端展示与交互sql 提供数据库初始化脚本mp4 为演示视频doc 为说明与论文文档另含配置文件、图片等素材目录结构清晰便于按模块查看和复现实验。目前已有 65 人学习下载。通过这份资源可拿到完整源码、文档说明、演示视频与论文材料既能了解协同过滤与文本向量化相结合的推荐实现思路也能为毕业设计撰写、代码调试和系统演示提供直接参考。1. 推荐系统的最后一公里协同过滤代码包与冷启动链路解决什么问题在模拟项目X里做过一次最亏的上线基于物品的协同过滤已经调通离线评分预测的误差也明显下降结果推荐栏的点击率反而比热门榜还低。排查了很久才发现用户点进来的全是刚上架的内容评分矩阵里一大半是 0相似度矩阵算出来全是空值和 NaN——这就是典型的冷启动场景。这套毕业设计《基于协同过滤算法购物网站商品推荐系统》把两件事打包在了一起一套基于内存的 User-based 与 Item-based 协同过滤推荐实现另一条基于 Word2Vec / Doc2Vec 的标签向量化链路专门用来解决没有用户行为时怎么推的问题。换句话说它同时覆盖了有行为时推得准和没行为时推得出这两层核心需求。适合正在做推荐系统毕业设计、需要完整源码和论文支撑的在校生也适合想把小规模电商或内容社区推荐从零搭起来的开发者。2. 协同过滤选型User-based 与 Item-based 的本质区别与三个边界条件2.1 两派算法在数学上到底差在哪基于内存的协同过滤推荐算法按照相似度计算维度的不同被分成两条路线基于用户的协同过滤User-based CF和基于物品的协同过滤Item-based CF。User-based 的核心逻辑是相似的人喜欢的东西你也大概率喜欢。它在用户维度上计算相似度找到与当前用户兴趣最接近的 TOP-N 个邻居再把这批邻居评分过且当前用户没见过的物品按加权分数汇总生成推荐列表。这个过程本质上是在做人找人然后用人找物。Item-based 的核心逻辑是喜欢这个物品的人通常也会喜欢与之相似的物品。它先在物品维度上计算两两相似度建立物品之间的相似关系表然后根据用户历史行为中的物品去匹配和这些物品相似的其他物品。这个过程是先人找物再用物找物。两者在数学上都在做同一个动作计算两两相似度后排序取 TOP-N再过滤掉用户历史中已经出现过的物品。但选型上有三个边界条件需要先想清楚。第一个条件是数据规模。User-based 需要维护用户与用户之间的相似度矩阵矩阵大小是用户数的平方。用户量从一万涨到十万计算量涨一百倍而商品数量往往比用户数量稳定得多。因此用户量级大、物品量级相对小的场景优先考虑 Item-based。第二个条件是实时性。User-based 每次推荐都要基于当前用户的近邻重新计算用户兴趣漂移后近邻集合变化很快Item-based 的物品相似度表可以离线算好线上只需要查表加聚合。第三个条件是冷启动。新用户进来时没有任何行为记录两种方法都失效新物品进来时Item-based 同样失效但基于标签的 Word2Vec 链路可以补位。这套项目把 User-based、Item-based 两条路线都写出来了正好可以对比着看边界在哪里。2.2 相似度公式怎么选余弦相似度与皮尔逊相关系数相似度计算是整个协同过滤的地基公式选错后面排序全是空中楼阁。项目里主要用的是余弦相似度与皮耳逊相关系数两种方式。余弦相似度计算两个向量的夹角余弦值公式化地说就是两个向量的点积除以模长乘积。它的特点是只关心方向不关心数值的整体大小。在评分数据里这既是优点也是隐患如果一个用户习惯打 4 到 5 分另一个用户习惯打 1 到 2 分但两人对物品的喜好顺序一致余弦相似度会给出一个偏高的值因为两个向量指向大致相同的方向但绝对数值相差很大。皮尔逊相关系数在余弦相似度的基础上做了均值中心化处理。它先把每个用户对物品的评分减去该用户自己的平均评分再去计算向量夹角。这样两个打分严苛程度不同但喜好顺序一致的用户会被更准确地判定为相似。项目里给出的实现同时支持这两种方式推荐在评分数据是显式打分且存在评分偏差时使用皮尔逊相关系数。还有一个容易被忽略的相似度修正在计算物品相似度时光看共同评分的用户数是不够的。热门物品和几乎所有物品都能产生共现导致相似度虚高所以要在计算时引入物品流行度惩罚。常见做法是让相似度分母乘上物品流行度的平方根或者对共现次数做对数压缩。这套项目里已经内置了这一类惩罚逻辑后续第 3 章会讲到具体实现。2.3 一个能跑的 User-based 代码骨架预测评分与参数说明我一般会先用小数据集把整个流程跑通再切到真实数据。下面这段代码就是基于用户维度的评分预测核心逻辑数据量小的时候完全可以直接用于毕业论文的对比实验。import pandas as pd # 构建用户-物品评分矩阵行是用户列是物品 ratings pd.DataFrame({ A: [5, 4, 0, 2], B: [3, 0, 3, 0], C: [0, 5, 2, 4], D: [4, 5, 4, 0], }, index[U1, U2, U3, U4]) # 计算用户之间的皮尔逊相关系数矩阵 user_sim ratings.T.corr(methodpearson).fillna(0)def predict_score(user_id, item_id, ratings, user_sim, top_k3): # 找到与当前用户最相似的 top_k 个邻居 sims user_sim[user_id].drop(user_id).sort_values(ascendingFalse).head(top_k) # 只保留对目标物品有评分的邻居否则没有分数可加权 rated_users ratings[ratings[item_id] 0].index sims sims[sims.index.isin(rated_users)] if sims.empty: return ratings.loc[user_id].mean() # 每个用户自己的平均评分 user_mean ratings.apply(lambda row: row[row 0].mean(), axis1) # 加权求和邻居相似度 * (邻居对物品的评分 - 邻居平均评分) weighted_sum sum( sims[u] * (ratings.loc[u, item_id] - user_mean[u]) for u in sims.index ) # 最终的预测分 当前用户平均分 加权修正项 return user_mean[user_id] weighted_sum / sims.abs().sum()这段代码里最关键的是加权修正那一行预测分不是直接对邻居评分求平均而是先把每个邻居的评分减去该邻居的平均评分得到相对偏好偏移再用相似度加权最后加回当前用户的平均分。这个处理解决了评分严苛程度不同导致的系统性偏差。top_k 控制邻居数量实践中取 10 到 30 比较常见太小则邻居噪声大太大则把弱相关的用户也卷进来。当用户数量超过五万时这个写法会有明显的性能问题因为用户相似度矩阵是稠密计算的。遇到这种情况我一般会先用倒排表把共同评分过的用户对筛出来再只对非零共现对计算相似度能省掉大量无效运算。第 3 章介绍的物品维度实现本质上也是靠倒排表来压缩计算规模的。3. Item-based 完整链路倒排表、流行度惩罚与 TOP-N 推荐参数3.1 从用户行为到共现矩阵的转换基于物品的协同过滤第一步不是直接算物品相似度而是先构建用户到物品的倒排表。原始数据往往长这样每条记录是用户ID物品ID评分同一行里包含多个用户对多个物品的评分。倒排表把数据重组成每个用户对应一个物品评分字典的结构。from collections import defaultdict # 原始行为数据用户ID, 物品ID, 评分 raw_data [ (1, 电影A, 5), (1, 电影B, 3), (1, 电影C, 4), (2, 电影A, 4), (2, 电影C, 5), (3, 电影B, 3), (3, 电影D, 4), (3, 电影E, 2), ] def build_inverted_index(raw_data): user_items defaultdict(dict) for uid, item_id, rating in raw_data: user_items[uid][item_id] rating return user_items user_items build_inverted_index(raw_data) # 输出示例{1: {电影A: 5, 电影B: 3, 电影C: 4}, ...}这里每个用户只保留有明确行为的物品评分值为 0 的记录在预处理阶段就应剔除。如果把 0 评分也放进来共现统计会被严重污染因为没看过不等于不喜欢。倒排表本身不参与相似度计算但它是后续所有统计的数据源。接下来统计共现矩阵遍历每个用户的物品列表内部两两配对相同物品对的出现次数累加。共现次数的含义是有多少个用户同时消费过这两个物品。item_count defaultdict(int) # 每个物品被多少用户消费过 co_occur defaultdict(int) # 物品对的共现次数 for items in user_items.values(): for iid in items: item_count[iid] 1 for jid in items: if iid ! jid: # 物品对排序后作为 key保证 (A,B) 与 (B,A) 是同一个统计项 key tuple(sorted((iid, jid))) co_occur[key] 1物品对排序这个细节很重要。如果不排序(A,B) 和 (B,A) 会变成两个不同的 key后面查表时容易漏数据。共现矩阵得到后就可以开始计算物品之间的相似度了。3.2 相似度计算为什么要给热门物品加惩罚基于共现次数直接算相似度会出现一个经典问题热门物品和谁都共现导致相似度虚高。比如一部热映电影和一部小众文艺片同时被几千人看过共现次数可能比两部小众片之间的共现还高但这不代表热映片和小众片相似。常见解决方案是引入物品流行度作为惩罚项。最朴素的形式是相似度等于共现次数除以两个物品各自流行度的乘积的平方根。def compute_item_similarity(item_count, co_occur): item_sim defaultdict(dict) for (iid, jid), co_cnt in co_occur.items(): if iid jid: continue # 余弦形式的归一化惩罚共现次数 / sqrt(流行度乘积) sim_score co_cnt / (item_count[iid] * item_count[jid]) ** 0.5 item_sim[iid][jid] sim_score item_sim[jid][iid] sim_score return item_sim item_sim compute_item_similarity(item_count, co_occur)这里除以平方根的作用是压低热门物品的整体相似度。如果两个物品都是热门物品分母变大相似度被抑制如果两个都是冷门物品但共同出现次数不低相似度会被相对放大。这个公式对应实践中常用的余弦相似度归一化写论文时可以直接用作改进点之一。更进一步的优化是在惩罚项上做文章。我见过不少项目把分母里的 item_count 换成 log(1 item_count)这样对热门的打压更平滑避免 top 物品的相似度全部趋近于零。具体选哪种取决于数据分布如果长尾明显用 log 版本效果更好如果数据量本身不大平方根版本更稳。3.3 推荐生成与已读过滤相似度矩阵算好后推荐过程可以分成两步先根据用户历史物品找出候选集再过滤掉用户已经消费过的物品最后按分数排序截断。def recommend_by_item_cf(user_items, item_sim, user_id, top_n10): user_history user_items[user_id] scores defaultdict(float) # 遍历用户历史中的每个物品 for item_id in user_history: # 取与该物品相似的其他物品 for related_item, sim_value in item_sim.get(item_id, {}).items(): # 用户已经消费过的物品直接跳过这是协同过滤必须做的过滤 if related_item in user_history: continue # 累加相关物品的相似度作为推荐分数 scores[related_item] sim_value # 按分数降序取 top_n ranked_items sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n] return [item_id for item_id, _ in ranked_items]这段代码里的过滤动作是协同过滤的命门如果忘记过滤用户历史物品推荐列表里会出现大量用户已经看过的东西评估指标虚高但实际转化率很差。我在模拟项目里踩过这个坑第一次做离线评估时精确率到了 0.7上线后点击率反而崩了最后发现测试集里没过滤已读物品。top_n 参数控制最终返回的推荐条数常规设置在 10 到 20 之间。scores 累加的就是物品相似度没有使用评分加权这种做法聚焦在行为关系上适合隐式反馈数据。如果评分数据充足可以在累加时乘上用户历史物品的评分值让高评分物品在推荐里占更大权重。3.4 参数配套与冷启动缺失时的表现Item-based 协同过滤跑通之后有几个参数需要联动调整而不是单独调一个。top_n 决定推荐列表长度影响覆盖率和精确率的平衡相似度归一化的惩罚强度决定推荐结果的头部效应如果引入评分加权还要考虑评分本身的分布。这套代码在物品覆盖行为充足时表现稳定但三个位置会露怯。第一是新物品没有共现记录相似度矩阵里根本没有它的影子第二是长尾物品共现次数太少相似度结果噪声很大第三是用户只有一两条行为记录时候选集往往来自这几个物品的相似物品多样性不足。这些问题单靠协同过滤调参解决不了需要进入第 4 章的标签向量化方案。4. Word2Vec 冷启动链路把标签变成向量的实现与评分融合4.1 为什么评分矩阵解决不了冷启动冷启动的本质是缺少行为共现。一个刚上架的物品没有任何用户评分它和任何物品的共现次数都是 0相似度矩阵里对应的行全部为空。无论 Item-based 还是 User-based在这个场景下都会输出空推荐。Word2Vec 在 2013 年被开源核心贡献是能把词映射成稠密向量让词与词之间可以用向量距离度量关系。这个能力放到推荐场景里有一个直接用途如果一个物品本身携带了标签或者属性描述我们可以把标签当作词来处理而物品则是标签组成的文档。消费者是否对两个物品产生过共同行为不重要只要标签语义上接近就能算出相似度。这套思路的关键是冷启动不再依赖评分矩阵而是依赖物品的内容属性。购物网站里的商品有类目、品牌、风格标签电影有类型、导演、演员标签图书有分类、主题标签都属于可以直接利用的属性。即使一个新品没有任何用户行为只要标签齐全就能通过标签向量找到一批语义相近的已有物品间接进入推荐候选集。4.2 Doc2Vec 的训练思路把标签列表当成文档Doc2Vec 是建立在 Word2Vec 之上的扩展区别在于 Word2Vec 训练出的是词向量Doc2Vec 直接训练出文档向量。文档可以是一句话、一段话也可以是一个物品的全部标签组合。对推荐场景来说把物品 ID 标签列表构造成一条文档训练完成后就能拿到每个物品的向量表示。from gensim.models.doc2vec import Doc2Vec, TaggedDocument # 物品标签数据key 是物品IDvalue 是标签列表 item_tags { 电影A: [科幻, 太空, 冒险], 电影B: [科幻, 悬疑, 外星文明], 电影C: [爱情, 都市, 剧情], 电影D: [动作, 犯罪, 悬疑], 电影E: [科幻, 动作, 冒险], } # 将标签列表包装成 TaggedDocumenttags 参数传入物品ID documents [ TaggedDocument(wordstags, tags[item_id]) for item_id, tags in item_tags.items() ] model Doc2Vec( documents, vector_size100, # 向量的维度维度越高语义区分越细但也越容易过拟合 window5, # 标签上下文窗口标签数小时影响不明显 min_count1, # 降低阈值保证低频标签也被训练 epochs40, # 训练轮数小数据集需要适当加大 seed42 # 固定随机种子方便复现实验 ) # 获取与电影A最相似的物品 similar_items model.dv.most_similar(电影A, topn5) print(similar_items)这段代码在训练时用物品 ID 作为标签训练结束后通过 model.dv.most_similar 就能直接取到相似物品列表。vector_size 是最值得调的参数标签总数在几百量级时100 维足够表达标签数量上万可以上调到 200 甚至 300。min_count 设置过高会把长尾标签直接丢弃导致冷门物品的向量质量下降。epochs 在小数据集上需要加大否则向量没有充分收敛相似度结果看起来会很随机。这套做法还额外解决了一个冷启动场景如果一个新物品进入系统时没有评分但有标签它不需要重新训练整个模型只需要把新标签文档丢进已有的 Doc2Vec 模型执行一次推断就能得到新物品的向量然后和所有已有物品计算距离。推理过程比重新训练快得多适合线上增量更新。4.3 标签不全时的替代做法预训练词向量加权平均Doc2Vec 依赖物品标签构成一段完整上下文。如果物品标签只有一两个词文档太短训练出的向量质量会很不稳定。我常用的替代方案是用更大的文本语料预训练 Word2Vec 词向量然后对一个物品的标签词取向量平均作为该物品的最终向量。from gensim.models import Word2Vec # 标签语料每个列表代表一个物品的所有标签词 tag_corpus list(item_tags.values()) # 训练一个小的 Word2Vec 模型 word_model Word2Vec(tag_corpus, vector_size100, window5, min_count1, epochs50) # 将物品的标签词向量做平均得到物品向量 def build_item_vector(item_id, item_tags, word_model): vectors [ word_model.wv[tag] for tag in item_tags[item_id] if tag in word_model.wv ] if not vectors: return None # 直接取平均作为物品向量 return sum(vectors) / len(vectors) item_vector_a build_item_vector(电影A, item_tags, word_model)词向量平均的做法实现简单任意一个训练好的 Word2Vec 模型都可以迁移过来。它的问题是对标签顺序不敏感但物品标签本身就是无序集合所以这个缺点在推荐场景里反而无所谓。用 Word2Vec 而不是 Doc2Vec 的另一个好处是词向量可以基于更大的商品标题描述语料训练不只局限于标签文本。4.4 与协同过滤融合冷启动分怎么进推荐列表Word2Vec 得到的相似物品不能直接替代 CF 结果原因有两个。第一标签语义相似不等于用户行为相似两部同为科幻类型的电影可能受众完全不同向量相似度高但实际转化率不高。第二Doc2Vec 只给出相似关系不给评分预测无法直接输出推荐分数。我的做法是把 CF 分数和向量相似度做加权融合。具体步骤是对候选集中的每个物品先计算 CF 产生的偏好分数再计算它与用户最近消费物品的向量相似度最后用权重合并。def fusion_score(cf_score, vector_sim, alpha0.7): # alpha 控制 CF 分数与向量相似度的占比 return alpha * cf_score (1 - alpha) * vector_simalpha 默认取 0.7因为正常场景下 CF 分数更贴近真实转化行为向量相似度只作为冷启动补偿。新物品没有任何评分时CF 分数缺失这时需要把 alpha 降为 0完全依赖向量相似度。等到新物品积累了一定量的用户行为再逐步恢复 alpha 的权重。这套项目里把这两套计算都封装好了正好可以拿来做横向对比实验单独用 CF、单独用 Doc2Vec、融合之后分别跑一遍离线评估论文里能多出三组实验数据。5. 推荐系统避坑相似度失真、矩阵稀疏与评分偏见的 4 个典型案例5.1 相似度分布全往热门物品集中推荐结果失去个性现象离线评估的精确率看着还行但推荐列表里全是同一个热门作者的几部作品不同用户的推荐结果高度重合点击率持续走低。原因计算物品相似度时只用了共现次数做分母没有加入流行度惩罚。热门物品由于曝光基数大跟长尾物品也产生了大量共现相似度分数虚高。解决把相似度计算公式调整为 co_occur / sqrt(item_count[i] * item_count[j])或者用 log(1 item_count) 做平滑。我一般会在算完相似度后再加一道后处理对每个物品的相似 Top 列表做一次去热门操作把全局流行度超过 90 分位的物品排除在相似物品之外保证推荐结果的个性化。5.2 新用户推荐全为空系统直接返回兜底现象新注册用户打开首页推荐位是空的前端只能展示大家都在看的热门榜。明明 Doc2Vec 已经训练好但推荐接口没有把新用户路由到向量推荐链路。原因工程实现里只把 Item-based CF 当作唯一推荐来源CF 面对空行为用户会输出空列表没有做降级处理。模型层面没问题问题出在链路设计。解决按用户行为数据量分阶梯处理。用户行为为 0 时走热门召回 Doc2Vec 相似扩充行为小于 5 条时走Doc2Vec 相似召回CF 打分排序行为大于等于 5 条时切回CF 召回 向量融合。这个阶梯规则建议直接写死在推荐服务入口避免每次都在业务代码里跳逻辑。5.3 评分预测集中在 3 分附近区分度不够现象用第 2 章的 predict_score 做离线预测预测值全在 2.8 到 3.2 之间用户真正喜欢的 5 分物品反而排不到前面。原因预测公式向用户平均分回归。当每个用户的评分分布都很集中加权修正项的贡献就被平均分吞掉了。评分值域本身很窄的项目里这种情况尤其明显。解决在计算相似度之前先做评分区间归一化把每个用户的评分映射到 0 到 1 区间或者直接用 rating - user_mean 后的偏差值参与相似度计算。这本质上就是皮尔逊相关系数做的事情。如果归一化后仍然区分度不够可以改成排序融合不直接预测分数而是把每个候选物品在多个相似邻居的偏好列表中的排名汇总取平均排名作为最终排序依据。5.4 物品标签拼写不一致语义向量全是噪声现象Doc2Vec 训练完成后某个物品的相似物品列表里出现了完全不相干的分类内容。排查发现标签字段里同一个概念被写成了动作片动作动作电影三种写法。原因标签没有做统一的预处理清洗。Word2Vec 和 Doc2Vec 都是基于精确匹配的同一个语义的不同字面形式被当成完全不同的词向量空间被切得稀碎。解决在构造标签语料前先做清洗流程统一大小写、合并同义词、去除停用词、把标签长度超过指定字数的做截断。越短的标签越要谨慎合并宁可保留两个近义词让模型自己学关系也不要人工合并过猛导致信息丢失。我一般会维护一张同义词典把高频别名映射到标准词表再进模型训练。6. 混合推荐的工程实践召回-排序-过滤链路评估6.1 三段式链路结构把 CF 和 Doc2Vec 真正放进生产推荐服务我习惯拆成召回、排序、过滤三个环节。召回阶段并行跑三路Item-based CF 按相似物品召回、User-based CF 按近邻喜欢的物品召回、Doc2Vec 按向量最近邻召回。三路结果合并去重后进入排序阶段排序分数用融合公式计算冷启动物品由向量相似度主导。过滤阶段做最后一道防线把用户已经看过的、下架的商品、明确点过不感兴趣的全部踢掉。6.2 离线评估指标怎么算def offline_evaluate(test_data, recommender, top_n10): precision_total, recall_total, hit_count 0, 0, 0 total_rec 0 total_test 0 for user_id, actual_items in test_data.items(): rec_items recommender(user_id, top_n) hit set(rec_items) set(actual_items) hit_count len(hit) total_rec len(rec_items) total_test len(actual_items) precision hit_count / total_rec recall hit_count / total_test return precision, recall这个评估函数忽略了一个容易出错的细节训练集和测试集要做严格切分测试集里的已交互物品不能出现在训练集里参与相似度计算。评估时 rec_items 已经做过了用户历史过滤不然精确率会虚高。6.3 参数记录习惯alpha 融合权重、top_n 列表长度、Doc2Vec 的 vector_size 和 epochs这四个参数每次实验都做记录。改动一个参数就重新跑一轮评估对比 precision 和 recall 的变化趋势。从那以后我每次跑推荐实验都强制把冷启动链路和数据清洗流程完整走一遍哪怕只是改了一个阈值也不再跳过预处理阶段。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?