Reproducing Hacker News writing style fingerprinting

Salvatore Sanfilippo

Hacker Newsの文章スタイル・フィンガープリンティングを再現する

3年ほど前、Hacker Newsでかなり興味深い投稿を見かけました。Christopher Tarryという学生が、コメントにおける頻出単語の頻度ベクトルに対してコサイン類似度を使い、似たHNアカウントを検出できるというものです。場合によっては、実際には同じユーザーが操作しているアカウント、つまり書き手の身元を隠すために使われた偽アカウントまで見つけられました。

元の投稿はこちらです: https://news.ycombinator.com/item?id=33755016

当時の私は、文章スタイルの検出に使うBurrows-Delta法を知りませんでした。頻出単語の頻度ベクトルを正規化するだけで、これほど目覚ましい結果が出るというのは、ちょっと魔法のように思えました。Wikipediaをいくつか読んで、頭の片隅に留めておきました。その後、RedisのVector Setsに取り組んでいたときにこの投稿を思い出し、ウェブで検索してみると、元のページはなくなっていました。しかも、元の投稿やウェブサイトでは、データをどう処理したのか、頻出単語をどう抽出したのか、特に何個使ったのか、といった点があまりきちんと説明されていませんでした。メインの作業が終わったら、Vector Setsを使ってこの研究を再現できそうだと思ったのです。新しいデータ型がリリース候補版に入った今、問題に取り組む時間も少し取れました。これは私が行ったことの記録です。ただ、その前に恒例のデモサイトを紹介しておきます。次のリンクから試せます:

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

注:データセットが700MBのRAMを使うため、この小さなサーバーでは、今後数か月のうちにサイトを停止するかもしれません。ただし、この記事の後半で、コードを使ってゼロからすべてを再現するためのリンクとGitHubリポジトリを紹介します。

注2:このウェブサイトが生き残ってくれるといいのですが、かなり粗末なPythonスクリプトです。こんな小さなサーバーでVSIMコマンドをベンチマークしたところ、それでも毎秒8万回のVSIMを処理できました!int8量子化と、いくつかの最適化のおかげです。ただしPythonスクリプトはひどいもので、毎回新しいRedis接続を作るなど、いろいろ問題があります。うまくいくことを祈っています。

生データのダウンロードと処理

さて、このようなことをするにあたって最初にぶつかった問題は、Hacker Newsのコメントアーカイブを見つけることでした。幸い、HNの開始時点から2023年までに投稿されたものが、おそらくすべて入っているアーカイブがありました。全データで10GBという巨大なものです。こちらにあります: https://huggingface.co/datasets/OpenPipe/hacker-news。正直なところ、これがスクレイピングで作られたのか、それともHNが何らかの形でデータを公開しているのかは、よく分かりません。

少なくとも公開データセットについては、私はバイナリファイルがあまり好きではありません。そこで、Parquetファイルをもっと小さく、扱いやすい形式に変換するため、2本のPythonスクリプトを使いました。最初のスクリプトgen-top-words.pyは、バイナリファイルを読み込み、データセットで使われている頻出単語上位N個のリストをtxtファイルとして生成します。デフォルトでは1万語を生成しますが、統計分析に必要なのはずっと少ない数です(というより、単語を多く使いすぎると、スタイルではなく、そのユーザーが何について話しているかを捉えてしまいます!)。次に別のPythonスクリプトが、ユーザーごとにすべてのコメントを集計し、非常に大きなJSONLファイルを生成します。そこにあるキーは、ユーザー名と、そのユーザーがHNの開始時点から2023年までの全履歴で使った単語の頻度表の2つだけです。各エントリは次のようになります:

{"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

Vector Setsの詳細についてはここでは説明しません。ドキュメントはこちらにあります → https://github.com/redis/redis/blob/unstable/modules/vector-sets/README.md

Redisは挿入されたベクトルをL2正規化しますが、VEMBで関連するベクトルを取得したときに元の値を返せるよう、L2値を記憶しています。そのため、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が使用頻度上位1万語に入っているため、私のRedisに関するコメントが大量に見つかります。そうです。最初、私はまさにこの間違いを犯しました。その結果、VSIMは私と似た「スタイル」ではなく、私と似た話題について話しているユーザーを報告し続けました。ただ幸いなことに、Internet ArchiveがChristopherによる「pg」アカウントの結果をキャッシュしていました:

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

そこで、似た結果が得られるよう、頻出単語の上位k個を調整できました。また、元の論文を読んで驚いたのですが、この分析をうまく機能させるには、わずか150語でも十分なのです。一般的には、150~500語の範囲が最適とされています。

注意してください。ユーザーを検索すれば、主に偽アカウントが見つかるなどとは思わないでください。多くの偽アカウントはデータが少なすぎます。身元を隠す必要があるときだけ使うため、使い捨てアカウントを作り、コメントをいくつか書いて、それで終わりにする人が多いからです。したがって、あるユーザーのスタイルに関連付けられるアカウントの大半は、偽アカウントではなく、似た文章スタイルを持つ別人のアカウントです。この方法は、母語話者かそうでないかを見分けるうえで、かなり強力だと思います。下のベクトルの可視化を見ると、それが特によく分かります。

検証と可視化……

私が再現したもう一つのこと(これも元の投稿者のアイデアです)は、同じユーザーをantirez_Aとantirez_Bのように2つのバリエーションで登録し、それぞれ異なるコメント集合を使うことでした。そして、antirez_Aに似たユーザーを検索したときにBが報告されるかを確認します。実際、これを試したユーザーのほとんどで非常にうまく機能し、多くの場合は最上位の結果になりました。つまり、この方法が実際に機能していることが分かります。

しかし、ベクトルからスタイルを「見る」のがこれほど簡単なら、肉眼ではどうでしょうか。最近、ターミナルをGhosttyに乗り換えました。GhosttyはKittyグラフィックスプロトコルに対応しているため、ターミナルウィンドウ内にビットマップを直接表示できます。以前からずっと試してみたいと思っていた機能です。今回、ようやく試すよい理由ができました。

上で行っているのは、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 により に公開されました。

この記事は「gpt-5.6-terra」を使用して翻訳されました。