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。删了。那玩意儿不该存在。
