在機器學習專案中,標記資料的取得往往是最大的瓶頸。當面對標記資料稀缺的情況,許多工程師會直接放棄或轉向規則式系統。然而,透過適當的技術,我們仍能在有限資料條件下構建高效能模型。

領域適應微調:讓預訓練模型更貼近目標領域

現在,讓我們重複微調過程,但這次使用的是經過領域適應的檢查點:

model_ckpt = f'{model_ckpt}-issues-128'
config = AutoConfig.from_pretrained(model_ckpt)
config.num_labels = len(all_labels)
config.problem_type = "multi_label_classification"

for train_slice in train_slices:
    model = AutoModelForSequenceClassification.from_pretrained(model_ckpt,
                                                              config=config)
    trainer = Trainer(
        model=model,
        tokenizer=tokenizer,
        args=training_args_fine_tune,
        compute_metrics=compute_metrics,
        train_dataset=ds_enc["train"].select(train_slice),
        eval_dataset=ds_enc["valid"],
    )
    trainer.train()
    pred = trainer.predict(ds_enc['test'])
    metrics = compute_metrics(pred)
    # DA refers to domain adaptation
    macro_scores['Fine-tune (DA)'].append(metrics['macro f1'])
    micro_scores['Fine-tune (DA)'].append(metrics['micro f1'])

這段程式碼展示瞭如何微調一個已經過領域適應的模型。首先載入經過領域適應的檢查點(注意模型名稱字尾有"-issues-128",表示已針對GitHub issues資料進行預訓練)。接著設定模型設定,特別指定這是一個多標籤分類別問題。然後,對每個訓練資料切片進行迭代,每次重新載入模型、設定Trainer並進行訓練。訓練後,在測試集上進行預測並計算指標,分別記錄巨集觀(macro)和微觀(micro) F1分數。這裡的"DA"標記表示使用了領域適應技術。

與直接根據原始BERT模型的微調相比,領域適應帶來的優勢在低資料域尤為明顯。即使在有較多標記資料的情況下,我們仍然能獲得幾個百分點的效能提升:

plot_metrics(micro_scores, macro_scores, train_samples, "Fine-tune (DA)")

這突顯出領域適應能夠利用未標記資料,以極小的努力為模型效能提供顯著提升。自然地,未標記資料越多、標記資料越少,這種方法的影響就越大。

在我的實踐中,當處理專業領域文字(如法律或醫療檔案)時,領域適應往往能將F1分數提高5-10個百分點,尤其是在每類別僅有數十個標記樣本的情況下。這種提升對實際應用至關重要,可能是專案能否佈署的決定性因素。

進階方法:充分利用未標記資料的技術

微調語言模型後再調整分類別頭是一種簡單而可靠的方法,但還有更複雜的技術可以進一步利用未標記資料。以下是一些值得探索的進階方法。

無監督資料擴增:一致性是關鍵

無監督資料擴增(Unsupervised Data Augmentation, UDA)的核心思想是:模型對一個未標記樣本和其略微扭曲版本的預測應該保持一致。這種扭曲通常透過標準資料擴增策略實作,如令牌替換和回譯。

一致性要求透過最小化原始樣本和扭曲樣本預測之間的KL散度來實作。這一過程透過在交叉熵損失中加入未標記樣本的額外項來實作。這意味著我們用標準監督方法在標記資料上訓練模型,同時約束模型在未標記資料上做出一致的預測。

UDA的表現令人印象深刻:使用少量標記樣本的BERT模型,經UDA訓練後能達到與使用數千樣本訓練的模型相似的效能。缺點是需要建立資料擴增管道,與訓練時間更長,因為需要多次前向傳播來生成未標記和擴增樣本的預測分佈。

在我的一個文字分類別專案中,使用UDA讓我只需50個標記樣本就達到了接近使用1000個樣本傳統訓練的效果。雖然實作過程複雜一些,但在資料標記成本高昂的場景中,這種投資絕對值得。

不確定性感知自我訓練:讓模型自我提升

另一種有前途的方法是不確定性感知自我訓練(Uncertainty-aware Self-Training, UST)。這一方法的思路是先在標記資料上訓練一個教師模型,然後使用該模型為未標記資料建立偽標籤。接著,在偽標記資料上訓練學生模型,訓練完成後學生模型成為下一輪迭代的教師。

這種方法的一個有趣之處在於偽標籤的生成方式:為了獲得模型預測的不確定性度量,相同的輸入在開啟dropout的情況下多次送入模型。然後,預測的變異性提供了模型對特定樣本確定性的代理指標。利用這種不確定性度量,偽標籤透過一種叫做BALD(Bayesian Active Learning by Disagreement)的方法進行取樣。

透過這種迭代方案,教師模型不斷改進建立偽標籤的能力,從而提高模型效能。最終,這種方法能達到接近使用全部訓練資料(數千樣本)訓練的模型的效果,甚至在某些資料集上超過UDA。

UST的一個關鍵優勢是它能夠識別模型不確定的樣本,這使得訓練過程更加高效。在我的實驗中,UST特別適合處理類別不平衡的問題,因為它能更好地處理稀有類別的樣本,這些樣本通常是模型最不確定的部分。

#思考:少量標記資料的最佳策略

即使只有少量甚至沒有標記資料,我們仍有多種有效策略可用。我們可以利用在其他任務上預訓練的模型(如BERT語言模型或訓練於Python程式碼的GPT-2)來處理GitHub問題分類別等新任務。此外,我們可以使用領域適應來獲得額外的效能提升。

哪種方法最適合特定使用案例取決於多種因素:有多少標記資料雜訊程度、資料與預訓練語料的相似度等。要找出最佳方法,建立評估管道並快速迭代是明智之舉。Transformers的靈活API允許快速載入多個模型並進行比較,無需任何程式碼變更。Hugging Face Hub上有超過10,000個模型,很可能有人曾處理過類別似問題,你可以在此基礎上繼續發展。

在實際應用中,我發現一個有效的策略是先使用領域適應來提升基礎效能,然後根據資料特性選擇UDA或UST進一步最佳化。對於文字分類別任務,如果有大量未標記資料但標記成本高,UDA通常是最佳選擇;而對於更複雜的序列標記任務,UST的不確定性機制往往能帶來更好的結果。

值得注意的是,在評估方法時,需要權衡複雜方法(如UDA或UST)與取得更多資料之間的關係。通常,標註幾百個樣本只需幾小時或幾天的工作,與有許多工具可以協助完成。根據你的目標,投入時間建立一個小型高品質資料集可能比設計複雜方法來彌補資料不足更有意義。本文介紹的方法可以確保你從珍貴的標記資料中取得最大價值。

在這裡,我們探索了低資料領域,並看到即使只有一百個樣本,transformer模型仍然強大。接下來,我們可以思考完全相反的情況:當我們擁有數百GB的資料和大量計算資源時能做什麼?例如,從頭訓練一個大型transformer模型來為我們自動完成程式碼。

在資料稀缺的世界中,選擇正確的方法可以讓你的模型表現得像擁有豐富資料一樣。關鍵是理解每種方法的優勢和侷限,並根據你的具體情境做出明智選擇。

大規模 Transformer 模型訓練:從零開發自己的語言模型

在深度學習領域的發展中,大模型語言已經成為推動自然語言處理進步的核心力量。當我們面對海量資料時,從零開始訓練一個 Transformer 模型可能是比微調既有模型更好的選擇。這篇文章將帶領大家探索如何處理大規模資料集、建立自訂分詞器,並實作多 GPU 分散式訓練。

從零訓練的關鍵挑戰

從零開始訓練 Transformer 模型涉及幾個核心挑戰:

  1. 收集與處理超大型資料集
  2. 為特定領域資料建立自訂分詞器
  3. 設計高效的分散式訓練策略
  4. 管理龐大的計算資源需求

雖然 Hugging Face Transformers 函式庫的 Trainer API 支援分散式訓練,但在這篇文章中,我將介紹 PyTorch 的 Accelerate 函式庫,這是一個更為強大的分散式訓練工具,特別適合訓練擁有數十億引數的大型模型。

尋找適合的大規模資料集

許多領域都存在大量可用的資料,從法律檔案到生物醫學資料集,再到程式碼函式庫。這些資料集通常是未標記的,由於其巨大的規模,通常只能透過啟發式方法或利用收集過程中儲存的中繼資料進行標記。

即使是未標記或僅透過啟發式方法標記的大型語料函式庫也能發揮重要作用。在處理特定領域資料時,如果你有足夠大的語料函式庫,從零開始訓練模型可能比微調現有模型更有效,特別是當現有預訓練模型的領域與你的目標領域差異較大時。

為什麼不直接使用預訓練模型?

使用預訓練模型意味著你必須使用該模型對應的分詞器,但如果分詞器是在另一個領域的語料函式庫上訓練的,效果通常會不理想。例如,在法律檔案、其他語言,甚至完全不同的序列(如音樂符號或 DNA 序列)上使用 GPT 的預訓練分詞器,會導致分詞效果不佳。

當你能夠取得的訓練資料量接近預訓練所使用的資料量時,考慮從零開始訓練模型和分詞器就變得很有意義,前提是你擁有必要的計算資源。

建立大規模語料函式庫的挑戰

模型預訓練後的品質很大程度上反映了預訓練語料函式庫的品質。模型會繼承預訓練語料函式庫中的任何缺陷。因此,在嘗試建立自己的語料函式庫之前,瞭解一些與建立大型語料函式庫相關的常見問題和挑戰是很重要的。

大型語料函式庫的特性與問題

隨著資料集規模的不斷擴大,你完全控制或至少準確瞭解其內容的可能性也隨之減小。超大型資料集通常不是由專業創作者一次性精心製作的,而更可能是透過自動或半自動方式收集而來,這些資料往往是其他活動的副產品。例如,它可能包含公司儲存的所有檔案(如契約、採購訂單等)、使用者活動日誌,或從網際網路收集的資料。

大規模資料集主要以高度自動化方式建立的事實帶來了幾個重要影響:

  1. 內容與建立方式的有限控制:這增加了在有偏見和低品質資料上訓練模型的風險
  2. 資料偏見問題:語料函式庫可能包含特定型別內容的過度表示或不足
  3. 品質不一致:自動收集的資料往往品質參差不齊
  4. 版權問題:大型資料集中可能存在版權侵犯

著名大型資料集的問題研究

對 BookCorpus 和 C4 等著名大型資料集的研究發現了一些值得注意的問題:

  1. C4 語料函式庫中有相當大比例的內容是機器翻譯而非人工翻譯
  2. C4 中的停用詞過濾導致了非裔美國人英語的不成比例的缺失,使這類別內容表示不足
  3. 在大型文字語料函式庫中,很難在包含過多色情或其他顯性內容與完全刪除所有與性或性別相關的內容之間找到平衡點
  4. BookCorpus 中有許多版權侵犯的情況,這在其他大型資料集中可能也很常見
  5. BookCorpus 中存在明顯的型別偏差,浪漫小説的比例過高

這些發現與在這些語料函式庫上訓練的模型的下游使用可能並不矛盾。例如,如果模型旨在用作浪漫小説寫作工具或用於構建遊戲,BookCorpus 中浪漫小説的強烈過度表示可能是可以接受的。

資料偏見如何影響模型行為

為了説明資料如何影響模型行為,讓我們比較 GPT 和 GPT-2 的文字生成結果。GPT 主要在 BookCorpus 上訓練,而 GPT-2 則在 Reddit 連結的網頁、部落格和新聞文章上訓練。我們將對相似規模的兩個模型使用相同的提示進行比較,這樣主要區別就是預訓練資料集:

from transformers import pipeline, set_seed
generation_gpt = pipeline("text-generation", model="openai-gpt")
generation_gpt2 = pipeline("text-generation", model="gpt2")

首先確認兩個模型的引數規模相近:

def model_size(model):
    return sum(t.numel() for t in model.parameters())

print(f"GPT size: {model_size(generation_gpt.model)/1000**2:.1f}M parameters")
print(f"GPT2 size: {model_size(generation_gpt2.model)/1000**2:.1f}M parameters")

上面的程式碼定義了一個 model_size 函式,用於計算模型的引數總數。函式使用 numel() 方法計算每個引數張量中的元素數量,並將所有引數的元素數量相加,最後轉換為百萬級單位顯示。結果顯示 GPT 模型有約 116.5M 引數,GPT-2 模型有約 124.4M 引數,兩者規模相近,這確保了我們的比較主要反映的是預訓練資料集的差異,而非模型架構或大小的差異。

接下來,我們使用相同的提示生成文字,看兩個模型的輸出有何不同:

def enum_pipeline_ouputs(pipe, prompt, num_return_sequences):
    out = pipe(prompt, num_return_sequences=num_return_sequences,
              clean_up_tokenization_spaces=True)
    return "\n".join(f"{i+1}." + s["generated_text"] for i, s in enumerate(out))

prompt = "\nWhen they came back"
print("GPT completions:\n" + enum_pipeline_ouputs(generation_gpt, prompt, 3))
print("")
print("GPT-2 completions:\n" + enum_pipeline_ouputs(generation_gpt2, prompt, 3))

這段程式碼定義了一個函式 enum_pipeline_ouputs,用於格式化模型生成的多個輸出序列。函式接受一個文字生成管道、提示文字和需要生成的序列數量作為引數,然後為每個生成的序列增加編號並連線成一個字元串。我們使用相同的提示 “When they came back” 分別讓 GPT 和 GPT-2 生成 3 個不同的文字完成,以比較它們的輸出差異。

從兩個模型的輸出中,我們可以明顯看出它們各自訓練資料集的特性。GPT 模型(在 BookCorpus 上訓練)的輸出更傾向於小説風格的敍事,而 GPT-2(在網頁和新聞文章上訓練)的輸出則更多樣化,包含更多事實性和新聞風格的內容。

為什麼需要從零訓練分詞器

分詞器在 NLP 模型中扮演著至關重要的角色,它將原始文字轉換為模型可以處理的標記序列。當處理特定領域的資料時,使用在該領域訓練的分詞器可以顯著提高模型效能。

領域特定分詞的重要性

讓我們考慮一個例子:假設我們要處理程式碼資料,而不是自然語言文字。程式碼有其特定的結構、語法和慣例,這與自然語言文字有很大不同。使用為自然語言設計的分詞器來處理程式碼會導致次優的分詞結果。

例如,程式碼中常見的結構如 variable_namefunction_call()if-else 陳述式等,在自然語言中可能很少見。一個為程式碼專門訓練的分詞器會將這些結構識別為單一標記或有意義的標記組合,而通用分詞器可能會將它們分割成多個無意義的片段。

自訂分詞器的優勢

當你有足夠的領域特定資料時,訓練自訂分詞器的優勢包括:

  1. 更有效的標記化:減少標記數量,提高處理效率
  2. 更好的語義捕捉:確保重要的領域特定概念被視為單一單元
  3. 改善稀有詞彙的處理:領域特定術語不會被分割為無意義的子詞
  4. 提高模型效能:適合領域的分詞器通常會導致更好的模型效能

在下一節中,我將討論如何為特定領域資料建立和訓練自訂分詞器,以及如何將其與 Transformer 模型結合使用。

分散式訓練:使用 PyTorch Accelerate

訓練大型 Transformer 模型需要大量計算資源。為了有效地在多個 GPU 上訓練模型,我們需要專門的分散式訓練工具。雖然 Hugging Face Transformers 的 Trainer API 支援分散式訓練,但 PyTorch 的 Accelerate 函式庫提供了更多靈活性和控制。

Accelerate 的優勢

Accelerate 函式庫的設計理念是讓分散式訓練變得簡單,同時保持對訓練過程的完全控制。使用 Accelerate,你可以將單 GPU 訓練程式碼轉換為多 GPU 或多節點訓練程式碼,只需少量修改。

主要優勢包括:

  1. 簡單的 API:最小化從單裝置到多裝置訓練的程式碼更改
  2. 裝置不可知:相同的程式碼可以在 CPU、單 GPU 或多 GPU 設定上執行
  3. 與 PyTorch 生態系統無縫整合:可以與其他 PyTorch 函式庫和工具一起使用
  4. 支援多種分散式訓練策略:包括 DDP(DistributedDataParallel)、DeepSpeed 和 FSDP(Fully Sharded Data Parallel)

基本 Accelerate 設定

使用 Accelerate 進行分散式訓練的基本步驟如下:

from accelerate import Accelerator

# 初始化加速器
accelerator = Accelerator()

# 準備模型、最佳化器和資料載入器
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
    model, optimizer, train_dataloader, eval_dataloader
)

# 正常的訓練迴圈
for epoch in range(num_epochs):
    for batch in train_dataloader:
        outputs = model(batch)
        loss = outputs.loss
        accelerator.backward(loss)
        optimizer.step()
        optimizer.zero_grad()

上面的程式碼展示了使用 Accelerate 進行分散式訓練的基本架構。首先建立一個 Accelerator 例項,然後使用其 prepare 方法處理模型、最佳化器和資料載入器。在訓練迴圈中,我們使用 accelerator.backward() 代替標準的 loss.backward()。這種簡單的修改使程式碼能夠在任何裝置設定上執行,無需額外更改。

高階 Accelerate 功能

對於更複雜的訓練場景,Accelerate 提供了多種高階功能:

  1. 梯度累積:當批次大小受限於 GPU 記憶體時特別有用
  2. 混合精確度訓練:自動處理 FP16/BF16 訓練,提高效能和減少記憶體使用
  3. 檢查點儲存與載入:簡化分散式環境中的模型儲存和還原
  4. 自定義訓練邏輯:完全控制訓練迴圈,同時利用分散式訓練的優勢

以下是一個更完整的範例,展示了這些高階功能:

from accelerate import Accelerator
from accelerate.utils import set_seed

# 設定隨機種子以確保可重現性
set_seed(42)

# 初始化加速器,啟用混合精確度訓練
accelerator = Accelerator(mixed_precision="fp16")

# 準備所有元件
model, optimizer, train_dataloader, scheduler = accelerator.prepare(
    model, optimizer, train_dataloader, scheduler
)

# 訓練迴圈
for epoch in range(num_epochs):
    model.train()
    for step, batch in enumerate(train_dataloader):
        outputs = model(**batch)
        loss = outputs.loss
        # 梯度累積
        loss = loss / gradient_accumulation_steps
        accelerator.backward(loss)
        
        if step % gradient_accumulation_steps == 0:
            optimizer.step()
            scheduler.step()
            optimizer.zero_grad()
    
    # 儲存檢查點
    if epoch % save_every == 0:
        accelerator.wait_for_everyone()
        unwrapped_model = accelerator.unwrap_model(model)
        accelerator.save(unwrapped_model.state_dict(), f"checkpoint-{epoch}")

這段更完整的程式碼展示了 Accelerate 的高階功能。我們啟用了 FP16 混合精確度訓練,實作了梯度累積(透過僅在累積了指定步數的梯度後更新模型),並展示瞭如何儲存檢查點。wait_for_everyone() 方法確保所有程式都達到該點後再儲存模型,而 unwrap_model() 方法回傳原始模型,以便我們可以儲存其狀態字典。

從零開始訓練程式碼生成模型

為了將這些概念付諸實踐,讓我們考慮一個具體案例:訓練一個專門用於生成 Python 程式碼的語言模型,我們可以稱之為 CodeParrot。

收集和處理程式碼資料集

對於程式碼生成模型,我們需要一個大型的程式碼語料函式庫。GitHub 是一個很好的資源,但我們需要考慮許可證和品質問題。

收集程式碼資料的步驟:

  1. 資料來源選擇:可以使用 GitHub 公共倉函式庫、程式碼競賽網站或開放原始碼程式函式庫
  2. 許可證過濾:僅選擇具有適當許可證的程式碼(如 MIT、Apache 等)
  3. 品質過濾
    • 移除過短的檔案(可能只是設定或空範本)
    • 移除過長的檔案(可能是自動生成的或資料檔案)
    • 過濾掉註解比例過高的檔案
    • 排除明顯的非程式碼內容

建立自訂程式碼分詞器

為程式碼建立自訂分詞器涉及以下步驟:

from tokenizers import ByteLevelBPETokenizer

# 初始化分詞器
tokenizer = ByteLevelBPETokenizer()

# 從代表性資料子集訓練分詞器
files = [str(x) for x in Path("./python_code_dataset").glob("**/*.py")]
tokenizer.train(
    files=files,
    vocab_size=50000,
    min_frequency=2,
    special_tokens=["<s>", "<pad>", "</s>", "<unk>", "<mask>"]
)

# 儲存分詞器
tokenizer.save_model("code-tokenizer")

這段程式碼使用 Hugging Face 的 tokenizers 函式庫初始化一個位元組級 BPE(Byte-Pair Encoding)分詞器,這種分詞器特別適合程式碼,因為它能有效處理特殊字元和識別符號。我們從 Python 程式碼資料集中收集所有 .py 檔案進行訓練,設定詞彙表大小為 50,000,最小頻率為 2(即一個標記至少出現兩次才會被納入詞彙表),並增加了一些特殊標記。訓練完成後,我們將分詞器儲存到磁碟。

與為自然語言設計的分詞器相比,程式碼分詞器會更好地處理:

  1. 程式碼語法:如縮排、括號、運算元
  2. 變數和函式名稱:通常使用 camelCase 或 snake_case
  3. 特殊標記:如 defclassimport 等 Python 關鍵字
  4. 註解和檔案字元串:程式碼中的自然語言部分

設計模型架構

對於程式碼生成,我們可以使用自迴歸模型,如 GPT 架構。以下是使用 Hugging Face Transformers 設定模型的方式:

from transformers import GPT2Config, GPT2LMHeadModel

# 定義模型設定
config = GPT2Config(
    vocab_size=50000,  # 與分詞器詞彙表大小一致
    n_positions=1024,  # 最大序列長度
    n_embd=768,        # 嵌入維度
    n_layer=12,        # Transformer 層數
    n_head=12,         # 注意力頭數
    bos_token_id=tokenizer.token_to_id("<s>"),
    eos_token_id=tokenizer.token_to_id("</s>"),
    pad_token_id=tokenizer.token_to_id("<pad>")
)

# 初始化模型
model = GPT2LMHeadModel(config)

這段程式碼建立了一個 GPT-2 風格的模型設定,並初始化了一個語言模型頭部的 GPT-2 模型。我們設定了詞彙表大小以比對我們的分詞器,並定義了序列長度、嵌入維度、層數和注意力頭數等超引數。我們還指定了特殊標記的 ID,如開始標記、結束標記和填充標記,這些對於正確處理序列至關重要。

實作分散式訓練

使用 Accelerate 實作多 GPU 訓練的完整指令碼:

import os
from accelerate import Accelerator, DistributedType
from accelerate.utils import set_seed
from transformers import get_scheduler
import torch
from torch.utils.data import DataLoader

# 初始化加速器
accelerator = Accelerator(
    gradient_accumulation_steps=8,
    mixed_precision="fp16"
)

# 設定隨機種子
set_seed(42)

# 準備資料載入器、模型和最佳化器
train_dataloader = DataLoader(train_dataset, batch_size=4, shuffle=True)
eval_dataloader = DataLoader(eval_dataset, batch_size=4)

optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5)

# 準備元件
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
    model, optimizer, train_dataloader, eval_dataloader
)

# 設定學習率排程器
num_train_epochs = 3
num_update_steps_per_epoch = len(train_dataloader)
num_training_steps = num_train_epochs * num_update_steps_per_epoch

lr_scheduler = get_scheduler(
    name="linear",
    optimizer=optimizer,
    num_warmup_steps=1000,
    num_training_steps=num_training_steps
)

# 訓練迴圈
for epoch in range(num_train_epochs):
    model.train()
    for step, batch in enumerate(train_dataloader):
        outputs = model(**batch)
        loss = outputs.loss
        loss = loss / accelerator.gradient_accumulation_steps
        accelerator.backward(loss)
        
        if step % accelerator.gradient_accumulation_steps == 0:
            optimizer.step()
            lr_scheduler.step()
            optimizer.zero_grad()
    
    # 評估
    model.eval()
    with torch.no_grad():
        for batch in eval_dataloader:
            outputs = model(**batch)
            # 計算評估指標
    
    # 儲存檢查點
    if accelerator.is_main_process:
        unwrapped_model = accelerator.unwrap_model(model)
        unwrapped_model.save_pretrained(f"./checkpoints/epoch_{epoch}")

這段程式碼展示了使用 Accelerate 進行分散式訓練的完整流程。我們初始化加速器時指定了梯度累積步數和混合精確度訓練。接著準備資料載入器、模型和最佳化器,並設定線性學習率排程器,其中包含一個預熱階段。在訓練迴圈中,我們實作了梯度累積(透過將損失除以累積步數並僅在累積了指定步數的梯度後更新模型)。評估階段在每個 epoch 後進行,我們還實作了檢查點儲存,但只在主程式中執行,以避免多個程式同時寫入檔案。

評估和最佳化模型

訓練後,我們需要評估模型的效能並進行必要的最佳化:

from transformers import pipeline

# 載入已訓練的模型和分詞器
code_generator = pipeline("text-generation", model="./checkpoints/epoch_2")

# 生成一些範例程式碼
prompt = "def fibonacci(n):"
generated_code = code_generator(
    prompt, 
    max_length=100, 
    num_return_sequences=1,
    temperature=0.7
)

print(generated_code[0]["generated_text"])

這段程式碼使用 Hugging Face 的 pipeline API 載入我們訓練的模型,並用它生成一些程式碼。我們提供了一個函式定義的開頭作為提示,並設定了生成引數,包括最大長度、回傳序列數和溫度(控制生成的隨機性)。透過調整這些引數,我們可以控制生成程式碼的創造性和準確性之間的平衡。

大模型語言的實際應用與倫理考量

成功訓練大模型語言後,我們可以將其應用於多種場景,但同時也需要考慮一些倫理問題。

應用場景

  1. 程式碼自動補全:在 IDE 中提供人工智慧程式碼建議
  2. 程式碼生成:根據自然語言描述生成程式碼
  3. 程式碼轉換:在不同程式語言間轉換程式碼
  4. 程式碼解釋:生成程式碼的自然語言解釋
  5. 錯誤檢測與修復:識別和修復程式碼中的錯誤

倫理考量

訓練和佈署大模型語言時,我們需要考慮以下倫理問題:

  1. 資料來源透明度:清楚説明模型訓練所用資料的來源和性質
  2. 偏見與公平性:評估和減輕模型中可能存在的偏見
  3. 環境影響:考慮大型模型訓練的能源消耗和碳足跡
  4. 版權問題:確保訓練資料的使用符合法律要求
  5. 濫用風險:考慮模型可能被濫用的方式,並實施適當的保護措施

最佳化訓練資源使用

訓練大型模型時,有效管理計算資源至關重要:

記憶體最佳化技術

  1. 梯度檢查點:在前向傳遞中只儲存某些層的啟用,而不是所有層,以節省記憶體
  2. 混合精確度訓練:使用 FP16 或 BF16 而不是 FP32,減少記憶體使用和計算時間
  3. 模型平行化:將模型的不同部分放在不同的 GPU 上
  4. 零冗餘最佳化器:分散最佳化器狀態,減少每個 GPU 的記憶體需求

訓練速度最佳化

  1. 批次大小最佳化:找到平衡記憶體使用和訓練效率的最佳批次大小
  2. 資料載入最佳化:使用高效的資料載入和預處理管道
  3. 通訊最佳化:在多 GPU 設定中最小化裝置間通訊

從零訓練的實用建議

根據我在訓練大型模型的經驗,以下是一些實用建議:

  1. 從小開始:先使用較小的模型和資料集進行實驗,確保整個管道正常工作
  2. 監控訓練:密切關注損失、梯度範數和學習率,以及 GPU 使用率和記憶體消耗
  3. 頻繁儲存檢查點:定期儲存模型檢查點,以防訓練中斷
  4. 驗證資料品質:在全面訓練前,花時間確保資料集品質良

理解模型行為:預訓練資料的影響力

當我們觀察不同模型的文字生成結果時,常會發現有趣的模式差異。這些差異直接反映了模型訓練資料的特性。以GPT系列和GPT-2比較為例,兩者在相同提示下產生的文字風格和內容傾向有明顯差異。

GPT模型經常傾向生成帶有浪漫元素的對話,通常包含一位女性和一位男性之間的互動。相較之下,GPT-2則是在Reddit相關網頁文字上訓練的,其生成內容多採用中性的「they」,與風格偏向部落格式或冒險相關的元素。

這種現象揭示了一個重要事實:任何模型都會反映其訓練資料中的語言偏見,以及人口和事件的過度或不足表示。在設計導向特定受眾的模型時,這些偏見必須被納入考量。Google的一篇論文提供了資料集開發的實用,對此有深入討論。

在建立大型文字語料函式庫時,這些挑戰尤為明顯。接下來,讓我們探討如何建立自己的資料集!

建立自訂程式碼資料集

為了簡化任務,玄貓將專注於建立一個僅針對Python程式語言的程式碼生成模型。首先,我們需要一個大型的Python原始碼預訓練語料函式庫。

幸運的是,有一個每位軟體工程師都熟知的自然資源:GitHub!這個知名的程式碼分享平台存放著數TB的程式碼儲存函式庫,這些儲存函式庫可以根據各自的許可證公開存取、下載和使用。在撰寫本文時,GitHub上已有超過2000萬個程式碼儲存函式庫,雖然其中許多是使用者為學習、未來的副專案或測試目的而建立的小型或測試儲存函式庫。

存取GitHub儲存函式庫的兩種主要方式

GitHub儲存函式庫可以透過兩種主要方式存取:

  1. 透過GitHub REST API - 類別似於我們在下載Transformers儲存函式庫的所有GitHub問題時所使用的方式
  2. 透過公共資料集清單,如Google BigQuery

由於REST API有速率限制,而我們的預訓練語料函式庫需要大量資料,所以我們將使用Google BigQuery來提取所有Python儲存函式庫。bigquery-public-data.github_repos.contents表包含所有大小於10MB的ASCII檔案副本。根據GitHub的許可證API,專案必須是開放原始碼的才能被納入。

值得注意的是,Google BigQuery資料集不包含星標或下游使用訊息。對於這些屬性,我們可以使用GitHub REST API或類別似Libraries.io的服務來監控開放原始碼套件。實際上,GitHub團隊最近發布了一個名為CodeSearchNet的資料集,使用Libraries.io的訊息過濾了至少用於一個下游任務的儲存函式庫。

使用Google BigQuery建立資料集

現在,讓我們看如何使用Google BigQuery建立程式碼資料集。首先,我們將從Google BigQuery上的快照中提取GitHub公共儲存函式庫中的所有Python檔案。為了可重複性,以及防止未來BigQuery的免費使用政策發生變化,我們也會在Hugging Face Hub上分享這個資料集。

以下是匯出這些檔案的步驟,這些步驟改編自TransCoder實作:

  1. 建立Google Cloud帳戶(免費試用應該足夠)
  2. 在您的帳戶下建立Google BigQuery專案
  3. 在此專案中建立資料集
  4. 在此資料集中建立一個表,存放SQL請求的結果
  5. 準備並在github_repos上執行以下SQL查詢(要儲存查詢結果,選擇"更多 > 查詢選項",勾選"為查詢結果設定目標表"框,並指定表名):
SELECT
f.repo_name, f.path, c.copies, c.size, c.content, l.license
FROM
`bigquery-public-data.github_repos.files` AS f
JOIN
`bigquery-public-data.github_repos.contents` AS c
ON
f.id = c.id
JOIN
`bigquery-public-data.github_repos.licenses` AS l
ON
f.repo_name = l.repo_name
WHERE
NOT c.binary
AND ((f.path LIKE '%.py')
AND (c.size BETWEEN 1024
AND 1048575))

這個SQL查詢從GitHub公共資料中提取Python檔案。它連線了三個表:files(包含檔案路徑)、contents(包含檔案內容)和licenses(包含許可證資訊)。查詢條件確保檔案不是二進位的、副檔名為.py,與大小在1KB到1MB之間。這樣可以過濾掉空檔案和像__init__.py這樣的小檔案,同時也排除過大的檔案。

這個命令處理約2.6TB的資料,提取了2680萬個檔案。結果是一個約50GB的壓縮JSON檔案資料集,每個檔案包含Python檔案的原始碼。我們過濾掉了空檔案和像__init__.py這樣不包含太多有用訊息的小檔案。我們也過濾掉了大於1MB的檔案,並下載了所有檔案的許可證,以便日後可以根據許可證過濾訓練資料。

接下來,我們需要將結果下載到本地機器。如果你想自己嘗試,請確保有足夠的頻寬和至少50GB的可用磁碟空間。將結果表下載到本地機器最簡單的方法是遵循以下兩步驟:

  1. 將結果匯出到Google Cloud: a. 在Google Cloud Storage (GCS)中建立一個儲存桶和資料夾 b. 透過選擇"匯出 > 匯出到GCS"將表匯出到這個儲存桶,匯出格式為JSON,使用gzip壓縮

  2. 使用gsutil函式庫將儲存桶下載到你的機器: a. 使用pip install gsutil安裝gsutil b. 使用gsutil config設定gsutil與你的Google帳戶 c. 使用以下命令將儲存桶複製到你的機器:

    gsutil -m -o "GSUtil:parallel_process_count=1" cp -r gs://<name_of_bucket>
    

或者,你可以直接從Hugging Face Hub使用以下命令下載資料集:

git clone https://huggingface.co/datasets/transformersbook/codeparrot

過濾雜訊:要還是不要?

任何人都可以建立GitHub儲存函式庫,因此專案的品質各不相同。關於我們希望系統在實際環境中如何表現,這裡有一些需要考慮的選擇。在訓練資料集中保留一些雜訊會使我們的系統在推論時對雜訊輸入更加穩健,但同時也會使其預測更加隨機。根據預期用途和整個系統整合,你可能會選擇更多或更少的雜訊資料,並增加前後過濾操作。

為了本文的教育目的並保持資料準備程式碼簡潔,我們不會根據星標或使用情況進行過濾,而只是取得GitHub BigQuery資料集中的所有Python檔案。然而,資料準備是一個關鍵步驟,你應該盡可能地清理你的資料集。

在我們的案例中,需要考慮的幾個因素包括:

  1. 是否平衡資料集中的程式語言
  2. 過濾低品質資料(例如,透過GitHub星標或其他儲存函式庫的參照)
  3. 移除重複的程式碼樣本
  4. 考慮版權訊息
  5. 調查檔案、評論或檔案字串中使用的語言

資料集的品質直接影響模型的表現。在實際應用中,我建議投入足夠的時間和資源進行資料清理和準備工作。從我的經驗來看,在資料準備上多花一小時,往往能節省模型訓練和調整上的數十小時。

資料品質與模型表現的關係

在建立自訂程式碼生成模型時,資料集的品質尤為重要。當我們從GitHub抓取Python程式碼時,需要考慮幾個關鍵因素:

  1. 程式碼品質:不是所有GitHub上的程式碼都符合最佳實踐。低品質的程式碼可能導致模型學習到不良的程式設計習慣。

  2. 檔案和註解:良好的程式碼通常包含清晰的檔案和註解。這些文字內容也會影響模型對程式碼意圖的理解。

  3. 程式碼多樣性:確保資料集涵蓋各種程式設計風格和應用場景,可以增強模型的通用性。

  4. 版權考量:使用開放原始碼程式碼時,必須尊重原始許可證的限制。

在建立Transformer模型時,資料集的多樣性和品質比單純的資料量更為重要。一個較小但經過精心企劃的資料集,往往比一個龐大但充滿雜訊的資料集能產生更好的結果。

資料準備的深層思考

從我在開發機器學習模型的經驗中,發現資料準備階段常被低估,但實際上它是整個流程中最關鍵的環節之一。在處理程式碼生成模型時,我們不僅需要考慮語法正確性,還要思考可讀性、效率和最佳實踐。

當從GitHub取得Python程式碼時,我們實際上是在建立一個"程式碼文化"的快照。這個快照將直接影響模型產生的程式碼風格和品質。因此,在決定是否過濾資料時,我們需要考慮:

  • 我們希望模型生成什麼樣的程式碼?
  • 目標使用者群體是誰?是初學者還是經驗豐富的開發者?
  • 模型應該遵循哪些程式設計正規化和風格?

這些問題沒有絕對的答案,但它們會引導我們做出更明智的資料準備決策。在某些情況下,保留一定程度的"雜訊"實際上可能是有益的,因為它能讓模型更好地處理現實世界中的各種輸入。

建立自訂程式碼資料集是訓練Transformer模型的關鍵步驟。透過Google BigQuery,我們可以從GitHub提取大量的Python程式碼,建立一個豐富的預訓練語料函式庫。

資料準備過程中需要考慮多個因素,包括程式碼品質、資料多樣性和版權問題。雖然為了教育目的我們選擇了一個簡化的方法,但在實際應用中,精心清理和準備資料集至關重要。

正如我們所見,模型的行為直接反映了其訓練資料的特性。因此,透過建立高品質的自訂資料集,我們可以顯著提升模型的效能和實用性。在下一階段,我們將使用這個資料集來訓練我們的Transformer模型,探索程式碼生成的奧妙世界。

大型資料集處理的挑戰與解決方案

處理50GB甚至更大的資料集是現代資料科學家和機器學習工程師常見的挑戰。當資料大小超過電腦的RAM容量時,傳統的資料載入方法往往會失敗。在我多年的機器學習專案經驗中,這個問題一直是讓許多開發者頭痛的瓶頸。

以我們的案例來説,我們需要處理的是約50GB的壓縮資料,解壓後高達200GB。這遠超出了一般筆電或桌上型電腦的記憶體容量。過去,這種情況可能迫使我們升級硬體或切割資料集,但現在有了更聰明的解決方案。

大型資料集的管理與分享

在訓練大型深度學習模型時,資料的管理至關重要。當我們處理像程式碼這樣的特殊領域資料時,不僅需要有效率地管理資料,還需要能夠輕鬆地與團隊分享。Hugging Face Hub 提供了一個絕佳的平台來實作這些需求。

將資料集推播至 Hugging Face Hub

完成資料集的初步處理後,接下來我們需要將其上載到 Hub,以便後續使用。以下是完整的上載流程:

# 刪除特設定檔案
$ rm ./file-000000000183.json.gz

# 提交並推播檔案
$ git add .
$ git commit -m "Adding dataset files"
$ git push

這段程式碼展示瞭如何使用 Git 命令來管理和上載資料集。首先,我們刪除了一個特定的檔案(可能是為了資料整理或空間考量)。然後,使用標準的 Git 工作流程將變更加入索引、提交變更並推播到遠端儲存函式庫。在處理大型資料集時,git add . 可能會花幾分鐘的時間,因為系統需要計算所有檔案的雜湊值。

接著,我們需要對驗證集執行相同的操作:

# 切換到驗證集目錄
$ cd ../codeparrot-valid

# 複製並重新命名檔案
$ cp ../codeparrot/file-000000000183.json.gz .
$ mv ./file-000000000183.json.gz ./file-000000000183_validation.json.gz

# 提交並推播
$ git add .
$ git commit -m "Adding dataset files"
$ git push

這段程式碼處理驗證資料集的上載流程。我們首先切換到驗證集的目錄,然後複製前面提到的檔案,並增加了 _validation 字尾以便後續識別。這種命名慣例很重要,因為它讓我們能夠在載入資料時將其識別為驗證分割。最後,同樣使用 Git 命令提交並上載檔案。

雖然上載過程可能需要一些時間,但這個步驟為後續的實驗奠定了基礎,使我們能夠使用串流技術更高效地處理資料。完成後,我們的資料集就可以在以下 URL 存取:

資料集檔案的重要性

在實務上,為資料集增加詳細的 README 檔案非常重要。一個有良好檔案的資料集更容易被其他人使用,也能幫助未來的自己理解資料集的結構和用途。Hugging Face 提供了資料集 README ,詳細説明如何撰寫高品質的資料集檔案。我們也可以使用 Hub 的網頁編輯器直接修改 README 檔案。

從實際經驗來看,一個好的資料集檔案至少應包含:

  • 資料來源和收集方法
  • 資料集結構和格式
  • 任何預處理步驟的詳細説明
  • 資料集的使用建議和限制
  • 相關的參照和授權資訊

建立客製化分詞器

在前面的工作中,我們準備了大型的程式碼資料集,現在需要有效地處理這些資料以供模型使用。在之前的實踐中,我們通常使用與預訓練模型配套的分詞器,這是合理的做法,因為這些模型是使用特定的預處理管道訓練的。然而,當我們要訓練全新的模型時,使用為其他資料集準備的分詞器可能並不理想。

現有分詞器的侷限性

使用現有分詞器可能會遇到以下問題:

  1. 領域不比對:T5 分詞器是在經過大量停用詞過濾的 C4 語料函式庫上訓練的,導致它無法識別一些常見的英文單詞,如 “sex”。

  2. 語言限制:CamemBERT 分詞器僅在法語文字上訓練,因此它對常見的英文單詞如 “being” 的處理不佳。

我們可以透過一個簡單的實驗來驗證這些問題:

from transformers import AutoTokenizer

def tok_list(tokenizer, string):
    input_ids = tokenizer(string, add_special_tokens=False)["input_ids"]
    return [tokenizer.decode(tok) for tok in input_ids]

tokenizer_T5 = AutoTokenizer.from_pretrained("t5-base")
tokenizer_camembert = AutoTokenizer.from_pretrained("camembert-base")

print(f'T5 tokens for "sex": {tok_list(tokenizer_T5,"sex")}')
print(f'CamemBERT tokens for "being": {tok_list(tokenizer_camembert,"being")}')

輸出結果:

T5 tokens for "sex": ['', 's', 'ex']
CamemBERT tokens for "being": ['be', 'ing']

這段程式碼展示了不同分詞器如何處理特定單詞。我們定義了一個函式 tok_list,它接受一個分詞器和一個字串,並回傳該字串被分詞後的各個部分。對於 T5 分詞器,“sex” 被分解為空字串、“s” 和 “ex”;而 CamemBERT 分詞器則將 “being” 分解為 “be” 和 “ing”。這種將短而常見的單詞分解成子部分的做法會增加輸入序列的長度,降低模型處理效率,特別是在模型上下文長度有限的情況下。

這個實驗清楚地表明,分詞器的領域和預處理方式對模型的效能有重大影響。因此,針對我們的程式碼資料集,訓練一個專用的分詞器是非常必要的。

分詞器的訓練與模型訓練的區別

值得注意的是,分詞器的「訓練」與神經網路模型的訓練有本質區別:

  • 模型訓練:從給定的權重開始,使用反向傳播從誤差訊號最佳化模型權重,以最小化損失函式並找到最佳權重組合。

  • 分詞器訓練:不涉及反向傳播或權重最佳化,而是建立一個從文字串到整數列表的最佳對映。現代分詞器通常包含一個詞彙表(原子字串列表)和一個方法,用於將文字轉換、規範化、切分或對映成使用該詞彙表的索引列表。

分詞器的核心元件

在之前的討論中,我曾提到分詞器是由四個步驟組成的處理管道:規範化、預分詞、分詞器模型和後處理。其中,可以在資料上訓練的部分是分詞器模型。常見的子詞分詞演算法包括 BPE(Byte-Pair Encoding)、WordPiece 和 Unigram。

BPE 與 Unigram 演算法

BPE 演算法從基本單元(單個字元)開始,透過不斷合併最常共同出現的基本單元來建立詞彙表,直到達到預定的詞彙量。

Unigram 演算法則採取相反的方法,它從語料函式庫中所有單詞和潛在子詞的完整集合開始,然後逐漸移除或分割較不實用的標記,直到達到目標詞彙量。

WordPiece是 Unigram 的前身,其官方實作從未被 Google 開放原始碼。

這些演算法對下游任務效能的影響因任務而異,很難明確判定哪一種演算法絕對優於其他演算法。BPE 和 Unigram 在大多數情況下都有合理的表現。

評估分詞器效能的指標

分詞器的最佳性和效能在實踐中難以衡量。一些可能的指標包括:

  1. 子詞肥沃度(Subword fertility):計算每個分詞後的單詞平均產生的子詞數量。
  2. 連續單詞的比例:指語料函式庫中被分割成至少兩個子標記的分詞單詞的比例。
  3. 覆寫率指標:如分詞語料函式庫中未知單詞或很少使用的標記的比例。

此外,對拼寫錯誤或噪音的魯棒性也常被評估,因為這強烈依賴於分詞過程。

這些指標提供了分詞器效能的不同視角,但它們往往忽略了分詞器與模型的互動。例如,可以透過在詞彙表中包含所有可能的單詞來最小化子詞肥沃度,但這會為模型產生一個非常大的詞彙表。

在實際應用中,評估各種分詞方法的效能通常以模型在下游任務上的表現作為最終指標。早期 BPE 方法的良好效能就是透過展示使用這些分詞器和詞彙表訓練的模型在機器翻譯任務上的改進效能來證明的,相比於根據字元或單詞的分詞方法。

為 Python 程式碼建立專用分詞器

對於程式碼,特別是 Python 程式碼,我們需要一個特殊的分詞器。在程式語言的預分詞方面,有一些值得討論的問題:

程式碼分詞的特殊考量

如果我們按空白分割並移除它們,我們將失去所有縮排訊息,而在 Python 中,縮排對程式的語義非常重要(想 while 迴圈或 if-then-else 陳述式)。另一方面,換行符沒有意義,可以在不影響語義的情況下增加或移除。

同樣,在標點符號上分割,比如下劃線(用於組成單個變數名)可能會導致問題。在程式碼中,變數名、函式名和類別名通常包含多個單詞,透過下劃線或駝峰命名法連線。如果我們簡單地按照標點符號分割,可能會破壞這些命名結構的完整性。

在實際設計 Python 程式碼分詞器時,我發現保留空白和縮排,但將標點符號(除了對命名重要的部分)視為獨立標記是一個較好的平衡點。這樣可以保持程式碼的語義結構,同時允許模型學習程式碼的語法和風格。

分詞器的建立流程

建立適合 Python 程式碼的分詞器需要考慮以下步驟:

  1. 確定基本單元:決定哪些字元或字元序列作為基本分詞單元
  2. 設計預處理規則:如何處理空白、縮排、註解等特殊元素
  3. 選擇分詞演算法:BPE、Unigram 或其他適合程式碼的演算法
  4. 訓練分詞器:在大量程式碼樣本上訓練分詞器
  5. 評估與最佳化:根據實際效果調整引數和規則

在下一部分中,我將詳細説明如何使用 Hugging Face 的工具實作這個流程,並展示如何評估分詞器的效能。

分詞器對模型訓練的影響

分詞器的選擇和設計對模型訓練有深遠的影響。一個好的分詞器可以:

  1. 減少序列長度:透過有效的標記化減少輸入序列的長度,使模型能處理更長的上下文。
  2. 提高處理效率:減少填充所需的標記數量,降低計算資源需求。
  3. 增強語義理解:透過適當的分詞幫助模型更好地理解程式碼結構和語義。
  4. 提高魯棒性:對拼寫錯誤或不常見的程式碼模式有更好的適應能力。

在程式碼生成和理解任務中,分詞器的品質往往是決定模型最終表現的關鍵因素之一。一個針對程式碼最佳化的分詞器可以幫助模型更好地捕捉程式碼的結構和邏輯,從而產生更高品質的程式碼建議或更準確的程式碼理解。

在實際工作中,我發現為特定領域(如 Python 程式碼)訓練專用分詞器的投資回報率非常高。雖然這需要額外的時間和資源,但所帶來的模型效能提升通常是顯著的,特別是在處理像程式碼這樣有特殊語法和結構的資料時。

在處理大型資料集和訓練 Transformer 模型時,資料集管理和分詞器最佳化是兩個關鍵環節。透過將資料集上載到 Hugging Face Hub,我們可以方便地分享和使用這些資料。同時,針對特定領域(如 Python 程式碼)訓練專用的分詞器,可以顯著提高模型的效能。

現有的預訓練分詞器可能不適合新的領域或任務,因此瞭解如何評估分詞器效能並建立自己的分詞器是深度學習實踐中的重要技能。分詞演算法如 BPE 和 Unigram 各有優勢,選擇哪一種應根據具體任務和資料特性。

最終,分詞器的品質應透過下游任務的模型表現來評估,而不僅是透過理論指標。一個好的分詞器可以減少序列長度、提高處理效率、增強語義理解並提高模型的魯棒性,尤其是在處理程式碼等結構化資料時。

自然語言前處理器對程式碼的限制與Byte-level Tokenizer的應用

在處理程式碼時,使用為自然語言設計的前處理器進行標記化(tokenization)往往不是最佳選擇。程式碼有其特殊的語法結構和語義,需要專門的標記化方法來保留這些特性。讓我們探索一下Hugging Face Hub上提供的tokenizer,尋找更適合處理程式碼的選項。

GPT-2 Tokenizer的位元組級處理

我們需要一個能夠保留空格的tokenizer,GPT-2的位元組級tokenizer是一個不錯的選擇。讓我們載入這個tokenizer並探索它的標記化特性:

from transformers import AutoTokenizer

python_code = r"""def say_hello():
print("Hello, World!")
# Print it
say_hello()
"""

tokenizer = AutoTokenizer.from_pretrained("gpt2")
print(tokenizer(python_code).tokens())

輸出結果:

['def', 'Ġsay', '_', 'hello', '():', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġprint', '("',
'Hello', ',', 'ĠWorld', '!"', ')', 'Ġ#', 'ĠPrint', 'Ġit', 'Ċ', 'Ċ', 'say', '_',
'hello', '()', 'Ċ']

這個輸出看起來有些奇怪,其中包含了一些特殊符號如ĠĊ。這些是GPT-2 tokenizer用來表示特定位元組的Unicode字元。Ġ表示空格,而Ċ表示換行符。這種表示方法允許tokenizer在位元組級別工作,同時保留程式碼的格式。

Python內建的tokenize模組與Rust實作的比較

Python有一個內建的tokenize模組,可以將Python程式碼字元串分割成有意義的單元(如操作碼、註解、縮排等)。但這個模組根據Python實作,受到Python全域直譯器鎖(GIL)的限制,通常較慢與效能有限。

相比之下,Transformers函式庫中的大多數tokenizer都由Tokenizers函式庫提供,並使用Rust語言實作。Rust實作的tokenizer在訓練和使用時速度快數個數量級,考慮到我們處理的語料函式庫規模,使用Rust實作的tokenizer是更明智的選擇。

探索GPT-2 Tokenizer的工作原理

讓我們進一步瞭解GPT-2 tokenizer的工作原理,首先檢視它使用的標準化處理:

print(tokenizer.backend_tokenizer.normalizer)

輸出:None

GPT-2 tokenizer不使用任何標準化處理,它直接在原始Unicode輸入上工作,不進行任何標準化步驟。

接下來看一下預標記化(pre-tokenization)過程:

print(tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(python_code))

輸出:

[('def', (0, 3)), ('Ġsay', (3, 7)), ('_', (7, 8)), ('hello', (8, 13)), ('():',
(13, 16)), ('ĊĠĠĠ', (16, 20)), ('Ġprint', (20, 26)), ('("', (26, 28)), ('Hello',
(28, 33)), (',', (33, 34)), ('ĠWorld', (34, 40)), ('!")', (40, 43)), ('Ġ#', (43,
45)), ('ĠPrint', (45, 51)), ('Ġit', (51, 54)), ('Ċ', (54, 55)), ('Ċ', (55, 56)),
('say', (56, 59)), ('_', (59, 60)), ('hello', (60, 65)), ('()', (65, 67)), ('Ċ',
(67, 68))]

這個輸出包含了標記和對應的位置訊息。每個元組中的第一個元素是標記,第二個元素是一個包含起始和結束位置的元組。例如,('hello', (8, 13))表示標記"hello"對應原始字元串中從第8個到第13個字元。

偏移追蹤與位元組級表示

Tokenizers函式庫有一個非常有用的功能,叫做偏移追蹤(offset tracking)。所有對輸入字元串的操作都被追蹤,因此可以準確知道標記化後的每個標記對應原始字元串的哪一部分。即使在標準化步驟中移除了某些字元,我們仍然能夠將每個標記與原始字元串中的相應部分關聯起來。

至於那些奇怪的字元,如ĊĠ,它們是位元組級表示的一部分。“位元組級"意味著這個tokenizer工作在位元組而非Unicode字元上。每個Unicode字元由1到4個位元組成,取決於字元本身。位元組的優點在於,雖然Unicode字母表中有143,859個Unicode字元,但位元組字母表中只有256個元素,並且可以將每個Unicode字元表示為這些位元組的序列。

如果我們在位元組上工作,就可以使用一個僅包含256個值的字母表來表示UTF-8世界中的所有字元串。這意味著我們可以有一個僅使用256個詞彙的模型,並能夠處理任何Unicode字元串。

位元組表範例

讓我們看一些字元的位元組表示:

a, e = u"a", u"€"
byte = ord(a.encode("utf-8"))
print(f'`{a}` is encoded as `{a.encode("utf-8")}` with a single byte: {byte}')
byte = [ord(chr(i)) for i in e.encode("utf-8")]
print(f'`{e}` is encoded as `{e.encode("utf-8")}` with three bytes: {byte}')

輸出:

`a` is encoded as `b'a'` with a single byte: 97
`` is encoded as `b'\xe2\x82\xac'` with three bytes: [226, 130, 172]

這個例子展示了不同字元的UTF-8編碼。字元’a’只需要一個位元組(97)來表示,而歐元符號’€‘需要三個位元組[226, 130, 172]來表示。這説明瞭Unicode字元的位元組表示的多樣性。

為什麼要在位元組級別工作?

在第2章中,我們討論了字元和詞標記之間的權衡。我們可以決定從143,859個Unicode字元建立詞彙表,但我們也想在詞彙表中包含單詞(即Unicode字元的組合),所以這個(已經非常大的)大小隻是詞彙表總大小的下限。這將使我們模型的嵌入層非常大,因為它為每個詞彙標記包含一個向量。

另一個極端是,如果我們只使用256個位元組值作為詞彙表,輸入序列將被分割成許多小片段(每個位元組構成Unicode字元),這樣我們的模型將不得不處理長輸入,並花費大量計算能力從單獨的位元組重建Unicode字元,然後從這些字元重建單詞。ByT5模型發布的論文對這種開銷進行了詳細研究。

BPE演算法:位元組對編碼

一個折中的解決方案是透過擴充套件256個詞的詞彙表,加入最常見的位元組合,構建一個中等大小的詞彙表。這就是BPE(Byte-Pair Encoding)演算法的方法。

BPE演算法的思想是透過迭代合併詞彙表中最頻繁共同出現的一對標記,逐步構建一個預定義大小的詞彙表。例如,如果th在英語中經常一起出現,我們會在詞彙表中增加一個標記th來模擬這對標記,而不是將它們分開。th標記保留在詞彙表中,用於標記化它們不一起出現的情況。從基本單元的基本詞彙表開始,我們可以高效地模擬任何字元串。

需要注意的是,不要混淆"位元組對編碼"中的"位元組"和"位元組級"中的"位元組”。位元組對編碼這個名稱來源於Philip Gage在1994年提出的一種資料壓縮技術,最初是在位元組上操作的。與這個名稱可能暗示的不同,NLP中的標準BPE演算法通常在Unicode字元串而不是位元組上操作(雖然有一種新型的BPE專門在位元組上工作,稱為位元組級BPE)。

GPT-2 Tokenizer的位元組對映

NLP中使用典型的BPE演算法存在一個問題。這些演算法被設計為處理乾淨的Unicode字元串作為輸入,而不是位元組,並且期望輸入中有規則的ASCII字元,沒有空格或控制字元。但在對應於前256個位元組的Unicode字元中,有許多控制字元(換行、製表符、轉義、換行和其他不可列印字元)。

為瞭解決這個問題,GPT-2 tokenizer首先將所有256個輸入位元組對映到可以輕鬆被標準BPE演算法處理的Unicode字元串,即我們將我們的256個基本值對映到全部對應於標準可列印Unicode字元的Unicode字元串。

這些Unicode字元是否每個都用1個或更多位元組編碼並不重要;重要的是我們最終有256個單一值,形成我們的基本詞彙表,並且這256個值被我們的BPE演算法正確處理。

from transformers.models.gpt2.tokenization_gpt2 import bytes_to_unicode

byte_to_unicode_map = bytes_to_unicode()
unicode_to_byte_map = dict((v, k) for k, v in byte_to_unicode_map.items())
base_vocab = list(unicode_to_byte_map.keys())
print(f'Size of our base vocabulary: {len(base_vocab)}')
print(f'First element: `{base_vocab[0]}`, last element: `{base_vocab[-1]}`')

輸出:

Size of our base vocabulary: 256
First element: `!`, last element: `Ń`

這段程式碼展示了GPT-2 tokenizer使用的位元組到Unicode的對映。我們看到基本詞彙表的大小正好是256,對應於256個可能的位元組值。第一個元素是!,最後一個元素是Ń。這種對映允許tokenizer在位元組級別工作,同時使用標準的BPE演算法處理這些對映後的Unicode字元。

透過這種方式,GPT-2 tokenizer能夠有效地處理包括程式碼在內的各種文字,保留其原始格式和結構,同時實作高效的標記化過程。