Reproducing Hacker News writing style fingerprinting

Salvatore Sanfilippo

复现 Hacker News 写作风格指纹识别

大约三年前,我在 Hacker News 上看到一个相当有趣又令人好奇的帖子。一位名叫 Christopher Tarry(克里斯托弗·塔里)的学生,能够利用余弦相似度(cosine similarity)对评论中最常用词的频率向量进行比较,从而检测出相似的 HN 账号——有时甚至能找出实际上由同一名用户控制的账号,也就是说,那些被用来掩盖作者真实身份的小号。

这是原始帖子:https://news.ycombinator.com/item?id=33755016

当时我并不了解 Burrows-Delta 这种文体检测方法:只需将最常用词的频率向量做归一化,就能得到如此出色的结果,这看起来有点神奇。我读了几页 Wikipedia,并在心里记下了这件事。后来,在我研究 Redis 的 Vectors 功能时,我想起了那个帖子,上网搜索却发现原网页已经消失了,而且作者在原始帖子和网站上并没有很好地解释数据是如何处理的、最常用词是如何提取的(尤其是用了多少个词)等等。我想,等主要工作完成后,我可以用 Vector Sets 复现这项工作。现在这个新数据类型已经进入 release candidate 阶段,我也抽出时间来研究这个问题。以下是我所做工作的报告,不过在继续之前,先奉上必备的演示网站:你可以通过下面的链接来体验:

https://antirez.com/hnstyle?username=pg&threshold=20&action=search

注意:由于这个数据集要占用 700MB 内存,而在我的小服务器上资源有限,接下来几个月我可能会把它下线。不过,在本文后面你会找到相关链接和 Github 仓库,里面有可以从零开始复现一切的代码。

注意2:我希望这个网站能存活下来,它是一个非常粗糙的 Python 脚本。我在这么小的服务器上对 VSIM 命令做了基准测试,它每秒竟能处理 8 万次 VSIM!这就是 int8 量化的神奇之处,再加上一些其他优化。但那个 Python 脚本很糟糕,每次都创建新的 Redis 连接,诸如此类。祈祷它能撑住吧。

原始数据下载与处理

要做这样的事情,我遇到的第一个问题就是找到一个包含 Hacker News 评论的存档。幸运的是,确实有一个存档,看起来包含了从 HN 创建之初到 2023 年的所有内容,总数据量高达 10GB。你可以在这里找到它:https://huggingface.co/datasets/OpenPipe/hacker-news。说实话,我不太清楚这些数据是如何获取的,是通过爬取,还是 HN 以某种方式公开了这些数据。

由于我不太喜欢二进制文件(至少对公开数据集而言是这样),我用了两个 Python 脚本把 Parquet 文件转换成更小、更易处理的东西。第一个脚本 gen-top-words.py 读取二进制文件,生成一个 txt 文件,其中包含数据集中使用频率最高的前 N 个词的列表。默认生成 1 万个词,但统计分析其实需要少得多(或者反过来说:如果用的词太多,你捕捉到的就不再是写作风格,而是一个用户谈论的内容类型了!)。然后,另一个 Python 脚本把每个用户的所有评论累积起来,生成一个非常大的 JSONL 文件,其中只有两个键:用户名和该用户从 HN 创建之初到 2023 年的全部历史中所有词的频率表。每条记录长这样:

{"by": "rtghrhtr", "freqtab": {"everyone": 1, "hates": 1, "nvidia": 1, "but": 1, "treats": 1, "ati": 1, "as": 1, "an": 1, "afterthought": 1, "another": 1, "completely": 1, "useless": 1, "tool": 1, "to": 1, "throw": 1, "on": 1, "the": 1, "pile": 1}}

到这一步,最后一个脚本 insert.py 就可以完成所有真正的工作了:对每个用户应用 Burrows 方法,创建用户风格向量,并插入 Redis。预先处理文件(这是个缓慢的操作)的好处是,插入脚本可以用不同的参数(尤其是使用多少个最常用词)更方便地反复调用,从而更快地看到不同的结果,而无需每次都重新处理 Parquet 文件。

Burrows 方法是如何工作的?

在原始帖子中,Christopher 写道,你只需要对词的使用频率做归一化,然后应用余弦相似度即可。实际上,这个过程要稍微复杂一些。首先,让我们问自己:这个方法的本质到底是什么?它是想捕捉每个特定用户相对于“平均”语言水平而言过度使用或使用不足的词。为此,我们实际上采用以下步骤(来自 Python 代码)。

对每个最常用词,我们是这样做的:

# Convert to relative frequency
rel_freq = frequency / total_words

# Standardize using z-score: z = (freq - mean) / stddev
mean = word_means.get(word, 0.0)
stddev = word_stddevs.get(word, 1.0)  # Default to 1.0 to avoid division by zero

z_score = (rel_freq - mean) / stddev

# Set the z-score directly in the vector at the word's index
vector[word_to_index[word]] = z_score

所以,我们首先通过减去该词的*全局*使用频率,对用户使用该词的频率进行“中心化”。这样,我们就得到一个数字,描述该用户对这个词是使用不足(负值)还是过度使用(正值)。但是,仔细想想,在不同作者的用法之间方差大得多的词,其变化就不那么重要。我们想要放大的信号,是那些该用户的使用程度远超该词正常方差的过度使用或使用不足的词。这就是为什么我们要把中心化后的频率除以该词的全局标准差。现在我们得到的就是所谓的“z 分数”,一个衡量某个词在某个方向上是离群值程度的调整后指标。

现在,我们只需一条命令就可以把这个向量插入 Redis 向量集:

VADD key FP32 [blob with 350 floats] username

(这里我不会介绍向量集的细节,因为你可以在这里找到文档 -> https://github.com/redis/redis/blob/unstable/modules/vector-sets/README.md

注意,Redis 会对插入的向量进行 L2 归一化,但会记住 L2 值,以便在使用 VEMB 检索关联向量时把值还原回来,所以 z_score 是按原样设置的。

最后,用 VSIM,我们就能得到相似的用户:

127.0.0.1:6379> vsim hn_fingerprint ele pg
 1) "pg"
 2) "karaterobot"
 3) "Natsu"
 4) "mattmaroon"
 5) "chc"
 6) "montrose"
 7) "jfengel"
 8) "emodendroket"
 9) "vintermann"
10) "c3534l"

所有代码(除了 Web 应用本身)都可以在这里找到:https://github.com/antirez/hnstyle

README 文件解释了如何复现每一个部分。

为什么是 350 个词?

原帖中缺少的一个信息(也正是激发我写这篇博客的原因之一)是:应该使用多少个最常用词。如果你用的词太多,你会看到许多关于 Redis 的评论——因为我自己就写过很多——因为 Redis 是使用频率最高的 1 万个词之一。你猜怎么着?我一开始就犯了这个错误,VSIM 一直报告的是和我谈论相似主题的用户,而不是写作*风格*相似的用户。但幸运的是,Internet Archive 缓存了 Christopher 针对 “pg” 账号的结果,在这里:

https://web.archive.org/web/20221126235433/https://stylometry.net/user?username=pg

于是我现在可以调整我的 top-k 词数,以得到相似的结果。另外,在阅读原始论文时,我惊讶地发现,要让分析效果良好,甚至只需 150 个词就足够了。而且一般来说,150 到 500 这个范围被认为是最佳的。

警告:不要以为你搜索某个用户时,找到的大多是小号。许多小号的数据太少,因为人们常常创建一次性账号,写几条评论就完事了。所以,与某个用户风格相关联的账号,大多只是写作风格相似的其他人。我相信这个方法在区分谁是母语者、谁不是方面相当强大。从下面的向量可视化中可以特别清楚地看到这一点。

验证与可视化……

我还复现了另一件事(也是原帖作者的一个想法):尝试用两组不同的评论,把同一个用户以两种变体插入,比如 antirez_A 和 antirez_B。然后检验一下,请求与 antirez_A 相似的用户时,是否会报告 B。确实,对于我测试的*大多数*用户来说,效果非常好,而且它常常就是排名第一的结果。所以我们知道这个方法确实有效。

既然从向量中如此容易“看到”一种风格,那我们肉眼能看出什么呢?最近我把终端换成了 Ghostty,它支持 Kitty graphics protocol,可以直接在终端窗口中显示位图。我想玩玩这个功能已经有一段时间了,终于有了个好理由来测试它。

上面发生的事情是:我们调用 VEMB 命令,它只返回一个浮点数列表(即向量)。然后,仓库中的另一个工具 vshow 会负责找出能容纳这个向量的最小正方形,并把正值显示为红色,负值显示为绿色。

如你所见,作为一个非母语者,我过度使用非常简单的词,而使用不足更复杂的词。其他作者会强调某些特定的词,还有些人则“平淡”得多,呈现出更少的特征痕迹。有一段时间我很好奇那里到底发生了什么:我究竟哪些词用得太多、哪些用得太少?所以在演示网站上,你还可以点击按钮来分析某个用户,查看过度使用和使用不足的前 10 个词。嗯,我自己的其中几个,显然要归咎于我英语语法上的问题 :D

好了,这项调查到此为止!Vector sets 现已进入 Redis 8 RC1,我还有更多工作要做,但这很好玩,而且我认为它表明,即使在 AI 出现之前,向量就已经非常酷了。感谢你读完这么长的一篇帖子。

编辑:我忘了说,insert.py 脚本还会插入 JSON 元数据,其中包含该用户写下的总词数。这样你就可以使用 FILTER,只显示具有一定词数以上的匹配结果。这对检测重复账号很有用,因为这类账号往往使用得很少,只在需要掩盖身份时才使用:

127.0.0.1:6379> vsim hn_fingerprint ele pg FILTER ".wordcount < 10000"
 1) "montrose"
 2) "kar5pt"
 3) "ryusage"
 4) "corwinstephen"
 5) "ElfinTrousers"
 6) "beaned"
 7) "MichaelDickens"
 8) "bananaface"
 9) "area51org"
10) "william42"

编辑2:如果这些匹配结果让你觉得可疑(看起来毫无意义),正如 tptacek 在这篇博客提交到 HN 后的评论中所指出的,这里有一个“可视化”的匹配,展示了比如 montrose 和 pg 在用词模式上确实非常相似: