我们把进度条拆了,换成一个会自己往下掉的数

做学习类产品,早晚会被同一个问题拦住:怎么衡量一个学生「学会了没有」。
最省事的答案是答对率。这篇讲它为什么撑不住,以及拾阶网最后换上的那套东西长什么样。
一、答对率是个坏指标
一个学生在「一元二次方程」上做了 10 道题,对 8 道,答对率 80%。这个数字里藏着四件事。
它不分难度。 对的 8 道全是送分题、错的 2 道是压轴题,跟反过来的情况,算出来一模一样,学情却是两回事。
它不分时间。 其中 6 道是三个月前做的。三个月前会,不等于现在会。
它无限累积。 做得越多,早期的错误被稀释得越干净。于是这个数只会越来越好看,反映不出最近的退步。
主观题只能算对错。 6 分的题拿 4 分记成「错」,一个基本掌握的知识点就被判成不会。
第三条最致命,因为它意味着这个指标是单调的。可学习的真实状态不单调,它会掉。
第四条也不是假想。我们的设计文档原本就按 is_correct 二值计分,写实现的时候才改成得分率。
二、大部分学习产品的进度条,只往前走
学过的知识点打个勾,那个勾就永远在那儿。
三个月后打开,满屏都是勾,看起来学得很好,而学生可能已经忘了一半。
一个只会涨的数,记录的是「曾经学过」。学生和家长真正想知道的是另一件事:现在会不会。
所以目标被改写成了一句很具体的话:做一个会自己往下掉的数。
三、四条规则
最后落地的是 0–100 的掌握度,由四条规则算出来。
#: 难度权重。难题答对更能说明问题,答错也更该扣。
DIFFICULTY_WEIGHT = {1: 0.6, 2: 0.8, 3: 1.0, 4: 1.3, 5: 1.6}
#: 近因半衰期(天)。30 天前的作答只算半份权重。
RECENCY_HALF_LIFE_DAYS = 30.0
#: 参与计算的最近作答条数。
RECENT_WINDOW = 20
#: 判定阈值。
MASTERED_THRESHOLD = 80.0
NEEDS_REVIEW_THRESHOLD = 60.0
四条规则,逐条对着上面四个毛病。
按难度加权

送分题 0.6,压轴题 1.6,差了将近三倍。做对一道压轴题,抵得上三道送分题。
近因半衰期 30 天
这是整套设计的核心。

权重是 0.5^(天数/30)。一个月前的作答算半份,两个月前算四分之一。
要紧的是实现方式:衰减内建在权重里,不是另跑一个定时任务去扣分。
这两条路看着等价,差别其实很大。定时任务扣分,你需要一个 cron,需要处理漏跑和重跑,扣分幅度是一个新的魔法数字,而学生会看到一个莫名其妙掉下去的分。内建在权重里,掌握度就是每次测完从历史作答重算的纯函数:你什么都不做,它自己就低了,因为旧记录的权重在衰减。没有定时任务,没有状态,没有漏跑。
def compute(records: list[AnswerRecord], *, watched: bool = False, now=None) -> float:
"""从历史作答算出掌握度(0–100)。纯函数。"""
纯函数这三个字是这个设计里最值钱的部分。同样的输入永远得到同样的输出,可以随时重算,可以单测,改了参数也不用迁移历史数据。
只看最近 20 次
为什么不看全部历史。
因为全看的话,一个前期基础差、后期突飞猛进的学生,得做很多很多题才能把早年的低分稀释掉。他明明已经进步了,数字却纹丝不动。
而一个反映不出进步的指标,用户第二周就不会再看它。
20 这个数是工程取舍。再往前的记录,配合 30 天半衰期,第 20 条往往已经衰减到 0.3 以下,权重小到没有意义;窗口再小,单次失误的影响又会过大。
主观题按得分率
# 原方案用 is_correct 二值计分,这里改用得分率。
# 主观题按 rubric 逐点给分,6 分的题拿 4 分记成「错」会低估掌握度。
判分模型本来就是逐点给分的,那就别在入口处把它压成一个布尔值。
四、一个不会报错的坑
掌握度是 0–100 的百分数,不是 0–1 的比率。
这个约定被破坏会怎样?什么都不会发生。不报错,不崩溃,类型检查照过。只是满分的学生会显示成「1%」,然后被后继知识点的解锁判定当成没学过。
所以这段话写在了模块的第一行:
"""掌握度、错题本、学习状态的回写。
⭐ 掌握度是 0–100 的百分数,不是 0–1 的比率。
这不是随便定的:kp_view_service.MASTERY_UNLOCK_THRESHOLD = 70.0 拿它做
后继知识点的解锁判定,学生端把它当百分比渲染。写成 0–1 不会报错,
只会让满分的学生显示成「1%」并被判定为没学过 —— 这类错误没有任何报错
信号,只能靠约定守住。
"""
没有报错信号的错误,只能靠注释守住。类型系统在这儿帮不上忙,float 就是 float。
同类的约定还有三个阈值:掌握线 80、需复习线 60、解锁线 70。它们分散在两个模块里,注释里互相点了名。
五、几个边界
判分失败的题不参与计算。
"""判分失败的题不参与 —— 系统故障不该影响学生的掌握度。"""
这是原则问题。我们的服务挂了,不能算在学生头上。
看完视频但没测过,给 20 分的底。
#: 看完讲解视频但还没测过的初始分,以及完整观看的加成。
WATCHED_BASELINE = 20.0
WATCHED_BONUS = 5.0
为什么不是 0。因为 0 分和「完全没碰过」分不出来,而这两种状态对「下一步推荐学什么」是完全不同的信号。
六、错题排期用的是同一套思路
#: 错题本的间隔重复节奏(天)。连续答对推进到下一档。
REVIEW_INTERVALS = (1, 3, 7, 15, 30)
#: 连续答对几次算订正完成,移出错题本。
MISTAKE_CLEAR_STREAK = 2
MISTAKE_CLEAR_STREAK = 2 值得单独说几句。
为什么不是 1。四选一蒙对的概率是 25%,做对一次就判「这个点过了」,意味着每四道题里有一道是假过关。而假过关比没过关更糟,它会被移出复习队列,从此再也不出现。
连对两次,蒙对概率降到 6.25%。两次之间还隔着好几天,靠短期记忆混过去的路也被堵了。
这条理由不写在注释里,下一个人多半会觉得「连对两次太严」,然后改成 1。
七、一个产品决定:它不排名
掌握度没有排行榜。不和同学比,不和班级平均比。
原因不在技术上。横向比较不提供任何可操作的信息:知道自己排第 23 名,推不出接下来该学什么,它唯一稳定的产出是焦虑。
掌握度的对照系只有一个,上一次的自己。从 61 到 74,中间发生了什么是清楚的,补了哪个点、做了哪些题,这种比较能直接落到下一步动作上。
产品里确实有个年级排行榜,但比的是学习时长和坚持,不是掌握度。这两件事被刻意分开:可以比努力,不比会不会。
八、如果你也要设计这类指标
先问它会不会掉。只会涨的数记录的是历史,不是状态。
把衰减写进权重,别用定时任务改分。前者是纯函数,能重算、能单测、不怕漏跑;后者是状态机,你得伺候它。
窗口要小到能反映进步。一个看不出变化的指标,用户会停止看它。
没有报错信号的约定,写进注释。量纲、阈值、取值范围,这些东西破坏了不会崩,只会静悄悄地错下去。
最后想清楚它跟谁比。跟自己比能推出动作,跟别人比只能产出情绪。
附:这个数在产品里长什么样
学科目录里,每个知识点右边那个圆环就是掌握度,一眼能看出哪些满了、哪些还空着。

拾阶在线学习网的学科目录:右侧圆环是掌握度
知识点页右上角是具体数值,这个学生的「相反数」是 94%。
注意上面那条黄色提示,「建议先学:数轴 0%」。这就是掌握度的直接用途:它不只是个展示数字,还参与后继知识点的解锁判定和路径推荐。前面说过解锁线是 70,这也是量纲必须是 0–100 而不是 0–1 的原因。

知识点页:掌握度 94%,以及基于掌握度的前置建议
测评结果里的「各难度得分率」,对应的是难度权重那条规则。难度 1、2 全对,难度 3 对一半,难度 4 全错,这组分布喂进掌握度之后,得到的分数会明显低于「8 对 2 错 = 80%」。

测评结果:按难度拆开的得分率
错题本是同一套思路,1/3/7/15 天排期,连对两次才移出。

错题本:间隔重复排期
一个指标做得好不好,看的不是它算得多精巧,是它能不能推出下一步动作。上面每一屏都在回答同一个问题:明天该学什么。
本作品采用《CC 协议》,转载必须注明作者和本文链接
关于 LearnKu