해커 뉴스 글쓰기 스타일 핑거프린팅 재현하기
약 3년 전 해커 뉴스에서 꽤 흥미롭고 신기한 글을 봤습니다. 학생인 Christopher Tarry가 댓글에 등장한 상위 단어 빈도로 만든 벡터에 코사인 유사도를 적용해 비슷한 HN 계정을 찾아냈는데, 때로는 실제로 같은 사람이 운영하는 계정, 즉 글쓴이의 정체를 숨기기 위해 만든 가짜 계정까지 찾아내기도 했습니다.
원문은 다음과 같습니다: https://news.ycombinator.com/item?id=33755016
그 당시 저는 문체 탐지에 쓰이는 Burrows-Delta 방법을 전혀 몰랐습니다. 상위 단어 빈도 벡터를 정규화하는 것만으로 그렇게 놀라운 결과를 얻을 수 있다는 게 거의 마법처럼 느껴졌습니다. 위키피디아 페이지 몇 개를 읽고 머릿속에 메모만 해 두었습니다. 그러다 Redis용 Vectors를 작업하던 중에 이 글이 떠올라 웹에서 검색해 보니 원본 페이지는 이미 사라졌고, 저자가 원문과 웹사이트에서 데이터가 어떻게 처리됐는지, 상위 단어를 어떻게 추출했는지(특히 몇 개나 사용했는지) 등을 그다지 자세히 설명하지 않았다는 걸 알게 됐습니다. 메인 작업이 끝나면 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이 어떤 방식으로든 공개한 것인지는 잘 모르겠습니다.
저는 특히 공개 데이터셋의 경우 바이너리 파일을 그다지 선호하지 않기 때문에 두 개의 Python 스크립트를 이용해 Parquet 파일을 더 작고 다루기 쉬운 형태로 변환했습니다. 첫 번째 스크립트인 gen-top-words.py는 바이너리 파일을 읽어 데이터셋에서 가장 많이 사용된 상위 N개 단어 목록을 txt 파일로 생성합니다. 기본값은 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 파일을 다시 처리할 필요 없이 다양한 결과를 더 빠르게 확인할 수 있다는 장점이 있습니다.
Burrow 방법은 어떻게 동작할까?
원문에서 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 정규화를 수행하지만 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에 관한 내용이 많이 포함되기 때문에 Redis가 상위 1만 개 단어 중 하나인 만큼 비슷한 결과가 나옵니다. 어떻게 됐을까요? 저도 처음에 정확히 이 실수를 저질렀고, VSIM은 비슷한 *문체*가 아니라 저와 비슷한 주제에 대해 이야기하는 사용자들을 계속 반환했습니다. 다행히 인터넷 아카이브에 ‘pg’ 계정에 대한 Christopher의 결과가 캐시되어 있었습니다:
https://web.archive.org/web/20221126235433/https://stylometry.net/user?username=pg
그래서 이제 상위 k개 단어를 조정해 비슷한 결과를 얻을 수 있었습니다. 또 원 논문들을 읽다 보니 놀랍게도 분석이 잘 동작하려면 단어 수가 150개 정도로 적어도 충분하다는 걸 알게 됐습니다. 일반적으로는 150개에서 500개 사이가 최적이라고 여겨집니다.
주의할 점: 사용자를 검색하면 대부분 가짜 계정이 나올 거라고 생각하지 마세요. 많은 가짜 계정은 데이터가 너무 적습니다. 사람들은 종종 일회용 계정을 만들어 댓글 몇 개만 쓰고 끝내기 때문입니다. 따라서 특정 사용자 문체와 연결된 계정 대부분은 그저 글쓰기 스타일이 비슷한 다른 사람들일 뿐입니다. 이 방법은 누가 원어민이고 아닌지를 구분하는 데 꽤 강력하다고 생각합니다. 아래 벡터 시각화에서 특히 분명하게 드러납니다.
검증과 시각화…
제가 재현한 또 다른 것(역시 원글 작성자의 아이디어입니다)은 같은 사용자를 antirez_A와 antirez_B처럼 두 가지 변형으로 나누어 서로 다른 댓글 집합으로 삽입해 보는 것이었습니다. 그런 다음 antirez_A와 유사한 사용자를 조회했을 때 B가 나오는지 확인하는 것입니다. 실제로 제가 테스트한 *대부분의* 사용자에 대해 아주 잘 동작했고, 종종 가장 상위에 결과가 나타났습니다. 이를 통해 우리 방법이 실제로 동작한다는 걸 알 수 있습니다.
그런데 벡터만으로 문체를 ‘보는’ 게 그렇게 쉽다면 우리 맨눈으로는 어떨까요? 최근 터미널을 Ghostty로 바꿨는데 Kitty 그래픽 프로토콜을 지원해서 터미널 창에 비트맵을 바로 표시할 수 있습니다. 이 기능을 한 번 가지고 놀아 보고 싶었던 지 꽤 됐는데 마침내 테스트해 볼 좋은 이유가 생겼습니다.

위에서 일어나고 있는 일은 VEMB 명령을 호출해 단순히 float 목록(벡터)을 반환받는 것입니다. 그런 다음 저장소에 함께 포함된 vshow 유틸리티가 벡터를 담을 수 있는 가장 작은 정사각형을 찾아 양수는 빨간색, 음수는 녹색으로 표시합니다.
보시다시피 원어민이 아닌 저는 아주 단순한 단어를 과도하게 많이 쓰고 더 정교한 단어는 적게 씁니다. 다른 저자들은 특정 단어를 강조하고, 또 어떤 이들은 훨씬 더 ‘담백’해서 시각적 특징이 덜 드러납니다. 문득 그 안에서 실제로 무슨 일이 일어나고 있는지 궁금해졌습니다. 제가 어떤 단어를 너무 많이 쓰고 너무 적게 쓰는 걸까요? 그래서 데모 웹사이트에서는 특정 사용자를 분석하는 버튼을 눌러 과다 사용 및 과소 사용 상위 10개 단어를 볼 수 있도록 했습니다. 제 경우 몇 개는 확실히 영어 문법 때문에 생긴 문제입니다 :D
자, 이쯤에서 탐구는 마무리하겠습니다! 벡터 세트는 이제 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가 단어 사용 패턴에서 얼마나 실제로 유사한지를 보여주는 ‘시각적’ 일치가 여기 있습니다:

글을 무작위로 읽기