简介:一套面向毕业设计场景的购物网站商品推荐系统,覆盖电影、音乐、图书等多类物品推荐,以协同过滤算法为技术核心。实现中利用 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(method="pearson").fillna(0)def predict_score(user_id, item_id, ratings, user_sim, top_k=3): # 找到与当前用户最相似的 top_k 个邻居 sims = user_sim[user_id].drop(user_id).sort_values(ascending=False).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(), axis=1) # 加权求和:邻居相似度 * (邻居对物品的评分 - 邻居平均评分) 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_n=10): 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(), key=lambda x: x[1], reverse=True)[: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 是物品ID,value 是标签列表 item_tags = { "电影A": ["科幻", "太空", "冒险"], "电影B": ["科幻", "悬疑", "外星文明"], "电影C": ["爱情", "都市", "剧情"], "电影D": ["动作", "犯罪", "悬疑"], "电影E": ["科幻", "动作", "冒险"], } # 将标签列表包装成 TaggedDocument,tags 参数传入物品ID documents = [ TaggedDocument(words=tags, tags=[item_id]) for item_id, tags in item_tags.items() ] model = Doc2Vec( documents, vector_size=100, # 向量的维度,维度越高语义区分越细,但也越容易过拟合 window=5, # 标签上下文窗口,标签数小时影响不明显 min_count=1, # 降低阈值,保证低频标签也被训练 epochs=40, # 训练轮数,小数据集需要适当加大 seed=42 # 固定随机种子,方便复现实验 ) # 获取与电影A最相似的物品 similar_items = model.dv.most_similar("电影A", topn=5) 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_size=100, window=5, min_count=1, epochs=50) # 将物品的标签词向量做平均,得到物品向量 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, alpha=0.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_n=10): 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 的变化趋势。从那以后我每次跑推荐实验,都强制把冷启动链路和数据清洗流程完整走一遍,哪怕只是改了一个阈值,也不再跳过预处理阶段。希望帮到你。
本文还有配套的精品资源,点击获取