score 设计
本页推导 src/score/impact_factor.mbt 中评分模型的性质,并解释它为何是这种形态。score API 列出了各个函数;架构指南说明索引构建器从哪里获得信号。
设计目标
分数必须依据本地注册表索引所能提供的信号,按生态对各包的依赖程度为注册表快照中的包排序。它必须计算廉价、结果确定且可解释:网页的读者应能看出一个包为何排在另一个之前,并且相同的信号在 MoonBit、索引构建器和浏览器中必须给出相同的分数。
数学背景
分数
记 、、 分别为依赖方、近期依赖方和下载量, 为距最新发布的天数。实现计算的是
其中 是基础分, 是阶梯函数
负的 按 处理。除此之外没有任何东西进入分数:不会相对于注册表其余部分做规范化,因此添加其他包时一个包的分数不会改变。
对数指数
因为 ,基础分是一个加权乘积的对数:
乘积 是平移后计数的 Cobb–Douglas 指数11 Cobb–Douglas 形式 源自生产经济学(Cobb 与 Douglas,1928)。它的对数关于 是线性的,这就是按 排序等同于按平移后计数的加权几何平均排序的原因。 ,权重就是它的弹性:。由此直接得出两个推论。
翻倍增加一个常数。 由于 ,把 翻倍会让 增加 分,与 原来是多少无关。对近期依赖方,同样一步值 分,对下载量值 分。拥有 1000 个依赖方的包再获得 1001 个,与拥有 10 个依赖方的包再获得 11 个,收益相同。
收益递减。 多一个依赖方会增加
这里用到了 ,其中 。一个依赖方的边际价值按 递减,因此没有哪个单一信号能主导排名。
以计数表示的等级阈值
等级桶是 上的阈值:S 从 起,A 从 起,B 从 起,C 从 起。对 求逆即可看出它们对应的计数。当 且只有一个权重为 的非零信号时,分数在以下条件下达到阈值
所以最小的整数计数是 :
| 阈值 | 仅依赖方() | 仅近期依赖方() | 仅下载量() |
|---|---|---|---|
C () | 3 | 6 | 9 |
B () | 18 | 58 | 148 |
A () | 114 | 785 | 3575 |
S () | 936 | 15208 | 135697 |
例如 ,而 ,所以 936 是单凭依赖方就能达到 S 的最小计数。实际中各信号会叠加:20 个依赖方、4 个近期依赖方和 300 次下载已能得到 。
增长与势头
快照对分数求值两次,一次基于当前信号,一次基于 30 天前的信号,并定义
由于 , 当且仅当所有历史计数都为 :该包在 30 天前既没有依赖方,也没有下载量。对这样的包,相对增长 没有定义,实现用 (即 100 %)代替 ,使 保持有限,便于存储和排序。
势头标签在两个层级上检验三个条件:
Rising 的条件蕴含 Hot 的条件,因此这些类别是同一刻度上嵌套的层级,而不是相互独立的标记。当 时, 等价于 ,所以 Rising 要求分数至少是旧分数的 倍,并且绝对增长 分。绝对下限防止很小的包从 分涨到 分就算作上升;相对下限防止大包仅凭庞大基数上的噪声就算作上升。
设计决策
对计数取对数
问题。 依赖方数量和下载量呈重尾分布:少数包有成千上万,大多数包一个也没有。线性分数会让排名变成各信号中最大包的排行榜。
选项。 原始计数;注册表内的名次或百分位;平方根;对数。
选择。 。平移 1 让 保持有限,并使 恰好对应一个包完全没有信号的情况。百分位需要整个注册表,并会在出现其他包时改变一个包的分数,从而破坏 CLI 一次一个包的约定。平方根仍然会增长,却没有上面推导出的尺度不变性。
加性权重
问题。 三个信号必须合并成一个数。
选择。 以 为权重的对数加权和。依赖方总数反映已经确立的采用度,权重最大。下载量是外部的流行度提示,对于构建器查不到的包会缺失,因此权重最小。近期依赖方是在依赖方总数之上额外计数的,所以近期窗口中的一个依赖方会同时贡献两项:近期项是对当前采用的奖励,而不是另一个独立的群体。这些权重是本项目的编辑性选择,而不是拟合得到的参数。
用时效乘数而非时效项
问题。 老旧、无人维护的包不应永远保持其等级,但年龄不能压过采用度。
选择。 一个介于 与 之间的乘性阶梯函数。因为它乘在 上,最多使分数变化 ,信号相同时最新与最旧的包之间的比值为 。这可以让一个包跨过一个等级边界(例如 在 时为 S,在 时为 A),但永远不会把无人使用的包变成有等级的包: 仍然是 。加性的年龄项则会给无人使用但刚发布的包一个正分。
标签使用固定阈值
问题。 网页需要简短、稳定的标签。
选择。 在 和 上使用常数阈值。因此标签在每个快照中含义相同,也不需要全注册表的统计量。分位数桶(“前 5 %“)和百分位分数一样,需要整个注册表。
整数输入,Double 输出
问题。 信号是计数,而分数是实数。
选择。 所有输入都是 Int,负值被截断而不是拒绝,因此每个函数都是全函数,可以直接作用于数据库中的原始值。评分函数从不中止,也不返回 Result;唯一的非有限输出是数值精度中描述的溢出。
正确性与不变量
单调性
对于 内的计数, 关于 、 和 单调不减,并且只要计数保持在 以下就严格递增: 严格递增,权重为正,且 ,因此
超过 后,经由 Float 的转换(见下文)可能把相邻计数映射到同一个值,严格递增因此退化为单调不减。 关于 单调不增,因为 如此。impact_factor_test.mbt 中的黑盒测试针对依赖方、下载量和发布年龄检查了这一性质的实例。
取值范围
,且当且仅当 时取等号。对于不超过 的计数,,因此
标签是全函数
rank_label 和 compute_momentum_label 对每个输入都返回它们的某个标签。与 NaN 的任何比较都为假,所以 NaN 分数的等级为 D,势头为 Stable。
数值精度
log_signal 先把 转换为 Float,再在 Double 中取对数。不超过 的每个整数在 Float 中都是精确的;超过之后,转换按就近舍入,相对误差为 。于是
这一转换在 中造成的误差至多为 ,此外还有几次运算中普通的 Double 舍入误差。索引构建器中仍有一份未被使用的 Python 公式副本(scripts/build_index.py 中的 compute_score),它调用 math.log1p 而没有经过 Float;它与 MoonBit 结果的差距在上述界限再加上末位几个单位以内。数据库本身是通过 MoonBit CLI 填充的。
加法 在 Int 中进行。当 时它会回绕为 ,负数的对数是 NaN,于是分数为 NaN。
快照一致性
compute_score_snapshot 的每个字段都来自同样的两次 compute_score 调用,所以 score_growth_30d == score - score_30d_ago 严格成立(这就是同一次浮点减法),并且 rank_label 与 momentum_label 始终是所存数值对应的标签。
分数如何排序
本包只计算分数,由使用方进行排序。仓库中的每种排序都以确定的方式处理并列:排名 feed 和默认搜索顺序先按 score 降序,再按 full_name 升序;Hot 和 Rising feed 先按 score_growth_30d 降序,再按 score,最后按 full_name。浏览器搜索的排序见 static_search 设计。
被否决的方案
- 依赖图上的 PageRank 式中心性会奖励被重要包依赖的包,但它需要整张图、迭代求解器和阻尼参数,而且无法在包页面上解释清楚。直接依赖方计数是该迭代的第一步,对这种规模的注册表已经足够。
- 传递依赖方没有被采用:它们会沿每条路径把同一个下游包计算多次,比对数所能修正的更偏向底层包。
- 从带标注的排名中学习权重需要并不存在的标注;固定权重写在代码和本页中。
- 对负输入返回
Result会把错误处理推给每个调用方,而这种情况本有显而易见的含义(没有信号)。
边界
- 分数衡量的是单个注册表快照内的采用度。它不衡量代码质量、正确性、安全性或维护投入。
- 本包按原样接收信号。收集信号、判定哪些依赖方算近期以及哪些下载量可信,是索引构建器的工作,见架构指南。
- 构建器目前把历史下载量传为
0,因此score_growth_30d包含了当前分数中完整的下载项 。请把增长与依赖方计数一起解读,势头标签正是通过要求近期依赖方做到这一点的。 - 不存在跨包的规范化,不存在窗口内的时间衰减,也不存在置信区间:分数是四个整数的确定性函数。
- 不支持值为
2147483647的计数(分数会变为NaN)。
Footnotes
-
Cobb–Douglas 形式 源自生产经济学(Cobb 与 Douglas,1928)。它的对数关于 是线性的,这就是按 排序等同于按平移后计数的加权几何平均排序的原因。 ↩