Skip to content

[已解決] Mistral 經過 SFTTrainer 微調後不會輸出 eos_token ``

Last Updated on 2024-02-20 by Clay

問題描述

HuggingFace 之前曾經發表過文章表示現在的 LLM最好是依照 ChatML 格式去訓練,在一般情況下,會按照 system、user、assistant 的三種不同角色來進行生成,格式如下:

<|im_start|>system
...system prompt...<|im_end|>
<|im_start|>user
...user message...<|im_end|>
<|im_start|>assistant
...

通常在訓練時,我們會讓 <|im_start|>assistant\n 之前的 token 全都不參與 loss 計算,換言之即是讓模型只學習如何作為 assistant 進行回答,並在輸出特殊 eos_token <|im_end|> 後結束生成。

然而今天在我透過 trl 的 SFTTrainer() 進行 Mistral(實際上是 Mistral 的微調模型 OpenHermes)的微調後,我發現我的模型不再會生成 <|im_end|> 符號了!它會喋喋不休地一直往下生出看似有關聯的敘述。

像這樣的非預期行為,是完全不能應用在產品上的,所以我花費了一點時間仔細確認這是什麼原因造成的。

我在 GitHub - model produces multiple responses in one after training with conservation datasets using sft 上發現了跟我的問題十分相像的 issue,底下的討論分別提出了兩種建議:

  1. 確認資料集:有一種可能是資料集本身卻缺失了 eos_token 的結束符號,這會造成模型學不到在何時生成結束符號
  2. 嘗試設定 tokenizer.pad_token = tokenizer.unk_token:這是讓模型在訓練時所填充的特殊符號指定為 unk_tokenunknown token

我嘗試翻了一下 SFTTrainer() 的原始碼,發現第二種解決方法正是我所需要的。


解決方法

為什麼我的 Mistral 模型微調後不再生成 eos_token 的原因,我猜正是因為訓練時看過太多 eos_token 被填充在訓練資料的序列開頭,導致模型被強迫學習了忽略 eos_token

這件事要從 SFTTraienr() 的原始碼說起:

if tokenizer is None:
    tokenizer = AutoTokenizer.from_pretrained(model.config._name_or_path)
    if getattr(tokenizer, "pad_token", None) is None:
        tokenizer.pad_token = tokenizer.eos_token


tokenizer 是可以由我們主動輸入給 SFTTrainer() 的;然而在沒有傳入 tokenizer 的情況下,SFTTrainer() 會主動由我們傳入模型的設定位置,自動替我們建立 tokenizer。

但問題就出在於 SFTTrainer() 會主動替我們檢查 pad_token 是否存在、並且在不存在的情況下,自動使用 eos_token 同時作為 pad_token

這個操作本身沒有問題,但是 SFTTrainer() 是預期我們在訓練時 padding 是從右側進行 padding 的。(這個操作的必要性是,在訓練時需要把一個 batch 的訓練資料通通 padding 成一樣長度才能批次訓練

if tokenizer.padding_side is not None and tokenizer.padding_side != "right":
    warnings.warn(
        "You passed a tokenizer with `padding_side` not equal to `right` to the SFTTrainer. This might lead to some unexpected behaviour due to "
        "overflow issues when training a model in half-precision. You might consider adding `tokenizer.padding_side = 'right'` to your code."
    )


從右側 padding 本身也是沒有問題的,因為 SFTTrainer() 預期模型訓練時可能會看到的填充後資料是:

<|im_start|>assistant
Today is a nice day!<|im_end|><|im_end|><|im_end|>...<|im_end|>

那模型自然會知道,生成結束後應該就是要輸出 eos_token

然而 Mistral 模型的問題在於,它本來在接受訓練時的 padding 方向就是左邊而非右邊,並且這個設置仍然被 tokenizer 所儲存著。

tokenizer = AutoTokenizer.from_pretrained("teknium/OpenHermes-2.5-Mistral-7B/")
print(tokenizer.padding_side)


Output:

'left'


那麼這時候選擇使用 tokenizer.pad_token = tokenizer.eos_token 就不好了。模型實際上看到的訓練資料是:

<|im_end|><|im_end|>...<|im_end|><|im_start|>assistant
Today is a nice day!<|im_end|>

在語言模型的建模中,起始位置、結束位置的特殊 token 是非常重要的。雖然我們預期模型在微調時也會學習到訓練資料序列尾端的 eos_token,但是填充特殊符號的意義在於讓模型學會省略那些填充符號,那在模型被強迫學會了忽略大量填充在左側的 eos_token 後,它可能沒辦法積極地學習識別與輸出尾端我們真正希望的 eos_token

而這就是 Mistral 模型在透過 SFTTrainer() 微調後不輸出 eos_token 這一問題可能發生的原因。

所以我個人在目前幾次微調的測試後,會建議自己設定 tokenizer 並使用 tokenizer.pad_token = tokenizer.unk_token 這個方法。當然,如果 unk_token 對於你的任務來說很重要的話,那還是透過 add_special_tokens() 去添加真正的 pad_token 比較好,我這作法算是應急。

當然,另一種可行的辦法就是把 Mistral 的 tokenizer.padding_side = "right" 這樣進行設定。但我個人之前進行這樣的設定後,發現 Mistral 模型訓練時的 eval_loss 振幅非常大,到現在仍下意識地覺得還是按照 Mistral 當初微調時的初始設定來比較好。

那時候的 Loss 視覺化結果我甚至還有傳給同事看,讓他看看驚人的 eval_loss 振幅。

padding_side = "right"
padding_side = "left"

希望大家都可以不要踩到這個坑。

2024/02/20 更新:根據同事的追蹤研究,後來發現確認就是 pad_token 不能跟 eos_token 一致,否則訓練資料中的 eos_token 會完全變成 -100 的標籤不參與訓練。總而言之就是設定 tokenizer.pad_token<unk> 即可。


References


Read More

Leave a Reply