iT邦幫忙

2024 iThome 鐵人賽

DAY 27
1
AI/ ML & Data

一個Kaggle金牌解法是如何誕生的?跟隨Kaggle NLP競賽高手的討論,探索解題脈絡系列 第 27

[Day 27]照妖(AI)鏡下的秘密-利用TF-IDF、BPE編碼、Kmeans Cluster和DetectGPT技術區分人類與AI寫作

  • 分享至 

  • xImage
  •  

標題提到一堆技術名詞...別擔心,今天都會串起來,像一個偵探故事一樣,每種技術都是線索,幫助我們揪出到底誰找 AI 當槍手。
👀

前言

昨天我們聊到僅用「錯字率」就可以很好地區別一篇文章是人類寫的還是 LLM 寫的(當然我們現在知道 Host 有為此在 dataset 中添加一些 noise 使得這兩者之間的差異不要那麼大)。

我們今天繼續透過一些 EDA 的技巧,觀察看看AI和人類學生還有哪些不一樣的寫作習慣呢?
以及從這些觀察,我們可以發展出什麼樣的檢測方法呢?

🤔AI 和人類學生有哪些不一樣的寫作習慣呢?

一樣我們先建構一個足夠大、足夠有代表性的 dataset 來分析多數 AI 的行為和人類學生的差異。
我們用各式各樣開源閉源的 LLM 生成文本,再結合 Host 提供的學生作文和 PERSUADE 2.0 Corpus 裡面的作文,一起建構一個共有 663,827 篇文章的大型 2 元分類的 dataset:
image
首先我們先看一下字數的分佈:
image
字數分佈看起來差不多,沒有明顯差異。

N-Gram 分佈

接下來我們來看一下 AI 生成的文本和人類學生寫的文章中,有哪些 1-gram 是只有 AI 會用,又有哪些是只有人類學生會用的呢?
image
看來 AI 和人類都有一些自己愛用的獨特 1-gram 喔~

如果我們仔細去看 AI 人類各自最愛用的前 10 名 1-gram,會發現:
image

AI 還滿喜歡用 emoji 的😅 xd

但不知道你有沒有注意到,AI 最常使用的前 10 個 1-gram ,竟然有 "don't",而這個是沒有在人類會用的 1-gram 詞表中的。

這怎麼可能? "don't " 應該要是人類也必須用到的字啊!

此 "don’t" 非彼 "don't"。

如果再仔細一點看,會發現 AI 用的是 "’" 而人類如果要用 don't 中間一定是用 "'"。

我們用 Unicode 編碼來解釋會更清楚。

ord_list = [ord(s) for s in "don’t"]    # only used by AIs
ord_list2 = [ord(s) for s in "don't"]   # used by both humans and AIs

print(ord_list)
print(ord_list2)

print 出來的結果是:

[100, 111, 110, 8217, 116]
[100, 111, 110, 39, 116]

發現了嗎?第一個的 Unicode 是 8217 另外一個則是 39,而人類學生在打英文的時候如果需使用分隔符號,一定會用 ' 而不會用 ,但不知為何大部分 LLM 使用 做為分隔符號。
這可能跟當初LLM訓練資料的 tokenize 和 encode 方式有關係,但我們這邊先簡單理解成我們發現 LLM 在生成文本時有一些「怪癖」。

所以這種使用 n-gram 習慣上的細微差異,也可以用來當作一種有用的 feature。

如果我們繼續看 AI 和人類學生各自使用的獨特 2-gram 數量分佈的話,會發現AI愛用而人類不會用的 2-gram 數量,相較只有人類會用的 2-gram 數量來說,少了許多。
image
一樣秀出兩者的 top10 frequency unique 2-gram:
image

Unique 3-gram 的數量差異又更明顯了:
image
image

AI 看起來很喜歡用 "to express" 去承接後面要表達的東西。

從簡單的 n-gram 觀察,似乎可以看出 AI 在用字遣詞上有一些明顯的習慣,我們可以捕捉這些習慣當作分類模型的輸入特徵。

(以上code與圖片參考自這裡)

🤔如何利用 AI 和人類兩者的寫作差異開發 solution 呢? - Baseline 探索

既然我們從 n-gram 上觀察到 AI 和人類學生在寫作習慣上有明顯差異,那我們何不用 TF-IDF 將文本轉成 vector 後,用一些簡單的 classifier 做二元分類看看效果呢?

說不定這樣就可以做得很好了呢!試試看吧~

TF-IDF Baseline

我們第一步就是將我們的擴增版 training data(混合其他 LLM 生成的文本)和 testing data,用 TfidfVectorizer 以 1-gram~3-gram 的 range,將輸入資料的 text 都轉成向量,準備當成之後 classifier 的 input feature。

train_df = train['text']
test_df = test['text']
vectorizer = TfidfVectorizer(ngram_range=(1, 3), 
                             sublinear_tf=True)
X = vectorizer.fit_transform(train_df)
Y = vectorizer.transform(test_df)

Classifier 的部分我們可以用兩個簡單一點的模型,各自預測後將預測的機率值取平均當成最終結果。
這邊我們選擇 ensemble Logistic RegressionSGDClassifier:

lr_model = LogisticRegression(solver="liblinear")
sgd_model = SGDClassifier(max_iter=1000, tol=1e-3, loss="modified_huber")

image

# Create the ensemble model
ensemble = VotingClassifier(estimators=[('lr', lr_model),('sgd', sgd_model)], voting='soft')
ensemble.fit(X, train.label)
preds_test = ensemble.predict_proba(Y)[:,1]

(👆🏻 code reference to here)

最終取得 0.895 的 LB 分數!

其實效果超乎預期的好耶!

不過有很多人認為 public test set 和 private test set 的分佈可能會有滿大的差異的(賽後來看確實也是如此,可以發現在 public test set 上取得好成績的前五名,都沒有在 private 榜單上的前五名),所以在 LB 上表現好不代表最終的 private score 也會這麼好,我們還是要多依賴本地的 CV 分數來調整策略,並抓取更多可能的 feature 來提升模型的泛化能力

(我們賽後來看,雖然這個解法取得 0.895 LB 分數,但是在 private dataset 只取得 0.696 的分數喔~)

優化 TF-IDF Baseline - 訓練自己的 Tokenizer (inspired from Typo)

如果執行上面的 code,會發現由於 TF-IDF 處理的是單詞級別的詞彙表,但是因為人類寫作本來就有的 typo 以及主辦單位自己人工加入的 typo,導致這個詞彙表變得比處理一般正常文本還要大很多。所有中間被插入i或是被 swapping 字母的單詞變體,都在詞彙表中變成一個和原身正確的單詞完全獨立的新單詞。

雖然我們也可以透過一些 preprocessing 的方式,把所有 typo 都透過 autocorrect 的工具更正,但本來「人類比AI更容易寫錯字」就是一個很明顯重要的特徵呀!去掉Typo之後我們反而少了一個強有力的特徵來區分兩者之間的差異了。
可是如果不處理,先不說TF-IDF的詞彙表變超級大、執行速度會很慢,每個Typo變體都變成一個獨立的單詞的這種 tokenize 的方式也不太好,因為這樣明明只是中間多一個 i ,就變成一個新的單字,徹底剝離和原生單詞的關係了。

於是網友@Ertuğrul Demir想了一個妙招:

我們何不把 TF-IDF 原先那種 "word" 級別的 encode 方式,改成 "Byte-Pair Encoding" 呢?

  • 什麼是 Byte-Pair Encoding
    Byte-Pair Encoding (BPE) 是一種基於統計的子詞分詞方法,用於壓縮文本或生成詞彙表。它的核心思想是通過合併語料庫中最常出現的字元對(byte pairs)來逐步生成更大的單元,直到達到預定的詞彙表大小。這種方法在自然語言處理(NLP)中非常有用,尤其是處理罕見詞或拼寫錯誤時。GPT, T5 等常見語言模型也是使用 BPE 作為他們的 tokenize 方式喔~

    • BPE 的基本步驟包含以下三步驟:
    1. 初始化詞彙表:從每個單字的字符開始,將每個字符視為一個單獨的符號。比如單字 "low" 被分割成字元序列 ['l', 'o', 'w']。
    2. 合併最常見的字符對:在語料庫中找到出現次數最多的字符對,然後將它們合併為一個新的單位。這個過程重複進行,直到達到所需的詞彙大小。
    3. 更新語料庫:每次合併後,更新語料庫以反映新的合併結果。

    舉例來說,假設我們的語料庫包含以下幾個單字:

    "low", "lower", "loweer", "lowest"
    

    第一步將每個單字分割為單獨的字元,初始詞彙表為:

    {'l', 'o', 'w', 'e', 'r', 's', 't'}
    

    所以語料表的所有單字可以被切割為:

    "low" -> ['l', 'o', 'w']
    "lower" -> ['l', 'o', 'w', 'e', 'r']
    "loweer" -> ['l', 'o', 'w', 'e', 'e', 'r']
    "lowest" -> ['l', 'o', 'w', 'e', 's', 't']
    

    接下來我們找出語料庫中最常出現的字符對。假設在這裡,"lo" 是最常見的字符對,因為它出現在每個單詞中。合併 "lo" 成為一個新的單位,更新分割表示:

    "low" -> ['lo', 'w']
    "lower" -> ['lo', 'w', 'e', 'r']
    "loweer" -> ['lo', 'w', 'e', 'e', 'r']
    "lowest" -> ['lo', 'w', 'e', 's', 't']
    

    接下來繼續合併"low",因為它是新的最常見的字符對::

    "low" -> ['low']
    "lower" -> ['low', 'e', 'r']
    "loweer" -> ['low', 'e', 'e', 'r']
    "lowest" -> ['low', 'e', 's', 't']
    

    然後是合併 "er",因為它在 "lower" 和 "loweer" 中出現:

    "low" -> ['low']
    "lower" -> ['low', 'er']
    "loweer" -> ['low', 'e', 'er']
    "lowest" -> ['low', 'e', 's', 't']
    

    這樣重複合併步驟,直到達到預定的詞彙表大小。
    使用這種 encode 方式會比 TF-IDF 原先預設的 "word-level" encode 還要更適合這次賽題的資料,因為 BPE 可以自動生成適合文本的子詞單位,即使文本中包含「拼寫錯誤」,也能找到合理的表示,進而控制整個詞彙表的大小。

所以在這次賽題中,我們要如何訓練自己的 tokenizer 能更好地在有大量 typo 的 dataset 上做分詞呢?

from tokenizers import (
    decoders,
    models,
    normalizers,
    pre_tokenizers,
    processors,
    trainers,
    Tokenizer,
)
from tqdm import tqdm
from datasets import Dataset
from transformers import PreTrainedTokenizerFast

# 參數設置
VOCAB_SIZE = 30000  # 設置詞彙表的大小,可以根據需求調整
LOWERCASE = True    # 是否轉換為小寫

# 步驟 1: 創建 BPE 分詞器
raw_tokenizer = Tokenizer(models.BPE(unk_token="[UNK]"))

# 步驟 2: 設置正規化和前處理
normalizer_list = [normalizers.NFC()] + [normalizers.Lowercase()] if LOWERCASE else []
raw_tokenizer.normalizer = normalizers.Sequence(normalizer_list)
raw_tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel()

# 步驟 3: 設置特殊字符並創建訓練器
special_tokens = ["[UNK]", "[PAD]", "[CLS]", "[SEP]", "[MASK]"]
trainer = trainers.BpeTrainer(vocab_size=VOCAB_SIZE, special_tokens=special_tokens)

# 步驟 4: 構建 Hugging Face 的 Dataset 對象
dataset = Dataset.from_pandas(train[['text']])

def train_corpus_iterator():
    """
    生成器函數,用於分批迭代資料集。
    """
    for i in range(0, len(dataset), 1000):
        yield dataset[i : i + 1000]["text"]

# 步驟 5: 使用迭代器從資料中訓練分詞器 (注意這裡是在擴增訓練集上進行訓練)
raw_tokenizer.train_from_iterator(train_corpus_iterator(), trainer=trainer)

# 步驟 6: 創建 Hugging Face 的快速分詞器
tokenizer = PreTrainedTokenizerFast(
    tokenizer_object=raw_tokenizer,
    unk_token="[UNK]",
    pad_token="[PAD]",
    cls_token="[CLS]",
    sep_token="[SEP]",
    mask_token="[MASK]",
)

# 步驟 7: 將測試集的文本資料進行分詞
tokenized_texts_test = [tokenizer.tokenize(text) for text in tqdm(test['text'].tolist())]

# 步驟 8: 將訓練集的文本資料進行分詞
tokenized_texts_train = [tokenizer.tokenize(text) for text in tqdm(train['text'].tolist())]

訓練好 tokenizer 之後,我們用這個新的 tokenizer 在 train/test data 上分詞,並把分詞的結果拿去轉成 TF-IDF 向量:

vectorizer = TfidfVectorizer(ngram_range=(3, 5), lowercase=False, sublinear_tf=True, analyzer = 'word',
    tokenizer = dummy,
    preprocessor = dummy,
    token_pattern = None, strip_accents='unicode'
                            )

vectorizer.fit(tokenized_texts_test)

# Getting vocab
vocab = vectorizer.vocabulary_

print(vocab)


# Here we fit our vectorizer on train set but this time we use vocabulary from test fit.
vectorizer = TfidfVectorizer(ngram_range=(3, 5), lowercase=False, sublinear_tf=True, vocabulary=vocab,
                            analyzer = 'word',
                            tokenizer = dummy,
                            preprocessor = dummy,
                            token_pattern = None, strip_accents='unicode'
                            )

tf_train = vectorizer.fit_transform(tokenized_texts_train)
tf_test = vectorizer.transform(tokenized_texts_test)

(👆🏻code改寫自這裡)
後面我們還是 ensemble 兩個不同的 classifier,最後評測的結果如下:

Method Public Score Private Score
word-level TF-IDF 0.895 0.696
BPE TF-IDF 0.946 0.825

可以發現,先使用 BPE 在本次賽題上相關的資料集上訓練自己的 tokenizer,再將 tokenize 的結果轉成 TF-IDF 向量,會比直接使用 default word-level tokenize 的方式,在 public/private score 上都有更明顯的增益!

簡單又有效!讚讚👏👏

一些 Unsupervised Baseline 的嘗試

除了用 TF-IDF 去訓練分類器,我們也可以根據一些對資料的觀察、domain knowledge,用 unsupervised 的方式來分類文章~

比方說,有人發現「學生的文章之間有很大的相似性,經常引用相同的來源和事件」。

我們可以怎麼利用這個觀察呢?

一個簡單的作法是,我們可以比對當前要測試的這篇文章,和我們資料庫中每一篇人類學生寫的文章的 embedding similarity(這個 embedding 可以是 Sparse 的,例如基於詞頻或是 Dense 的,基於深度模型的representation),以及和所有 LLM 生成的文章計算相似度。

之後我們各取前30名最高分的平均分數,計算:match_score_student / match_score_llm 這個比例,當作這篇測試文章「有多大的機率是出自人類學生之手」。
類似的作法在[Day 7]別著急訓練模型,挖掘好用的 feature 是成功的一半: EDA 實戰演練(中)- 從 Linguistic 和 Semantic 的角度計算文本相似度也有提過,雖然是不同賽題,但是有興趣的朋友可以交替一起看。

同樣是利用相似文本應該就是相同來源(人類或AI)的概念,我們還可以有另外一種做法----Cluster

如果我們把訓練和測試資料都轉成 TF-IDF 向量:

df = pd.concat([train['text'], test['text']], axis=0)

vectorizer = TfidfVectorizer(ngram_range=(3, 5), sublinear_tf=True)
X = vectorizer.fit_transform(df)

再用UMAP把 TF-IDF vector 降維到 2 維,然後可視化在 2-D平面上,一起看一下這些文章的分佈先心裡有底:

先按照 prompt 分顏色:
image
每個 prompt都分得算還滿開的,還不錯!

接下來按照 label 分顏色(藍色是人類寫的文章,橘色是AI寫的文章):
image
還好,沒有藍色橘色完全混在一起,有些叢看起來是可以從中切一刀分開橘色和藍色點。

接著重點來了,我們假設,人類寫的文章,在向量空間上應該會和其他人類寫的文章距離比較近;反之,AI寫的文章就會和人類寫的文章距離比較遠。

所以我們用 KMEANS 把人類寫的文本向量(UMAP降維後)分成 7 個 cluster(有七個 prompt),然後計算每個要測試的文章向量和最靠近的cluster 的距離,最後把這個距離當成 predict 的結果輸出。

距離越近數值越小,代表越像 human 寫的文章;反之,距離越大數值越大,就越像 AI 寫的文章。

我們應用這種「計算和人類文章 cluster 的最近距離」的作法,看看在 validation set 可以拿多少分:

藍色是validation set 中人類寫的文章的和最近cluster的距離;橘色則是AI文章的最近距離分佈。
image

可以發現validation set 中人類寫的文章距離其他人類文章 cluster 明顯比 AI 小很多,這種作法在 validation set 上可以做到 0.983 的 AUC score,在 LB 可以拿到 0.828 的分數。
(代碼與圖片參考自這裡)

另外,還記得我們在介紹本次賽題的第一天([Day 26]"是人是AI,一照便知" - 沒想到最終能找出LLM槍手的原因,是因為LLM太完美了?!)有介紹過 GPTZero 他們透過 perplexity 來當作分辨AI和人類文章的重要指標嗎?

DetectGPT: Zero-Shot Machine-Generated Text Detection using Probability Curvature這篇論文有提到並驗證一個假設:

"This paper poses a simple hypothesis: minor rewrites of model-generated text tend to have lower log probability under the model than the original sample, while minor rewrites of human-written text may have higher or lower log probability than the original sample. In other words, unlike human-written text, model-generated text tends to lie in areas where the log probability function has negative curvature (e.g., local maxima of the log probability). We empirically verify this hypothesis, and find that it holds true across a diverse body of LLMs, even when the minor rewrites, or perturbations, come from alternative language models. We leverage this observation to build DetectGPT, a zero-shot method for automated machine-generated text detection."

換到本次賽題,我們也可以嘗試來應用一下這篇論文的發現。

我們在自己擴增的混合有大量 LLM 生成的文本以及人類學生寫的作文的 dataset上,用 GPT2 來計算不同 prompt name 下AI和人類文章的平均 perplexity及其分佈:
image
(👆🏻圖片來源)

其實兩者的分佈雖然有一部份重疊,但是可以看到波鋒是錯開的,代表 perplexity 應該也可以當作一種有用的 feature 呦~

今天討論更多可以用來區分 AI 和人類學生寫的文章的重要特徵,明天就會開始介紹金牌解法囉~

我們明天見!


謝謝讀到最後的你,希望你會覺得有趣!
如果喜歡這系列,別忘了按下訂閱,才不會錯過最新更新,也可以按讚⭐️給我鼓勵唷!
如果有任何回饋和建議,歡迎在留言區和我說✨✨


LLM - Detect AI Generated Text 解法分享系列)


上一篇
[Day 26]"是人是AI,一照便知" - 沒想到最終能找出LLM槍手的原因,是因為LLM太完美了?!
下一篇
[Day 28]不講武德的Host-不能太倚賴錯字率,一起看第二名「復刻米其林三星料理式」的資料擴增法,與「雞蛋放兩籃」的 ensemble 思路
系列文
一個Kaggle金牌解法是如何誕生的?跟隨Kaggle NLP競賽高手的討論,探索解題脈絡30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言