在线课程推荐系统实战:协同过滤+Spring Boot+Vue前后端源码解析
简介一套基于推荐算法的在线课程推荐系统设计与实现源代码面向 Java 后端学习者、毕业设计学生及需要快速搭建课程推荐项目的开发者完整解决从数据库设计、后端接口到前端页面的全流程实现问题。项目基于 Spring Boot MySQL 开发管理员端涵盖用户管理、课程信息管理、课程类型管理、课程评价管理、学习进度管理、意见反馈与互动交流等模块用户端支持个人中心、课程评价、学习进度、我的发布与我的收藏。资源共 617 个文件包含 121 个 Java 源码文件、93 个 Vue 前端文件、63 个 JS 脚本、15 个 XML 配置及 1 个 SQL 数据库脚本另有说明文档、LW 文档和演示视频压缩包约 34.53MB。读者可获得完整可运行的前后端工程、数据库初始化脚本、部署说明及操作演示已有 42 人学习/下载适合毕业设计参考、课程设计答辩或二次开发。1. 基于推荐算法的在线课程推荐系统这套完整前后端源码到底能跑出什么当课程数量从几十门涨到几百门用户面对一屏列表根本不知道先点哪门课时推荐就是用数据替用户做选择。这个标题的源码包不是一份算法论文堆砌而是一套“用户能登录、课程能浏览、评分能记录、首页能推荐”的完整闭环前端有页面可点、后端有接口可调、MySQL 有表可查外加说明文档与 LW 设计报告是典型的毕设/课程设计级全栈项目。适合三类人正在纠结毕设题目的在校生、想从增删改查转向推荐方向的后端开发者以及想拿一个可运行基线再逐步优化的初学者。拿到它之后你最需要想清楚的只有一件事推荐算法到底是怎么和业务数据真正连起来的。2. 先定推荐方案再建表课程场景下的协同过滤选型与 MySQL 建模2.1 课程推荐为什么优先走协同过滤而不是内容分析在线课程系统的业务特征很明显课程有明确分类和简介用户有注册 ID、浏览记录和评分入口。表面上有两条推荐路线可走——基于课程内容的推荐或者基于用户行为的协同过滤。很多初学者一上来就想做内容分析觉得“推荐”必须理解课程讲什么结果卡在关键词提取和文本相似度上最后做出来的东西既不像推荐系统也解释不清效果。内容推荐需要为每门课维护标签体系还要对课程简介做分词和向量化工作量大且效果很难量化矩阵分解类算法虽然精度高但要处理矩阵稀疏、迭代收敛和正则化参数调参周期比写代码还长。协同过滤的优势在于它只需要一张评分表用户给课程打了多少分就能算出用户之间或课程之间的相似度。而且 UserCF基于用户的协同过滤天然适合“和你兴趣相似的人也在学某某课程”这种解释做毕设答辩时一句话就能讲清楚推荐依据。对比下来这个场景的最优起点就是 UserCF 加一个热门榜兜底。我一般会把算法选型写成一张小表放在项目说明里既方便自己梳理也方便答辩时展示选型过程。方案数据要求冷启动表现实现成本可解释性基于内容课程标签/文本新课程友好高一般矩阵分解 SVD评分矩阵充足差高差UserCF 协同过滤用户评分行为靠热门榜兜底低强ItemCF 协同过滤用户评分行为新用户仍难低强2.2 三张核心表用户表、课程表、评分表整套系统的数据模型不需要一上来就设计十几张表。用户、课程、评分这三张表就能跑通推荐主链路剩下的表比如收藏表、学习记录表本质都是在给评分表提供辅助信息来源。我习惯先把这三张表的结构固定下来再考虑是否扩展。下面的 SQL 是三张表的常见建表写法适用于 MySQL 5.7 或更高版本CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT 密码密文, nickname VARCHAR(50) DEFAULT COMMENT 昵称, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE course ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 课程ID, title VARCHAR(100) NOT NULL COMMENT 课程名称, category VARCHAR(50) DEFAULT COMMENT 分类, difficulty TINYINT DEFAULT 1 COMMENT 难度 1入门 2进阶 3高阶, cover_url VARCHAR(255) DEFAULT COMMENT 封面图, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表; CREATE TABLE rating ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, course_id BIGINT NOT NULL COMMENT 课程ID, score TINYINT NOT NULL COMMENT 评分1-5, rating_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 评分时间, PRIMARY KEY (id), UNIQUE KEY uk_user_course (user_id, course_id), KEY idx_course (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程评分表;这三张表的核心是uk_user_course唯一索引它保证一个用户对同一门课只有一条评分记录后续做“用户是否评过分”的判断时不需要写额外的去重逻辑。course表加分类索引是为了做分类榜兜底和后台课程管理。字段类型上要注意score用TINYINT而不是INT既节约空间也表达了取值范围约束。建表阶段最常见的翻车点是把时间字段设成VARCHAR或者评分没有唯一索引导致重复数据把相似度算偏。评分表里的时间字段不要默认给值因为推荐系统做时间衰减时要拿评分时间和当前时间做差如果所有行时间一样衰减就失去了意义。2.3 显式评分与隐式行为权重的合并策略真实的在线课程平台里愿意主动打星级的用户很少大多数用户只看课、收藏课、学到一半退出。这种情况下只用rating表做协同过滤会发现矩阵极度稀疏几乎每两个用户之间都没有共同评分课程。常见做法是把隐式行为折算成评分后写回评分表或者建一张行为表再实时换算。我一般会建一张user_behavior表来记录浏览、收藏、学习完成三类行为然后定期用一条 SQL 把行为折算成分数并归并到rating表。折算规则可以做成常量配置浏览一次算 1 分、收藏一次算 2 分、章节学习完成算 3 分、显式评分按原始分 5 分制直接使用。合并公式取加权平均INSERT INTO rating (user_id, course_id, score, rating_time) SELECT user_id, course_id, ROUND(0.7 * MAX(score) 0.3 * ( SUM(CASE WHEN behavior_type VIEW THEN 1 ELSE 0 END) * 1 SUM(CASE WHEN behavior_type FAV THEN 2 ELSE 0 END) * 2 SUM(CASE WHEN behavior_type LEARN THEN 3 ELSE 0 END) * 3 ), 1) FROM ( SELECT r.user_id, r.course_id, r.score, b.behavior_type FROM rating r LEFT JOIN user_behavior b ON r.user_id b.user_id AND r.course_id b.course_id WHERE r.score IS NOT NULL ) t GROUP BY user_id, course_id ON DUPLICATE KEY UPDATE score VALUES(score), rating_time NOW();这段 SQL 的执行逻辑是先把带显式评分的记录与行为表做关联再对每个用户-课程对做聚合最后通过唯一索引把折算结果写回rating表。权重 0.7 和 0.3 不是定死的冷启动阶段用户显式评分太少我会把行为权重调到 0.5 以上平台上活跃打分用户多了以后再恢复成显式评分为主。需要注意ON DUPLICATE KEY UPDATE依赖uk_user_course唯一索引如果建表时漏掉这条唯一索引这个归并写法就会退化成重复插入。折算后的score可能出现小数所以要保留一位小数并确保后端算法读取评分时做浮点处理不要强转成整数否则区分度会明显下降。3. 用 Spring Boot 把 UserCF 协同过滤写进后端服务3.1 后端工程结构与推荐接口的依赖组织拿到这套源码先看后端目录结构。常见做法是 Spring Boot 配合 MyBatis-Plus按 controller/service/mapper/entity 四层组织。MyBatis-Plus 能减少大量单表 CRUD 的样板代码评分表的插入、查询全部用它的 LambdaQueryWrapper 就能完成不需要手写 XML 映射文件。src/main/java/com/example/course/ ├── controller/ │ ├── AuthController.java │ ├── CourseController.java │ └── RecommendController.java ├── service/ │ ├── RatingService.java │ └── RecommendService.java ├── mapper/ │ ├── UserMapper.java │ ├── CourseMapper.java │ └── RatingMapper.java ├── entity/ │ ├── User.java │ ├── Course.java │ └── Rating.java └── config/ └── CorsConfig.java依赖配置要注意一个原则不要追求最新版本用你本地环境里已经验证过的一整套版本组合。不同版本的 Spring Boot 对 MyBatis-Plus 的兼容性差异较大换版本后经常出现启动报错或 Mapper 扫描失效。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId /dependency这些依赖里我没有写具体版本号因为版本组合和你的 Spring Boot 父工程强相关。建议的做法是先建一个空的 Spring Boot 项目再依次加入 MyBatis-Plus 和 MySQL 驱动启动通过后再把推荐代码搬进去。这个顺序能帮你把“环境问题”和“代码问题”分开排查避免两件事混在一起时找不到头绪。3.2 用户相似度计算与 TopN 推荐的完整实现UserCF 的完整链路是加载评分数据 → 构建用户-课程评分表 → 计算目标用户与其他用户的余弦相似度 → 取 K 个最近邻 → 加权预测未学课程的得分 → 排序取 TopN。下面这段代码是核心逻辑可以直接放到RecommendService里作为第一版实现Service public class RecommendService { Autowired private RatingMapper ratingMapper; Autowired private CourseMapper courseMapper; private final int TOP_N 10; private final int NEIGHBOR_K 20; private final double SIMILARITY_THRESHOLD 0.1; public ListCourse recommendForUser(Long userId) { ListRating allRatings ratingMapper.selectList(null); if (allRatings.isEmpty()) { return courseMapper.selectHotCourses(TOP_N); } MapLong, MapLong, Double userCourseScoreMap new HashMap(); for (Rating r : allRatings) { userCourseScoreMap .computeIfAbsent(r.getUserId(), k - new HashMap()) .put(r.getCourseId(), r.getScore().doubleValue()); } MapLong, Double userSimMap new HashMap(); MapLong, Double neighborScoreSum new HashMap(); MapLong, Double neighborSimSum new HashMap(); MapLong, Double targetUserCourses userCourseScoreMap.get(userId); if (targetUserCourses null || targetUserCourses.isEmpty()) { return courseMapper.selectHotCourses(TOP_N); } for (Map.EntryLong, MapLong, Double entry : userCourseScoreMap.entrySet()) { Long otherUserId entry.getKey(); if (otherUserId.equals(userId)) { continue; } MapLong, Double otherCourses entry.getValue(); double dot 0.0, normA 0.0, normB 0.0; for (Map.EntryLong, Double courseEntry : targetUserCourses.entrySet()) { Double otherScore otherCourses.get(courseEntry.getKey()); if (otherScore ! null) { dot courseEntry.getValue() * otherScore; } normA courseEntry.getValue() * courseEntry.getValue(); } if (dot 0.0) { continue; } for (Double s : otherCourses.values()) { normB s * s; } double sim dot / (Math.sqrt(normA) * Math.sqrt(normB)); if (sim SIMILARITY_THRESHOLD) { userSimMap.put(otherUserId, sim); } } if (userSimMap.isEmpty()) { return courseMapper.selectHotCourses(TOP_N); } ListMap.EntryLong, Double sortedSims new ArrayList(userSimMap.entrySet()); sortedSims.sort((a, b) - Double.compare(b.getValue(), a.getValue())); ListMap.EntryLong, Double neighbors sortedSims.subList(0, Math.min(NEIGHBOR_K, sortedSims.size())); for (Map.EntryLong, Double neighbor : neighbors) { Long neighborId neighbor.getKey(); double sim neighbor.getValue(); MapLong, Double neighborCourses userCourseScoreMap.get(neighborId); for (Map.EntryLong, Double courseEntry : neighborCourses.entrySet()) { Long courseId courseEntry.getKey(); if (targetUserCourses.containsKey(courseId)) { continue; } neighborScoreSum.put(courseId, neighborScoreSum.getOrDefault(courseId, 0.0) sim * courseEntry.getValue()); neighborSimSum.put(courseId, neighborSimSum.getOrDefault(courseId, 0.0) sim); } } MapLong, Double predictScoreMap new HashMap(); for (Long courseId : neighborScoreSum.keySet()) { double predictScore neighborScoreSum.get(courseId) / neighborSimSum.get(courseId); predictScoreMap.put(courseId, predictScore); } return predictScoreMap.entrySet().stream() .sorted((a, b) - Double.compare(b.getValue(), a.getValue())) .limit(TOP_N) .map(entry - courseMapper.selectById(entry.getKey())) .collect(Collectors.toList()); } }这段代码的思路是先算目标用户和其他每个用户的余弦相似度把低于阈值的用户直接丢掉然后从相似度最高的前 K 个邻居里收集他们没有学过、而目标用户也没学过的课程按相似度加权做预测评分。dot / (normA * normB)就是余弦相似度公式它天然把评分向量的长度归一化掉了不会因为某个用户评了很多门课就和他过度相似。参数上有三个地方值得关注TOP_N决定推荐列表长度一般设 10 到 20NEIGHBOR_K是邻居数量评分数据稀疏时可以调到 30密度高时 15 就够SIMILARITY_THRESHOLD是相似度下限课程评分矩阵通常很稀疏用户间相似度普遍在 0.1 到 0.5 之间阈值设到 0.3 以上很容易让所有邻居被过滤掉最终退化成热门榜。3.3 相似度阈值、最近邻 K 值与冷启动兜底的调参方法这套参数没有绝对的最优值必须结合自己的评分数据来定。一个有效的方法是先写一段测试代码导出每个用户与所有其他用户的相似度分布看看平均值和中位数再决定阈值。假如大多数相似度都在 0.05 到 0.2 之间那么阈值设 0.1 是合理的设 0.3 就会让每个用户都找不到邻居系统变成了纯热门推荐。参数常见区间数据稀疏时数据稠密时TOP_N 推荐数量10~201020NEIGHBOR_K 邻居数15~303015SIMILARITY_THRESHOLD0.05~0.30.050.2冷启动兜底热门榜 TOP10依赖热门榜依赖个性化冷启动兜底在 UserCF 里是必须的新注册用户没有评分记录协同过滤无从谈起。我一般会写一个selectHotCourses查询按评分人数倒序取前 10 门课。这不只是应付界面的手段也是推荐系统上线初期的通用策略——没有用户行为数据时热度就是最可靠的推荐信号。需要特别强调的是不要把协同过滤代码和热门榜逻辑混在一个方法里。我见过有同学在recommendForUser里先查热门课程再套一个“过滤掉已学过课程”的条件结果相似度为 0 时返回空列表前端白屏。正确做法是无评分用户直接走热门榜有评分但找不到邻居时也走热门榜两者之间互不污染。4. Vue.js 前端联调课程列表、评分入口与“为你推荐”页4.1 页面构成与路由设计前端最常见的落地方案是 Vue.js 搭配 Element UI页面数量控制在 4 到 5 个登录注册页、课程列表页、课程详情页、推荐页和后台管理页。路由设计要按“能不能进推荐页”来区分权限未登录用户访问推荐页时直接跳转登录这既符合产品逻辑也能避免后端接口拿到一个空 userId 时做无意义计算。const routes [ { path: /login, component: Login, meta: { public: true } }, { path: /courses, component: CourseList, meta: { public: true } }, { path: /course/:id, component: CourseDetail }, { path: /recommend, component: Recommend, meta: { requiresAuth: true } } ];路由守卫里检查 token 是否存在不存在就重定向到/login。课程列表页设为公开访问是因为用户浏览列表本身就是一种行为记录后端可以在用户点击详情时写入浏览记录为后续行为折算提供数据。推荐页置于登录状态之后因为推荐系统要以用户 ID 为输入才能返回个性化结果。4.2 后端推荐接口与前端 Axios 调用推荐接口的设计重点是返回结构要稳定。我习惯把后端结果统一包装成{ code, message, data }三段式前端无论拿到成功还是失败都能按同一套逻辑处理。下面是接口的 Controller 写法和前端调用方式RestController RequestMapping(/api) public class RecommendController { Autowired private RecommendService recommendService; GetMapping(/recommend/{userId}) public ResultListCourse recommend(PathVariable Long userId, RequestParam(defaultValue 10) int topN) { if (userId null) { return Result.error(未登录); } ListCourse courses recommendService.recommendForUser(userId); return Result.success(courses); } }export function fetchRecommend(userId, topN 10) { return axios.get(/api/recommend/${userId}, { params: { topN } }); }前端拿到data数组后用一个el-row配合el-col渲染课程卡片。每个卡片展示封面、标题、分类和难度标签点击后跳转课程详情页。这里要提醒一下推荐接口返回的课程对象里应包含评分人数或者推荐指数否则用户看不出“为什么推荐这门课”界面会显得很空。常见的做法是在 Course 实体里加一个recommendReason字段后端填充“与你兴趣相似的同学也在学”之类的文案。4.3 用户打分后推荐结果如何立刻变化用户在前端点击评分1 到 5 星后前端调用后端POST /api/rating写入rating表。这是推荐结果产生变化的触发点。很多毕设项目在这里直接重新调用recommendForUser全量计算一遍数据量小时没问题评分表超过几千条后接口就会明显卡顿。推荐引擎里常见的做法是加一层缓存推荐结果按 userId 缓存用户评分后只删除该用户的缓存下次请求再重算。下面是一个用本地 Map 做缓存的简化实现适合演示环境Component public class RecommendCache { private MapLong, ListCourse cache new ConcurrentHashMap(); public ListCourse get(Long userId) { return cache.get(userId); } public void put(Long userId, ListCourse courses) { cache.put(userId, courses); } public void evict(Long userId) { cache.remove(userId); } }RatingService里在保存评分记录后调用recommendCache.evict(userId)推荐接口在返回前先查缓存没有命中再走完整算法。这一步做完用户打分后刷新推荐页就能看到新结果而其他用户的缓存不受影响性能体验都正常。这个缓存方案虽然简单但已经是生产环境里常见的“写穿透型”缓存思路。5. 部署与联调避坑乱码、跨域、相似度全零与“全是热门”的排查记录5.1 课程表中文乱码评分记录却正常现象课程列表中英文正常中文课程名全部显示为问号而评分记录里的数字评分显示正常。原因建表时DEFAULT CHARSET不是utf8mb4或者 JDBC 连接串缺了characterEncodingutf8参数。数字不受字符集影响所以评分记录看起来正常只有中文文本暴露问题。解决先检查连接串再检查表结构。连接串要显式加上字符集参数并按下面的 SQL 把表结构调整到utf8mb4ALTER TABLE course CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;修改完表结构后还要确认前端页面里的Content-Type请求头包含charsetUTF-8否则前端提交的中文数据仍然会乱。这个问题最容易出现在 Windows 环境下建议全部环境统一用 UTF-8不要用系统默认编码。5.2 后端接口通了浏览器却报 CORS 跨域错误现象前端启动在 8080 端口后端启动在 8081 端口前端请求/api/recommend/1时浏览器控制台报Access-Control-Allow-Origin错误但用 Postman 直接调后端接口又是通的。原因浏览器有同源策略Postman 不受这个限制所以接口本身没问题只是前端和后端端口不同。解决在后端加一个全局 CORS 配置类允许前端域名访问。下面是常见的配置写法Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:8080) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }这个配置只放行/api/**路径避免把后台管理接口也暴露给任意来源。allowCredentials(true)要和具体的allowedOrigins配合使用不能用*否则浏览器会拒绝带 cookie 的请求。如果项目使用了 Spring SecurityCORS 配置还要在安全过滤链里同时开启否则拦截器先拦截请求CORS 过滤器根本没机会处理。5.3 相似度矩阵全为 0推荐页永远空白现象评分表里有几十条数据但推荐页始终返回空数组接口也不报错。后来加了日志才发现目标用户与所有其他用户的相似度都是 0。原因循环里只对“有共同评分课程”的用户累加dot但normA和normB的计算条件不一致。更隐蔽的原因是score字段存的是整数代码里却用getScore()与doubleValue()做运算某些中间步骤被强转成了整数。解决在相似度计算循环里加一行打印语句看每个邻居的dot、normA、normB分别是多少System.out.printf(user%d, dot%.2f, normA%.2f, normB%.2f, sim%.4f%n, otherUserId, dot, normA, normB, sim);如果sim全是 0重点检查dot是否为 0也就是两个用户之间确实没有共同评分的课程如果dot有值但sim仍为 0那就是normA或normB计算出错。这个排查思路同样适用于 ItemCF核心是先把相似度这个中间产物打出来不要直接看最终推荐结果。5.4 推荐结果全是最热课程毫无个性化现象推荐接口能返回结果但每个用户的推荐列表都差不多几乎全是评分人数最多的那几门课。原因评分数据太稀疏每个用户只对一两门课打过星协同过滤找不到足够邻居代码里命中“热门榜兜底”分支。另一个常见原因是相似度阈值设太高比如 0.5导致所有邻居都被过滤。解决分两步走。第一步降低SIMILARITY_THRESHOLD从 0.1 降到 0.05 再观察第二步给平台补充行为数据把浏览、收藏折算成评分增加用户间共同评价课程的概率。如果时间来不及可以做一个简单的混合推荐把个性化结果和热门结果按比例融合ListCourse personalized recommendService.recommendForUser(userId); ListCourse hot courseMapper.selectHotCourses(TOP_N); // 按 80% 个性化 20% 热门 做兜底融合这个 80/20 的比例只是起点不是标准值。数据稀疏时个性化结果质量差把热门比例调高到 40% 反而更合理。关键在于让推荐列表既包含若干热门课程也包含基于相似用户产生的新课程这样界面看起来才有“个性化”的感觉。5.5 推荐接口第一次访问超时假死第二次却正常现象服务刚启动时访问推荐页浏览器转圈十几秒才出结果刷新一次后速度变快。原因缓存失效或未命中时算法全量加载评分表并做双层循环评分数据量在几千条以上时计算耗时明显。第一次没有缓存所以慢第二次命中缓存所以快。解决给本地缓存加上“启动时预热”逻辑。在RecommendService的初始化阶段找几个活跃用户先跑一遍推荐并写入缓存另外给缓存加一个定时过期策略避免用户长期不访问导致数据陈旧。需要注意的是本地 Map 缓存只适合单机演示环境如果部署到多实例每台机器的缓存会不一致那时应该换 Redis 或者统一缓存服务。6. 进阶技巧离线评估与混合加权让推荐结果不仅“能看”还能“扛问”拿到这套推荐系统、跑通前后端之后大多数人的下一步是往算法里堆参数。这里我建议你先停下来做一件事把评分数据按时间切分成训练集和测试集用离线评估代替肉眼判断。否则推荐效果的好坏全凭感觉答辩或评审时被问一句“你的推荐准确率是多少”会很难收场。具体做法是取最近 20% 的评分记录作为测试集其余 80% 作为训练集。注意一定要按时间切分不能随机切分。如果随机切分训练集里包含了未来的评分评估出来的指标虚高上线后却可能翻车。切分后用测试集里的用户 ID 去跑推荐算法看推荐列表里有没有命中用户在测试集中真正评分的课程。命中一门算一个正例准确率等于命中数除以推荐列表长度召回率等于命中数除以该用户在测试集中的评分课程数。这两项指标跑出来推荐质量就有了一组可汇报的数字。之后再做融合加权。写一个混合推荐方法把 UserCF 的预测得分、课程热度得分、新课程加分按权重相加再统一排序final_score 0.6 * userCF_score_normalized 0.3 * popularity_score_normalized 0.1 * new_course_bonus权重仍然建议用离线评估来定不要凭空拍脑袋。我自己的习惯是每次调完参数先跑一遍离线评估看准确率、召回率和覆盖率三个指标指标没降再上线看实际效果。覆盖率容易被忽略它反映的是推荐列表里出现了多少门不同的课程——如果永远推那几门热课准确率可能很好看但用户会很快失去兴趣。我自己第一次做推荐项目时只盯着推荐列表“像不像回事”没算覆盖率结果答辩时被问“你是不是永远推那几门课”当场问住了。后来养成了先离线评估再上线的习惯连参数调整都有了依据。推荐系统这块最容易让人沉迷于调参但真正能说明价值的永远是那些可以复现、可以比较的数字。希望帮到你。本文还有配套的精品资源点击获取