繼前一篇小結了一下概念,誠如前一篇所述,接下來將實際做做看一個 LLM。以下的段落會以最有名的 Transformer 架構為例,來逐步打造其中的不同模組,而第一個會先從處理文字資料的分詞器 Tokenizer 開始。
前情提要
我們已經知道模型只能讀數字,也就是向量。所以提問的句子不會是以句子的方式被丟進這個方程式,而是會透過 Tokenzier 將句子拆成一個個基本單位 ….
Tokenizer 第一步需要將一段句子拆分成語意的最小單位,英文可以是單詞、中文可能是字,我們可以從最簡單的情況,英文的字串處理開始並以空白字元跟標點符號來分詞。
import re
text = "Hello, world. Here is ithome 2025"
result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
在分詞中,可以發現一些空白,也被切在分詞中。如果今天是一個程式碼專攻的 LLM 可能需要保留,因為對於程式碼而言,有時不同縮排是有意義的。但在一般的英文閱讀上可以清除。而大小寫則可以保留,因為這有助於讓模型辨識專有名詞。
# 如果想要清除空白
preprocessed = [item.strip() for item in result if item.strip()]
清除空白後,需要將一系列的分詞轉為一系列的 ID 替他取一個數字代號,
all_words = sorted(set(preprocessed))
vocab = {token:integer for integer,token in enumerate(all_words)}
稍微整理一下,以上就是一個超入門的分詞類別,能夠將一串字串透過 encode
取得對應號碼,也能透過對應後碼 decode
為原本的文字,為的是在最後模型生成出對應的數字後,還能轉回文字。
class SimpleTokenizerV1:
def __init__(self, vocab):
self.str_to_int = vocab
self.int_to_str = {i:s for s,i in vocab.items()}
def encode(self, text):
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
preprocessed = [
item.strip() for item in preprocessed if item.strip()
]
ids = [self.str_to_int[s] for s in preprocessed]
return ids
def decode(self, ids):
text = " ".join([self.int_to_str[i] for i in ids])
# Replace spaces before the specified punctuations
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
return text
模型在接受一個個 Token 傳入時,並不只是考慮文字本身,正因為是一個個文字傳入,對於模型來說他並不清楚現在這個 Token 是句子的頭、尾,這些時候我們需要穿插一些標記用的記號在對照表中。
例如,最常見的有句尾 [EOS]
end of sentence,又或者 [BOS]
beginning of sentence,甚至如果今天有一些神秘文字真的沒有被存進對照表中,也有一個 [UNK]
unknown 的特殊符號去標記。
# 將特殊符號加入 token list 中
all_tokens.extend(["<|endoftext|>", "<|unk|>"])
# 並補上 Unknown 的相關判斷
class SimpleTokenizerV1:
# ...
def encode(self, text):
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
preprocessed = [
item if item in self.str_to_int
else "<|unk|>" for item in preprocessed
]
ids = [self.str_to_int[s] for s in preprocessed]
return ids
# ...
接下來再去 encode 與 decode 就能處理特殊符號了。
tokenizer.encode(text)
tokenizer.decode(tokenizer.encode(text))
但我們需要的單詞可不只有以上僅測試資料中涵蓋的短短幾個,我們需要一個能盡可能涵蓋所有英文單詞需要的詞表,才能解析所有英文文章。
當然都發展出 LLM 了,前面的超簡單 Tokenizer 早已發展出更好的作法,並被封裝成可被工程師們使用的 package。例如:GPT-2 就使用了 BytePair Encoder (BPE) 的算法,而現在有實做此算法最常見的 package 是 tiktoken。
import importlib
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
text = (
"Here is a non word called blablok. <|endoftext|>"
)
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
BPE 算法的分詞,不單單只是從單詞切分,甚至從字根字尾 subword 單位下去切詞。
前情提要
我們已經知道 LLM 的本質是一個文字接龍機器,給他一段文字,他會預測最有可能出現的下一個字。
現在我們有一筆測試資料,一個稍長一點點的句子 ”iThome article challenge is very good for any developer to join it.” 如果今天要以此資料做出一個實際餵給模型的測試資料,該怎麼做?最常見的一種作法是透過 Sliding Window 的方式進行取樣。視覺化後看起來會像下方區塊,4 個字一組區塊向右移動,其中 4 個字就是我們設定的 Context Size,[]
中括號區域內的是輸入,””
引號是輸出。
[iThome] "article" challenge is very good for any developer to join it.
[iThome article] "challenge" is very good for any developer to join it.
[iThome article challenge] "is" very good for any developer to join it.
[iThome article challenge is] "very" good for any developer to join it.
iThome [article challenge is very] "good" for any developer to join it.
所以我們可以先透過 Tokenizer 將文字轉為數字陣列,接著設定 Slide Window 的 context size,跑過整陣列將 context
raw_text = "iThome article challenge is very good for any developer to join it."
enc_text = tokenizer.encode(raw_text)
context_size = 4
for i in range(1, context_size+1):
# 輸入
context = enc_sample[:i]
# 輸出
desired = enc_sample[i]
接著,我們需要對資料稍微做資料結構上的調整,方便後續模組使用,我們開了新的處理類別叫做 GPTDatasetV1,在 init 時會將資料讀入並轉存為 tensor 的形式,希望可以取得輸入與輸出的 tensor。而這一塊 PyTorch 也有直接封裝,並提供相關的 utils,可以直接使用。
import torch
from torch.utils.data import Dataset, DataLoader
class GPTDatasetV1(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
self.input_ids = []
self.target_ids = []
token_ids = tokenizer.encode(txt, allowed_special={"<|endoftext|>"})
# 避免超出 conetxt 限制
assert len(token_ids) > max_length, "Number of tokenized inputs must at least be equal to max_length+1"
# 將 sliding window 資料轉成 tensor 的形式(多維陣列)
for i in range(0, len(token_ids) - max_length, stride):
input_chunk = token_ids[i:i + max_length]
target_chunk = token_ids[i + 1: i + max_length + 1]
self.input_ids.append(torch.tensor(input_chunk))
self.target_ids.append(torch.tensor(target_chunk))
def __len__(self):
return len(self.input_ids)
def __getitem__(self, idx):
return self.input_ids[idx], self.target_ids[idx]
接下來就可以使用 GPTDatasetV1
來取得 DataLoader 了,以下看到可以傳入的參數都是在訓練時可以調整的 Hyper Parameter,會影響到訓練的成果。
# max_length 就是前面提到的 context_size, stide 則是每次的移動量。
def create_dataloader_v1(txt, batch_size=4, max_length=256,
stride=128, shuffle=True, drop_last=True,
num_workers=0):
tokenizer = tiktoken.get_encoding("gpt2")
dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
# Create dataloader
dataloader = DataLoader(
dataset,
batch_size=batch_size, #一次處理幾個資料
shuffle=shuffle,
drop_last=drop_last, # 如果剛好最後一批文字不足 batch_size 是否要丟棄
num_workers=num_workers # 有多少 CPU 程序數量來進行預處理
)
return dataloader
dataloader = create_dataloader_v1(
raw_text, batch_size=1, max_length=4, stride=1, shuffle=False
)
data_iter = iter(dataloader)
first_batch = next(data_iter)
透過 Tiktoken 的 BPE 算法進行分詞轉換為 ID、PyTorch 將分詞結果轉為 Tensor,一種更適合被模型消化的資料結構,最後會將 Token 轉為 Embedding,讓 Token 真的可以在數學意義上的被使用。