score 设计

本页推导 src/score/impact_factor.mbt 中评分模型的性质,并解释它为何是这种形态。score API 列出了各个函数;架构指南说明索引构建器从哪里获得信号。

设计目标

分数必须依据本地注册表索引所能提供的信号,按生态对各包的依赖程度为注册表快照中的包排序。它必须计算廉价、结果确定且可解释:网页的读者应能看出一个包为何排在另一个之前,并且相同的信号在 MoonBit、索引构建器和浏览器中必须给出相同的分数。

数学背景

分数

记 DD、RR、WW 分别为依赖方、近期依赖方和下载量,tt 为距最新发布的天数。实现计算的是

σ(n)=ln⁡(1+max⁡(n,0)),B=38 σ(D)+27 σ(R)+22 σ(W),S=m(t) B,\sigma(n) = \ln\bigl(1 + \max(n, 0)\bigr), \qquad B = 38\,\sigma(D) + 27\,\sigma(R) + 22\,\sigma(W), \qquad S = m(t)\,B,

其中 BB 是基础分,mm 是阶梯函数

m(t)={1.12t≤301.0630<t≤901.0090<t≤1800.94180<t≤3650.88t>365m(t) = \begin{cases} 1.12 & t \le 30 \\ 1.06 & 30 < t \le 90 \\ 1.00 & 90 < t \le 180 \\ 0.94 & 180 < t \le 365 \\ 0.88 & t > 365 \end{cases}

负的 tt 按 00 处理。除此之外没有任何东西进入分数:不会相对于注册表其余部分做规范化,因此添加其他包时一个包的分数不会改变。

对数指数

因为 ln⁡a+ln⁡b=ln⁡ab\ln a + \ln b = \ln ab,基础分是一个加权乘积的对数:

B=38ln⁡(1+D)+27ln⁡(1+R)+22ln⁡(1+W)=ln⁡((1+D)38 (1+R)27 (1+W)22).\begin{aligned} B &= 38\ln(1+D) + 27\ln(1+R) + 22\ln(1+W) \\ &= \ln\bigl((1+D)^{38}\,(1+R)^{27}\,(1+W)^{22}\bigr). \end{aligned}

乘积 (1+D)38(1+R)27(1+W)22(1+D)^{38}(1+R)^{27}(1+W)^{22} 是平移后计数的 Cobb–Douglas 指数11 Cobb–Douglas 形式 ∏ixiαi\prod_i x_i^{\alpha_i} 源自生产经济学(Cobb 与 Douglas,1928)。它的对数关于 ln⁡xi\ln x_i 是线性的,这就是按 BB 排序等同于按平移后计数的加权几何平均排序的原因。 ,权重就是它的弹性:∂B/∂ln⁡(1+D)=38\partial B / \partial \ln(1+D) = 38。由此直接得出两个推论。

翻倍增加一个常数。 由于 σ(2n+1)=ln⁡(2n+2)=ln⁡2+σ(n)\sigma(2n+1) = \ln(2n+2) = \ln 2 + \sigma(n),把 1+D1 + D 翻倍会让 BB 增加 38ln⁡2≈26.3438\ln 2 \approx 26.34 分,与 DD 原来是多少无关。对近期依赖方,同样一步值 27ln⁡2≈18.7127\ln 2 \approx 18.71 分,对下载量值 22ln⁡2≈15.2522\ln 2 \approx 15.25 分。拥有 1000 个依赖方的包再获得 1001 个,与拥有 10 个依赖方的包再获得 11 个,收益相同。

收益递减。 多一个依赖方会增加

38(σ(D+1)−σ(D))=38ln⁡D+2D+1≤38D+1,38\bigl(\sigma(D+1) - \sigma(D)\bigr) = 38\ln\frac{D+2}{D+1} \le \frac{38}{D+1},

这里用到了 ln⁡(1+x)≤x\ln(1+x) \le x,其中 x=1/(D+1)x = 1/(D+1)。一个依赖方的边际价值按 1/D1/D 递减,因此没有哪个单一信号能主导排名。

以计数表示的等级阈值

等级桶是 SS 上的阈值:S 从 260260 起,A 从 180180 起,B 从 110110 起,C 从 5050 起。对 σ\sigma 求逆即可看出它们对应的计数。当 m=1m = 1 且只有一个权重为 ww 的非零信号时,分数在以下条件下达到阈值 TT

wln⁡(1+n)≥T  ⟺  n≥eT/w−1,w \ln(1 + n) \ge T \iff n \ge e^{T/w} - 1,

所以最小的整数计数是 ⌈eT/w−1⌉\lceil e^{T/w} - 1 \rceil:

阈值仅依赖方(w=38w = 38)仅近期依赖方(w=27w = 27)仅下载量(w=22w = 22)
C (T=50T = 50)369
B (T=110T = 110)1858148
A (T=180T = 180)1147853575
S (T=260T = 260)93615208135697

例如 38ln⁡937≈260.0238\ln 937 \approx 260.02,而 38ln⁡936≈259.9838\ln 936 \approx 259.98,所以 936 是单凭依赖方就能达到 S 的最小计数。实际中各信号会叠加:20 个依赖方、4 个近期依赖方和 300 次下载已能得到 B≈284.7B \approx 284.7。

增长与势头

快照对分数求值两次,一次基于当前信号,一次基于 30 天前的信号,并定义

G=S−S30,r={G/S30S30>01S30=0, G>00S30=0, G≤0.G = S - S_{30}, \qquad r = \begin{cases} G / S_{30} & S_{30} > 0 \\ 1 & S_{30} = 0,\ G > 0 \\ 0 & S_{30} = 0,\ G \le 0. \end{cases}

由于 m(t)>0m(t) > 0,S30=0S_{30} = 0 当且仅当所有历史计数都为 00:该包在 30 天前既没有依赖方,也没有下载量。对这样的包,相对增长 G/S30G / S_{30} 没有定义,实现用 11(即 100 %)代替 +∞+\infty,使 rr 保持有限,便于存储和排序。

势头标签在两个层级上检验三个条件:

Rising  ⟺  G≥35∧r≥0.35∧R≥3,Hot  ⟺  ¬Rising∧G≥18∧r≥0.18∧R≥2.\text{Rising} \iff G \ge 35 \land r \ge 0.35 \land R \ge 3, \qquad \text{Hot} \iff \lnot\text{Rising} \land G \ge 18 \land r \ge 0.18 \land R \ge 2.

Rising 的条件蕴含 Hot 的条件,因此这些类别是同一刻度上嵌套的层级,而不是相互独立的标记。当 S30>0S_{30} > 0 时,r≥ρr \ge \rho 等价于 S≥(1+ρ)S30S \ge (1 + \rho) S_{30},所以 Rising 要求分数至少是旧分数的 1.351.35 倍,并且绝对增长 3535 分。绝对下限防止很小的包从 11 分涨到 22 分就算作上升;相对下限防止大包仅凭庞大基数上的噪声就算作上升。

设计决策

对计数取对数

问题。 依赖方数量和下载量呈重尾分布:少数包有成千上万,大多数包一个也没有。线性分数会让排名变成各信号中最大包的排行榜。

选项。 原始计数;注册表内的名次或百分位;平方根;对数。

选择。 ln⁡(1+n)\ln(1 + n)。平移 1 让 σ(0)=0\sigma(0) = 0 保持有限,并使 S=0S = 0 恰好对应一个包完全没有信号的情况。百分位需要整个注册表,并会在出现其他包时改变一个包的分数,从而破坏 CLI 一次一个包的约定。平方根仍然会增长,却没有上面推导出的尺度不变性。

加性权重

问题。 三个信号必须合并成一个数。

选择。 以 38:27:2238 : 27 : 22 为权重的对数加权和。依赖方总数反映已经确立的采用度,权重最大。下载量是外部的流行度提示,对于构建器查不到的包会缺失,因此权重最小。近期依赖方是在依赖方总数之上额外计数的,所以近期窗口中的一个依赖方会同时贡献两项:近期项是对当前采用的奖励,而不是另一个独立的群体。这些权重是本项目的编辑性选择,而不是拟合得到的参数。

用时效乘数而非时效项

问题。 老旧、无人维护的包不应永远保持其等级,但年龄不能压过采用度。

选择。 一个介于 0.880.88 与 1.121.12 之间的乘性阶梯函数。因为它乘在 BB 上,最多使分数变化 ±12 %\pm 12\,\%,信号相同时最新与最旧的包之间的比值为 1.12/0.88≈1.271.12 / 0.88 \approx 1.27。这可以让一个包跨过一个等级边界(例如 B=240B = 240 在 1.121.12 时为 S,在 0.880.88 时为 A),但永远不会把无人使用的包变成有等级的包:B=0B = 0 仍然是 00。加性的年龄项则会给无人使用但刚发布的包一个正分。

标签使用固定阈值

问题。 网页需要简短、稳定的标签。

选择。 在 SS 和 GG 上使用常数阈值。因此标签在每个快照中含义相同,也不需要全注册表的统计量。分位数桶(“前 5 %“)和百分位分数一样,需要整个注册表。

整数输入,Double 输出

问题。 信号是计数,而分数是实数。

选择。 所有输入都是 Int,负值被截断而不是拒绝,因此每个函数都是全函数,可以直接作用于数据库中的原始值。评分函数从不中止,也不返回 Result;唯一的非有限输出是数值精度中描述的溢出。

正确性与不变量

单调性

对于 [0,231−2][0, 2^{31} - 2] 内的计数,SS 关于 DD、RR 和 WW 单调不减,并且只要计数保持在 2242^{24} 以下就严格递增:σ\sigma 严格递增,权重为正,且 m(t)>0m(t) > 0,因此

D<D′  ⟹  38 σ(D)<38 σ(D′)  ⟹  S(D,R,W,t)<S(D′,R,W,t).D < D' \implies 38\,\sigma(D) < 38\,\sigma(D') \implies S(D, R, W, t) < S(D', R, W, t).

超过 2242^{24} 后,经由 Float 的转换(见下文)可能把相邻计数映射到同一个值,严格递增因此退化为单调不减。SS 关于 tt 单调不增,因为 mm 如此。impact_factor_test.mbt 中的黑盒测试针对依赖方、下载量和发布年龄检查了这一性质的实例。

取值范围

S≥0S \ge 0,且当且仅当 D,R,W≤0D, R, W \le 0 时取等号。对于不超过 231−22^{31} - 2 的计数,σ≤ln⁡231≈21.49\sigma \le \ln 2^{31} \approx 21.49,因此

S≤1.12⋅(38+27+22)⋅31ln⁡2≈2093.7.S \le 1.12 \cdot (38 + 27 + 22) \cdot 31 \ln 2 \approx 2093.7 .

标签是全函数

rank_label 和 compute_momentum_label 对每个输入都返回它们的某个标签。与 NaN 的任何比较都为假,所以 NaN 分数的等级为 D,势头为 Stable。

数值精度

log_signal 先把 n+1n + 1 转换为 Float,再在 Double 中取对数。不超过 2242^{24} 的每个整数在 Float 中都是精确的;超过之后,转换按就近舍入,相对误差为 ∣δ∣≤u=2−24|\delta| \le u = 2^{-24}。于是

∣ln⁡((n+1)(1+δ))−ln⁡(n+1)∣=∣ln⁡(1+δ)∣≤∣δ∣1−∣δ∣≤u1−u≈5.96×10−8,\begin{aligned} \bigl|\ln\bigl((n+1)(1+\delta)\bigr) - \ln(n+1)\bigr| &= |\ln(1+\delta)| \\ &\le \frac{|\delta|}{1 - |\delta|} \le \frac{u}{1-u} \approx 5.96 \times 10^{-8}, \end{aligned}

这一转换在 SS 中造成的误差至多为 1.12⋅87⋅u/(1−u)≈5.8×10−61.12 \cdot 87 \cdot u/(1-u) \approx 5.8 \times 10^{-6},此外还有几次运算中普通的 Double 舍入误差。索引构建器中仍有一份未被使用的 Python 公式副本(scripts/build_index.py 中的 compute_score),它调用 math.log1p 而没有经过 Float;它与 MoonBit 结果的差距在上述界限再加上末位几个单位以内。数据库本身是通过 MoonBit CLI 填充的。

加法 n+1n + 1 在 Int 中进行。当 n=231−1n = 2^{31} - 1 时它会回绕为 −231-2^{31},负数的对数是 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 包含了当前分数中完整的下载项 22 m(t) σ(W)22\,m(t)\,\sigma(W)。请把增长与依赖方计数一起解读,势头标签正是通过要求近期依赖方做到这一点的。
  • 不存在跨包的规范化,不存在窗口内的时间衰减,也不存在置信区间:分数是四个整数的确定性函数。
  • 不支持值为 2147483647 的计数(分数会变为 NaN)。

Footnotes

  1. Cobb–Douglas 形式 ∏ixiαi\prod_i x_i^{\alpha_i} 源自生产经济学(Cobb 与 Douglas,1928)。它的对数关于 ln⁡xi\ln x_i 是线性的,这就是按 BB 排序等同于按平移后计数的加权几何平均排序的原因。 ↩