跳到主要内容
百分比加不到 100,是这张图的优点

百分比加不到 100,是这张图的优点

原石
原石

· 阅读约 5 分钟

Hacker Atlas 那张话题地图的图例里有一行小字:百分比指的是所有主题归属,不是唯一故事数。多数人扫一眼就过去了。我盯着这行看了很久。

整张图的可信度就压在这句话上。

先贴数据结构。

struct assignment {
    int story_id;
    int topic_id;
};

就两列。没有 weight。没有唯一约束保证一条故事只出现一次。这就是全部。

颜色是主题,图块面积是故事数,连线是主题之间的关联,拖拽缩放,点进去看历史。所有那些东西都是这张表的渲染层。一条 HN 故事可以同时归到多个主题,所以这张表的行数大于故事数。图上的面积、份额、百分比,算的都是这张表的行数占全表的比例:

share(t) = 100.0 * rows(t) / rows(all);

一行就是一个归属。干净得有点不像真的。

然后每个做过 dashboard 的人都会跳出来说:这不对。一份东西被数了两次,加起来会超过 100%。

于是就有了那个修法。把每条故事的权重均摊到它归属的 N 个主题上:

double w = 1.0 / n_topics(story);
share(t) += w;

这下所有主题的份额加起来正好 100%。账面干净得很。

我一开始是站这边的。后来改了。

一条同时讲"AI agent 的监管"和"LLM 应用"的故事,它在这两个话题上都成立。你在两个话题里都看到它,那是信息,不是重复计数。硬把它切成两半,等于宣布这个故事只有一半属于 agent 话题——可它明明是一整个 agent 故事。你为了让一个总和凑够 100 的美观,把每个主题的信号都削薄一层。越细分的主题被削得越狠:一条故事挂五个 topic,每个只剩 0.2。

"看起来对"和"语义对"打架的时候,我站语义。

顺手看了一眼最近七天。AI governance and societal impacts 421 条,LLMs and AI applications 354 条,AI assistants and agents 327 条。前三名全是 AI。第四名是 History and cultural essays,310 条。我不解读这个,只想拿它说明一件事:如果这四条都按 1/N 稀释,它们会一起矮下去,然后你会觉得这周挺平静。

凑出来的 100%,比不整齐的真话贵。

那行小字还有后半句,更少人注意:没被归类的故事根本不进统计。所以那个百分比不是"占全体",是"占已归类部分"。分母本身就是一个筛过的分母。

/* 分母 */
denom = count(*) from assignment;
/* 不是 */
denom = count(*) from stories;

它没假装自己覆盖了一切——这一点比什么可视化都重要。

说句题外话——分类从来不是发现,是发明。206 这个数字不是从数据里长出来的,是有人坐下来切出来的。换一把刀,就是另一张地图。所以我不指望这张图告诉我"世界现在什么样",我只指望它告诉我"在这把刀底下,过去这周什么样"。扯回来。

第二个决定比第一个狠

归档里所有时间段,都用当前的分类结果重算。

这意味着它不是一个 append-only 的流水账。你今天看到的去年某个主题的数字,和你去年看到的那个数字,很可能对不上。

我几乎能听见有人拍桌子:这是篡改历史。

我的判断是:这个工具就该这么干。这是它最不讨喜、但最正确的一处设计。

地图是用来比趋势的,不是用来考古的。如果每个月都把当时的分类结果冻起来,你得到的那条曲线没法读——主题涨了多少、分类器改了多少,两种变化叠在一条线里,谁也拆不开。重算之后,整条时间轴上每一格都是同一个分类器打的分。曲线上任何一个隆起,都只能来自外界。

代价也得说清楚:你失去了"分类本身怎么演化"这条信息。这个代价我认。分类器的版本史该待的地方是 git log,不是用户面前的那张图。

实现上大概是这样:

create table assignment (
    story_id  int not null,
    topic_id  int not null
);

没有 classify_version 这一列。重跑分类不是 update,是整表重建:

truncate(assignment);
for (s in stories) {
    insert_assignments(classify(s));
}

肯定有人要说,那加一列 version,查询的时候按 version 过滤,不就两全了?

/* 所谓"两全" */
insert_assignment(story_id, topic_id, VERSION);

多加一列、多一个索引、多一个"这次查询该选哪个 version"的判断、多一条只在部分查询里正确的路径。为了一个没人真会用的功能,给每一次查询都加一层条件。

这是典型的抽象税,我不交。

第三个决定很小,但是同一种诚实

最新那一格的数据可能还没跑完,站点直接标了。

大部分看板不标。你看到"本周"是一根矮柱子,以为这周冷清,其实才周三。故事还在被归类,数字还在往上爬,你看的是个还在变的数。

要标出一个"未完整",系统得先知道自己的时间边界在哪。周从周一到周日,月和年按 UTC 日历切。这不是随手一个 date_trunc,是把边界定义写死在代码里,再让"现在"去和它比:

int period_complete(time_t now, const period *p) {
    return now >= p->end;   /* UTC 整周、整月、整年 */
}

一行。但它把"这个数还没长完"从一句免责声明变成了一个能被算出来的状态。区别很大:免责声明是写给读者的,这个判断是写给程序的。前者可以拿来糊弄,后者不行。

收

这三件事其实是同一件事。地图上每个数字都是"当前分类器 × 当前归档"的一个投影,不是归档本身。承认这一点,比修掉它难得多。修掉它只要一段均摊的代码,承认它要点脸。

所以规则就一条:凡是有人要你为了数据"看起来对"去动它的语义,先让他把那行小字写上。写完还觉得非改不可,再改。

我手头那个玩具存储引擎,去年也干过一次同样的事。给每个 key 加了个权重字段想支持优先级,加了三天,发现调用方全在传 1.0。删了。那玩意儿不该存在。

原石
原石

把代码当文章写的系统工程师,以源码立论、单线程式拒绝复杂度。

查看主页 →