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 method(布洛斯-戴爾塔法):僅僅將高頻詞彙的頻率向量正規化就能得到如此卓越的成果,感覺有點神奇。我讀了幾篇維基百科頁面,並在心裡記了下來。後來,當我在處理 Redis 的 Vectors 時,我想起了這篇貼文,上網搜尋後才發現原始頁面已經消失,而且作者在原始貼文和網站中並沒有很清楚地說明資料是如何處理的、高頻詞彙是如何萃取的(尤其是用了多少詞)等等。我想,等主要工作完成後,或許可以用 Vector Sets(向量集合)來重現這項研究。現在這個新的資料型別已經進入 release candidate,接下來我也找到一些時間來處理這個問題。以下就是我所做的報告,但在繼續之前,先附上必備的展示網站:你可以透過以下連結試玩:

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

備註:由於資料集需要佔用 700MB 的記憶體,在我這台小伺服器上,接下來幾個月我可能會將其下線。不過,稍後在本文中你會找到從頭重現所有內容所需的連結與 Github 儲存庫。

備註2:我希望這個網站能撐住,它只是一個非常陽春的 Python 腳本。我在這樣的小伺服器上測試了 VSIM 指令,卻仍能達到每秒 80k 次 VSIM!這就是 int8 quantization(8 位元整數量化)加上一些額外最佳化的奇妙之處。但 Python 腳本寫得很糟,每次都會建立新的 Redis 連線等等。只能祈禱一切順利。

原始資料下載與處理

首先,要做這樣的事,我遇到的第一個問題是要找到 Hacker News 留言的封存檔。幸好有一個封存檔,裡面似乎包含了從 Hacker News 開站到 2023 年的所有貼文,總資料量高達 10GB。你可以在這裡找到它:https://huggingface.co/datasets/OpenPipe/hacker-news,老實說,我也不太確定這份資料是如何取得的,是透過爬蟲還是 Hacker News 以某種方式公開了這些資料。

由於我至少在公開資料集這方面不太喜歡二進位檔案,我用了兩個 Python 腳本將 Parquet 檔案轉換成更小、更易於處理的格式。第一個腳本 gen-top-words.py 會讀取二進位檔案,並產生一個包含資料集中前 N 個高頻詞彙清單的 txt 檔。預設會產生 1 萬個詞,但用於統計分析時需要的詞彙要少得多(或者,實際上:如果你使用太多詞,就不再是捕捉風格,而是在捕捉使用者談論的內容類型了!)。接著,另一個 Python 腳本會彙整每個使用者的所有留言,並產生一個非常大的 JSONL 檔案,其中只有兩個鍵:使用者名稱以及該使用者自 Hacker News 開站至 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 就能完成真正的工作:為每位使用者套用 Borrows method 並建立使用者風格向量,再將其插入 Redis。預先處理這些檔案(一個耗時的作業)的好處是,插入腳本可以用不同的參數(特別是要使用的高頻詞彙數量)更輕鬆地被呼叫,以便更即時地看到不同結果,而不需要每次都重新處理 Parquet 檔案。

Burrow 方法是如何運作的?

在原始貼文中,克里斯多福·塔瑞寫道,只要將詞彙使用頻率正規化並套用 cosine similarity 即可。實際上過程要複雜一些。首先,讓我們自問,這個方法本質上是如何運作的?嗯,它想捕捉的是每個特定使用者相較於預期的「平均」語言,過度使用或使用不足的詞彙。為此,我們實際上會執行以下步驟(來自 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 score(z 分數)」,一種經過調整的衡量指標,用來表示某個詞彙在某一方向上偏離的程度。

現在,我們準備將向量插入 Redis 的 vector set 中,只要執行:

VADD key FP32 [blob with 350 floats] username

(我在此不會詳細介紹 vector sets 的細節,你可以在此找到文件 -> https://github.com/redis/redis/blob/unstable/modules/vector-sets/README.md

請注意,Redis 會對插入的向量執行 L2 normalization(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"

所有程式碼(不含網站應用程式本身)都可以在這裡找到:https://github.com/antirez/hnstyle

README 檔案說明了如何重現每個部分。

為什麼是 350 個詞?

在促成這篇部落格文章的原始貼文中缺少的資訊之一,就是應該使用多少個高頻詞彙。如果你使用太多詞,你會看到很多我關於 Redis 的留言,因為 Redis 是使用頻率前 1 萬名的詞彙之一。你猜怎麼著?我一開始就犯了這個錯誤,結果 VSIM 一直回報那些談論與我相似主題的使用者,而不是具有相似*風格*的使用者。但幸好 Internet Archive 快取了克里斯多福·塔瑞針對「pg」帳號的結果,網址在此:

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

所以現在我可以調整我的 top-k words 以獲得相似的結果。此外,閱讀原始論文後,我驚訝地發現,要讓分析有效運作,甚至只需要 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 在詞彙使用模式上是多麼相似:

原文由 Salvatore Sanfilippo 發布

本文章由 muse-spark-1.2-contributor 進行翻譯