前面介紹的方法又是擴增一堆訓練數據集,又是 ensemble 一堆不同架構、不同訓練方法的模型,真的心滿累的。
所以,今天要跟大家分享的是本次賽題第八名的作法,在我心中是一種宛如「從變形金剛到清新小白花」般令人心曠神怡的存在!他們的作法非常好復現,作者也做了詳細的 ablation study 來探討到底他的作法中是哪些部分發揮作用。
我們就一起來看看吧!
📢作者有話要說:(10/14更新)我有更新昨天[Day 29]模型與資料的超級 ensemble 體 - 淺談 Curriculum Learning 訓練方法與 Ghostbuster (捉鬼人)如何識別 AI 和人類作文的內文呦~目前新增了關於第三名作法的解析,並著重介紹 "Curriculum Learning" 在本次賽題的應用,歡迎看過的朋友再回去看看<3
第三名的團隊主要利用 PPL 困惑度和 GLTR 兩者當作特徵,送入一個由XGBClassifier
, LGBMClassifier
, CatBoostClassifier
和 RandomForestClassifier
所集成的 VotingClassifier
,由最後這個 voting classifier 做最終結果的預測。
GLTR 是什麼?
GLTR: Statistical Detection and Visualization of Generated Text 是 2019 年發表在 ACL 上的一篇關於幫助檢測AI和人類文本的 demo paper。
這篇 paper 的直覺來自於語言模型生成文本的方式,也就是根據 LLM 通常是如何 decode 文字的作法,回推檢測文本來源的作法。
什麼意思呢?
我們都知道LLM輸出的不是特定某個字,而是整個詞彙表的機率分佈,常見的 decode 策略包含 greedy, beam search, top-p, top-k 這些解碼方法都是根據每個 token 的機率去 sample 現在要輸出哪個 token。像是 greedy search,只會輸出當下機率最大的這個 token;topk 則引入一些隨機性,會在機率最高的前 k 個 token 中,隨機選擇一個 token 輸出。
但不管是哪一種解碼策略,當語言模型生成文本時,它們通常會根據預測機率選擇最可能的單詞,這些單詞集中於概率分佈的「頭部」(high-probability tokens)。因此,生成的文本往往包含大量高機率單詞,這會讓模型生成的文本顯得過於「流暢」,而缺乏人類文本中的隨機性與變化。
而人類文本則更加多樣,可能會使用一些罕見的詞彙或不符合常規的表達方式,因此人類撰寫的文本中的單詞會更常出現於模型預測機率的「尾部」(low-probability tokens)。
基於這些假設,GLTR 可以通過檢查文本中詞彙的概率分佈來幫助識別生成文本。他們提估了三種測試方法:
Test 1: 利用每個 token 的機率
檢查每個詞在語言模型中的預測機率。如果某個詞的預測概率很高,說明該詞很可能是基於常見模式生成的。
Test 2: 單詞的絕對排名
將每個詞按預測機率排名,並計算它們在前 10、前 100、前 1000 甚至超過 1000 的分佈。如果生成的文本中大多數詞都來自於高概率詞,那麼該文本可能是由模型生成的。
Test 3: 上下文的熵
計算預測單詞時的上下文熵。如果模型對下一個詞的預測非常確定(低熵),則它很可能是在固定的語言模式下生成的。
舉例來說,下面是一篇 GPT-2-small 產生的文章,我們用 GPT-2-small 來測試,你會發現幾乎所有 token 都變成綠色,這代表這篇文章中使用到的所有 token 全部都出自 test model 預測出的 top10 高機率 token。那這就代表這篇文章非常有可能是 AI 生成的文章。
再舉下面一個人類寫的學術報告為例子:
你會發現就有不少 token 是來自 top10~100 名的,甚至還有 top1000 之後的低機率 token 被使用,那這就比較有可能是來自人類之手,因為人類在寫作的時候變化性比較大,不太會依照某種固定的模式去書寫。
當然這篇 paper 是 2019 年提出的了,使用它們 demo 網站的 gpt-2-small 不太適合檢測我們現在用的一些大語言模型。
如果我們讓 gpt-4o 生成一篇關於 "小兔子被大老鷹抓走" 的故事:
你會發現其實也用了不少低機率的 token,這意味 gpt-4o 在生成故事的時候其實相較 GPT-2 是更有創造力和變化性的。
不過第八名的作者受到這篇論文的啟發,除了使用 perplexity 當作檢測使用的一個特徵,也將每個文本中的 token 是在測試模型預測的詞彙機率分佈中的第幾名(rank)這個資訊當作一個 feature,讓分類器根據這兩個 feature 來判斷當前文本的來源。
接下來我們詳細說明他的作法:
簡單來說,PPL 是衡量語言模型性能的一個常見指標,它反映了模型對文本預測的信心。具體來說,PPL 是文本在模型下的負對數似然值的指數,PPL 越低,表示模型越能準確預測該文本。由於大型語言模型(如 GPT-2)在訓練時學習了大量的語言模式,因此它們能生成符合常見語言結構的文本,並且在這些生成的文本中通常具有較低的 PPL。
作者分析這次賽題的 dataset(有主辦單位提供的也有其他參賽者擴增的),發現 AI 生成的文本在句子級和文本級的 PPL 都比人類文本低(如上圖),所以他用'gpt2-small', 'gpt2-medium', 'gpt2-large'這三個模型,計算每個測試文本的關於 PPL 的七個特徵,包含:
text_ppl
:整篇文本的平均困惑度。max_sent_ppl
:每個句子的最大困惑度。這表示文本中最困難的句子是多麼難以預測。較高的值意味著某些句子與模型的預測模式差異較大。sent_ppl_avg
:所有句子的平均困惑度。這是整篇文本中每個句子的困惑度平均值,反映文本的總體難度。sent_ppl_std
:句子困惑度的標準差,表示句子困惑度的變異性。如果標準差較大,表示文本中一些句子相對容易預測,另一些句子則非常難以預測。max_step_ppl
:累積困惑度的最大值。這是基於逐步累積的困惑度計算,找出最困難的時刻。step_ppl_avg
:累積困惑度的平均值,衡量隨著文本進行,困惑度的變化趨勢。step_ppl_std
:累積困惑度的標準差,這反映了模型在不同位置的困惑度變化情況。以及關於 GLTR-style rank 的四個特徵,這些特徵是用來計算模型對於文本中每個 token 的預測排名,反映這些 token 是否來自模型預測的高機率區域,包含這些:
rank_0
:表示模型預測的詞在前 10 名中的 token 數量,這些 token 屬於模型最自信的預測區域。rank_10
:表示模型預測排名在 10 到 100 名之間的 token 數量,這些詞屬於中等置信度的預測區域。rank_100
:表示模型預測排名在 100 到 1000 名之間的 token 數量,這些詞較不常見,模型不太自信。rank_1000
:表示模型預測排名在 1000 名以後的 token 數量,這些詞非常罕見,模型的預測置信度極低。所以最後送到他的 classifier 的 input feature 會類似長這樣:
{
'text_ppl': 25.4,
'max_sent_ppl': 50.3,
'sent_ppl_avg': 22.5,
'sent_ppl_std': 10.2,
'max_step_ppl': 45.7,
'step_ppl_avg': 20.1,
'step_ppl_std': 5.8,
'rank_0': 35,
'rank_10': 12,
'rank_100': 20,
'rank_1000': 5
}
每一個測試文本,都會被這 10 個統計數值給詮釋,然後交給 classifier 判斷。
大概理解他的作法後,我們一起看一下他是怎麼實作的:(以下代碼重構自他們的原始 inference notebook)
下面這個 function 負責從文本中提取特徵,主要分為三部分:
詳細每一個步驟要怎麽做,請見下方代碼註釋。
def gpt2_features(text, tokenizer, model, sent_cut):
# Step 1: Tokenize and prepare input IDs
input_max_length = tokenizer.model_max_length - 2
token_ids = []
offsets = [] # to keep track of sentence token ranges
sentences = sent_cut(text)
for s in sentences:
tokens = tokenizer.tokenize(s)
ids = tokenizer.convert_tokens_to_ids(tokens)
# Check if the sentence exceeds the model's max length
difference = len(token_ids) + len(ids) - input_max_length
if difference > 0:
ids = ids[:-difference] # Trim the sentence if too long
offsets.append((len(token_ids), len(token_ids) + len(ids))) # Track start/end positions
token_ids.extend(ids)
if difference >= 0:
break # Stop if we've reached the maximum length
# Prepare input IDs for GPT-2, add beginning-of-sequence token
input_ids = torch.tensor([tokenizer.bos_token_id] + token_ids).to(DEVICE)
# Step 2: Compute logits from the GPT-2 model
logits = model(input_ids).logits
# Shift logits and targets to compute cross-entropy loss (PPL calculation)
shift_logits = logits[:-1].contiguous()
shift_target = input_ids[1:].contiguous()
loss = CROSS_ENTROPY(shift_logits, shift_target)
# Step 3: Calculate probabilities and token ranks for GLTR features
all_probs = torch.softmax(shift_logits, dim=-1)
sorted_ids = torch.argsort(all_probs, dim=-1, descending=True) # Sort token probabilities
expanded_tokens = shift_target.unsqueeze(-1).expand_as(sorted_ids)
indices = torch.where(sorted_ids == expanded_tokens)
rank = indices[-1]
# Count tokens in different rank categories (GLTR-style)
counter = [
rank < 10, # Top 10 tokens
(rank >= 10) & (rank < 100), # Rank 10-100
(rank >= 100) & (rank < 1000), # Rank 100-1000
rank >= 1000 # Rank 1000+
]
counter = [c.long().sum().item() for c in counter]
# Step 4: Calculate PPL metrics for the whole text and individual sentences
text_ppl = loss.mean().exp().item()
sent_ppl = []
for start, end in offsets:
nll = loss[start: end].sum() / (end - start) # Calculate loss for each sentence
sent_ppl.append(nll.exp().item())
max_sent_ppl = max(sent_ppl)
sent_ppl_avg = sum(sent_ppl) / len(sent_ppl)
sent_ppl_std = torch.std(torch.tensor(sent_ppl)).item() if len(sent_ppl) > 1 else 0
# Step 5: Calculate cumulative step PPL metrics
mask = torch.ones(loss.size(0)).to(DEVICE)
step_ppl = loss.cumsum(dim=-1).div(mask.cumsum(dim=-1)).exp()
max_step_ppl = step_ppl.max(dim=-1)[0].item()
step_ppl_avg = step_ppl.sum().div(loss.size(0)).item()
step_ppl_std = step_ppl.std().item() if step_ppl.size(0) > 1 else 0
# Aggregate PPL features
ppls = [
text_ppl, max_sent_ppl, sent_ppl_avg, sent_ppl_std,
max_step_ppl, step_ppl_avg, step_ppl_std
]
return counter, ppls # GLTR rank features, PPL features
接下來我們會呼叫'gpt2-small', 'gpt2-medium', 'gpt2-large'這三個模型,分別取得這些模型預測出的 token 機率分佈,再為這些分佈計算統計數值當成 feature,送給 classifier 做預測。
models_test_feats = []
for model in ['gpt2-small', 'gpt2-medium', 'gpt2-large']:
# Load the tokenizer and model
TOKENIZER_EN = AutoTokenizer.from_pretrained(f'/kaggle/input/gpt-models-tokenizers/{model}/tokenizer')
MODEL_EN = AutoModelForCausalLM.from_pretrained(f'/kaggle/input/gpt-models-tokenizers/{model}/model').to(DEVICE)
test_ppl_feats = []
test_gltr_feats = []
# Extract features for each text sample
with torch.no_grad():
for text in test.text.values:
gltr, ppl = gpt2_features(text, TOKENIZER_EN, MODEL_EN, sent_cut_en)
test_ppl_feats.append(ppl)
test_gltr_feats.append(gltr)
# Store the extracted features in a dataframe
X_test = pd.DataFrame(
np.concatenate((test_ppl_feats, test_gltr_feats), axis=1),
columns=[f'{model}-{col}' for col in cols]
)
models_test_feats.append(X_test)
# Clean up to free memory
del X_test, TOKENIZER_EN, MODEL_EN, test_ppl_feats, test_gltr_feats
gc.collect()
torch.cuda.empty_cache()
# Combine features from all models
test_feats = pd.concat(models_test_feats, axis=1)
他們選擇的 classifier 也是比較簡單地、tree-based 的 classifier。
關於classifier的選擇和這些特徵工程的計算,我覺得思路和昨天介紹的第一名的Gostbuster作法有異曲同工之妙,有興趣的朋友歡迎回去看看:[Day 29]模型與資料的超級 ensemble 體 - 淺談 Curriculum Learning 訓練方法與 Ghostbuster (捉鬼人)如何識別 AI 和人類作文
xgb = XGBClassifier(n_estimators=256, n_jobs=-1)
lgb = LGBMClassifier(n_estimators=256, n_jobs=-1)
cat = CatBoostClassifier(n_estimators=256, verbose=0)
rfr = RandomForestClassifier(n_estimators=256, n_jobs=-1)
clf = VotingClassifier(
n_jobs=-1,
voting='soft',
weights=[4, 4, 5, 4],
estimators=[('xgb', xgb), ('lgb', lgb), ('cat', cat), ('rfr', rfr)]
# )
# Use the classifier to predict the probability that the text is AI-generated
sub['generated'] = clf.predict_proba(test_feats)[:, 1]
同時他們已做了一份很有價值的 ablation study:
我們可以從 data size, model size 和 feature 三個角度來討論:
Data Size: 原本他們只用 44K 的數據量來訓練 classifier,結果發現不管用 PPL, GLTR 還是兩個一起上的輸入特徵,都沒有在 LB 分數上有什麼反響,LB score 的結果也不能直持他們的假設。
可是一旦數據量擴增到變成 800K,不只 LB score 起飛(從 0.6 多到 0.9 多);也可以觀察到 PPL+GLTR 會比只單獨用其中一個特徵還要更好,這是符合他們假設中應該要有的趨勢。
但有趣的地方來了,在比賽前期,很多參賽者在討論區分享相關論文,有提到可以用 perplexity 當作 input feature,但是很多人都反饋這招在這次賽題中行不通,最多就是拿 0.6 多的分數,還不如去用 TF-IDF 呢!於是如果我們仔細去研究得獎解法,大部分都是訓練 deberta
或是 TF-IDF Kernel
的作法,頂多 ensemble 的時候會放一些weight給基於 perplexity 或其他 unsupervised 的模型預測出來的結果。
不過我們從第八名的分析可以發現,不是 perplexity 和 GLTR 不好用,而是因為前面嘗試的人所使用的數據量太小了,模型沒辦法在這個規模的數據集中發現 GLTR 和 PPL 的規律。
就好比下面這兩張圖:
這兩張圖的中間其實都有一隻鴨子形狀的pattern,但是左邊那張圖的噪音太大,所以一眼望過去看不出中間有鴨子;右邊那張圖的噪音比較少、信噪比比較大,我們就可以很清楚地識別中間有一個疑似鴨子形狀的亮區。
所以從他們的經驗也可以讓我們學到重要的一課----
有時候在開發模型時發現實驗結果不符合當初的假設,未必是假設錯誤,有可能是數據量要到一定規模,模型才能從中學會其中的一些規律。所以我們未必要急著否定自己的假設,可以試看看把訓練數據逐步擴增後會不會有什麼改變。
Model Size: 這部分就沒什麼好說的,模型參數變多,不管是用哪一種 Featur,都在 Public/Private LB 上看到增益。
Feature: 如果我們忽略 data size = 44k 的那些實驗,直接看 data size = 800k 的實驗結果,從LB分數還看,會發現使用 GLTR + PPL > GLTR > PPL。我現在不太確定為什麼使用 GLTR 會大於 PPL,希望有熱心網友可以告訴我,但結合兩個 feature 的效果會是最好的,這點倒是還滿符合預期的。
第八名的解法就介紹到這邊~
今天是鐵人賽的最後一天,也是本次賽題--LLM - Detect AI Generated Text的最後一天,但卻不是本系列文的最後一天。因為根據當初的規劃,其實還有一個賽題沒介紹到:LLM 20 Questions,之後幾天也會持續更新。
這是我第三次嘗試參加 IT 鐵人賽,也很開心終於在第三年順利完賽了🎉🎉,先不管寫出的內容品質如何、有沒有人看、內容是否有幫助到別人,堅持下來就是一件好值得驕傲的事情!
超級恭喜自己也恭喜今年一起完賽的其他參賽者,我每天看著不斷有人持續發文,就有一種我們是一個精神共同體,大家一起努力進步的感動!
創作這個系列一開始的初衷是想藉由這個「30天不間斷發文」的比賽設定,逼自己在這段時間去做之前一直想做、但是因為不緊急就一直拖延沒去做的事情。
研究 Kaggle 金牌解法就是一件我一直很好奇很想知道,但又好懶得去花時間去做的事情。
...不過確實是有逼到自己,這一個月來我每天都在焦慮今天要產出的內容。
但是也很驚喜的發現,透過文字書寫挖掘了很多自己的盲點,也積極地擴展自己的知識層面;在輸出的過程中,我試圖理解別人的作法、用自己的語言表達出來,甚至自己做圖、做假設做實驗分析為什麼會在比賽中有某些現象、為什麼他們這樣做有好的效果,簡直比寫論文還要認真,但是非常有成就感呦!
總之,我想今天不是告別的日子,在這三十天內默默被 IT 鐵人賽養成每天大量閱讀然後輸出的習慣,也在過程中發現了一些有興趣的議題,規劃了接下來要繼續研究的方向~
之後也會持續分享文章,歡迎有興趣的朋友繼續 follow 呦~❤️❤️💗💗
謝謝讀到最後的你,希望你會覺得有趣!
如果喜歡這系列,別忘了按下訂閱,才不會錯過最新更新,也可以按讚⭐️給我鼓勵唷!
如果有任何回饋和建議,歡迎在留言區和我說✨✨
(LLM - Detect AI Generated Text 解法分享系列)