iT邦幫忙

2024 iThome 鐵人賽

DAY 30
1
AI/ ML & Data

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

[Day 30]告別複雜巨獸 - 一起看第八名的小清新解法與IT鐵人賽後回望

  • 分享至 

  • xImage
  •  

前面介紹的方法又是擴增一堆訓練數據集,又是 ensemble 一堆不同架構、不同訓練方法的模型,真的心滿累的。
image

所以,今天要跟大家分享的是本次賽題第八名的作法,在我心中是一種宛如「從變形金剛到清新小白花」般令人心曠神怡的存在!他們的作法非常好復現,作者也做了詳細的 ablation study 來探討到底他的作法中是哪些部分發揮作用。

我們就一起來看看吧!

📢作者有話要說:(10/14更新)我有更新昨天[Day 29]模型與資料的超級 ensemble 體 - 淺談 Curriculum Learning 訓練方法與 Ghostbuster (捉鬼人)如何識別 AI 和人類作文的內文呦~目前新增了關於第三名作法的解析,並著重介紹 "Curriculum Learning" 在本次賽題的應用,歡迎看過的朋友再回去看看<3

8th Solution

第三名的團隊主要利用 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 可以通過檢查文本中詞彙的概率分佈來幫助識別生成文本。他們提估了三種測試方法:

    1. Test 1: 利用每個 token 的機率
      檢查每個詞在語言模型中的預測機率。如果某個詞的預測概率很高,說明該詞很可能是基於常見模式生成的。

    2. Test 2: 單詞的絕對排名
      將每個詞按預測機率排名,並計算它們在前 10、前 100、前 1000 甚至超過 1000 的分佈。如果生成的文本中大多數詞都來自於高概率詞,那麼該文本可能是由模型生成的。

    3. Test 3: 上下文的熵
      計算預測單詞時的上下文熵。如果模型對下一個詞的預測非常確定(低熵),則它很可能是在固定的語言模式下生成的。

    舉例來說,下面是一篇 GPT-2-small 產生的文章,我們用 GPT-2-small 來測試,你會發現幾乎所有 token 都變成綠色,這代表這篇文章中使用到的所有 token 全部都出自 test model 預測出的 top10 高機率 token。那這就代表這篇文章非常有可能是 AI 生成的文章。
    image

    再舉下面一個人類寫的學術報告為例子:
    你會發現就有不少 token 是來自 top10~100 名的,甚至還有 top1000 之後的低機率 token 被使用,那這就比較有可能是來自人類之手,因為人類在寫作的時候變化性比較大,不太會依照某種固定的模式去書寫。
    image
    當然這篇 paper 是 2019 年提出的了,使用它們 demo 網站的 gpt-2-small 不太適合檢測我們現在用的一些大語言模型。
    如果我們讓 gpt-4o 生成一篇關於 "小兔子被大老鷹抓走" 的故事:
    你會發現其實也用了不少低機率的 token,這意味 gpt-4o 在生成故事的時候其實相較 GPT-2 是更有創造力和變化性的。
    image

不過第八名的作者受到這篇論文的啟發,除了使用 perplexity 當作檢測使用的一個特徵,也將每個文本中的 token 是在測試模型預測的詞彙機率分佈中的第幾名(rank)這個資訊當作一個 feature,讓分類器根據這兩個 feature 來判斷當前文本的來源。

接下來我們詳細說明他的作法:

簡單來說,PPL 是衡量語言模型性能的一個常見指標,它反映了模型對文本預測的信心。具體來說,PPL 是文本在模型下的負對數似然值的指數,PPL 越低,表示模型越能準確預測該文本。由於大型語言模型(如 GPT-2)在訓練時學習了大量的語言模式,因此它們能生成符合常見語言結構的文本,並且在這些生成的文本中通常具有較低的 PPL。
image
作者分析這次賽題的 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 負責從文本中提取特徵,主要分為三部分:

  • Tokenization: 將文本切分成 token,並計算它們在 GPT-2 模型中的機率。
  • Perplexity (PPL) 計算: 計算文本和句子的困惑度,用來衡量模型預測的困難度。
  • GLTR-style rank: 計算 GPT-2 預測的 token 排名,這些排名用來衡量文本中的 token 是否來自高機率分佈區域。

詳細每一個步驟要怎麽做,請見下方代碼註釋。

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:
image
我們可以從 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 的規律。

    就好比下面這兩張圖:
    image
    這兩張圖的中間其實都有一隻鴨子形狀的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 解法分享系列)


上一篇
[Day 29]模型與資料的超級 ensemble 體 - 淺談 Curriculum Learning 訓練方法與 Ghostbuster (捉鬼人)如何識別 AI 和人類作文
系列文
一個Kaggle金牌解法是如何誕生的?跟隨Kaggle NLP競賽高手的討論,探索解題脈絡30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
marsgoat
iT邦新手 5 級 ‧ 2024-10-15 00:28:13

恭喜完賽
終於有空來細看你的文章了

我要留言

立即登入留言