简介:面向计算机专业毕业设计、课程设计及推荐系统入门者,内容围绕基于 Spark 的电影推荐系统展开,重点解决海量影片场景下的个性化筛选与推荐算法落地问题。压缩包共 1 个 docx 文件,大小约 7.46MB,以论文文档形式组织,涵盖绪论、Spark/MongoDB/Web 开发技术、基于人口统计学/内容/协同过滤的推荐算法、系统分析与设计、登录注册、个性化推荐、电影搜索、用户评分及功能与性能测试等章节。读者可据此梳理推荐系统的完整设计脉络,理解不同算法的适用场景与组合方式,并参考其需求分析、模块划分、实现流程和测试思路,用于开题、论文写作或项目复现。目前已有 207 人学习,适合需要从算法原理到系统实现建立整体认知的本科及初学开发者,也可作为毕业设计选题与答辩材料整理的参考。
1. 从一条失败的推荐链路说起:这套 Spark 电影推荐系统要解决什么
做基于 Spark 的电影推荐系统,最容易翻车的往往不是协同过滤的公式写错,问题更常出在数据链路上:离线任务跑完,前端拿不到结果;用户刚评完分,首页推的还是那三部老片子。这套大数据场景下的电影推荐系统,把离线统计、内容推荐、协同过滤和实时推荐拆成四条互不阻塞的链路,各自产出候选集再统一合并,任何一路挂掉都不至于让首页开天窗。它面向的是要交一份完整毕设或课程设计的人:手上有 Spark 环境、有一份评分数据,需要一套能演示、能讲清、能写进论文的完整方案,而不只是跑在 notebook 里的算法 demo。
2. 技术选型与总体架构:三层结构、四类推荐入口和六张结果表
先算一笔账:一个课程设计级别的电影数据集,评分表通常在百万到千万行之间。这个量级用单机脚本也能跑,但要让人信服你为什么上 Spark,选型理由就得站得住:ALS 属于迭代型算法,训练十轮就要对同一份评分矩阵读写十次,Spark 把中间结果留在内存里,MapReduce 每轮都往磁盘落一次,十轮下来是十次 I/O。演示的时候这一点很直观,任务监控页上每个 stage 的耗时能直接把差别摆出来。
2.1 为什么是 Spark + MongoDB + Redis + Elasticsearch 这四件套
Spark 承担计算,理由前面说了。真正容易被忽略的是存储侧的搭配,这三样各管一段,缺一段系统就会在某个环节变得很难看。
MongoDB 管主数据和结果表。电影这条数据的字段天生不规整:类别是数组、演员是数组、有的片子有片长有的没有、有的有上映地区有的没有。用关系型数据库就得先建宽表再改表,文档库直接把 JSON 塞进去,字段增减不影响已有记录。更关键的是 Spark 有官方 Connector,支持按条件读、整表覆盖写,离线任务把算好的 DataFrame 一次性写回去,不用拼一堆 INSERT。
Redis 管缓存。首页的热门榜单是典型的读多写少,每秒可能被刷几十次,每次都去 MongoDB 聚合一遍会把库压垮。榜单算完直接写进 Redis 的有序集合,按分数取前 N 条是 O(log N),接口响应从几百毫秒降到几毫秒。
Elasticsearch 管搜索。搜索框里输入两个字,要能在片名、简介、演员名里做模糊匹配,MongoDB 的正则查询在这个文档量级上会走全表扫描,加索引也只能前缀匹配。倒排索引天然适合这种场景,而且它还顺带能存一份电影文档,详情页的相似推荐可以直接用它的 more_like_this 做兜底。
提示:这四件套不必一开始全装上。先把 Spark + MongoDB 跑通,等首页接口压测出现慢查询,再引入 Redis;等搜索体验被吐槽,再上 Elasticsearch。一次装全,调试成本会翻倍。
2.2 分层结构:view 层、business 层、算法层各自管什么
系统按三层切开,切的原则只有一条:算法层和业务层之间只通过结果表耦合,业务层永远不调用算法。
view 层用 AngularJS 做响应式页面,负责渲染和收集操作,不做任何业务判断。登录注册、评分、打标签、搜索、取推荐列表,全部走 business 层的 REST 接口。这一层薄一点没关系,薄才好在答辩时讲清楚"页面只是个壳"。
business 层用 Spring 搭,Maven 管依赖。它的职责是鉴权、参数校验、组装返回结构,以及从结果表里按优先级取推荐。它不算推荐,只读表。
算法层分两条:离线批任务用 Spark SQL 和 RDD,每天凌晨跑一次,产出各类榜单和用户推荐表;实时流任务用 Spark Streaming,监听评分事件,几百毫秒内更新当前用户的推荐结果。两条链路写的是不同的表,互不干扰。
读推荐结果这段逻辑值得单独写一下,因为它是"兜底"发生的地方。用户打开首页时,接口按下面的顺序找数据,找到就返回:
// 推荐结果读取的兜底顺序,任一层有数据就返回,避免首页开天窗 public List<Movie> getRecommendList(String uid, int size) { // 1. 优先取实时推荐结果(用户最近一次评分后 24 小时内有效) List<Movie> streamRecs = mongoRepo.findStreamRecs(uid, size); if (streamRecs.size() >= size) { return streamRecs; } // 2. 取离线协同过滤结果(UserRecs 表,每天凌晨刷新) List<Movie> offlineRecs = mongoRepo.findUserRecs(uid, size); if (!offlineRecs.isEmpty()) { return merge(streamRecs, offlineRecs, size); } // 3. 冷启动兜底:按平均评分取高分电影,再按类别 Top10 补齐 return hotFallback(size); }这段代码的关键在注释里写明的三层顺序。第一层看实时结果,第二层看离线结果,第三层看热门兜底。参数size是前端要的条数,merge方法负责去重和截断,同一个mid只保留一次。做法上,我一般会把三层都写成独立方法,方便单测时逐个 mock,也方便在答辩现场单独演示某一层失效后系统还能不能出结果。三层全空的情况几乎不会出现,因为第三层不依赖任何用户行为,只要有电影数据就有输出。
2.3 数据模型:原始三张表与结果六张表怎么定字段
数据模型是这个系统里最值得先定下来的东西,改一次字段,离线、实时、前端三处都要跟着改。原始数据只有三张表:用户表、电影表、评分表。结果表有六张,全部落在 MongoDB 里。
| 表名 | 类型 | 主要字段 | 产出方 | 消费方 |
|---|---|---|---|---|
| User | 原始 | uid、username、password、genres | 注册接口 | 登录、推荐 |
| Movie | 原始 | mid、name、genres、actors、directors、year | 初始化导入 | 详情页、搜索 |
| Rating | 原始 | uid、mid、score、timestamp | 评分接口 | 所有算法 |
| AverageMovies | 结果 | mid、avgScore | 离线统计 | 高分榜、兜底 |
| GenresTopMovies | 结果 | genres、mid、avgScore | 离线统计 | 类别页 |
| RateMoreMovies | 结果 | mid、count | 离线统计 | 热门榜 |
| RateMoreRecentlyMovies | 结果 | mid、count、month | 离线统计 | 新片榜 |
| UserRecs | 结果 | uid、recs[{mid, score}] | 离线 CF | 首页、猜你喜欢 |
| UserStreamRecs | 结果 | uid、recs[{mid, score}]、ts | 实时流 | 首页优先 |
recs字段用内嵌数组而不是单独一张关联表,原因是推荐结果的读取模式永远是"给定 uid 取整条",内嵌之后一次查询就够,不需要 join。数组内部再按 score 降序排好,取前 N 条直接切数组。
电影表的genres字段用竖线分隔的字符串存,比如"动作|科幻|冒险",不是数组。这个决定看着是倒退,但它让离线任务的 SQL 更好写,也避免了导入数据时数组解析失败整批挂掉。切分的工作放到算法侧用 UDF 做,代价可接受。
3. 离线统计推荐:四条 Spark SQL 作业把"热门"和"榜单"算明白
离线部分是整个系统的地基。它每天凌晨跑一次,把前一天 accumulate 的评分数据重新算一遍,产出六张结果表。这部分不需要用户画像,不需要模型训练,用 Spark SQL 就能全部搞定,但它决定了新用户进来看见什么,也决定了推荐算法全部挂掉时系统还剩什么。
四条作业的输入都是同一张 Rating 表,输出落到不同的集合。它们之间没有依赖,可以并行跑,也可以单独重跑某一条而不影响其他。这种设计在调试阶段特别省事,改一条 SQL 就只重跑那一条。
3.1 历史热门与近期热门:两份聚合的差别就在时间窗
历史热门的统计逻辑最简单:按mid分组,数一下有多少条评分记录,倒序排出前 N 名。它反映的是"这部电影被讨论的总量",老片天然占优势。
// 历史热门:按电影分组统计评分次数,取 Top N val ratingDF = spark.read .format("com.mongodb.spark.sql.DefaultSource") .option("uri", mongoUri) .option("collection", "Rating") .load() val rateMoreMovies = ratingDF .groupBy("mid") .count() // 评分次数即热度 .withColumnRenamed("count", "count") .orderBy(desc("count")) .limit(100) // 只存前 100,够首页轮播用 rateMoreMovies.write .format("com.mongodb.spark.sql.DefaultSource") .option("uri", mongoUri) .option("collection", "RateMoreMovies") .mode("overwrite") // 整表覆盖,保证幂等 .save()mode("overwrite")这个参数是整段代码里最重要的一个。如果写成 append,每跑一次任务表里就多一份结果,跑一周之后首页会开始出现重复电影,而且很难查出是哪天开始错的。整表覆盖的代价是任务执行期间这张表短暂不可用,但离线任务在凌晨跑,这个窗口可以接受。
近期热门的逻辑一样,只是多了个时间过滤条件。timestamp字段存的是毫秒时间戳,拿当前时间减去 30 天的毫秒数做比较:
// 近期热门:只统计最近 30 天内的评分,避免老片长期霸榜 val recentRateMoreMovies = ratingDF .filter(col("timestamp") > System.currentTimeMillis() - 30L * 24 * 3600 * 1000) .groupBy("mid") .count() .orderBy(desc("count")) .limit(100)这里有个取舍要讲清楚:近期热门的窗口设成 30 天,是为了让榜单有新片,但数据集小的时候这个窗口可能过滤掉太多数据,导致榜单只有几条。做课程设计的数据集往往只覆盖一两年,30 天窗口内可能只有几千条评分。我的做法是先算一下窗口内的记录数,低于一个阈值(比如 5000 条)就把窗口放宽到 90 天,这个判断写成一个配置项,答辩时能讲"窗口是自适应的",比写死一个数字更有说服力。
3.2 平均评分与类别 Top10:UDF 切分类别是这段的核心
平均评分的统计有个坑:直接对score求平均,会出现"一个人打了 5 分就冲上榜首"的情况。标准的做法是加一个人数门槛,评分人数少于 10 人的电影不进入榜单。
// 平均评分:过滤掉评分人数过少的电影,避免小样本刷榜 val averageMovies = ratingDF .groupBy("mid") .agg( round(avg("score"), 1).as("avgScore"), // 保留一位小数,减少展示误差 count("score").as("ratingCount") ) .filter(col("ratingCount") >= 10) // 门槛值可按数据量调整 .select("mid", "avgScore")round保留一位小数的原因不只是好看,还因为前端展示时两位小数会让排行榜的排序看起来"抖动"——4.25 和 4.24 在页面上几乎看不出差别,但排序会变。一位小数能让榜单稳定一些。
类别 Top10 是四条作业里最麻烦的一条。电影表里genres是"动作|科幻|冒险"这样的字符串,要按类别分组,就得先把它拆成多行。项目里的原始思路是用 UDF 切分后做笛卡尔积匹配再过滤,能跑通,但 shuffle 量大。通常的做法是切分成数组后直接explode:
// 类别 Top10:把 "动作|科幻" 拆成多行,再用窗口函数取每个类别的前十 val splitGenres = udf((genres: String) => genres.split("\\|")) val genreTopMovies = movieDF .withColumn("genre", explode(splitGenres(col("genres")))) // 一行变多行 .join(averageMovies, Seq("mid")) // 关联平均分 .withColumn("rank", row_number().over( Window.partitionBy("genre").orderBy(desc("avgScore")) )) .filter(col("rank") <= 10) // 每个类别只留前 10 .select("genre", "mid", "avgScore")explode把数组炸成多行,一部电影属于三个类别就变成三行,后面按genre分区排序,row_number()给每个类别内部编号,最后筛出编号小于等于 10 的。这个写法比笛卡尔积少一次全量 join,在数据量上去之后差别很明显。窗口函数的partitionBy一定要写在orderBy前面,写反了不会报错,但结果是全局排名而不是分类别排名,这个错误很隐蔽。
3.3 结果落库与调度:幂等和重试比性能更重要
四条作业算完之后统一写 MongoDB,每条作业对应一个集合,都使用 overwrite 模式。写入这一步的性能不是瓶颈,因为结果表都很小,几千到几万条。真正要设计的是失败处理。
一套可行的调度方式是:离线任务写成一个可执行 jar,用 crontab 每天凌晨两点触发,同时把每次运行的时间、处理行数、耗时写进一张日志表。任务失败时,日志表里留下的是"开始时间有、结束时间无"的记录,第二天早上打开页面一眼就能看出来昨晚没跑完。
重试策略上,我一般设三次重试,间隔五分钟。三次都失败就放弃,让页面继续用前一天的旧数据——因为结果表是整表覆盖,失败的那次不会破坏已有数据,这也是幂等设计带来的好处。
注意:不要给四条作业共用一个 jar 入口然后串行执行。串行的话,第一条失败后面全不跑;并行的话,某一条失败不影响其他三条的结果更新。启动脚本里用
&加wait就能做到并行。
4. 内容推荐与协同过滤:详情页相似、实时加权和 ALS 兜底的三条实现线
离线统计解决的是"所有人都看什么",解决不了"我该看什么"。个性化这部分要三条线配合:详情页的相似推荐解决"看完这部还想看什么",实时推荐解决"我刚才评了分,首页得立刻变",协同过滤解决"给我一批我没见过但可能喜欢的"。三条线的算法不同、触发时机不同、写的结果表也不同。
4.1 详情页相似推荐:标签向量化加余弦相似度
用户在详情页点开一部电影,下面要出一排"相似电影"。这一步用的是基于内容的推荐,特征是电影的元数据:类别、导演、主演、年份。
特征的权重需要区分。类别是最强的信号,一部科幻片最像的另一部片子大概率也是科幻;导演和主演次之,同一个导演的作品风格接近但不一定同类型;年份的权重最低,只在其他特征接近时用来微调。实际实现时给每个特征一个权重系数,类别 1.0、导演 0.6、主演 0.4、年份 0.2,加权后拼成一个稀疏向量。
// 详情页相似推荐:加权拼特征向量,用余弦相似度找最近的 N 部 def buildFeatureVector(movie: Movie): Map[String, Double] = { val genreFeatures = movie.genres.map(g => s"genre:$g" -> 1.0) // 类别权重最高 val directorFeatures = movie.directors.map(d => s"director:$d" -> 0.6) val actorFeatures = movie.actors.take(5).map(a => s"actor:$a" -> 0.4) // 只取前 5 位主演 (genreFeatures ++ directorFeatures ++ actorFeatures).toMap } // 余弦相似度:点积除以模长乘积,结果落在 0 到 1 之间 def cosine(a: Map[String, Double], b: Map[String, Double]): Double = { val dot = a.keySet.intersect(b.keySet).map(k => a(k) * b(k)).sum val normA = math.sqrt(a.values.map(v => v * v).sum) val normB = math.sqrt(b.values.map(v => v * v).sum) if (normA == 0 || normB == 0) 0.0 else dot / (normA * normB) }take(5)这个截断是必要的。有些电影的演员表列了几十个人,全量参与计算会让向量维度爆炸,而且排在后面的演员对相似度的贡献很小。取前五位主演,是准确度和计算量之间的一个平衡点,具体取几位可以按数据集调整。
这段计算不需要分布式。单部电影的相似候选集,先按类别做一次粗筛(只跟同类别或类别有交集的电影比),候选集通常只有几千部,单机算余弦相似度几毫秒就完事。结果可以缓存进 Redis,key 是similar:{mid},详情页直接读缓存。只有新电影入库时才触发一次重算。
4.2 实时推荐:评分事件触发的一次加权合并
实时推荐的触发点是用户打分。用户对电影 m 打了 4 分,系统要立刻回答一个问题:基于这个 4 分,该给他推什么。
流程分四步。第一步,从缓存里取出 m 的相似电影列表(就是 4.1 算出来的那批)。第二步,按相似度给每部候选电影打分,相似度越高、用户给的原始分越高,候选分越高。第三步,把这批候选和用户已有的实时推荐结果合并,同一个mid取较高分。第四步,按分数排序保留前 20 条,写回结果表并刷新 Redis。
// 实时推荐核心:用户评分 -> 找相似电影 -> 加权打分 -> 合并去重 val userRating = 4.0 // 本次评分 val similarMovies = redis.getSimilar(mid) // 相似电影及其相似度 val candidates = similarMovies.map { case (simMid, simScore) => // 加权公式:相似度 × 评分归一化值,评分 5 分制除以 5 (simMid, simScore * (userRating / 5.0)) } val existing = mongo.getStreamRecs(uid) // 已有的实时推荐 val merged = (candidates ++ existing) .groupBy(_._1) .map { case (mid, pairs) => (mid, pairs.map(_._2).max) } // 同一部取最高分 .toList .sortBy(-_._2) .take(20)pairs.map(_._2).max这个合并策略值得说一下。如果用求和,用户反复给同类电影打分会让某些电影分数虚高,最终霸占整个推荐位;取最大值则更稳定,一部电影的最高可能得分由它和用户看过的最相似的那部决定。参数上,20 条是结果表的容量上限,前端只展示前 8 条,留余量是为了前端做"换一批"时不用重新请求。
实时链路的输入源,课程设计阶段用本地目录或者 socket 就够,评分接口把事件写成一行日志,Streaming 监听目录。数据量真上去了再换消息队列,但这一步不建议一开始就做,多一个组件就多一处配置出错的地方。
4.3 协同过滤 ALS:参数怎么调,RMSE 低不代表推荐好
离线个性化推荐用 ALS(交替最小二乘)。它把用户-电影评分矩阵拆成两个低维矩阵,用户矩阵和电影矩阵,两个矩阵相乘就能预测任意用户对任意电影的评分。
// ALS 训练:先切训练集测试集,再用 RMSE 评估参数 val splits = ratingRDD.randomSplit(Array(0.8, 0.2)) // 8:2 切分 val (trainData, testData) = (splits(0), splits(1)) val model = ALS.train( trainData, rank = 50, // 隐因子维度,数据量小就降到 20 iterations = 10, // 迭代轮数,观察 RMSE 收敛后停 lambda = 0.01 // 正则项,防止过拟合 ) // 在测试集上算 RMSE,作为论文里的评估指标 val predictions = model.predict(testData.map(r => (r.user, r.product))) val rmse = math.sqrt( predictions.map(p => math.pow(p.rating - testData.lookup(p.user, p.product), 2)).mean() )三个参数的调整思路:rank是隐因子维度,它决定模型能表达多复杂的偏好,但太大会过拟合,数据量小的时候 20 就够,50 是常见起点。iterations看 RMSE 什么时候不再下降,通常 10 到 20 轮收敛。lambda是正则强度,评分数据稀疏时要调大,0.01 到 0.1 之间试。
这里有个血泪经验:RMSE 低不代表推荐结果好看。RMSE 衡量的是预测分和真实分的接近程度,模型完全可以通过给所有电影都预测 3.5 分来把 RMSE 压得很低,但这样推出来的结果是随机的。判断推荐质量还得看另一个指标——把用户评分最高的若干部电影从训练集里挖掉,看模型能不能把它们排进推荐列表的前 N 名,这个指标叫命中率,比 RMSE 更贴近实际体验。
还有一个必踩的坑:ALS 训练出来的结果里,会包含用户已经看过的电影。写结果表之前必须做一次过滤,把用户评分过的mid从推荐列表里剔除,否则首页会出现"猜你喜欢:你刚看完的那部"。
4.4 混合策略:四路结果怎么合并成一个列表
四个推荐入口各有各的适用场景,最终的首页列表是它们加权合并的结果。
| 推荐入口 | 算法 | 触发时机 | 权重 | 结果表 |
|---|---|---|---|---|
| 实时推荐 | 内容相似度加权 | 用户评分后秒级 | 0.4 | UserStreamRecs |
| 离线个性化 | ALS 协同过滤 | 每天凌晨 | 0.3 | UserRecs |
| 近期热门 | 时间窗计数 | 每天凌晨 | 0.2 | RateMoreRecentlyMovies |
| 类别 Top10 | 分组排序 | 每天凌晨 | 0.1 | GenresTopMovies |
权重的分配原则是"越贴近当前行为的结果越靠前"。实时推荐权重最高,因为它反映的是用户刚刚表达的兴趣;协同过滤次之,它反映长期偏好;热门榜单用来填充,保证列表永远有内容;类别 Top10 最后兜底。
合并之后必须做两件事:去重和多样性控制。去重好办,按mid分组取最高分就行。多样性控制容易被忽略——如果前 8 条推荐全是科幻片,用户体验会很差。我的做法是限制同一类别的电影最多占 3 条,超出的往后排,用优先队列实现。这个逻辑写起来不到二十行,但答辩演示时的观感差别很大。
5. 避坑与常见问题排查:环境、连接、冷启动和实时链路的五个高频翻车点
这套系统涉及四个中间件加一套集群,配置项加起来上百个。下面五条是按"最容易卡住人"的顺序排的,每一条都在实际搭建中出现过。
5.1 集群启动了但任务一直停在等待状态
现象:执行start-all.sh之后访问集群监控页,Worker 列表是空的,或者只有部分节点。提交任务后一直卡在等待资源的阶段,日志里没有报错。
原因:九成是主机名解析的问题。集群配置里写的是主机名,但各节点的 hosts 文件没配全,节点之间互相解析不到。另一种情况是 Worker 启动时用的内存超过了机器的实际可用内存,进程起来后立刻被杀掉,监控页上看起来就是"没注册上"。
解决:先在每个节点上ping一遍其他节点的主机名,通了再启动集群。内存配置上,Worker 分配的内存加上驱动程序内存不能超过物理内存的 70%,四个虚拟节点跑在一台物理机上尤其要克制,每个节点给 1G 到 2G 是常见配置。配置改完必须重启整个集群,改配置文件不重启是不生效的。
5.2 离线任务能跑完但写库时报连接超时
现象:Spark 任务在控制台里正常跑完,各个 stage 都显示成功,最后写 MongoDB 那一步报超时或者认证失败。
原因:大多是连接串写错,或者驱动程序版本和 Spark 版本不匹配。连接串里的数据库名、集合名、认证参数any一个写错都会导致连接失败,而报错信息往往只说"超时",看不出具体哪里错。
解决:先在本地用命令行客户端连一次,确认连接串本身可用,再放到代码里。如果命令行能连而代码连不上,检查驱动包有没有打进 jar——用 Maven 打包时 Connector 的依赖要设成 compile 而不是 provided,否则集群上找不到类。这个错误的表现是"ClassNotFound",和连接超时完全不同,日志里要看清第一个异常是什么。
5.3 新注册用户的首页一片空白
现象:老用户登录正常有推荐,新注册用户登录后推荐区域是空的,或者显示"暂无数据"。
原因:协同过滤需要历史评分,新用户一条评分都没有,ALS 模型算不出结果,UserRecs 表里也就没有他的记录。
解决:读推荐结果的接口必须带三级兜底,这个在第 2 章已经写了。另外可以在注册流程里加一步"选三个你喜欢的类别",把选择结果写进用户表,新用户第一次进首页时按类别从 GenresTopMovies 里取数据。这一步的成本很低,但对"冷启动"这个答辩必问点的回答很有帮助。
5.4 实时推荐结果越堆越多,同一部电影出现好几次
现象:用户评分几次之后,实时推荐列表里同一部电影重复出现,或者列表长度超过设定的上限。
原因:合并逻辑写成了追加而不是去重合并。每来一次评分就往结果数组里 push 一批,没有对已有数据做去重。
解决:合并前先按mid分组,同一部电影取最高分,再排序截断。写库时用整条覆盖而不是数组追加。如果已经写脏了数据,写一个一次性脚本,遍历 UserStreamRecs 每条记录,内部去重后覆盖回去,这个脚本十分钟能写完,比手工清理靠谱。
5.5 模型评估指标很好看,推荐出来全是同一类
现象:RMSE 在测试集上表现不错,但实际推荐结果里清一色是同一个类别的电影,多样性很差。
原因:ALS 在评分数据稀疏、类别分布不均时会向热门类别塌缩。数据里某个类别的评分记录占了一半以上,模型学到的隐因子主要反映这个类别的特征,推荐自然全往那边走。
解决:两个方向。一是在训练数据里做类别均衡采样,对记录数过多的类别做下采样;二是在最终合并阶段加类别配额,这个前面提过。前者改的是模型,后者改的是展示,两个一起做效果最好。评估指标也要换,除了 RMSE 再加上覆盖率和多样性的统计,论文里把这两个指标一起列出来,比单写一个 RMSE 完整得多。
6. 验收与答辩:三张表、一条链路把系统"跑得动"证明给评委看
验收这件事,很多人栽在"系统确实能跑,但讲不出来"。评委不会花二十分钟看你点页面,他要的是三样东西:功能清单、测试记录、一条能从头走到尾的数据链路。
功能测试建议按角色分类列成表,每一项标清楚输入、预期、实际结果。下面这张表是我常用的格式,直接照着填就行,重点是"边界输入"那一列,它体现你有没有认真测过。
| 模块 | 测试项 | 输入 | 预期结果 | 实际 |
|---|---|---|---|---|
| 注册登录 | 重复用户名 | 已存在用户名 | 提示已占用,不创建 | 一致 |
| 注册登录 | 空密码 | 密码为空 | 前端拦截,不提交 | 一致 |
| 评分 | 重复评分 | 同一用户同一电影 | 覆盖旧分,不新增记录 | 一致 |
| 搜索 | 模糊词 | 输入片名中两个字 | 返回匹配列表 | 一致 |
| 推荐 | 新用户首页 | 无评分记录账号 | 返回类别热门,非空 | 一致 |
| 推荐 | 实时更新 | 评分后刷新 | 推荐列表出现相关电影 | 一致 |
性能测试不用做得太复杂,两组数据就够汇报:一是离线任务的耗时,记录不同数据量下四条 SQL 作业各自的执行时间,画个折线图;二是首页接口的响应时间,对比接缓存前后的差别。数据量小的时候差别不明显,可以把缓存关掉再测一次,把 MongoDB 直查的耗时也记下来,两组数字摆在一起,"为什么用 Redis"这个问题就不用再解释了。
答辩现场最有说服力的一招,是当场走一遍完整链路。提前准备好一个测试账号,从登录开始,点开一部电影,打个分,返回首页,指着推荐列表里新出现的那部电影说:"这条数据是刚才那次评分触发的,实时任务在几百毫秒内写进了结果表,首页读的就是它。"整个过程三十秒,比讲十分钟算法原理管用。为了这一步不出意外,演示前一定要把实时任务的日志打开,万一现场没刷新出来,日志里能看到数据走到哪一步了,不至于当场卡住。
有个细节容易被忽略:答辩用的数据集要提前准备好,别用全量数据。几十万条评分跑一次离线任务要几分钟,现场等不起。准备一份裁剪过的小数据集,几千条记录,任务几十秒跑完,效果肉眼可见,讲的时候还能说"这份数据是项目的子集,全量跑起来是一样的流程"。
最后说个我自己的教训。第一次做这类系统的时候,我把所有精力放在算法调参上,RMSE 压到很低,结果答辩时被问"用户给一部电影打分的完整流程里,数据经过哪几个组件",我卡了半分钟才答上来。从那以后我每次做项目,都会强制走一遍"一条数据从产生到消费"的全链路追踪,把每一步的存储位置和格式记在纸上。这件事花不了半小时,但它决定了你能不能把系统讲明白。希望帮到你。
本文还有配套的精品资源,点击获取