☰
推荐系统评分预测实战:从均值基线到矩阵分解的完整流程
2026/9/25 4:18:31 网站建设 项目流程

简介:面向电子设计大赛(电赛)参赛者、数据挖掘与推荐系统学习者的百度电影推荐比赛评分预测项目资料包,聚焦“根据用户历史评分、电影属性、评分时间等多维信息构建预测模型”的任务,完整覆盖数据预处理、特征工程、模型选择与调优、评估验证等关键环节,也可作为毕业设计开展综合实践的重要参考。包内共34个文件,以14个java源码与7个jar依赖库为主,辅以readme说明、properties运行配置、trainingset训练集、movie2id和user2id映射表、predict预测输出、gitignore等项目工程文件,整体约6.11MB。已有40人学习下载。资料保留了实际参赛项目的工程目录和日志输出,便于读者快速复现运行环境,理解从数据清洗、评分特征构造到模型输出、结果落盘的具体实现路径,进而迁移到其他推荐系统或数据竞赛任务中,提升机器学习与数据挖掘的实战能力。

1. 电赛:百度电影推荐评分预测,为什么回归不能当分类做

那年我拿电赛的百度电影推荐题练手,第一次线上提交就被现实泼了冷水:我把评分当成分类问题,输出四舍五入的整数,RMSE卡在1.2下不去,排名掉到后半段。后来改成回归输出浮点数,同一个模型分数明显抬了一截。这个比赛的核心就是评分预测——给一批用户-电影-评分三元组训练样本,再给一批待预测的(user, movie)对,要求预测1到5分的评分,官方用RMSE评定。适合刚能跑通回归模型、想完整刷一遍推荐系统流程的人;这套参赛包把数据梳理、基线、协同过滤、矩阵分解到融合提交的路径串好了,照着复现能直接上手。

2. 数据口径:三张表、四个字段、一个RMSE脚本

打开这份参赛包,通常不是文件夹越复杂越好,反而是越朴素越省事。推荐系统比赛的数据基本就三类:用户信息表、电影信息表、评分记录表。比赛给的数据里,评分记录是核心,用户和电影表用于做特征工程。我在动手之前会先把文件挨个看一遍,确认列名、分隔符、缺失值,这一步比调模型更影响结果——后面所有验证都依赖你对数据口径的理解。

2.1 参赛包目录长什么样

先看目录结构。这个zip里的数据文件一般按下面这种形式组织:

文件路径内容主要字段
data/user.txt用户属性user_id, gender, age, occupation
data/movie.txt电影信息movie_id, title, year, genre
data/rating_train.txt训练评分user_id, movie_id, rating, timestamp
data/rating_test.txt待预测对user_id, movie_id
doc/数据说明.txt官方赛制与提交格式评分范围、评估指标

评分记录表是最容易看走眼的。字段虽然就四个,但user_id和movie_id往往是字符串,比如“u1001”“m2034”,直接读进来会变成object类型。如果后面要接Sklearn或Spark,第一步必须统一ID的编码方式,否则merge时会出现大量空值。timestamp这一列能利用的价值很高,后面按时间切验证集全靠它,不要一上来就删掉。

电影表里的year和genre是很好的特征。genre一般是一串用竖线分隔的标签,比如“动作|冒险|科幻”,做特征时要拆开统计,而不是把它当单一类目处理。user表相对简单,但年龄和职业在冷启动阶段可以兜底——当某个新用户没有任何评分历史时,至少能靠同类用户的平均分做个粗糙预测。

2.2 先写验证脚本,别在没尺子的情况下调模型

数据看完了,我习惯先把验证框架固定下来,再碰任何模型。评分预测最怕的不是模型差,而是你根本不知道模型差在哪。验证脚本里要做两件事:算RMSE、切验证集。训练测试比分两种看法:一种是用train_test_split随机抽,另一种是按时间切。比赛要预测的是用户未来的评分,随机抽样会把未来信息混进训练集,导致线下验证虚高、线上翻车,所以我一般直接按时间切。

# rmse_utils.py import numpy as np import pandas as pd def rmse(truth, pred): truth = np.asarray(truth, dtype=np.float64) pred = np.asarray(pred, dtype=np.float64) return float(np.sqrt(np.mean((pred - truth) ** 2))) def split_by_time(df, ratio=0.8): df = df.sort_values('timestamp').reset_index(drop=True) cut = int(len(df) * ratio) return df.iloc[:cut], df.iloc[cut:]

这个脚本的作用有两个:rmse函数负责算最终指标的平方根误差;split_by_time按时间戳把样本排序,取前80%当训练、后20%当验证。这样做是因为线上需要预测的评分都发生在训练数据之后,只有用时间切分,验证集才能模拟线上环境。

参数说明里,ratio一般取0.8到0.9,太小验证集波动大,太大训练集不充分。具体跑的时候可以在0.8附近多试两档,看误差变化是否稳定。如果数据没有timestamp,就退而求其次用train_test_split(df, test_size=0.2, random_state=42),但心里要知道这种随机切分偏乐观,最终分数要打点折扣看。

2.3 提交格式的细节决定你能否进决赛

很多人前期模型调得顺利,最后栽在提交格式上。这个比赛要求的提交文件一般是一个不带表头的文本文件,每行对应一个待预测样本,字段顺序为user_id、movie_id、rating_pred,评分推荐保留六位小数。行数必须与测试集完全一致,多一行或少一行都可能被判无效。

检查项要求常见错误
行数等于测试集行数合并测试表用了inner join,丢了冷门样本
预测值范围建议在1到5之间模型预测出负值或大于5的值,没有截断
小数位保留六位直接提交了整数或者科学计数法
文件编码UTF-8微软默认保存成GBK,中文备注乱码或换行符错乱

这里有个坑我当时踩得很深:用DataFrame做merge时,如果测试集里有电影在训练评分表里没有出现过,map函数会返回NaN,最后生成的文件行数看起来没变,但NaN会被写成空字符串,提交的时候就出事了。所以每次生成提交文件后,我用print(submit.shape)核对行数,用print(submit.isnull().sum())检查空值,这两个打印语句比模型本身还重要。

3. 基线跑通:均值模型、偏置项与梯度下降

任何评分预测任务,先跑通一个最朴素的基线,再谈高级模型。很多新手上来就套深度学习,结果模型框架跑起来了,数据预处理没跟上,最后分数还不如一个均值模型。先做基线不是为了刷榜,而是为了建立一条清晰的参考线:后续模型如果连物品均值都打不过,那说明问题不在模型结构,而在数据或特征侧。

3.1 全局均值与物品均值:先确认代码没写崩

第一个基线是全局均值模型。它的逻辑很简单——不管用户是谁、电影是谁,预测值都取所有评分的平均值。看起来蠢,但它能验证一条完整的数据通路:读数据、算平均值、生成提交文件。分数大概会落在1.2附近的RMSE,这个数字会成为后面所有模型的起点。

import pandas as pd import numpy as np train = pd.read_csv('data/rating_train.txt', sep='\t', names=['user_id', 'movie_id', 'rating', 'timestamp']) test = pd.read_csv('data/rating_test.txt', sep='\t', names=['user_id', 'movie_id']) train['rating'] = train['rating'].astype(float) global_mean = train['rating'].mean() movie_mean = train.groupby('movie_id')['rating'].mean() test['pred_global'] = global_mean test['pred_movie'] = test['movie_id'].map(movie_mean).fillna(global_mean) print('global mean predict RMSE on train:', rmse(train['rating'], np.full(len(train), global_mean)))

逻辑上这步做的是用groupby算出每部电影的平均分,然后映射到测试集。核心点在fillna(global_mean)——冷门电影在训练集里没有评分,映射后是空值,必须用全局均值兜底,否则提交文件里会出现空字段。这个兜底动作后面会反复用到。

从pred_movie这条预测线来看,物品均值通常比全局均值低0.1到0.2个RMSE。原因是评分行为有强烈的物品倾向:好电影就是容易得高分,烂片就是容易得低分。用物品均值等于先把“这个电影本身的口碑”吃进去了,这在冷启动场景下是性价比最高的一步。

3.2 用户偏置加电影偏置:用梯度下降手写一个baseline

物品均值之后,第二个该做的是偏置模型。它的思想是:评分 = 全局均值 + 用户偏置 + 电影偏置。用户偏置表达的是“这个用户打分是偏松还是偏严”,电影偏置表达“这部电影整体口碑的偏移”。

from collections import defaultdict def train_bias(df, lr=0.01, reg=0.02, iters=20): global_mean = df['rating'].mean() bu = defaultdict(float) bi = defaultdict(float) for _ in range(iters): for u, m, r in df[['user_id', 'movie_id', 'rating']].values: pred = global_mean + bu[u] + bi[m] err = r - pred bu[u] += lr * (err - reg * bu[u]) bi[m] += lr * (err - reg * bi[m]) return global_mean, bu, bi

这个训练函数用的是随机梯度下降的雏形:每个样本进来,先算预测误差,再沿着误差方向更新该用户和该电影的偏置。lr是学习率,控制每一步迈多大;reg是正则化系数,防止某个只出现过几次的用户或电影偏置被冲到极端值。

参数设置上,lr=0.01、reg=0.02、iters=20是起步档。数据量大时可以把iters压到10,数据量小时可以放到30,判断依据是验证集RMSE是否还在下降。与物品均值相比,偏置模型一般能再往下拉0.05左右,因为它额外吸收了用户打分习惯的信息。

注意这里用的是Python的defaultdict(float),好处是没见过的key自动返回0.0,预测时不需要再写判断。这个模型最大的短板在于它假设用户和电影的影响是线性的、互不干扰的,真实情况下用户喜欢动作片还是文艺片,会影响他对一部电影的评分——这部分交给第4章的矩阵分解。

3.3 验证集上怎么比较模型

验证集必须一视同仁。切完验证集后,把所有模型都predict到同一批验证样本上,算RMSE,再做横向对比。千万别搞混——有些模型用了全部数据训练,又拿到全部数据上评,这种情况下看到的RMSE是训练集的,不能代表线上水平。

我在这个阶段会用一张表记录每个模型的表现:

模型验证集RMSE说明
全局均值约1.2基准线
物品均值约1.1冷启动用全局均值兜底
用户偏置+电影偏置约1.05梯度下降训练
后续矩阵分解约0.95加了隐向量

这张表的目的不是记数字,而是确认每一步改进的方向是对的。如果某个模型比上一个还差,先检查代码有没有bug,再考虑模型设计问题。大部分时候都是数据处理出了毛病,不是算法的错。

4. 矩阵分解:从ItemCF到SVD,以及什么时候换Scala

偏置模型做到头,想再往下压RMSE,就得建模用户和电影之间的交互。最经典的手段是协同过滤和矩阵分解。这章会用ItemCF和SVD两条路来讲:ItemCF偏向直观的可解释预测,SVD则是评分预测里性价比最高的模型。竞赛圈里常说的“电影推荐系统scala”版本,就是在这个阶段把训练搬到Spark上去做的。

4.1 先算ItemCF:用相似电影打分做加权

ItemCF的原理是:想预测用户u对电影m的评分,先找和m最相似的k部电影,再看用户u在那些电影上打过分多少,按相似度加权求平均。选ItemCF而不是UserCF,主要原因是物品数量通常远小于用户数量,计算量小且结果稳定;而且物品相似度相对用户在短期内变化更慢,缓存后能反复用。

from scipy.sparse import csr_matrix from sklearn.metrics.pairwise import cosine_similarity R = csr_matrix((train['rating'], (train['movie_id'], train['user_id']))) R = R.toarray() sim = cosine_similarity(R.T) # 电影与电影的相似度矩阵 def itemcf_predict(movie_id, user_id, sim, R, k=30): if movie_id not in movie_id_index: return global_mean idx = movie_id_index[movie_id] sim_scores = sim[idx] rated = np.where(R[:, user_id] > 0)[0] rated = rated[np.argsort(sim_scores[rated])[-k:]] if len(rated) == 0: return global_mean w = sim_scores[rated] w[w < 0] = 0 return np.average(R[rated, user_id], weights=w)

这里我把评分矩阵R的行当电影、列当用户。cosine_similarity(R.T)算的是电影间的相似度,结果存在sim里。预测时取出用户打过的所有电影,找和目标电影最像的top 30部,用相似度作为权重做加权平均。

这段代码只是功能演示,真实工程中建议用稀疏矩阵的knn实现,不要toarray展开,否则内存会爆掉。它的推广价值在于让“相似物品”这个概念具象化——你会发现续集、同系列电影经常聚成一个紧密的簇,这类信号在评分预测里非常强。ItemCF在验证集上通常能跑到和偏置模型持平或略好,但它和偏置模型信息重合度高,最后融合时权重不宜给太大。

4.2 手写正则化SVD:矩阵分解的落地写法

真正让评分预测分数有明显提升的,是带偏置的矩阵分解,也就是常说的SVD。它的公式是:评分 ≈ 全局均值 + 用户偏置 + 电影偏置 + 用户隐向量·电影隐向量。隐向量就是把用户和电影都映射到一个低维空间,维度通常取10到50,这个空间里的点积表达了用户和电影之间的匹配程度。

import numpy as np from collections import defaultdict def train_svd(df, dim=20, lr=0.005, reg=0.02, epochs=30): global_mean = df['rating'].mean() bu = defaultdict(float) bi = defaultdict(float) pu = defaultdict(lambda: np.random.normal(0, 0.1, dim)) qi = defaultdict(lambda: np.random.normal(0, 0.1, dim)) for epoch in range(epochs): df = df.sample(frac=1, random_state=42) for u, m, r in df[['user_id', 'movie_id', 'rating']].values: pred = global_mean + bu[u] + bi[m] + pu[u].dot(qi[m]) err = r - pred pu[u] += lr * (err * qi[m] - reg * pu[u]) qi[m] += lr * (err * pu[u] - reg * qi[m]) bu[u] += lr * (err - reg * bu[u]) bi[m] += lr * (err - reg * bi[m]) return pu, qi, bu, bi, global_mean

这个train_svd函数把之前偏置模型的更新逻辑扩展了一层:除了更新偏置,还更新用户隐向量和电影隐向量。点积pu[u].dot(qi[m])是模型逼近用户偏好的核心机制,err是当前样本的预测误差,沿着误差的反方向推参数,就是SGD的本质。

参数说明里,dim是隐向量维度,太小拟合不足,太大会过拟合。lr=0.005比偏置模型小,因为隐向量的参数空间更大,步子太大容易震荡。reg=0.02是正则化,它在每次更新时让向量往零的方向缩一点,避免某个用户看了一部电影就被赋予极端权重。epochs决定遍历数据的次数,这个值不需要贪多,15到30轮之间很容易逼近最优值。

和ItemCF比,SVD能捕捉到“用户喜欢这类电影”的潜在偏好,而不是依赖可观测的显式相似。我的经验是,SVD和偏置模型分别单独跑完后再融合,效果往往好过只用其中任何一个,因为偏置模型负责大势,SVD负责细节。

4.3 数据量大了再换Scala和Spark

如果训练数据从几十万条涨到几百万条,Python里的这个双循环就成了瓶颈。我一般把SVD的训练搬到Spark的ALS上,用Scala写训练流程。这也是“电影推荐系统scala”这个搜索词常出现的原因——用Spark处理大规模推荐矩阵是工业级的标配做法。

import org.apache.spark.ml.recommendation.ALS val als = new ALS() .setMaxIter(15) .setRank(50) .setRegParam(0.05) .setUserCol("user_id") .setItemCol("movie_id") .setRatingCol("rating") .setColdStartStrategy("drop") val model = als.fit(training) val predictions = model.transform(testing)

这里的关键参数和Python版本一一对应:maxIter相当于epochs,rank相当于dim,regParam相当于reg。setColdStartStrategy("drop")是Spark里一个容易忽略的坑:如果不设置,测试集里出现没见过的user或movie,ALS会输出NaN,设置成drop会丢弃这些预测,但要注意丢掉的样本需要在提交前手动补全局均值。alpha参数用于隐式反馈,但评分预测是显式反馈场景,保持默认即可。

什么时候换Scala?我的判断标准是:当单机训练一轮SVD超过15分钟,或者内存吃力时,就搬到Spark。比赛阶段大多数数据集用Python已经够用,没必要为了“技术先进”提前上分布式;但如果你要来年再接数据量更大的推荐比赛,提前熟悉ALS这套接口会轻松很多。

5. 避坑:评分预测最容易翻车的五个细节

评分预测赛题看着简单,坑几乎都在数据口径和验证逻辑里。这章把我在这个项目里踩过、也见过别人踩的五个问题整理出来,每条按“现象→原因→解决”的顺序说清楚。

5.1 验证集随机切分导致线上翻车

现象:本地验证RMSE很好看,线上提交后分数倒退一大截,排名反而落后。

原因:随机抽样把未来时间的评分混进了训练集,模型学到了“未来”的信息;而线上测试集里的评分全部发生在训练集之后,随机切分的验证结论天然偏乐观。

解决:优先按时间戳排序再切分,用前80%当训练、后20%当验证。没有时间戳时,退而求其次按user_id哈希分层抽样,至少要保证同一用户的评分不会被全部塞进训练集或验证集。

5.2 提交文件行数少了或出现空值

现象:生成的提交文件只有九成行,或者提交后被提示非法。

原因:合并测试集时用了inner join,冷门电影在训练集里没有评分,被直接丢弃;或者map之后没有fillna,空值写入了文件。

解决:测试集一律用how='left'合并,预测列全部fillna(global_mean);提交前强制检查submit.shape[0] == test.shape[0]和submit.isnull().sum().sum() == 0,两行断言能拦住八成事故。

5.3 把评分当分类导致RMSE卡住

现象:预测值全是1到5的整数,RMSE死活下不去。

原因:评分看起来是离散值,但RMSE是面向连续值的平方损失。模型输出四舍五入的整数等于人为限制了预测精度,稍微偏一点就产生不可挽回的损失。

解决:训练和预测全程用浮点数输出,提交前如果官方要求保留六位小数就照做,不要round。只有在展示给用户看时,再做离散化处理。

5.4 Spark上ID类型不一致导致特征错乱

现象:同一个user_id,在训练集和测试集里分别被读成了整数和字符串,模型分不对人。

原因:训练数据清洗时把ID转成了数字,测试数据保持原样,或者反过来。Spark ALS要求user和item列类型一致,否则运行时报错;就算不报错,也是悄悄把ID错配。

解决:在Spark里统一用StringIndexer给ID建映射,训练和预测共用同一套映射关系,提交前用IndexToString把预测结果对应回原始user_id和movie_id。

5.5 训练损失下降但验证损失反弹

现象:训练RMSE一路走低,到第12轮后验证RMSE开始回升,我还是跑完了30轮。

原因:模型过拟合了。推荐数据里噪声很多,有些用户只看了一部电影就打分,这类样本很容易被模型背下来。

解决:每个epoch结束后在验证集上算一次RMSE,记录最优轮次;如果验证RMSE连续两轮上升就提前停。SVD的正则化系数从0.02往0.05方向调,通常能换回一点稳定性。

6. 融合与提交:让三个模型互补,再踩一脚油门

在评分预测比赛里,单模型冲到一定分数后就会撞天花板。这时候真正管用的手段是融合:把偏置模型、ItemCF和SVD的预测结果按权重叠起来,靠各自的优势互补。融合不是玄学,它有清晰的逻辑——不同模型的误差来源不一样,线性加权能抵消一部分偏差。

6.1 加权线性融合的小脚本

我一般直接在验证集上优化三个权重,目标函数还是RMSE,约束是权重和为1。

from scipy.optimize import minimize def blend_loss(w): return rmse(val_truth, w[0] * p_bias + w[1] * p_itemcf + w[2] * p_svd) def constraint(w): return 1.0 - sum(w) ini = [0.4, 0.2, 0.4] res = minimize(blend_loss, ini, method='Nelder-Mead', constraints={'type': 'eq', 'fun': constraint})

这段代码把三个模型在验证集上的预测列当作三个特征,用minimize搜索最优权重。blend_loss返回的是当前权重下的RMSE;constraint保证三个权重加起来等于1。经过几轮迭代,通常会发现SVD的权重在0.5上下,偏置模型和ItemCF瓜分剩下部分。

权重稳定后,把这组权重应用到测试集上,融合预测的RMSE一般比最好的单模型再低0.02到0.03。这个提升在排名榜上可能是几十名的差距。

6.2 提交前十分钟的检查清单

融合完模型,我有一张固定的提交前检查表,照着走一遍就安心了:

检查维度方法通过标准
行数与测试集文件逐行比对完全一致
空值isnull().sum()为零
预测范围截断到1和5之间无NaN、无负值
小数位文件里取几条看格式六位小数
文件编码用UTF-8保存中文不乱码

这张表看着简单,每一行都是我翻车后总结出来的。现在每次提交前都强制走一遍,前后不超过十分钟,但能挡掉八成提交事故。

6.3 让最终分数再涨一点的小技巧

最后说一个提升很细微但很稳定的技巧:在融合后的预测上,叠加用户最近评分均值。做法是统计每个用户最近10条评分的时间加权平均值,把它作为偏置加到预测结果里。这个特征捕捉的是用户近期打分的松紧变化——比如用户最近连续给了几部烂片低分,他对下一部电影也会带有类似的严苛倾向。融合后加上这个微调,验证集RMSE通常还能降0.005到0.01,幅度不大,但在排名胶着时很有价值。

从那以后,我每次做评分预测任务都会强制走一遍这套流程:先按时间切验证集,再跑均值基线和偏置模型,上SVD,融合,提交前查行数和空值。这已经是我的肌肉记忆了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询