在自然語言處理的技術棧中,文本的數值化表示是連接人類語言與機器學習模型的關鍵橋樑。人類透過文字與符號傳達思想,而機器學習模型則在數值空間中運算與學習。如何有效地將離散的文本符號轉換為連續的數值表示,並保留語言的語義與句法資訊,是 NLP 領域長期探索的核心問題。
Tokenization 作為文本處理的第一步,負責將原始文本分割成有意義的基本單元。這個看似簡單的任務實則蘊含諸多挑戰,從空白字符分割到複雜的子詞分割演算法,從單一語言到多語言處理,不同的 Tokenization 策略會直接影響後續模型的效能與效率。詞嵌入技術則在 Tokenization 的基礎上,將離散的詞彙映射到連續的向量空間,讓語義相似的詞彙在向量空間中彼此接近。
在台灣的 NLP 研究與應用領域,從搜尋引擎的查詢理解到智慧客服的意圖識別,從社群媒體的情感分析到新聞文本的自動摘要,Tokenization 與詞嵌入技術無處不在。中文文本的處理相較於英文更為複雜,缺乏天然的詞界標記,需要依賴分詞演算法識別詞彙邊界。同時,繁體中文與簡體中文的差異、台灣特有的用語習慣,都對 Tokenization 策略提出了特殊要求。
本文將從理論與實務的角度全面探討 Tokenization 與詞嵌入技術,涵蓋基礎概念、演算法原理、實作細節與應用案例。透過深入分析 BPE、WordPiece 等子詞演算法,以及 Word2Vec、GloVe、BERT 等詞嵌入模型,讀者將能掌握現代 NLP 系統的核心技術,並在實際專案中選擇與優化最適合的方案。
Tokenization 基礎原理與演進
文本分詞是 NLP 流程的起點,其目標是將連續的字符序列切分為有意義的基本單元。在英文等使用空白字符分隔詞彙的語言中,最簡單的 Tokenization 策略是按空白字符與標點符號分割。然而,這種方法存在諸多限制,無法處理縮寫、複合詞與特殊符號等情況。
早期的 NLP 系統主要依賴詞典匹配與規則方法進行分詞。這類方法維護一個詞彙表,透過最長匹配或最短路徑等策略識別詞彙邊界。對於中文等缺乏詞界標記的語言,分詞更是必不可少的預處理步驟。傳統的中文分詞演算法包含基於詞典的正向最大匹配、反向最大匹配,以及基於統計模型的條件隨機場等方法。
@startuml
!define PLANTUML_FORMAT svg
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
package "Tokenization 技術演進" {
component "基於規則\n(Rule-based)" as rule
component "基於詞典\n(Dictionary-based)" as dict
component "基於統計\n(Statistical)" as stat
component "基於神經網路\n(Neural)" as neural
package "子詞分割技術" {
component "BPE" as bpe
component "WordPiece" as wordpiece
component "Unigram LM" as unigram
component "SentencePiece" as sentencepiece
}
}
rule --> dict : 演進
dict --> stat : 演進
stat --> neural : 演進
neural --> bpe : 子詞技術
neural --> wordpiece : 子詞技術
neural --> unigram : 子詞技術
neural --> sentencepiece : 子詞技術
note right of rule
簡單直觀
依賴人工規則
難以泛化
end note
note bottom of bpe
資料驅動
處理 OOV
可調節粒度
end note
@enduml
詞表大小的選擇是 Tokenization 設計的重要考量。過小的詞表無法充分表示語言的豐富性,導致大量未登錄詞。過大的詞表則會增加模型參數量,降低訓練效率,並可能導致資料稀疏問題。子詞分割技術的出現為這個問題提供了優雅的解決方案,它在詞彙與字符之間找到了平衡點。
#!/usr/bin/env python3
"""
基礎 Tokenization 實作範例
展示從簡單空白分割到基於規則的進階分詞
"""
import re
from typing import List, Dict, Set
from collections import Counter
class BasicTokenizer:
"""基礎分詞器實作"""
def __init__(self, lowercase: bool = True):
self.lowercase = lowercase
self.vocab = {}
self.vocab_size = 0
def tokenize_whitespace(self, text: str) -> List[str]:
"""
基於空白字符的簡單分詞
Args:
text: 輸入文本
Returns:
分詞結果列表
"""
if self.lowercase:
text = text.lower()
# 簡單空白分割
tokens = text.split()
return tokens
def tokenize_advanced(self, text: str) -> List[str]:
"""
基於規則的進階分詞
處理標點符號、數字與特殊字符
Args:
text: 輸入文本
Returns:
分詞結果列表
"""
if self.lowercase:
text = text.lower()
# 在標點符號前後添加空白
text = re.sub(r"([.!?,:;])", r" \1 ", text)
# 處理縮寫 (如 don't -> do n't)
text = re.sub(r"n't", " n't", text)
text = re.sub(r"'re", " 're", text)
text = re.sub(r"'ve", " 've", text)
text = re.sub(r"'ll", " 'll", text)
# 分割並移除空白 token
tokens = [t for t in text.split() if t.strip()]
return tokens
def build_vocab(self, texts: List[str], min_freq: int = 1) -> Dict[str, int]:
"""
建立詞彙表
Args:
texts: 文本列表
min_freq: 最小詞頻閾值
Returns:
詞彙到索引的映射字典
"""
# 統計詞頻
word_counts = Counter()
for text in texts:
tokens = self.tokenize_advanced(text)
word_counts.update(tokens)
# 過濾低頻詞
filtered_words = [
word for word, count in word_counts.items()
if count >= min_freq
]
# 添加特殊 token
special_tokens = ['<PAD>', '<UNK>', '<CLS>', '<SEP>']
# 建立詞彙表
vocab = {token: idx for idx, token in enumerate(special_tokens)}
for word in sorted(filtered_words):
if word not in vocab:
vocab[word] = len(vocab)
self.vocab = vocab
self.vocab_size = len(vocab)
return vocab
def encode(self, text: str) -> List[int]:
"""
將文本編碼為 token ID 序列
Args:
text: 輸入文本
Returns:
token ID 列表
"""
tokens = self.tokenize_advanced(text)
# 將 token 轉換為 ID,未知詞使用 <UNK>
token_ids = [
self.vocab.get(token, self.vocab.get('<UNK>'))
for token in tokens
]
return token_ids
def decode(self, token_ids: List[int]) -> str:
"""
將 token ID 序列解碼為文本
Args:
token_ids: token ID 列表
Returns:
解碼後的文本
"""
# 建立反向詞彙表
id_to_token = {idx: token for token, idx in self.vocab.items()}
# 解碼
tokens = [id_to_token.get(idx, '<UNK>') for idx in token_ids]
# 合併為文本
text = ' '.join(tokens)
return text
def get_vocab_stats(self) -> Dict[str, int]:
"""獲取詞彙表統計資訊"""
return {
'vocab_size': self.vocab_size,
'special_tokens': sum(1 for t in self.vocab if t.startswith('<')),
'regular_tokens': self.vocab_size - sum(1 for t in self.vocab if t.startswith('<'))
}
# 使用範例
if __name__ == "__main__":
# 初始化分詞器
tokenizer = BasicTokenizer(lowercase=True)
# 測試文本
sample_texts = [
"Hello, world! This is a test.",
"Natural Language Processing is fascinating.",
"Don't forget to tokenize your text properly!",
"NLP models can't work without good tokenization."
]
print("=== 基礎分詞示範 ===\n")
# 空白分割
print("[空白分割]")
for text in sample_texts[:2]:
tokens = tokenizer.tokenize_whitespace(text)
print(f"輸入: {text}")
print(f"輸出: {tokens}\n")
# 進階分詞
print("\n[進階分詞]")
for text in sample_texts[:2]:
tokens = tokenizer.tokenize_advanced(text)
print(f"輸入: {text}")
print(f"輸出: {tokens}\n")
# 建立詞彙表
print("\n=== 詞彙表建立 ===\n")
vocab = tokenizer.build_vocab(sample_texts, min_freq=1)
print(f"詞彙表大小: {len(vocab)}")
print(f"前 10 個詞彙: {list(vocab.items())[:10]}\n")
# 編碼與解碼
print("\n=== 編碼與解碼 ===\n")
test_text = "Hello, NLP world!"
encoded = tokenizer.encode(test_text)
decoded = tokenizer.decode(encoded)
print(f"原始文本: {test_text}")
print(f"編碼結果: {encoded}")
print(f"解碼結果: {decoded}\n")
# 詞彙表統計
print("\n=== 詞彙表統計 ===\n")
stats = tokenizer.get_vocab_stats()
for key, value in stats.items():
print(f"{key}: {value}")
這個基礎分詞器展示了從簡單空白分割到基於規則的進階處理的完整流程。在實際應用中,還需要考慮更多細節,例如處理 URL、電子郵件地址、表情符號等特殊情況。
BPE 子詞分割演算法
Byte Pair Encoding 最初是一種資料壓縮演算法,在 NLP 領域被巧妙地應用於子詞分割。BPE 的核心思想是從字符層級開始,迭代地合併最頻繁出現的符號對,逐步建立詞彙表。這種自底向上的方法能夠自動發現語言中的常見詞綴與詞根,有效處理未登錄詞問題。
@startuml
!define PLANTUML_FORMAT svg
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
start
:初始化字符級詞彙表;
:統計符號對頻率;
:選擇最高頻符號對;
:合併符號對為新符號;
:更新詞彙表;
if (達到目標詞彙表大小?) then (是)
:完成訓練;
stop
else (否)
:繼續迭代;
:統計符號對頻率;
endif
note right
迭代過程示例:
1. 初始: l o w e s t
2. 合併 'e' 's': low est
3. 合併 'es' 't': low est
4. 合併 'low' 'est': lowest
end note
@enduml
BPE 演算法的訓練過程具有確定性,給定相同的語料與目標詞彙表大小,總能得到相同的結果。這與某些基於神經網路的方法不同,後者可能因隨機初始化而產生不同的分詞結果。BPE 的另一個優勢是其分詞結果具有可解釋性,每個子詞都對應語料中的頻繁片段。
#!/usr/bin/env python3
"""
BPE (Byte Pair Encoding) 演算法完整實作
展示從訓練到推論的完整 BPE 流程
"""
from typing import List, Dict, Tuple, Set
from collections import defaultdict, Counter
import re
class BPETokenizer:
"""BPE 分詞器實作"""
def __init__(self, vocab_size: int = 1000):
self.vocab_size = vocab_size
self.merges = [] # 儲存合併操作序列
self.vocab = {} # 最終詞彙表
self.bpe_codes = {} # 合併規則字典
def get_stats(self, vocab: Dict[Tuple[str, ...], int]) -> Dict[Tuple[str, str], int]:
"""
統計符號對的出現頻率
Args:
vocab: 當前詞彙表,格式為 {詞序列: 頻率}
Returns:
符號對到頻率的映射
"""
pairs = defaultdict(int)
for word, freq in vocab.items():
symbols = word
for i in range(len(symbols) - 1):
# 統計相鄰符號對
pairs[(symbols[i], symbols[i + 1])] += freq
return pairs
def merge_vocab(
self,
pair: Tuple[str, str],
vocab: Dict[Tuple[str, ...], int]
) -> Dict[Tuple[str, ...], int]:
"""
合併指定的符號對
Args:
pair: 要合併的符號對
vocab: 當前詞彙表
Returns:
更新後的詞彙表
"""
new_vocab = {}
# 建立合併後的符號
bigram = ' '.join(pair)
replacement = ''.join(pair)
for word, freq in vocab.items():
# 將詞序列轉換為字串
word_str = ' '.join(word)
# 替換符號對
new_word = word_str.replace(bigram, replacement)
# 轉換回序列
new_vocab[tuple(new_word.split())] = freq
return new_vocab
def train(self, corpus: List[str], verbose: bool = False) -> None:
"""
訓練 BPE 模型
Args:
corpus: 訓練語料列表
verbose: 是否顯示訓練過程
"""
# 初始化詞彙表(字符級)
vocab = defaultdict(int)
for text in corpus:
# 預處理:分詞並添加詞尾標記
words = text.lower().split()
for word in words:
# 在每個字符之間添加空白,詞尾添加 </w>
word_chars = tuple(word) + ('</w>',)
vocab[word_chars] += 1
if verbose:
print(f"初始詞彙表大小: {len(vocab)}")
print(f"初始符號數量: {sum(len(word) for word in vocab.keys())}\n")
# 迭代合併
num_merges = self.vocab_size - len(set(c for word in vocab for c in word))
for i in range(num_merges):
# 統計符號對頻率
pairs = self.get_stats(vocab)
if not pairs:
break
# 選擇最高頻符號對
best_pair = max(pairs, key=pairs.get)
# 合併符號對
vocab = self.merge_vocab(best_pair, vocab)
# 記錄合併操作
self.merges.append(best_pair)
self.bpe_codes[best_pair] = i
if verbose and (i + 1) % 100 == 0:
print(f"迭代 {i + 1}: 合併 {best_pair}, 頻率 {pairs[best_pair]}")
# 建立最終詞彙表
self.vocab = {
''.join(word): idx
for idx, word in enumerate(sorted(set(
symbol for word in vocab for symbol in word
)))
}
if verbose:
print(f"\n訓練完成!")
print(f"最終詞彙表大小: {len(self.vocab)}")
print(f"合併操作數量: {len(self.merges)}")
def encode_word(self, word: str) -> List[str]:
"""
對單個詞進行 BPE 編碼
Args:
word: 輸入詞
Returns:
BPE 子詞列表
"""
# 初始化為字符序列
word = tuple(word) + ('</w>',)
# 迭代應用合併規則
while len(word) > 1:
# 找出可以合併的符號對
pairs = [(word[i], word[i + 1]) for i in range(len(word) - 1)]
# 找出優先級最高的符號對
bigram = min(
pairs,
key=lambda pair: self.bpe_codes.get(pair, float('inf'))
)
# 如果沒有可合併的符號對,終止
if bigram not in self.bpe_codes:
break
# 合併符號對
new_word = []
i = 0
while i < len(word):
if i < len(word) - 1 and (word[i], word[i + 1]) == bigram:
new_word.append(word[i] + word[i + 1])
i += 2
else:
new_word.append(word[i])
i += 1
word = tuple(new_word)
return list(word)
def tokenize(self, text: str) -> List[str]:
"""
對文本進行 BPE 分詞
Args:
text: 輸入文本
Returns:
BPE token 列表
"""
words = text.lower().split()
tokens = []
for word in words:
tokens.extend(self.encode_word(word))
return tokens
def get_merge_sequence(self, top_n: int = 10) -> List[Tuple[str, str]]:
"""獲取前 N 個合併操作"""
return self.merges[:top_n]
# 使用範例
if __name__ == "__main__":
# 準備訓練語料
corpus = [
"low lower lowest",
"new newer newest",
"wide wider widest",
"happy happier happiest",
"good better best",
"bad worse worst"
]
print("=== BPE 訓練示範 ===\n")
print(f"訓練語料: {corpus}\n")
# 初始化並訓練 BPE
bpe = BPETokenizer(vocab_size=50)
bpe.train(corpus, verbose=True)
# 顯示前 10 個合併操作
print("\n=== 合併操作序列 ===\n")
merges = bpe.get_merge_sequence(10)
for i, (a, b) in enumerate(merges, 1):
print(f"{i}. 合併 ('{a}', '{b}') -> '{a}{b}'")
# 測試分詞
print("\n=== 分詞測試 ===\n")
test_words = ["lowest", "newer", "happiest", "unknown"]
for word in test_words:
tokens = bpe.encode_word(word)
print(f"'{word}' -> {tokens}")
# 測試完整文本
print("\n=== 文本分詞 ===\n")
test_text = "the lowest and newest buildings are the happiest"
tokens = bpe.tokenize(test_text)
print(f"輸入: {test_text}")
print(f"輸出: {tokens}")
這個完整的 BPE 實作展示了從訓練到推論的整個流程。在實際應用中,BPE 通常會在大規模語料上訓練,詞彙表大小可能達到數萬。訓練好的 BPE 模型可以序列化儲存,供後續使用。
詞嵌入技術基礎
詞嵌入技術的核心目標是將離散的詞彙符號映射到連續的向量空間,使得語義相似的詞彙在向量空間中距離接近。傳統的 One-Hot 編碼將每個詞表示為高維稀疏向量,維度等於詞彙表大小,每個詞對應的向量中只有一個維度為 1,其餘為 0。這種表示方法無法捕捉詞彙間的語義關係,且隨著詞彙表增大,向量維度快速膨脹。
分佈式假設是詞嵌入技術的理論基礎,它認為詞彙的意義由其上下文決定。出現在相似上下文中的詞彙往往具有相似的語義。基於這個假設,詞嵌入演算法透過分析詞彙在大規模語料中的共現模式,學習詞彙的向量表示。
@startuml
!define PLANTUML_FORMAT svg
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
package "詞嵌入技術演進" {
component "One-Hot 編碼" as onehot
component "共現矩陣" as cooccur
component "矩陣分解\n(SVD)" as svd
package "神經詞嵌入" {
component "Word2Vec\n(CBOW/Skip-gram)" as w2v
component "GloVe" as glove
component "FastText" as fasttext
}
package "上下文感知嵌入" {
component "ELMo" as elmo
component "BERT" as bert
component "GPT" as gpt
}
}
onehot --> cooccur : 考慮上下文
cooccur --> svd : 降維
svd --> w2v : 神經網路
w2v --> glove : 全域統計
w2v --> fasttext : 子詞資訊
glove --> elmo : 雙向語言模型
fasttext --> bert : Transformer
elmo --> bert : 預訓練
bert --> gpt : 單向建模
note right of onehot
高維稀疏
無語義資訊
end note
note bottom of w2v
低維稠密
靜態表示
高效訓練
end note
note right of bert
動態表示
上下文感知
遷移學習
end note
@enduml
詞嵌入的維度選擇是模型設計的重要考量。較低的維度可能無法充分表示詞彙的語義豐富性,較高的維度則會增加模型複雜度與訓練成本。實務中,常見的詞向量維度在 100 到 300 之間,大型預訓練模型如 BERT 的隱藏層維度則可達 768 或更高。
Word2Vec 模型原理與實作
Word2Vec 是 Google 在 2013 年提出的詞嵌入模型,因其高效的訓練演算法與優秀的效果而廣受歡迎。Word2Vec 包含兩種架構,分別是 CBOW 與 Skip-gram。CBOW 根據上下文預測目標詞,而 Skip-gram 則反過來,根據目標詞預測上下文。
Skip-gram 模型的訓練目標是最大化給定中心詞預測上下文詞的機率。對於語料中的每個詞,模型將其作為中心詞,並在一定視窗範圍內採樣上下文詞。模型學習兩組詞向量,一組是中心詞向量,另一組是上下文詞向量。預測機率透過內積與 softmax 函數計算。
#!/usr/bin/env python3
"""
Word2Vec Skip-gram 模型實作
使用 PyTorch 實作 Skip-gram 架構的詞嵌入訓練
"""
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from typing import List, Tuple, Dict
from collections import Counter
import numpy as np
class Word2VecDataset(Dataset):
"""Word2Vec 訓練資料集"""
def __init__(
self,
corpus: List[str],
window_size: int = 2,
min_count: int = 5
):
self.window_size = window_size
# 建立詞彙表
self.vocab = self._build_vocab(corpus, min_count)
self.vocab_size = len(self.vocab)
# 建立詞到索引的映射
self.word2idx = {word: idx for idx, word in enumerate(self.vocab)}
self.idx2word = {idx: word for word, idx in self.word2idx.items()}
# 生成訓練樣本(中心詞,上下文詞)對
self.training_pairs = self._generate_training_data(corpus)
def _build_vocab(self, corpus: List[str], min_count: int) -> List[str]:
"""建立詞彙表,過濾低頻詞"""
word_counts = Counter()
for text in corpus:
words = text.lower().split()
word_counts.update(words)
# 過濾低頻詞
vocab = [
word for word, count in word_counts.items()
if count >= min_count
]
return sorted(vocab)
def _generate_training_data(
self,
corpus: List[str]
) -> List[Tuple[int, int]]:
"""生成訓練資料(中心詞,上下文詞)對"""
training_pairs = []
for text in corpus:
words = text.lower().split()
# 將詞轉換為索引
word_indices = [
self.word2idx[word]
for word in words
if word in self.word2idx
]
# 滑動視窗生成訓練對
for center_idx, center_word in enumerate(word_indices):
# 定義上下文範圍
context_start = max(0, center_idx - self.window_size)
context_end = min(len(word_indices), center_idx + self.window_size + 1)
# 採樣上下文詞
for context_idx in range(context_start, context_end):
if context_idx != center_idx:
training_pairs.append(
(center_word, word_indices[context_idx])
)
return training_pairs
def __len__(self) -> int:
return len(self.training_pairs)
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
center, context = self.training_pairs[idx]
return (
torch.tensor(center, dtype=torch.long),
torch.tensor(context, dtype=torch.long)
)
class SkipGramModel(nn.Module):
"""Skip-gram 模型實作"""
def __init__(self, vocab_size: int, embedding_dim: int):
super(SkipGramModel, self).__init__()
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
# 中心詞嵌入層
self.center_embeddings = nn.Embedding(vocab_size, embedding_dim)
# 上下文詞嵌入層
self.context_embeddings = nn.Embedding(vocab_size, embedding_dim)
# 初始化權重
self._init_weights()
def _init_weights(self):
"""初始化嵌入權重"""
initrange = 0.5 / self.embedding_dim
self.center_embeddings.weight.data.uniform_(-initrange, initrange)
self.context_embeddings.weight.data.uniform_(-initrange, initrange)
def forward(
self,
center: torch.Tensor,
context: torch.Tensor
) -> torch.Tensor:
"""
前向傳播
Args:
center: 中心詞索引 [batch_size]
context: 上下文詞索引 [batch_size]
Returns:
預測分數 [batch_size]
"""
# 獲取中心詞向量 [batch_size, embedding_dim]
center_embed = self.center_embeddings(center)
# 獲取上下文詞向量 [batch_size, embedding_dim]
context_embed = self.context_embeddings(context)
# 計算內積得分 [batch_size]
score = torch.sum(center_embed * context_embed, dim=1)
return score
def get_embeddings(self) -> np.ndarray:
"""獲取訓練好的詞向量"""
return self.center_embeddings.weight.data.cpu().numpy()
class Word2VecTrainer:
"""Word2Vec 訓練器"""
def __init__(
self,
vocab_size: int,
embedding_dim: int = 100,
learning_rate: float = 0.025,
num_negative_samples: int = 5
):
self.model = SkipGramModel(vocab_size, embedding_dim)
self.optimizer = optim.Adam(self.model.parameters(), lr=learning_rate)
self.criterion = nn.BCEWithLogitsLoss()
self.num_negative_samples = num_negative_samples
def train(
self,
dataloader: DataLoader,
num_epochs: int = 10,
device: str = 'cpu'
):
"""
訓練模型
Args:
dataloader: 資料載入器
num_epochs: 訓練輪數
device: 計算設備
"""
self.model.to(device)
self.model.train()
for epoch in range(num_epochs):
total_loss = 0
for center, context in dataloader:
center = center.to(device)
context = context.to(device)
# 正樣本
positive_score = self.model(center, context)
positive_target = torch.ones_like(positive_score)
# 負採樣
negative_samples = torch.randint(
0,
self.model.vocab_size,
(center.size(0), self.num_negative_samples),
device=device
)
negative_scores = []
for i in range(self.num_negative_samples):
neg_score = self.model(center, negative_samples[:, i])
negative_scores.append(neg_score)
negative_score = torch.stack(negative_scores, dim=1)
negative_target = torch.zeros_like(negative_score)
# 計算損失
loss = self.criterion(positive_score, positive_target)
loss += self.criterion(negative_score, negative_target).mean()
# 反向傳播
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / len(dataloader)
print(f"Epoch {epoch + 1}/{num_epochs}, Loss: {avg_loss:.4f}")
# 使用範例
if __name__ == "__main__":
# 準備語料
corpus = [
"the quick brown fox jumps over the lazy dog",
"never jump over the lazy dog quickly",
"the fox is quick and the dog is lazy",
"a quick brown dog jumps over a lazy fox"
] * 100 # 重複以增加訓練樣本
print("=== Word2Vec Skip-gram 訓練 ===\n")
# 建立資料集
dataset = Word2VecDataset(corpus, window_size=2, min_count=1)
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)
print(f"詞彙表大小: {dataset.vocab_size}")
print(f"訓練樣本數: {len(dataset)}\n")
# 訓練模型
trainer = Word2VecTrainer(
vocab_size=dataset.vocab_size,
embedding_dim=50,
learning_rate=0.01
)
trainer.train(dataloader, num_epochs=10)
# 獲取詞向量
embeddings = trainer.model.get_embeddings()
print(f"\n訓練完成!")
print(f"詞向量維度: {embeddings.shape}")
# 顯示部分詞向量
print("\n部分詞向量:")
for word in ['quick', 'fox', 'dog', 'lazy'][:3]:
if word in dataset.word2idx:
idx = dataset.word2idx[word]
vec = embeddings[idx]
print(f"{word}: {vec[:5]}...")
Word2Vec 的高效來自於其訓練技巧。負採樣透過採樣少量負例代替計算完整的 softmax,大幅降低計算複雜度。層次化 softmax 則透過二元樹結構將 softmax 的計算複雜度從 O(V) 降低到 O(log V)。
BERT 與上下文感知詞嵌入
BERT 代表了詞嵌入技術的重大突破,它透過 Transformer 架構與雙向語言模型預訓練,實現了上下文感知的詞表示。與 Word2Vec 等靜態詞嵌入不同,BERT 為每個詞在不同上下文中生成不同的向量表示,有效解決了多義詞問題。
BERT 的預訓練採用兩個任務,分別是遮蔽語言模型與下一句預測。在遮蔽語言模型中,隨機遮蔽輸入序列中的部分詞彙,要求模型根據上下文預測被遮蔽的詞。這種訓練方式讓模型同時利用左側與右側的上下文資訊,學習更豐富的語義表示。
#!/usr/bin/env python3
"""
BERT 詞嵌入提取與應用
使用 Hugging Face Transformers 提取 BERT 詞向量
展示下游任務的應用
"""
from transformers import BertTokenizer, BertModel
import torch
import numpy as np
from typing import List, Tuple
from sklearn.metrics.pairwise import cosine_similarity
class BERTEmbeddingExtractor:
"""BERT 詞嵌入提取器"""
def __init__(self, model_name: str = 'bert-base-uncased'):
self.tokenizer = BertTokenizer.from_pretrained(model_name)
self.model = BertModel.from_pretrained(model_name)
self.model.eval()
def extract_embeddings(
self,
text: str,
pooling_strategy: str = 'mean'
) -> np.ndarray:
"""
提取 BERT 詞嵌入
Args:
text: 輸入文本
pooling_strategy: 池化策略 ('mean', 'cls', 'max')
Returns:
詞嵌入向量
"""
# Token化
inputs = self.tokenizer(
text,
return_tensors='pt',
padding=True,
truncation=True,
max_length=512
)
# 提取隱藏層狀態
with torch.no_grad():
outputs = self.model(**inputs)
hidden_states = outputs.last_hidden_state # [1, seq_len, hidden_size]
# 池化策略
if pooling_strategy == 'cls':
# 使用 [CLS] token 的表示
embedding = hidden_states[0, 0, :].numpy()
elif pooling_strategy == 'mean':
# 平均池化(排除 padding)
attention_mask = inputs['attention_mask']
mask = attention_mask.unsqueeze(-1).expand(hidden_states.size()).float()
masked_hidden = hidden_states * mask
summed = torch.sum(masked_hidden, 1)
counts = torch.clamp(mask.sum(1), min=1e-9)
embedding = (summed / counts).squeeze().numpy()
elif pooling_strategy == 'max':
# 最大池化
embedding = torch.max(hidden_states, dim=1)[0].squeeze().numpy()
else:
raise ValueError(f"未知的池化策略: {pooling_strategy}")
return embedding
def extract_token_embeddings(
self,
text: str
) -> Tuple[List[str], np.ndarray]:
"""
提取每個 token 的詞嵌入
Args:
text: 輸入文本
Returns:
token 列表與對應的嵌入矩陣
"""
# Token化
inputs = self.tokenizer(text, return_tensors='pt')
tokens = self.tokenizer.convert_ids_to_tokens(inputs['input_ids'][0])
# 提取隱藏層狀態
with torch.no_grad():
outputs = self.model(**inputs)
hidden_states = outputs.last_hidden_state[0] # [seq_len, hidden_size]
return tokens, hidden_states.numpy()
def compute_similarity(
self,
text1: str,
text2: str,
pooling_strategy: str = 'mean'
) -> float:
"""
計算兩段文本的語義相似度
Args:
text1: 第一段文本
text2: 第二段文本
pooling_strategy: 池化策略
Returns:
餘弦相似度分數
"""
emb1 = self.extract_embeddings(text1, pooling_strategy)
emb2 = self.extract_embeddings(text2, pooling_strategy)
similarity = cosine_similarity(
emb1.reshape(1, -1),
emb2.reshape(1, -1)
)[0, 0]
return similarity
# 使用範例
if __name__ == "__main__":
print("=== BERT 詞嵌入提取示範 ===\n")
# 初始化提取器
extractor = BERTEmbeddingExtractor()
# 測試文本
text = "Natural Language Processing is fascinating!"
# 提取句子嵌入(不同池化策略)
print("[句子嵌入提取]\n")
for strategy in ['cls', 'mean', 'max']:
embedding = extractor.extract_embeddings(text, strategy)
print(f"{strategy.upper()} 池化:")
print(f" 維度: {embedding.shape}")
print(f" 前 5 維: {embedding[:5]}\n")
# 提取 token 嵌入
print("[Token 嵌入提取]\n")
tokens, token_embeddings = extractor.extract_token_embeddings(text)
print(f"Token 數量: {len(tokens)}")
print(f"嵌入矩陣形狀: {token_embeddings.shape}\n")
for token, embedding in zip(tokens[:5], token_embeddings[:5]):
print(f"Token: {token:15s} 嵌入前 3 維: {embedding[:3]}")
# 語義相似度計算
print("\n[語義相似度計算]\n")
sentence_pairs = [
("The cat sits on the mat.", "A feline rests on a rug."),
("I love programming.", "I hate bugs."),
("Machine learning is fun.", "Deep learning is interesting.")
]
for sent1, sent2 in sentence_pairs:
sim = extractor.compute_similarity(sent1, sent2)
print(f"句子 1: {sent1}")
print(f"句子 2: {sent2}")
print(f"相似度: {sim:.4f}\n")
BERT 的成功催生了一系列變體與改進,包含 RoBERTa、ALBERT、DistilBERT 等。這些模型在訓練策略、模型架構與效率優化等方面各有特色,但核心的 Transformer 架構與預訓練思想保持一致。
總結
Tokenization 與詞嵌入技術是現代 NLP 系統的基礎設施,其效能直接影響下游任務的表現。從簡單的空白分割到複雜的子詞演算法,從靜態的詞向量到動態的上下文表示,這些技術持續演進,推動著 NLP 領域的進步。
在台灣的 NLP 應用環境中,中文文本處理的特殊性要求我們在選擇與優化 Tokenization 策略時需要特別考量。子詞分割技術如 BPE 與 SentencePiece 在處理繁體中文時展現了良好的效果,能夠有效處理詞彙變異與新詞問題。詞嵌入技術的選擇則需要平衡模型複雜度、訓練成本與任務需求。
展望未來,多語言統一建模、高效的預訓練方法與領域適應性將是重要的研究方向。如何在有限的計算資源下訓練高品質的詞嵌入模型,如何讓模型更好地理解特定領域的語言特性,如何實現跨語言的知識遷移,這些都是值得持續探索的課題。持續學習與實踐,掌握這些核心技術的原理與應用,將有助於建構更智慧、更高效的 NLP 系統。