23 级山东大学软件学院创新实训 - 个人纪录(五)
LinguaSpark 单词复习算法的缺陷发现与改进实践在 LinguaSpark 智能外语学习平台上线运行后我通过日志监控与数据分析发现了单词背诵模块中 SM-2 间隔重复算法存在的严重缺陷——用户可以通过高频刷同一个单词使间隔天数指数膨胀至数百天导致遗忘曲线算法完全失效。针对这一问题我深入排查了根因设计了基于实际经过天数的改进方案并重新规划了推送接口与主动复习接口的职责边界。改进后的算法从根本上杜绝了数据膨胀问题同时使间隔计算更精准地反映用户真实的记忆周期。一、核心业务场景系统涉及两个关键接口接口路径功能更新复习记录/api/words/user-words/update用户背诵单词后提交评估结果记得住/模糊/没记住系统计算下次复习时间推送需复习单词/api/words/user-words/review根据遗忘曲线推送已到复习时间的单词供用户回顾此外平台还提供了一个单词书自由复习功能——用户可自主选择任意单词进行背诵不受推送机制约束。这一设计带来了额外的数据安全挑战。二、初版实现与问题发现2.1 初版算法设计初版实现采用了经典的 SM-2 公式以repetitions连续背诵成功次数作为间隔计算的核心驱动因子如果状态为记得住 repetitions 1 如果 repetitions 1interval 1 天 如果 repetitions 2interval 6 天 否则interval int(interval × ease_factor) # 指数增长 如果状态为模糊 interval max(1, int(interval × 0.8)) # 略微缩短 # repetitions 不变 如果状态为没记住 repetitions 0 # 清零连续正确次数 interval 1 # 重置为1天这一设计的理论假设是用户严格按系统推送的节奏复习repetitions的递增能准确反映记忆巩固的进程。2.2 上线后的数据异常系统上线后我通过日志监控发现了两个严重的数据异常异常一间隔天数异常膨胀某测试用户在 5 天内连续复习同一个单词每次选择记得住间隔天数出现了非理性增长第 N 天实际间隔repetitionsinterval计算值问题分析1—11 天正常21 天26 天⚠️ 实际才隔 1 天却跳到 6 天31 天315 天⚠️ 仍在每天刷间隔却到 15 天41 天437 天❌ 严重脱节51 天592 天❌ 数据完全失真5 天后该单词的间隔被计算为92 天超过 3 个月而用户实际每天只隔了1 天。这种指数膨胀使遗忘曲线算法完全失去意义——用户的数据被刷高了。异常二迟到复习未获正向激励与此相反另一个场景中某单词的预计复习日期已过 20 天用户才回来复习且仍然记得住。但初版算法不考虑这个20 天的实际间隔实际经过20 天 用户表现记得住说明记忆很强 算法计算repetitions3 → interval15 天 应得间隔20 × 2.5 50 天 差距15 天 vs 50 天相差 3.3 倍用户用 20 天的实际表现证明了对这个词的掌握但算法只给了 15 天间隔这是一种负向反馈——好的记忆表现没有获得应有的奖励。2.3 根因分析经过深入排查我确认了三个根本缺陷缺陷一算法完全依赖repetitions计数不包含时间维度信息。无论用户是隔了 1 天还是 30 天才来复习只要repetitions间隔就按指数增长。缺陷二推送接口存在兜底逻辑当到期单词不足时会推送未到期单词。这导致用户可能在单词尚未到期时就被推送从而在不合理的短间隔内反复更新记录。缺陷三update接口缺少防刷保护结合前端单词书的自由复习功能用户可以反复刷同一个单词使数据膨胀。三、算法改进方案设计3.1 核心改进思路引入实际经过天数改进的核心思想是将计算驱动因子从虚的repetitions切换为实的actual_days实际经过天数实际经过天数 (actual_days) max(1, 今天 - 上次复习日期) 新间隔 int(actual_days × ease_factor) 保护机制 如果 new_interval ≤ 记录中已有的 interval_days 则拒绝更新 interval 和 repetitions防止刷数据为什么使用max(1, ...)保证至少为 1这是为了处理用户在同一天内多次复习的极端情况——即使同一天内也按 1 天计算避免出现除零等问题。3.2 改进后的全量算法actual_days max(1, (today - last_review_date).days) 状态 记得住 (status 1) ┌─────────────────────────────────────────────────────┐ │ if actual_days 1: new_interval 1 │ │ elif actual_days 2: new_interval max(3, int(2×ef)) │ │ else: new_interval int(actual_days × ease_factor) │ │ │ │ new_ease_factor min(2.5, ease_factor 0.1) │ │ │ │ ★ 保护判断 │ │ if new_interval record.interval_days: │ │ interval 不变 (保护) │ │ repetitions 不变 (保护) │ │ ease_factor new_ease_factor (可以微增) │ │ else: │ │ interval new_interval (更新) │ │ repetitions 1 (正常递增) │ │ ease_factor new_ease_factor │ └─────────────────────────────────────────────────────┘ 状态 模糊 (status 2) repetitions 0 interval 1 ease_factor max(1.3, ease_factor - 0.15) 状态 没记住 (status 3) repetitions 0 interval 1 ease_factor max(1.3, ease_factor - 0.2)3.3 推送接口的职责分离推送接口/user-words/review也进行了两次迭代优化第一轮去掉了other_words兜底逻辑。原本当到期即将到期单词不足时会用 7 天外的未到期单词补齐这破坏了记忆曲线的节奏。第二轮最终版去掉了upcoming_words7 天内即将到期单词只推送真正已到期的单词next_review_date today。设计理念如下┌─────────────────────────────────────────────────────────┐ │ 用户背单词的两条路径 │ ├──────────────────────┬──────────────────────────────────┤ │ 推送复习被动提醒 │ 主动复习单词书自由选择 │ │ /user-words/review │ /user-words/update │ ├──────────────────────┼──────────────────────────────────┤ │ 只推送已到期单词 │ 接受任意单词的复习请求 │ │ 保证用户按科学节奏复习 │ 保护机制防止数据被刷 │ │ next_review_date≤today │ new_interval≤记录interval→不更新 │ ├──────────────────────┴──────────────────────────────────┤ │ 两条路径互补推送控制节奏主动复习允许自由但数据安全 │ └─────────────────────────────────────────────────────────┘推送是被动提醒——系统告诉用户该复习了。如果推送未到期单词就会打破遗忘曲线的科学节奏。而主动复习是用户自主行为——想多学几个是合理的但通过保护机制确保数据不被刷坏。四、改进效果验证4.1 场景一用户每天刷同一个单词刷数据行为天数 实际经过 new_interval 记录中interval 是否更新 repetitions ──────────────────────────────────────────────────────────────────── 1 1天 1 1初始 ✓ 更新 1 2 1天 1×2.52.5→2 1 ✗ 保护 1 3 1天 1×2.62.6→2 1 ✗ 保护 1 4 1天 1×2.62.6→2 1 ✗ 保护 1 5 1天 1×2.62.6→2 1 ✗ 保护 1 6 1天 1×2.62.6→2 1 ✗ 保护 1 7 1天 1×2.62.6→2 1 ✗ 保护 1结论保护机制生效。从第 2 天开始new_interval始终为 2小于等于记录中的 1触发保护。interval 和 repetitions 保持稳定不会被刷高。4.2 场景二用户按正常间隔复习正常使用天数 实际经过 状态 new_interval 记录中interval 是否更新 repetitions ──────────────────────────────────────────────────────────────────────────── 1 - 创建 1 1 - 1 2 1天 记得住 1×2.52→2 1 ✗ 保护 1 8 6天 记得住 6×2.515 1 ✓ 更新 2 23 15天 记得住 15×2.639 15 ✓ 更新 3结论当用户真正等了足够天数第 8 天复习时实际经过 6 天new_interval15大于记录中的1触发正常更新。间隔天数从 1→15→39合理反映记忆巩固过程。4.3 场景三到期后很久才复习但还记得住条件上次 interval6 天next_review_date第10天 用户第30天才复习逾期20天选择记得住 实际经过 30 - 2 28 天 旧算法repetitions3 → interval int(6×2.5) 15 天 新算法28 × 2.5 70 天 差距70 天 vs 15 天4.7 倍结论新算法正确奖励了用户的强记忆。间隔 28 天仍记住 → 说明掌握非常好 → 给予 70 天的长间隔减少不必要的复习负担。4.4 场景四推送接口行为假设用户有 20 条记录难度cet4每日目标10 状态 数量 next_review_date 推送 ───────────────────────────────────────────────── 已到期逾期3天 3条 2026-06-02 ✓ 已到期今天到期 2条 2026-06-05 ✓ 即将到期明天 5条 2026-06-06 ✗ 即将到期3天后 4条 2026-06-08 ✗ 未到期8天后 6条 2026-06-13 ✗ 推送结果只推送 5 条已到期单词不补齐到 10 条结论推送接口严格只推送已到期单词不会为了让数量凑够而推送未到期内容。前端可引导用户当前没有需要复习的单词可以主动去单词书学习。五、改进前后对比总结对比维度改进前改进后间隔计算驱动因子repetitions纯计数无时间维度actual_days实际经过天数精确到日刷数据行为可被利用间隔指数膨胀1→6→15→37→92…被阻止保护机制拒绝更新逾期后仍记住间隔不足仅 15 天未奖励强记忆间隔充足70 天准确反映记忆水平模糊/没记住处理间隔略微减少直接清零间隔回到 1 天推送已到期单词✓✓推送即将到期单词—✗已移除推送未到期单词会other_words 兜底✗已移除主动复习保护无数据可被刷有保护机制兜底整体数据真实性低与实际间隔脱节高直接反映实际间隔六、工程实现要点6.1 技术栈后端框架FastAPIPython 3.10ORMSQLAlchemy数据库MySQLAWS RDS缓存Redis打卡状态、每日计数、分布式锁容器化Docker Docker Compose6.2 核心代码实现router.post(/user-words/update)asyncdefupdate_user_word_review(data:WordUserUpdate,db:Session):基于实际天数的 SM-2 改进算法recorddb.query(WordUser).filter(...).first()ifnotrecord:raiseHTTPException(status_code404)todaydatetime.now().date()# 1. 计算实际经过天数核心改进点ifrecord.last_review_date:actual_daysmax(1,(today-record.last_review_date).days)else:actual_days1ease_factorrecord.ease_factor/100.0intervalrecord.interval_days repetitionsrecord.repetitionsifdata.status1:# 记得住# 2. 基于实际天数计算新间隔ifactual_days1:new_interval1elifactual_days2:new_intervalmax(3,int(actual_days*ease_factor))else:new_intervalint(actual_days*ease_factor)new_ease_factormin(2.5,ease_factor0.1)# 3. 保护机制间隔不足则不更新ifnew_intervalrecord.interval_days:intervalrecord.interval_days# 保持不变# repetitions 保持不变ease_factornew_ease_factorelse:intervalnew_interval repetitions1ease_factornew_ease_factorelifdata.status2:# 模糊repetitions0interval1ease_factormax(1.3,ease_factor-0.15)else:# 没记住repetitions0interval1ease_factormax(1.3,ease_factor-0.2)# 4. 持久化更新record.ease_factorint(ease_factor*100)record.interval_daysinterval record.repetitionsrepetitions record.next_review_datetodaytimedelta(daysinterval)record.last_review_datetoday record.last_statusdata.status db.commit()db.refresh(record)return{message:复习记录更新成功,ease_factor:ease_factor,interval_days:interval,repetitions:repetitions,next_review_date:record.next_review_date.isoformat(),actual_days:actual_days}七、总结7.1 项目成果完成了 SM-2 算法的工程化实现与改进解决了初版中间隔与实际脱节、数据可被刷高两个核心缺陷引入了actual_days作为间隔计算驱动因子使算法具备真实的时间维度感知能力设计了保护机制确保推送复习和主动复习两条路径数据安全编写了完整的技术文档涵盖算法原理、缺陷分析、改进方案和效果验证7.2 技术亮点从repetitions到actual_days的驱动因子迁移这是整个改进的核心——让算法从计数驱动变为时间驱动从根本上解决了数据失真问题保护机制的巧妙设计仅用一个不等式new_interval record.interval_days即可实现防刷保护无需额外状态标记简洁且高效推送与主动复习的职责分离通过不同的接口行为约束既保证了科学复习节奏又保留了用户的学习自由度全面的测试验证通过多个场景的模拟验证确保改进后的算法在各种边界条件下都能正确运行以下为提示词需求修改/user-words/update接口的复习逻辑一、核心问题当前接口存在以下问题即使所有单词都未到期调用接口仍会推送并更新未到期的单词导致初始单词被反复推送间隔天数过度增长无法利用用户逾期后仍记得清晰的真实表现二、改进方案1. 改用“实际天数”计算下次间隔公式实际天数 原间隔天数 (当前日期 − 过期日期)处理逻辑比较结果操作实际天数 ≤ 原间隔天数不更新间隔、不更新连续成功次数实际天数 原间隔天数按实际天数更新间隔2. 用户选择“模糊 / 忘记”时连续成功次数 → 清零下次间隔 → 重置为 1 天3. 最终更新根据最终确定的间隔天数重新计算并更新过期时间三、为什么这样设计防止用户通过单词书反复背诵同一单词来刷数据真实反映用户记忆水平不推送未到期单词避免间隔失控四、效果对比示例场景原逻辑新逻辑单词应 10 天后复习用户 20 天后才复习且记得仍按原间隔增长使用实际间隔 20 天反复调用接口背同一单词间隔不断增长实际间隔未超过记录时不更新逾期后仍记得清晰仍按原计划增长可延长间隔减少冗余复习五、输出要求接口修改说明字段、计算逻辑改进前后数据对比示例能避免的异常行为说明如刷数据、间隔失控