簡化NLP專案開發

到目前為止,我們使用的都是預先訓練好的模型,直接應用於特定任務。然而在實際專案中,通常需要在自己的資料上微調模型。但模型訓練只是NLP專案中的一小部分—高效處理資料、與同事分享結果、確保工作可重現性等都是關鍵組成部分。

Hugging Face生態系統提供了一系列工具,支援現代機器學習工作流程的各個方面。這個生態系統主要由兩部分組成:一系列函式庫和Hub平台,函式庫提供程式碼,而Hub提供預訓練模型權重、資料集、評估指標指令碼等資源。

Hugging Face Hub:資源分享與模型發現

轉移學習是推動Transformer成功的關鍵因素之一,它使我們能夠將預訓練模型用於新任務。因此,能夠快速載入預訓練模型並進行實驗至關重要。

Hugging Face Hub託管了超過20,000個免費可用的模型。Hub提供了任務、框架、資料集等多種過濾器,幫助使用者導航並快速找到合適的模型。正如我們在pipeline例子中看到的,只需一行程式碼就能在自己的專案中載入這些模型。這使得嘗試各種模型變得簡單,讓開發者能夠專注於專案的特定領域部分。

除了模型權重外,Hub還託管資料集和計算指標的指令碼,讓使用者能夠重現已發表的結果或為自己的應用利用額外資料。

Hub還提供模型和資料集卡片,記錄模型和資料集的內容,幫助使用者做出明智決策。Hub最酷的功能之一是可以透過各種特定任務的互動式小工具直接嘗試任何模型。

Hugging Face Tokenizers:高效文書處理

在我們之前看到的每個pipeline例子背後,都有一個分詞步驟,將原始文字分割成更小的片段,稱為tokens。Tokens可能是單詞、單詞的部分或標點符號等字元。Transformer模型是在這些tokens的數值表示上訓練的,所以這一步對整個NLP專案至關重要。

Tokenizers提供了多種分詞策略,並且由於其Rust後端,在分詞文字時極其快速。它還處理所有預處理和後處理步驟,如規範化輸入和將模型輸出轉換為所需格式。使用Tokenizers,我們可以像使用Transformers載入預訓練模型權重一樣載入分詞器。

Hugging Face Datasets:簡化資料處理

載入、處理和儲存資料集可能是一個繁瑣的過程,特別是當資料集大到無法放入筆記型電腦的RAM時。此外,通常需要實作各種指令碼來下載資料並將其轉換為標準格式。

Datasets透過為Hub上的數千個資料集提供標準介面來簡化這一過程。它還提供人工智慧快取(所以不必在每次執行程式碼時重做預處理)並透過利用稱為記憶體對映的特殊機制避免RAM限制,該機制將檔案內容儲存在虛擬記憶體中,使多個程式能夠更有效地修改檔案。該函式庫還與Pandas和NumPy等流行框架相容,所以不必離開熟悉的資料處理環境。

實際應用中的考量與最佳實踐

在實際應用這些技術時,有幾點值得注意:

  1. 模型選擇:不同的任務需要不同的模型。例如,翻譯任務應選擇專門針對特定語言對訓練的模型,如我們使用的Helsinki-NLP/opus-mt-en-de。

  2. 計算資源:大型Transformer模型需要相當的計算資源。在生產環境中,可能需要考慮模型量化、知識蒸餾等技術來減少資源消耗。

  3. 資料隱私:使用外部API進行翻譯或文書處理可能涉及資料隱私問題。在處理敏感資料時,本地佈署模型可能是更安全的選擇。

  4. 評估與微調:預訓練模型在特定領域的表現可能不盡如人意。在實際應用中,通常需要在領域資料上進行微調,並建立適當的評估機制。

  5. 生成內容控制:特別是在文字生成任務中,控制生成內容的相關性、準確性和適當性至關重要。可以考慮使用引導技術或後處理規則來確保生成內容的品質。

Hugging Face生態系統極大地簡化了這些技術的應用過程。無論是快速實驗不同模型,還是在生產環境中佈署穩健的NLP解決方案,這些工具都提供了強大的支援。

在後續文章中,我將探討如何在自己的資料上微調這些模型,以及如何將它們整合到實際應用中。自然語言處理技術的快速發展為各種應用場景開啟了新的可能性,掌握這些工具將使我們能夠充分利用這些機遇。

Hugging Face 生態系統的核心元件

在深度學習和自然語言處理領域,擁有優質的資料集和強大的模型只是成功的一部分。如果無法可靠地測量模型表現,這些資源的價值將大幅降低。不幸的是,傳統的 NLP 評估指標往往有多種不同實作方式,這些微小的差異可能導致誤導性的結果。

Datasets 函式庫:標準化評估指標

Hugging Face 的 Datasets 函式庫不僅提供了豐富的資料集,還內建了許多評估指標的標準實作。這確保了實驗的可重現性,讓研究結果更加可信。在我開發情緒分析系統時,正是依靠這個函式庫提供的標準化評估方法,才能確保模型表現的一致性衡量。

透過 Datasets 提供的指標實作,我們可以:

  1. 使用統一的評估標準比較不同模型
  2. 避免因指標實作差異導致的結果偏差
  3. 提高研究的可重現性和可信度

結合 Transformers、Tokenizers 和 Datasets 函式庫,我們已經擁有了訓練自己的 transformer 模型所需的一切工具。然而,有些情況下我們需要對訓練迴圈進行更精細的控制,這就是 Accelerate 函式庫發揮作用的地方。

Accelerate:簡化訓練流程

如果你曾經用 PyTorch 撰寫過自己的訓練指令碼,很可能在嘗試將在筆電上執行的程式碼移植到組織的運算叢集時遇到了困難。Accelerate 為標準訓練迴圈增加了一層抽象,負責處理訓練基礎設施所需的所有自定義邏輯。

Accelerate 的核心優勢在於:

  • 簡化了基礎設施變更需求
  • 加速開發工作流程
  • 減少從單 GPU 到多 GPU 或 TPU 遷移時的程式碼修改

在我的實踐中,Accelerate 讓我能夠專注於模型架構和訓練邏輯,而不必擔心底層硬體的差異。例如,同一份訓練指令碼可以在我的開發筆電上使用單 GPU 進行測試,然後無需修改即可在多 GPU 伺服器上進行完整訓練。

Transformer 模型的主要挑戰

雖然 transformer 模型在各種 NLP 任務上表現出色,但它們並非萬能的解決方案。在實際應用中,這些模型面臨著幾個重要挑戰:

語言限制

NLP 研究領域仍然以英語為主導。雖然已有針對其他語言的模型,但對於稀有或低資源語言,找到預訓練模型仍然很困難。在處理多語言任務時,我發現多語言 transformer 模型可以執行零樣本跨語言遷移,但效果往往不如針對特定語言最佳化的模型。

資料可用性

雖然遷移學習大幅減少了模型所需的標記訓練資料量,但與人類學習同一任務相比,這仍然是相當大的數量。在一個專案中,我需要構建一個特定領域的分類別器,但只有不到 100 條標記樣本,這種小樣本學習場景需要特殊的技術來解決。

處理長檔案

自注意力機制在段落級別的文字上表現極佳,但當處理更長的文字(如整篇檔案)時,計算成本會急劇增加。在實際應用中,我經常需要採用特殊策略來處理長文字,例如分段處理或使用改進的注意力機制。

不透明性

與其他深度學習模型一樣,transformer 模型在很大程度上是不透明的。很難或幾乎不可能解析模型「為什麼」做出特定預測。在模型被佈署用於關鍵決策時,這一挑戰尤為嚴重。

我曾在金融文字分析專案中遇到這個問題,當模型做出重要預測時,無法向利益相關者解釋決策依據。探索模型錯誤的方法和提高可解釋性成為了關鍵需求。

偏見問題

Transformer 模型主要在網路文字資料上預訓練,這將資料中存在的所有偏見都印刻到了模型中。確保模型不具有種族主義、性別歧視或其他有害偏見是一項艱巨的任務。

在一個客戶服務應用中,我發現模型對某些人口統計群體的回應存在系統性差異,這凸顯瞭解決模型偏見的重要性。

文字分類別:情緒分析實戰

文字分類別是 NLP 中最常見的任務之一,可用於廣泛的應用,如將客戶反饋分類別或根據語言路由支援票據。你的電子郵件程式的垃圾郵件過濾器很可能就在使用文字分類別來保護你的收件箱!

另一種常見的文字分類別是情緒分析,旨在識別給定文字的極性。例如,像特斯拉這樣的公司可能會分析 Twitter 上的貼文,以確定人們是否喜歡其新車頂設計。

構建情緒檢測器

假設你是一位資料科學家,需要構建一個系統,自動識別人們在 Twitter 上表達的情緒狀態,如「憤怒」或「喜悅」。這類別任務可以使用 DistilBERT 這樣的 BERT 變體來解決。

DistilBERT 的主要優勢在於它能達到與 BERT 相當的效能,同時模型尺寸更小、效率更高。這使我們能夠在幾分鐘內訓練分類別器,如果想訓練更大的 BERT 模型,只需更改預訓練模型的檢查點即可。

使用 Hugging Face Datasets 探索資料

讓我們使用 Datasets 函式庫下載情緒資料集:

from datasets import list_datasets, load_dataset

# 檢視可用資料集
all_datasets = list_datasets()
print(f"Hub 上當前有 {len(all_datasets)} 個資料集")
print(f"前 10 個是: {all_datasets[:10]}")

# 載入情緒資料集
emotions = load_dataset("emotion")

資料集結構探索

載入資料後,我們可以檢查其結構:

print(emotions)

資料集被組織成一個類別似字典的結構,每個鍵對應一個不同的分割(訓練集、驗證集和測試集)。我們可以使用標準字典語法存取單個分割:

train_ds = emotions["train"]
print(train_ds)
print(f"訓練集大小: {len(train_ds)}")

檢查單個樣本

Dataset 物件的行為類別似普通 Python 陣列或列表,我們可以透過索引存取單個樣本:

print(train_ds[0])
print(f"資料欄位名稱: {train_ds.column_names}")

每一行以字典形式表示,鍵對應於欄位名稱,值是推文和情緒標籤。

瞭解資料型別

我們可以檢視資料型別:

print(train_ds.features)

這顯示 text 欄位的資料型別是字串,而 label 欄位是一個特殊的 ClassLabel 物件,包含有關類別名及其對映到整數的訊息。

批次檢查資料

我們可以使用切片存取多個行:

print(train_ds[:5])

或取得完整列:

print(train_ds["text"][:5])

透過 Hugging Face Datasets 函式庫,資料載入和探索變得極為簡單,讓我們能夠專注於模型開發和訓練。在實際專案中,這種高效的資料處理能力可以大幅提升開發效率。

文字分類別工作流程

使用 transformer 模型進行文字分類別的典型工作流程包括:

  1. 資料載入與預處理
  2. 模型選擇與設定
  3. 訓練與評估
  4. 最佳化與佈署

在下一步中,我們將探索如何使用 Tokenizers 函式庫處理文字資料,以及如何使用 Transformers 函式庫微調預訓練模型用於情緒分類別任務。

這種端對端的工作流程展示了 Hugging Face 生態系統的強大之處,讓我們能夠快速從原始文字到可用於推論的微調模型。無論是研究還是生產環境,這些工具都能大幅提升開發效率和模型效能。

當你的資料集不在Hugging Face Hub上怎麼辦?

在實際工作中,我們經常需要處理不在Hugging Face Hub上的資料。這些資料可能儲存在你的筆電上,或是組織的遠端伺服器中。幸運的是,Hugging Face的Datasets函式庫提供了多種載入指令碼,可以處理本地和遠端資料集。

支援多種資料格式的載入方式

Datasets函式庫支援多種常見的資料格式,每種格式都有對應的載入指令碼。以下是常見資料格式的載入方法:

# CSV格式
dataset = load_dataset("csv", data_files="my_file.csv")

# 純文字格式
dataset = load_dataset("text", data_files="my_file.txt")

# JSON格式
dataset = load_dataset("json", data_files="my_file.jsonl")

使用這些載入指令碼時,我們只需要將對應的指令碼名稱傳給load_dataset()函式,並透過data_files引數指定一個或多個檔案的路徑或URL。

實際案例:從Dropbox載入情緒資料集

以情緒資料集為例,其原始檔案實際上託管在Dropbox上。我們可以先下載其中一個分割檔案:

dataset_url = "https://www.dropbox.com/s/1pzkadrvffbqw6o/train.txt"
!wget {dataset_url}

注意:上面的命令中使用了!字元,這是因為我們在Jupyter notebook中執行命令。如果你在終端中執行,只需移除這個字首。

讓我們看訓練檔案的第一行內容:

!head -n 1 train.txt
# 輸出: i didnt feel humiliated;sadness

從輸出可以看出,這個檔案沒有欄位標題,每條推文和對應的情緒標籤用分號分隔。雖然這不是標準的CSV格式,但我們仍然可以使用csv載入指令碼來處理:

emotions_local = load_dataset("csv", data_files="train.txt", sep=";",
                             names=["text", "label"])

在這裡,我們指定了分隔符號型別和欄位名稱。更簡單的方法是直接將URL傳給data_files引數:

dataset_url = "https://www.dropbox.com/s/1pzkadrvffbqw6o/train.txt?dl=1"
emotions_remote = load_dataset("csv", data_files=dataset_url, sep=";",
                              names=["text", "label"])

這樣Datasets會自動下載並快取資料集。load_dataset()函式非常靈活,建議查閱Datasets官方檔案以取得完整功能概述。

從Dataset轉換到DataFrame

為何需要轉換到DataFrame

雖然Datasets提供了許多底層功能來切分和處理資料,但將Dataset物件轉換為Pandas DataFrame通常更方便,這樣我們就能使用高階API進行資料視覺化和分析。

轉換方法

Datasets提供了set_format()方法來改變Dataset的輸出格式。這不會改變底層的資料格式(仍然是Arrow表),只是改變了我們存取資料的方式:

import pandas as pd
emotions.set_format(type="pandas")
df = emotions["train"][:]
df.head()

這段程式碼首先匯入pandas函式庫,然後將emotions資料集的格式設定為pandas。接著,我們提取訓練集的所有資料並轉換為DataFrame,最後顯示前5行。emotions.set_format()只是改變了輸出格式,原始資料仍然保持Arrow格式不變,這種設計讓我們可以靈活地在不同格式間切換而不需要重新載入資料。

轉換後的結果會保留原始欄位名稱,但標籤以整數形式表示。我們可以使用標籤特徵的int2str()方法來建立一個新欄位,顯示對應的標籤名稱:

def label_int2str(row):
    return emotions["train"].features["label"].int2str(row)

df["label_name"] = df["label"].apply(label_int2str)
df.head()

這段程式碼定義了一個將標籤整數轉換為字串的函式。emotions["train"].features["label"].int2str()是Datasets提供的方法,可以將數字標籤轉換為其對應的文字表示。我們使用pandas的apply()函式將這個轉換應用到「label」欄位的每一行,並將結果儲存在新的「label_name」欄位中。這樣我們就可以同時保留數字標籤(機器學習模型使用)和文字標籤(方便人類理解)。

深入瞭解資料集

在建立分類別器之前,我們應該先深入瞭解資料集的特性。正如Andrej Karpathy在他著名的部落格文章《訓練神經網路的方法》中所說,成為"與資料融為一體"是訓練優秀模型的關鍵步驟。

檢視類別分佈

在處理文字分類別問題時,檢查各類別範例的分佈情況非常重要。類別分佈不平衡的資料集在訓練損失和評估指標方面可能需要不同的處理方式。

使用Pandas和Matplotlib,我們可以快速視覺化類別分佈:

import matplotlib.pyplot as plt
df["label_name"].value_counts(ascending=True).plot.barh()
plt.title("類別頻率分佈")
plt.show()

這段程式碼使用pandas的value_counts()函式運算每個情緒類別出現的次數,並按升序排列。然後使用matplotlib的plot.barh()方法建立水平條形圖,最後增加標題並顯示圖表。這種視覺化方式直觀地展示了資料集中各情緒類別的分佈情況,幫助我們識別可能的類別不平衡問題。

從圖表中可以看出,這個資料集嚴重不平衡:「喜悅」和「悲傷」類別出現頻率較高,而「愛」和「驚訝」類別則少了5-10倍。

處理不平衡資料有幾種方法:

  1. 對少數類別進行隨機過取樣
  2. 對多數類別進行隨機欠取樣
  3. 收集更多來自代表性不足類別的標記資料

為了保持簡單,在本文中我們將使用原始的、不平衡的類別頻率。如果你想了解更多這些取樣技術,我建議檢視Imbalanced-learn函式庫。但請確保在建立訓練/測試分割之前不要應用取樣方法,否則會導致資料洩漏問題。

檢查推文長度

Transformer模型有一個最大輸入序列長度,稱為最大上下文大小。對於使用DistilBERT的應用,最大上下文大小是512個標記,相當於幾個段落的文字。標記是文字的原子單位;現在,我們可以將標記視為單個詞。

我們可以透過檢視每條推文的詞數分佈來粗略估計不同情緒的推文長度:

df["Words Per Tweet"] = df["text"].str.split().apply(len)
df.boxplot("Words Per Tweet", by="label_name", grid=False,
          showfliers=False, color="black")
plt.suptitle("")
plt.xlabel("")
plt.show()

這段程式碼首先計算每條推文的詞數,透過將文字拆分成詞並計算長度。然後使用pandas的boxplot()方法按情緒類別繪製詞數的箱形圖。我們設定grid=False移除網格線,showfliers=False隱藏異常值,並將圖形顏色設為黑色。最後,我們移除預設的標題和x軸標籤,並顯示圖表。箱形圖能有效地展示資料的分佈情況,包括中位數、四分位數範圍和極值。

從圖表中看到,每種情緒的大多數推文長度約為15個詞,最長的推文也遠低於DistilBERT的最大上下文大小。如果文字長度超過模型的上下文大小,就需要截斷,這可能導致效能下降,特別是當被截斷的文字包含關鍵訊息時。在這個案例中,看起來這不會是個問題。

現在我們來瞭解如何將這些原始文字轉換為適合Transformers的格式。同時,讓我們重置資料集的輸出格式,因為我們不再需要DataFrame格式:

emotions.reset_format()

從文字到標記

Transformer模型如DistilBERT不能直接收原始字元串作為輸入;相反,它們假設文字已被標記化並編碼為數值向量。標記化是將字元串分解為模型使用的原子單位的步驟。有多種標記化策略可以採用,最佳的詞分割通常是從語料函式庫中學習得來的。在檢視DistilBERT使用的標記器之前,讓我們考慮兩種極端情況:字元標記化和詞標記化。

字元標記化

最簡單的標記化方案是將每個字元單獨輸入到模型中。在Python中,str物件實際上是底層的陣列,這使我們可以使用一行程式碼快速實作字元級標記化:

text = "Tokenizing text is a core task of NLP."
tokenized_text = list(text)
print(tokenized_text)

這段程式碼將一個句子轉換為字元列表,其中每個字元(包括空格和標點符號)都成為一個獨立的標記。字元級標記化的優點是詞彙表非常小(通常少於1000個標記),並且能夠處理任何文字,包括拼寫錯誤和未知詞。然而,缺點是序列長度變得很長,與單個字元通常缺乏足夠的語義訊息。

這是個好的開始,但還沒完成。我們的模型期望每個字元轉換為整數,這個過程有時被稱為數值化。一種簡單的方法是用唯一整數編碼每個唯一標記(在這種情況下是字元):

token2idx = {ch: idx for idx, ch in enumerate(sorted(set(tokenized_text)))}
print(token2idx)

這段程式碼建立了一個從字元到整數索引的對映字典。首先,我們使用set()函式取得所有唯一字元,然後用sorted()函式對它們進行排序以確保一致性。接著使用enumerate()函式為每個字元分配一個索引,最後用字典推導式建立對映關係。這種方法確保每個唯一字元都有一個唯一的整數表示,這是將文字資料轉換為機器學習模型可處理格式的關鍵步驟。

在NLP中,這種從標記到整數的對映通常被稱為詞彙表或詞典。從輸出可以看到,我們的簡單句子產生了一個包含20個標記的詞彙表,每個標記對應一個唯一的索引。

在處理大型文字資料集時,標記化和數值化是關鍵的預處理步驟,它們將原始文字轉換為機器學習模型可以理解的數值表示。這些步驟的選擇對模型效能有顯著影響,因此瞭解不同標記化策略的優缺點至關重要。

將資料轉換為適合深度學習模型的格式是NLP專案中的基礎步驟。透過掌握Hugging Face提供的工具,我們可以輕鬆處理各種資料格式,並為後續的模型訓練做好準備。

在實際應用中,我們需要根據具體任務和資料特性選擇適當的資料處理策略。無論是處理類別不平衡問題,還是選擇合適的標記化方法,這些決策都會直接影響模型的效能和效果。

透過深入瞭解資料集的特性,如類別分佈和文字長度,我們可以更有針對性地設計和最佳化模型。這種「與資料融為一體」的方法,正是成功實施機器學習專案的關鍵所在。

從文字到令牌:理解文書處理的基礎

將令牌轉換為數值識別碼

當我們處理自然語言時,將文字轉換為機器可理解的數值形式是第一步。讓我們看如何將標記化後的文字轉換為整數列表:

input_ids = [token2idx[token] for token in tokenized_text]
print(input_ids)
# [5, 14, 12, 8, 13, 11, 19, 11, 13, 10, 0, 17, 8, 18, 17, 0, 11, 16, 0, 6, 0, 7, 14, 15, 8, 0, 17, 6, 16, 12, 0, 14, 9, 0, 3, 2, 4, 1]

這段程式碼將每個令牌對映到其對應的數值識別碼。我們使用先前建立的token2idx字典,將每個字元令牌轉換為唯一的數字。這種轉換是必要的,因為神經網路無法直接處理文字串,需要數值輸入。

One-Hot 編碼:類別資料的表示方法

將令牌轉換為數字後,下一步是將這些數字轉換為 one-hot 向量。這是機器學習中常用的類別資料編碼方法:

import torch
import torch.nn.functional as F
input_ids = torch.tensor(input_ids)
one_hot_encodings = F.one_hot(input_ids, num_classes=len(token2idx))
one_hot_encodings.shape  # torch.Size([38, 20])

這裡我們將數字識別碼轉換為 one-hot 向量,每個向量只有一個位置是 1(「熱」),其餘位置都是 0。這種表示方法有幾個優點:

  1. 避免了數字識別碼可能隱含的順序關係
  2. 使得令牌之間的操作(如相加)變得有意義,表示令牌的共同出現
  3. 為神經網路提供清晰的類別表示

注意設定 num_classes 引數很重要,它確保 one-hot 向量的維度與詞彙表大小一致。

字元級別標記化的侷限性

透過我們的例子可以看出,字元級別的標記化忽略了文字中的任何結構,將整個字串視為字元流。雖然這種方法有助於處理拼寫錯誤和罕見詞彙,但主要缺點是:

  1. 語言結構(如單詞)需要從資料中學習
  2. 需要大量的計算資源、記憶體和資料
  3. 處理長文字時效率較低

因此,字元標記化在實踐中很少使用。相反,我們通常會在標記化步驟中保留一些文字結構。

詞級別標記化:更高層次的文書處理

詞級別標記化是一種直接的方法,它將文字分割成單詞,而非字元:

tokenized_text = text.split()
print(tokenized_text)
# ['Tokenizing', 'text', 'is', 'a', 'core', 'task', 'of', 'NLP.']

詞級別標記化的優點是模型可以直接使用單詞,不需要先從字元學習單詞,這降低了訓練過程的複雜性。然而,這種方法也有明顯的問題:

  1. 標點符號處理不當(如「NLP.」被視為單個令牌)
  2. 詞彙表規模可能爆炸(詞形變化、拼寫錯誤等導致詞彙量輕易達到百萬級)
  3. 大詞彙表意味著神經網路需要更多引數

例如,如果有100萬個獨特單詞,與要將輸入向量壓縮到1000維,第一層的權重矩陣將包含10億個權重,這與最大的GPT-2模型(約15億引數)相當!

為瞭解決這個問題,一種常見方法是限制詞彙表大小,僅保留最常見的詞(如10萬個),將罕見詞標記為「未知」(UNK)。但這意味著我們會丟失一些可能重要的訊息。

子詞標記化:平衡的解決方案

子詞標記化結合了字元和詞標記化的優點:

  1. 將罕見詞拆分為更小的單位,使模型能處理複雜詞彙和拼寫錯誤
  2. 保持常見詞作為獨立實體,控制輸入長度
  3. 從預訓練語料函式庫中學習,使用統計規則和演算法

在 Transformers 函式庫中,我們可以使用 AutoTokenizer 類別快速載入與預訓練模型相關的標記器:

from transformers import AutoTokenizer
model_ckpt = "distilbert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)

AutoTokenizer 類別會自動檢索模型的設定、預訓練權重或詞彙表。這使得在不同模型之間切換變得簡單。我們也可以直接載入特定模型的標記器,例如:

from transformers import DistilBertTokenizer
distilbert_tokenizer = DistilBertTokenizer.from_pretrained(model_ckpt)

DistilBERT 使用的 WordPiece 是一種常見的子詞標記化演算法,它能夠在保留詞彙結構的同時,有效處理罕見詞和複雜詞彙。

標記化方法的演進

標記化技術的演進反映了自然語言處理領域對於文字表示的不斷探索:

  1. 字元標記化:最細粒度,能處理任何文字,但忽略語言結構
  2. 詞標記化:保留語言結構,但詞彙表過大與難處理罕見詞
  3. 子詞標記化:平衡的解決方案,保留部分結構同時處理罕見詞

在實際應用中,選擇哪種標記化方法取決於具體任務、可用資源和模型架構。現代大模型語言如BERT、GPT等,大多採用子詞標記化方法,這也說明瞭其在效能和效率間取得了良好平衡。

子詞標記化的出現代表了NLP領域的重要進步,它使模型能夠更有效地處理自然語言的複雜性,同時保持計算效率。這種平衡是現代語言模型取得突破性成果的關鍵因素之一。

文字標記化的內部運作:從文字到數字的轉換

在自然語言處理的世界裡,文字標記化是將人類語言轉換為機器可理解形式的關鍵步驟。當我們深入研究標記器的工作原理時,會發現這個過程比表面看起來要複雜得多。讓我們透過一個簡單的例子來理解標記器的實際運作方式。

標記化過程探析

以一個簡單的句子「Tokenizing text is a core task of NLP.」為例,我們可以看到標記化後的結果:

encoded_text = tokenizer(text)
print(encoded_text)

輸出結果為:

{'input_ids': [101, 19204, 6026, 3793, 2003, 1037, 4563, 4708, 1997, 17953, 2361, 1012, 102], 
 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}

這個輸出包含兩個關鍵部分:input_idsattention_maskinput_ids是將文字中的每個標記轉換為唯一整數的結果,這些整數對應於模型詞彙表中的特定標記。而attention_mask則是一個二進位掩碼,用於指示哪些標記是實際內容(值為1),哪些是填充標記(值為0)。

我們可以將這些ID轉換回標記,以更好地理解發生了什麼:

tokens = tokenizer.convert_ids_to_tokens(encoded_text.input_ids)
print(tokens)

輸出結果:

['[CLS]', 'token', '##izing', 'text', 'is', 'a', 'core', 'task', 'of', 'nl', '##p', '.', '[SEP]']

觀察這些標記,我們可以發現幾個有趣的現象:

  1. 特殊標記的增加:[CLS](分類別標記)被增加到序列開始,[SEP](分隔標記)被增加到序列結尾。這些標記在不同模型中可能有所不同,但它們的主要作用是指示序列的開始和結束。

  2. 單詞拆分:「tokenizing」被拆分為「token」和「##izing」,「NLP」被拆分為「nl」和「##p」。這種子詞標記化方法可以有效處理不常見的詞彙。

  3. 字首標記:「##」字首表示該標記應該與前一個標記合併,沒有空格。這對於將標記轉換回原始文字非常重要。

我們可以使用convert_tokens_to_string()方法將標記轉換回字元串:

print(tokenizer.convert_tokens_to_string(tokens))

輸出結果:

[CLS] tokenizing text is a core task of nlp. [SEP]

標記器的關鍵屬性

標記器具有多個重要屬性,提供了關於其功能的基本訊息:

  1. 詞彙表大小:
tokenizer.vocab_size  # 輸出:30522
  1. 模型的最大上下文大小:
tokenizer.model_max_length  # 輸出:512
  1. 模型在前向傳遞中期望的輸入欄位名稱:
tokenizer.model_input_names  # 輸出:['input_ids', 'attention_mask']

這些屬性對於理解標記器的能力和限制至關重要。在使用預訓練模型時,確保使用與模型訓練時相同的標記器尤為重要。從模型的角度來看,更換標記器就像打亂詞彙表一樣,會導致模型無法正確理解輸入。

批次標記化:處理整個資料集

對於實際應用,我們通常需要處理整個資料集而非單個文字。Dataset函式庫提供了map()方法,可以方便地將處理函式應用於資料集中的每個元素。

標記化函式定義

首先,我們需要定義一個標記化函式:

def tokenize(batch):
    return tokenizer(batch["text"], padding=True, truncation=True)

這個函式接受一批範例,並對其中的「text」欄位應用標記器。引數padding=True會用零填充批次中的範例,使它們達到批次中最長範例的長度;truncation=True則會截斷超過模型最大上下文大小的範例。

讓我們看這個函式在實際批次上的表現:

print(tokenize(emotions["train"][:2]))

輸出結果顯示了填充的效果:第一個元素比第二個短,所以增加了零使它們長度相同。這些零對應於詞彙表中的[PAD]標記。

特殊標記及其ID

模型詞彙表中的特殊標記及其對應ID如下:

特殊標記[PAD][UNK][CLS][SEP][MASK]
特殊標記ID0100101102103

注意力掩碼的作用

標記器除了回傳編碼後的input_ids,還回傳了attention_mask陣列。這是因為我們不希望模型被填充標記混淆:注意力掩碼允許模型忽略輸入的填充部分。在批處理中,輸入序列會填充到批次中的最大序列長度,而注意力掩碼則用於模型中忽略輸入張量的填充區域。

應用於整個資料集

定義好處理函式後,我們可以用一行程式碼將其應用於語料函式庫中的所有拆分:

emotions_encoded = emotions.map(tokenize, batched=True, batch_size=None)

預設情況下,map()方法單獨作用於語料函式庫中的每個範例,設定batched=True會批次編碼推文。由於我們設定了batch_size=None,我們的tokenize()函式將作為單個批次應用於完整資料集。這確保了輸入張量和注意力掩碼全域具有相同的形狀。

這個操作向資料集增加了新的input_idsattention_mask列:

print(emotions_encoded["train"].column_names)
# 輸出:['attention_mask', 'input_ids', 'label', 'text']

在實際應用中,全域填充特別有用,尤其是當我們需要從整個語料函式庫中提取特徵矩陣時。在後續章節中,我們還會看到如何使用資料整理器動態填充每個批次中的張量。

文字分類別器的訓練:從預訓練到實際應用

預訓練模型如DistilBERT主要是為了預測序列中的遮蔽詞而設計的,但我們不能直接將這些語言模型用於文字分類別。我們需要對它們進行適當的修改。

DistilBERT架構解析

讓我們看根據編碼器的模型(如DistilBERT)的架構:

  1. 標記化與編碼:文字首先被標記化並表示為獨熱向量(稱為標記編碼)。標記器詞彙表的大小決定了標記編碼的維度,通常包含2萬至20萬個唯一標記。

  2. 嵌入轉換:這些標記編碼被轉換為標記嵌入,這些嵌入向量位於較低維度的空間中。

  3. 編碼器處理:標記嵌入然後透過編碼器塊層,為每個輸入標記產生隱藏狀態。

  4. 預測層:對於語言建模的預訓練目標,每個隱藏狀態都被送入預測遮蔽輸入標記的層。對於分類別任務,我們將語言建模層替換為分類別層。

在實際應用中,PyTorch會跳過建立標記編碼的獨熱向量步驟,因為將矩陣與獨熱向量相乘等同於從矩陣中選擇一列。這可以透過直接從矩陣中取得帶有標記ID的列來完成。

文字分類別的兩種方法

在Twitter資料集上訓練這樣的模型有兩種選擇:

  1. 特徵提取

    • 使用隱藏狀態作為特徵,僅在這些特徵上訓練分類別器
    • 不修改預訓練模型的引數
    • 計算成本較低,適用於資源受限的情況
  2. 微調

    • 端對端地訓練整個模型
    • 更新預訓練模型的引數
    • 通常效能更好,但需要更多計算資源
    • 適合於目標任務與預訓練目標差異較大的情況

這兩種方法各有優缺點,選擇哪一種取決於具體任務、可用資源以及所需的效能水平。在實踐中,我發現對於相對簡單的文字分類別任務,特徵提取通常已經能提供不錯的效能,而複雜任務則可能需要完整的微調來達到最佳效果。

深入理解:標記化與分類別的關鍵考量

在實際應用NLP模型時,有幾個關鍵點值得注意:

  1. 標記器與模型的比對:確保使用與模型預訓練時相同的標記器至關重要。更換標記器就像打亂詞彙表,會導致模型理解能力下降。

  2. 批次處理策略:根據資料集大小和可用資源選擇適當的批次處理策略。全域填充適合特徵提取,而動態填充則更適合批次訓練。

  3. 模型架構的選擇:不同的預訓練模型有不同的優勢。DistilBERT是BERT的輕量級版本,提供了良好的效能與效率平衡。

  4. 特徵表示的層級:在使用預訓練模型時,不同層的隱藏狀態捕捉不同級別的語言訊息。較低層通常捕捉語法特徵,而較高層則捕捉更多語義訊息。

標記化和分類別是NLP流程中的基礎環節,理解它們的工作原理對於有效應用預訓練模型至關重要。透過適當的資料處理和模型調整,我們可以充分利用這些強大工具的潛力,解決各種語言理解任務。

自然語言處理技術正在迅速發展,標記化方法和預訓練模型不斷演進。理解這些基本概念不僅有助於應用現有技術,也為跟進未來發展奠定了堅實基礎。無論是進行簡單的情感分析還是複雜的語言理解任務,這些知識都將證明是無價的。

DistilBERT的兩種應用方式:探索最佳選擇

在自然語言處理領域,預訓練模型徹底改變了我們處理文字的方式。在這篇文章中,讓我們探討DistilBERT模型的兩種主要應用方法,並分析各自的優缺點。

將Transformer作為特徵提取器

使用Transformer模型作為特徵提取器是一種相對簡單但高效的方法。這種方法的核心在於「凍結」模型主體的權重,然後利用模型產生的隱藏狀態(hidden states)作為下游分類別器的特徵輸入。

![特徵提取方法示意圖:凍結的DistilBERT模型為分類別器提供特徵]

這種方法有幾個顯著優勢:

  1. 訓練速度快 - 由於只需訓練一個較小或較淺的分類別模型
  2. 靈活性高 - 分類別器可以是神經網路層,也可以是不依賴梯度的方法(如隨機森林)
  3. 資源需求低 - 特別適合GPU資源有限的情況,因為隱藏狀態只需計算一次

這種特徵提取的方法在實際應用中非常實用,當我嘗試在資源受限的環境中佈署NLP解決方案時,經常會採用這種策略來平衡效能與計算資源。

使用預訓練模型進行實作

載入預訓練模型

Transformers函式庫提供了便捷的AutoModel類別似於AutoTokenizer,它有一個from_pretrained()方法可以載入預訓練模型的權重。讓我們使用這個方法來載入DistilBERT的檢查點:

from transformers import AutoModel
import torch

model_ckpt = "distilbert-base-uncased"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = AutoModel.from_pretrained(model_ckpt).to(device)

這段程式碼首先匯入必要的函式庫,然後設定模型檢查點為「distilbert-base-uncased」(這是一個未區分大小寫的基本DistilBERT模型)。接著檢測是否有GPU可用,如果有就使用GPU,否則使用CPU。最後,從預訓練檢查點載入模型並將其移動到適當的裝置上。使用.to(device)方法確保模型在GPU可用時能充分利用GPU加速,大幅提升處理速度。

框架間的互操作性

雖然我在這篇文章中主要使用PyTorch,但Transformers函式庫提供了與TensorFlow和JAX的緊密互操作性。這意味著只需更改幾行程式碼,就能在你喜歡的深度學習框架中載入預訓練模型!例如,我們可以使用TFAutoModel類別在TensorFlow中載入DistilBERT:

from transformers import TFAutoModel
tf_model = TFAutoModel.from_pretrained(model_ckpt)

這段程式碼展示了Transformers函式庫的跨框架相容性。只需要將AutoModel替換為TFAutoModel,就能在TensorFlow中載入相同的預訓練模型。這種靈活性在實際工作中非常寶貴,因為它允許團隊成員使用各自熟悉的框架,同時分享相同的預訓練模型資源。

這種互操作性在某些模型只在一種框架中發布時特別有用。例如,如果你想在TensorFlow中使用只有PyTorch權重的XLM-RoBERTa模型:

# 這會導致錯誤
tf_xlmr = TFAutoModel.from_pretrained("xlm-roberta-base")

# 正確的方法是指定from_pt=True
tf_xlmr = TFAutoModel.from_pretrained("xlm-roberta-base", from_pt=True)

這個例子說明瞭當你需要在TensorFlow中使用只有PyTorch權重的模型時,可以透過指定from_pt=True引數,讓函式庫自動下載並轉換PyTorch權重。這解決了跨框架使用模型的常見問題,使開發者不必受限於特定框架。在我的實踐中,這個功能節省了大量時間,避免了手動轉換模型權重的繁瑣工作。

在Transformers函式庫中,切換框架通常只需要在類別名前加上「TF」字首,就能獲得等效的TensorFlow 2.0類別。當我們使用「pt」字串(PyTorch的縮寫)時,只需替換為「tf」(TensorFlow的縮寫)即可。

提取最後的隱藏狀態

讓我們從一個簡單的例子開始,提取單個字元串的最後隱藏狀態。首先需要對字元串進行編碼,並將令牌轉換為PyTorch張量。這可以透過向tokenizer提供return_tensors="pt"引數來完成:

text = "this is a test"
inputs = tokenizer(text, return_tensors="pt")
print(f"Input tensor shape: {inputs['input_ids'].size()}")

輸出:

Input tensor shape: torch.Size([1, 6])

這段程式碼展示瞭如何將文字轉換為模型可接受的張量格式。return_tensors="pt"引數告訴分詞器回傳PyTorch張量而不是普通的Python列表。輸出顯示張量形狀為[1, 6],代表一個批次中有一個句子,該句子包含6個令牌(包括特殊令牌如[CLS]和[SEP])。

現在我們有了編碼後的張量,下一步是將它們放在與模型相同的裝置上,並傳遞給模型:

inputs = {k:v.to(device) for k,v in inputs.items()}
with torch.no_grad():
    outputs = model(**inputs)
print(outputs)

輸出:

BaseModelOutput(last_hidden_state=tensor([[[-0.1565, -0.1862, 0.0528, ...,
-0.1188, 0.0662, 0.5470],
[-0.3575, -0.6484, -0.0618, ..., -0.3040, 0.3508, 0.5221],
[-0.2772, -0.4459, 0.1818, ..., -0.0948, -0.0076, 0.9958],
[-0.2841, -0.3917, 0.3753, ..., -0.2151, -0.1173, 1.0526],
[ 0.2661, -0.5094, -0.3180, ..., -0.4203, 0.0144, -0.2149],
[ 0.9441, 0.0112, -0.4714, ..., 0.1439, -0.7288, -0.1619]]],
device='cuda:0'), hidden_states=None, attentions=None)

這段程式碼首先將輸入張量移動到與模型相同的裝置上(CPU或GPU)。使用torch.no_grad()上下文管理器停用了梯度的自動計算,這在推理階段非常有用,因為它可以減少計算的記憶體佔用。模型輸出是BaseModelOutput類別的例項,類別似於Python中的namedtuple,可以透過名稱存取其屬性。在這個例子中,模型只回傳一個屬性,即最後的隱藏狀態。

讓我們檢查一下隱藏狀態的形狀:

outputs.last_hidden_state.size()

輸出:

torch.Size([1, 6, 768])

隱藏狀態張量的形狀為[1, 6, 768],這意味著對於批次中的1個句子,每個令牌(共6個)都有一個768維的向量表示。這768維向量捕捉了令牌在當前上下文中的語義訊息。

對於分類別任務,常見的做法是僅使用與[CLS]令牌關聯的隱藏狀態作為輸入特徵。由於此令牌出現在每個序列的開頭,我們可以透過簡單地索引outputs.last_hidden_state來提取它:

outputs.last_hidden_state[:,0].size()

輸出:

torch.Size([1, 768])

透過選取第一個位置(索引0)的隱藏狀態,我們得到了一個形狀為[1, 768]的張量,它代表整個序列的摘要表示。[CLS]令牌的隱藏狀態在訓練過程中學會了聚合整個序列的訊息,因此特別適合用於分類別任務。

為整個資料集提取隱藏狀態

現在我們知道了如何取得單個字元串的最後隱藏狀態,讓我們為整個資料集執行相同的操作,建立一個新的hidden_state列來儲存這些向量。與分詞器類別似,我們將使用DatasetDictmap()方法一次性提取所有隱藏狀態。

首先,我們需要將之前的步驟包裝在一個處理函式中:

def extract_hidden_states(batch):
    # 將模型輸入放在GPU上
    inputs = {k:v.to(device) for k,v in batch.items()
             if k in tokenizer.model_input_names}
    # 提取最後的隱藏狀態
    with torch.no_grad():
        last_hidden_state = model(**inputs).last_hidden_state
    # 回傳[CLS]令牌的向量
    return {"hidden_state": last_hidden_state[:,0].cpu().numpy()}

這個函式接收一批資料,將相關輸入移動到適當的裝置上,透過模型提取最後的隱藏狀態,然後回傳與[CLS]令牌關聯的向量。與之前的邏輯的唯一區別是最後一步,我們將最終隱藏狀態放回CPU並轉換為NumPy陣列。當我們使用批處理輸入時,map()方法要求處理函式回傳Python或NumPy物件。

由於我們的模型期望張量作為輸入,接下來要做的是將input_idsattention_mask列轉換為"torch"格式:

emotions_encoded.set_format("torch",
                          columns=["input_ids", "attention_mask", "label"])

這行程式碼設定了資料集的格式,指定input_idsattention_masklabel列應以PyTorch張量的形式回傳。這是為了確保資料與模型期望的輸入格式比對。

然後,我們可以一次性提取所有分割的隱藏狀態:

emotions_hidden = emotions_encoded.map(extract_hidden_states, batched=True)

這行程式碼對資料集中的所有樣本應用extract_hidden_states函式,batched=True引數表示函式將接收批次資料而不是單個樣本。由於沒有設定batch_size=None,將使用預設的batch_size=1000。這種批處理方法大加速了特徵提取過程,尤其是在GPU上。

正如預期的那樣,應用extract_hidden_states()函式為我們的資料集增加了一個新的hidden_state列:

emotions_hidden["train"].column_names

輸出:

['attention_mask', 'hidden_state', 'input_ids', 'label', 'text']

建立特徵矩陣

預處理後的資料集現在包含了我們訓練分類別器所需的所有訊息。我們將使用隱藏狀態作為輸入特徵,使用標籤作為目標。我們可以按照Scikit-learn的格式輕鬆建立相應的陣列:

import numpy as np
X_train = np.array(emotions_hidden["train"]["hidden_state"])
X_valid = np.array(emotions_hidden["validation"]["hidden_state"])
y_train = np.array(emotions_hidden["train"]["label"])
y_valid = np.array(emotions_hidden["validation"]["label"])
X_train.shape, X_valid.shape

輸出:

((16000, 768), (2000, 768))

這段程式碼從資料集中提取特徵和標籤,並將它們轉換為NumPy陣列,這是機器學習函式庫(如Scikit-learn)所期望的格式。X_trainX_valid的形狀分別是(16000, 768)和(2000, 768),表示訓練集有16000個樣本,驗證集有2000個樣本,每個樣本都由768維的特徵向量表示。

在訓練模型之前,最好進行一個快速檢查,確保這些隱藏狀態為我們想要分類別的情緒提供了有用的表示。這通常可以透過降維技術(如UMAP或t-SNE)來視覺化特徵空間,觀察不同情緒類別的樣本是否形成明顯的聚類別。