Java机器学习图书推荐系统源码:ItemCF与TF-IDF工程实践

发布时间:2026/11/5 11:11:40
Java机器学习图书推荐系统源码:ItemCF与TF-IDF工程实践

简介:这份源码包围绕基于机器学习的图书推荐系统展开,面向Java Web课程设计、毕业设计以及希望实践协同过滤推荐算法的开发者,重点解决图书个性化推荐与后台管理问题。项目包含图书管理、读书会管理及科技、人文、历史、中小学等图书分类推荐模块;推荐部分先提取图书特征,再依据用户点击行为记录偏好,计算喜好图书与候选图书间的距离并返回近邻结果,前端由HTML、CSS与JSP页面呈现,后端基于SpringMVC组织。压缩包共172个文件、约17MB,含25个java源文件、58个class、32个jsp页面、12个jar依赖、8个xml配置及1个sql脚本等,覆盖源码、页面、依赖与建库脚本,目录按控制层、数据访问层、实体与页面资源组织。已有259人学习,适合对照完整工程理解推荐流程、模块划分与数据库设计,也可作为二次开发的基础,并参考特征提取、行为记录、距离计算到推荐输出的实现链路。

1. 一份 Java 写的机器学习图书推荐系统源码,真正的门槛在哪

第一次打开这类源码的人,注意力一般都在算法部分:是不是用了矩阵分解、有没有上深度学习。我拆这份机器学习图书推荐系统源码时,先看的却是另外两处——评分表的时间字段,和相似度矩阵的存储方式。原因很直接:图书推荐这个业务的天然特征是“用户少、图书多、行为稀疏”,绝大多数翻车都发生在数据口径和工程实现上,而不是模型选得不够新。这套源码用 Java 承担在线服务,Python 侧做离线训练和相似度计算,中间靠 CSV 与模型文件交换,适合中小体量的图书电商、馆藏检索终端、内容社区的书单模块。下面按“能不能跑通 → 怎么调 → 坑在哪”的顺序往下拆。

2. 工程结构与数据口径:先把“推荐什么”定义清楚

很多人拿到源码第一反应是改模型,我一般反过来:先把数据链路和目录结构读一遍,搞清楚线上那次请求到底用到了哪几份产物。图书推荐的效果上限,八成在样本口径定下来那一刻就决定了。同样一份 ItemCF 代码,评分阈值取 3 和取 4,跑出来的推荐列表能差出一个量级,这不是玄学,是样本定义变了。

2.1 源码目录的三层划分:业务层、算法层、调度层

这套工程大致分三层,读代码时按层切比按文件切快得多。

目录职责改动频率
controller/service/mapper请求处理、鉴权、分页、缓存读写高,业务需求都在这
algo(Java 侧)加载相似度矩阵、多路召回、融合排序中,调权重时动
offline(Python 侧)相似度计算、内容向量、离线指标低,跑通后很少碰
job定时导出样本、触发训练、回灌产物低
resources/model相似度文件、TF-IDF 向量、ID 映射表由 job 覆盖

分层的价值在回滚成本。模型产物和代码解耦之后,某次重训效果变差,只要把上一版相似度文件和 ID 映射一起换回去,服务重启即可恢复,不用回滚代码。我见过把相似度矩阵直接算在 Service 方法里的写法,换一版权重就得发一次版,线上的问题还没定位完,发布流程先走完了。

调度层的任务顺序也不能乱:先导出样本,再算相似度,最后写 ID 映射。映射表必须和相似度文件同一批次产出,否则线上拿新映射去查旧矩阵,轻则推荐错乱,重则数组越界。

2.2 三张核心表的字段与采样口径

图书推荐最小可用的数据模型就三张表:图书、用户、评分(或行为)。字段设计不难,难的是口径。

-- 图书表:内容召回的原料都在这里 CREATE TABLE book ( book_id BIGINT PRIMARY KEY, title VARCHAR(128) NOT NULL, author VARCHAR(64), category VARCHAR(32), -- 一级分类,用于打散 tags VARCHAR(255), -- 逗号分隔,内容召回的主力特征 intro TEXT, -- 简介,注意截断,别整段塞进向量 publish_year SMALLINT, heat INT DEFAULT 0 -- 近 30 天曝光点击加权,热度兜底用 ); -- 行为表:隐式反馈和显式评分可以并存,用 type 区分 CREATE TABLE rating ( user_id BIGINT, book_id BIGINT, score TINYINT, -- 1~5,浏览/收藏折算成 1~3 type TINYINT, -- 1 评分 2 收藏 3 浏览 ts BIGINT, -- 毫秒时间戳,切训练测试集全靠它 PRIMARY KEY (user_id, book_id, type) );

采样口径有四条硬规则,建议直接写进导出 SQL 而不是事后在 Python 里补:

  1. 正样本阈值取 4 分以上。3 分是“还行”,把它当正样本会把中庸图书的相似度整体抬高。
  2. 时间窗取近 180 天。图书兴趣衰减比商品慢,但超过半年的行为噪声明显变大。
  3. 交互少于 5 条的用户直接剔除。这类用户算不出稳定邻居,留在训练集里只会污染共现矩阵。
  4. 同一用户对同一本书多次行为只保留最高分那条,否则重复行为会把共现次数刷高。

提示:把阈值做成配置项而不是硬编码。换业务场景(比如换成教材推荐)时,正样本阈值和窗口长度几乎一定要重调。

2.3 从数据库到模型文件:一次完整的训练样本导出

Java 侧负责把样本导出成 UTF-8 的 CSV,Python 侧只认文件,不连数据库。这个约定看着土,但排查问题时极其省事——出问题先把那份 CSV 拉下来看一眼,样本对不对立刻清楚。

/** * 导出训练样本:只导出时间窗内、交互数达标的用户 * @param from 起始时间,一般取 now - 180d * @param out 输出文件,按批次命名 ratings_20240601.csv */ public void exportRatings(LocalDate from, Path out) throws Exception { String sql = """ SELECT r.user_id, r.book_id, MAX(r.score) AS score, MAX(r.ts) AS ts FROM rating r JOIN (SELECT user_id FROM rating WHERE ts >= ? GROUP BY user_id HAVING COUNT(DISTINCT book_id) >= 5) u ON u.user_id = r.user_id WHERE r.ts >= ? GROUP BY r.user_id, r.book_id ORDER BY r.ts """; long fromMs = from.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); try (Connection c = dataSource.getConnection(); PreparedStatement ps = c.prepareStatement(sql); BufferedWriter w = Files.newBufferedWriter(out, StandardCharsets.UTF_8)) { ps.setLong(1, fromMs); ps.setLong(2, fromMs); ps.setFetchSize(5000); // 防止一次性把结果集拉进堆 try (ResultSet rs = ps.executeQuery()) { w.write("user_id,book_id,score,ts\n"); while (rs.next()) { w.write(rs.getLong(1) + "," + rs.getLong(2) + "," + rs.getInt(3) + "," + rs.getLong(4) + "\n"); } } } }

逻辑上有两个点值得说。一是子查询先把“交互数 ≥ 5”的用户筛出来再 JOIN,比全表扫完再在内存里过滤快一个数量级;二是ps.setFetchSize(5000),MySQL 驱动默认会把整个结果集读进内存,几十万行样本就够把导出任务撑爆,这个参数不加,导出跑一半 OOM 是常事。

参数上,from决定窗口长度,HAVING COUNT >= 5决定用户门槛,两个数都建议外置到配置文件。图书量在十万级、行为在千万级以内时,这套“Java 导出 + Python 单机算”完全够用,上分布式计算框架反而增加运维负担;等行为数过了亿级再考虑换引擎,那时候瓶颈也不在算法上了。

3. 三路召回怎么落地:ItemCF、TF-IDF 与热度兜底

召回决定推荐列表的上限,排序只决定这几百个候选怎么排。很多源码只做了协同过滤一路,新书和长尾书永远出不来,冷启动用户直接空白页。这套工程做了三路:协同过滤管“和你口味相似的人还看了什么”,内容召回管“和你看过的书主题相近”,热度兜底保证任何情况下都有结果可出。

3.1 ItemCF 相似度矩阵:共现、归一化与 top-K 稀疏化

图书侧的数量远大于用户侧,做物品相似度比做用户相似度稳定得多,而且相似度矩阵可以离线算好、增量更新,线上只查表。这就是选 ItemCF 而不是矩阵分解的第一个理由:可解释、可增量、不需要在线推理框架。第二个理由是图书的共现信号强,用户看了 A 又看 B,语义关联通常很明确。

import numpy as np, pandas as pd from scipy.sparse import csr_matrix df = pd.read_csv("ratings_20240601.csv") users = {u: i for i, u in enumerate(df.user_id.unique())} books = {b: i for i, b in enumerate(df.book_id.unique())} # 隐式反馈口径:4 分及以上记 1,其余记 0 vals = (df.score >= 4).astype(np.float32) R = csr_matrix((vals, (df.user_id.map(users), df.book_id.map(books))), shape=(len(users), len(books))) # 图书-图书共现矩阵 C = (R.T @ R).toarray() diag = np.diag(C).copy() np.fill_diagonal(C, 0) # 余弦归一化:抑制热门书把相似度整体拉高 S = C / (np.sqrt(diag)[:, None] * np.sqrt(diag)[None, :] + 1e-9) np.fill_diagonal(S, 0) # 只保留每本书 top-K 邻居,控制内存与线上扫描成本 K = 200 idx = np.argpartition(-S, K, axis=1)[:, :K] rows, cols, data = [], [], [] for i in range(S.shape[0]): for j in idx[i]: if S[i, j] > 0: rows.append(i); cols.append(j); data.append(S[i, j]) np.savez_compressed("itemcf_topk.npz", row=rows, col=cols, val=data, books=np.array(list(books.keys())))

np.fill_diagonal(C, 0)这一步不能省。不做的话每本书和自己共现次数最高,归一化之后自己跟自己的相似度恒为 1,召回时第一条永远是用户刚看过的那本——这就是那份“推荐已读”问题的源头之一。

argpartition取 top-K 而不是全排序,是因为十万本书做全排序既慢又没必要。K 取 200 是经验值:取值太小,长尾书召不回来;取值太大,线上每个种子书要扫 200 个邻居,二十个种子就是四千次打分,接口延迟肉眼可见。取值大于 500 时建议改成倒排切分,按种子书分片并行。

注意:R.T @ R在十万本书、千万条行为下会得到稠密的十万乘十万矩阵,内存直接顶到几十 GB。真正上线前要先按热度裁剪图书池,只保留有评分记录的书(这批通常只占总量的两三成),再算共现。别在本地拿全量数据硬跑,这一点踩过就知道疼。

3.2 内容召回:中文图书标签的 TF-IDF 与一个分词坑

内容召回解决的是协同过滤的两类盲区:新入库的书没人评过,冷启动用户没有行为。做法是把书名、分类、标签、简介拼成一段文本,向量化之后算余弦相似度。

import jieba, pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.preprocessing import normalize bks = pd.read_csv("books.csv").fillna("") bks["intro"] = bks.intro.str.slice(0, 300) # 简介截断,头部信息密度最高 def seg(s): # 中文必须显式分词,TfidfVectorizer 默认按空白切,中文会整段成一个 token return " ".join(jieba.cut(str(s))) corpus = (bks.title + " " + bks.category + " " + bks.tags.str.replace(",", " ") + " " + bks.intro).map(seg) vec = TfidfVectorizer(max_features=20000, min_df=2, ngram_range=(1, 2)) X = normalize(vec.fit_transform(corpus)) # L2 归一化后点积即余弦

中文分词是这个环节最常见的翻车点。TfidfVectorizer默认的token_pattern处理中文会把整句话当成一个特征,最后得到的相似度全是 0 或者全是很高,看着像模型不行,其实是根本没切开。用 jieba 先切好、空格拼接之后再喂进去,是最省事的路子。

参数上,min_df=2过滤只出现一次的词,简介里的编辑套话基本都被清掉;ngram_range=(1,2)保留二元组,能让“数据结构”和“数据结构 教材”这类组合被识别出来;max_features=20000是为了控制向量文件大小,图书标签的词表通常在几万量级,两万已经能覆盖头部。简介截断到 300 字,是因为图书简介后半段大多是推荐语和作者生平,对主题判断几乎没有贡献,全量塞进去反而稀释了权重。

内容相似度不需要全量存,取每本书 top-50 就够,剩下靠协同过滤补。

3.3 三路融合与 MMR 打散:Java 侧怎么把候选攒起来

离线产出三份文件:itemcf_topk.npz、content_topk.csv、hot_list.csv。Java 服务启动时全部加载到内存,用Map<Long, double[]>或基本类型数组存放,避免每条记录都 new 一个对象——这条在图书量上十万时直接影响 GC 表现。

public List<Long> recommend(long userId, int size) { Map<Long, Double> cand = new HashMap<>(1024); // 1. 协同召回:用最近 20 条正向行为当种子,权重 1.0 for (long seed : recentPositive(userId, 20)) { neighbors(seed).forEach((bookId, sim) -> cand.merge(bookId, sim * 1.0, Double::sum)); } // 2. 内容召回:用最近 5 条行为的向量均值当用户画像,权重 0.6 double[] profile = profileOf(userId, 5); for (long bookId : contentTopN(profile, 100)) { cand.merge(bookId, contentScore(profile, bookId) * 0.6, Double::sum); } // 3. 热度兜底:归一化后权重压到 0.1,只保证有结果,不抢戏 hotList(200).forEach((bookId, h) -> cand.merge(bookId, h * 0.1, Double::sum)); // 4. 过滤已读/已购,再按分数取候选池 cand.keySet().removeAll(readSet(userId)); List<Map.Entry<Long, Double>> pool = cand.entrySet().stream() .filter(e -> !blocked(e.getKey())) .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(500) .toList(); return mmr(pool, size, 0.7); // 相关性 0.7 / 多样性 0.3 }

三个权重不是拍脑袋定的,顺序是:先把协同权重钉在 1.0 作为基准,再调内容权重,最后加热度。内容权重从 0.3 试到 1.0,一般 0.5~0.7 之间用户侧体感最好;高于 1.0 之后推荐会明显“偏题”——主题都对,但都是冷门书,点击率反而掉。

mmr(pool, size, 0.7)是最大边际相关性打散,公式是每次选书时用λ * 相关性 - (1-λ) * 与已选集合的最大相似度重新打分。图书推荐里λ取 0.6~0.75,小了推荐会变成大杂烩,大了前三本之后全是同一作者同一系列。另外无论 MMR 怎么算,都要硬性限制同一作者、同一分类最多出现 2 本,规则比调参可靠。

候选池limit(500)这个数也不是随便写的:排序层如果用粗排加精排,500 是够的;如果只做规则排序,200 就够,再大只是白烧 CPU。

4. 排序与评估:离线指标怎么算才不作假

调参最怕的是指标好看、线上没动静。图书推荐尤其容易这样,因为行为稀疏,评估集稍微切错一点,NDCG 能虚高 30%。这一章说三件事:指标怎么实现、数据集怎么切、参数按什么顺序动。

4.1 Precision@K、Recall@K 与 NDCG@K 的实现

三个指标各有用途:Precision 看列表前几条准不准,Recall 看用户真实喜欢的有多少被捞回来,NDCG 看顺序对不对。图书推荐首页只有一屏六本,NDCG@10 是最贴近体感的那个。

import numpy as np def precision_at_k(rec, truth, k): return len(set(rec[:k]) & truth) / k def recall_at_k(rec, truth, k): return len(set(rec[:k]) & truth) / len(truth) if truth else 0.0 def ndcg_at_k(rec, truth, k): dcg = sum(1 / np.log2(i + 2) for i, b in enumerate(rec[:k]) if b in truth) idcg = sum(1 / np.log2(i + 2) for i in range(min(len(truth), k))) return dcg / idcg if idcg else 0.0

实现本身没难度,坑在分母。recall_at_k里len(truth)用的是测试集里该用户实际交互过的书数,如果测试集切得太碎(比如只留了 1 本),Recall 会虚高到接近 1,这个数没有任何参考意义。我一般要求测试集里每个用户至少留 3 本,不够的用户整条剔除。

另一个常被忽略的点是评估时要对全体用户取平均还是只对“有推荐结果的用户”取平均。只算有结果的用户,指标会好看一大截,但冷启动那部分用户被悄悄藏起来了。正确做法是分两段报:全体用户的均值,以及冷启动用户单独一段,后者才是真实体感。

4.2 时间切分:把“未来信息”挡在训练集之外

随机切分是最常见的泄漏源。用户在 6 月评了 A 书,5 月的模型却“知道”这条行为,算出来的相似度自然漂亮,上线就崩。正确做法是按ts排序后切。

# 按时间切:前 80% 训练,后 20% 测试 df = df.sort_values("ts") cut = int(len(df) * 0.8) train, test = df.iloc[:cut], df.iloc[cut:] # 只保留在训练集里出现过的用户和书,冷启动单独评估 seen_users = set(train.user_id) test = test[test.user_id.isin(seen_users)]

切完之后还有一层过滤不能漏:测试集里出现、但训练集里从没出现过的书,任何模型都推不出来,把它们算进 Recall 分母等于凭空扣分。评估前要把这批书剔掉,或者单独统计成“新书曝光覆盖率”这一个独立指标。

提示:把训练集的时间上界和模型文件的生成时间一起写进文件名,比如itemcf_20240601_20240531.npz。出问题时一眼就能看出线上加载的是哪天的模型、用了哪段数据,比翻日志快得多。

4.3 超参取值区间与调参顺序

下面这张表是这类图书推荐工程里真正会动的参数,起步值可以直接抄,后面按业务体感微调。

参数作用起步值调大后的影响
ItemCF 邻居数 K每本书保留的相似书数200覆盖率升,内存与召回耗时升
最小共现次数过滤偶然共现5相似度更稳,长尾覆盖下降
正样本阈值隐式反馈口径4 分样本更干净,数量明显减少
召回权重 w_cf / w_cb / w_hot三路融合1.0 / 0.6 / 0.1热度权重超过 0.3 会明显同质化
MMR λ相关性 vs 多样性0.7越大越相关,越小越发散
候选池大小排序前候选数500排序成本近似线性增长
单作者/单分类上限硬性打散规则2 本大于 3 之后首页会显得重复

调参顺序建议固定成:先定样本口径(阈值、窗口)→ 再定邻居数 K → 再调三路权重 → 最后调 MMR。顺序反了会白跑很多轮,因为样本口径一变,前面调好的权重全部作废。每次只动一个参数,离线指标和人工抽查各看一遍——只看指标不看列表,很容易调出一个“数字漂亮但推的是同一批书”的模型。

5. 排查清单:推荐结果跑偏的五个典型现象

线上问题基本都能归到这五类,我把它们按出现频率排了序,每条都带上定位手段。

5.1 现象:所有用户首页推的是同一批书

现象很直观:换几个账号登进去,前六本几乎一样。

原因是候选池被热度兜底吃掉了。协同召回没出结果(用户行为太少、或者种子书没有邻居),内容召回向量为空,最后只剩热度那一路在贡献分数,权重虽然只有 0.1,但其他两路是 0,排序结果自然全是热门书。另一个隐性原因是相似度矩阵只加载了头部图书,长尾书查不到邻居,等于静默失败。

解决分三步:先在召回层加埋点,把三路各自贡献的候选数打出来,看是哪一路归零;再把热度兜底改成“仅在候选数小于 size 时启用”,而不是无条件合并;最后检查相似度文件的图书覆盖率,低于 60% 就说明算矩阵时图书池裁得太狠,需要补算。

5.2 现象:推荐接口 P99 从 80ms 涨到 1.2s

先看是不是某次发布之后开始的。这类延迟突增九成是实时计算混进了请求链路:有人在 Service 里直接遍历相似度矩阵算相似度,或者每次请求都去数据库查一遍用户行为。

排查顺序:打点记录每路召回耗时、取书详情耗时、序列化耗时,先定位是哪一段。如果确实是召回段,把种子行为列表加本地缓存(Caffeine,过期时间 5 分钟),相似度查询改成数组下标访问而不是 Map 查找,批量取书用IN一次查完而不是循环单查。

注意:图书详情这类基础数据用本地缓存没问题,但用户的已读列表不能缓存太久。用户刚评完分立刻刷新看到同一本书,体验上比慢 200ms 更糟。

5.3 现象:离线 NDCG 0.4,线上点击率没动

离线指标不低但线上没反应,优先怀疑两件事:评估集泄漏,或者线上根本没用到模型结果。前者按 4.2 的时间切分重跑一遍;后者更常见——检查线上请求是否真的走到了推荐接口,很多工程有降级开关,配置里默认开着降级,线上一直在返回热度列表,模型跑得再好也没用。

还有一种情况是推荐位太靠下。首页推荐模块在第三屏,曝光本来就少,点击率的分母被稀释,模型再好也体现不出来。这类问题不是算法能解的,得先调产品结构。

5.4 现象:服务重启后内存一路涨到 OOM

相似度矩阵的存储方式决定内存曲线。如果代码里存的是Map<Long, Map<Long, Double>>,十万本书、每本 200 个邻居,光对象头就吃掉好几个 GB。再加上每次请求都新建临时 Map,GC 压力更大。

解决:换成long[] keys加double[] vals的平行数组,或者Map<Long, double[]>配long[]邻居 ID,序列化用堆外或 mmap 读文件。另外 top-K 的 K 值也要回头看,200 是给十万级图书定的,图书量翻倍时 K 要相应压到 100 左右才能把内存守住。

5.5 现象:推荐列表里出现用户刚评过分的书

过滤逻辑写了但没生效,通常是这三个原因:已读集合是异步写入缓存的,请求时读到旧值;过滤用的是book_id的Long包装类型比较,==比的是引用,十万级 ID 一旦超过缓存范围就判不相等;或者只过滤了主表,没过滤用户在其他入口产生的行为。

前两个最容易踩。Long比较必须用equals或先转longValue(),这条在书 ID 上尤其隐蔽,因为小 ID 在 Integer 缓存范围内时表现正常,ID 一大就开始随机失效。稳妥做法是请求时强制回查一次近期行为(走主键索引,很快),再在结果层做一次兜底过滤,双保险比单点可靠。

5.6 上线前的一套五分钟自检流程

改完任何一环,我都按这个顺序走一遍,比直接发版省事:

  1. 拉一个新注册用户,看首页有没有结果、是不是全是热门书。
  2. 拉一个行为很多的老用户,看列表里有没有已读书、同一作者是否超过 2 本。
  3. 检查推荐接口的日志,确认三路召回的候选数都大于 0。
  4. 用测试集跑一遍 Precision@10 和 NDCG@10,和上一版对比,波动超过 15% 就停下来看样本。
  5. 压测单接口 200 QPS,看 P99 是否在 150ms 以内。

6. 增量更新与冷启动:让模型文件不停机换掉

模型重训完要上线,最省事的做法是重启服务,但推荐服务通常挂在首页,重启意味着几十秒的空白。这套源码里我改过一个点:用双缓冲把模型切换做成原子替换。

// 模型持有者:读多写少,用 volatile 保证可见性 public class ModelHolder { private volatile Model current = Model.empty(); // 定时任务重训完调用,整份替换,不修改旧对象 public void swap(Model next) { if (next.bookIndex.size() < current.bookIndex.size() * 0.8) { log.warn("新模型覆盖率骤降,放弃切换"); return; // 覆盖率跌破 80% 就回滚,别切 } this.current = next; log.info("model swapped, books={}", next.bookIndex.size()); } public Model get() { return current; } }

关键在于整份替换而不是原地修改。请求线程持有的是替换前那个对象的引用,它会把这一次请求算完,新请求拿到的就是新对象,中间没有加锁,也没有中间态。切换前加一道覆盖率校验,是因为重训脚本最常出的问题不是算错,而是数据导出中断导致模型只覆盖了一半图书,这种版本切上去,长尾书的推荐会整片消失,而且很难第一时间发现。

冷启动这块,我一般按三类用户分开处理:

用户类型判定条件策略
完全新用户无任何行为分类热度榜 + 编辑精选,按注册渠道分流
有 1~4 条行为低于协同阈值内容召回为主,权重 1.0,协同直接跳过
行为充足5 条以上三路融合,正常权重

第三类里还有个小技巧:种子行为不要全用,只取最近 20 条,并且按行为类型加权(收藏 1.0、评分 1.0、浏览 0.3)。早期我用全量历史当种子,结果一个两年前看了几十本考研书的用户,首页推的全是考研资料,而他现在买的是童书——兴趣是会漂移的,时间衰减比什么都管用。

最后说个教训。早期我调这个系统的时候,只盯着离线 NDCG,从 0.21 调到 0.38,特别有成就感,直接切了线上。结果第二天数据一看,点击率掉了 8%。翻了三小时日志才发现,新模型把长尾书推得太猛,前六本里有四本是用户根本没听过的冷门书——离线评估集里这些书确实被“命中”了,但那是因为测试集本身偏长尾,和真实曝光分布完全不是一回事。从那以后我每次切模型都强制走一遍 5.6 那套自检流程,另外再拉 20 个真实账号人工看一眼列表,指标只当门槛,不当结论。希望帮到你。

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