Huấn luyện: từ số ngẫu nhiên tới chữ tiếng Việt

Trọng số của mô hình từ đâu ra? Huấn luyện một GPT 3 triệu tham số trên Wikipedia tiếng Việt trong một phút bằng CPU: dữ liệu, tokenizer, hàm loss, vòng lặp huấn luyện, xem chữ dần thành hình, và đo mô hình như một thuật toán nén.

Ba bài trước dùng 494 triệu con số trong file trọng số của Qwen2.5 như một thứ có sẵn: bài 2 nạp chúng vào 70 dòng PyTorch, bài 3 đo xem đọc chúng tốn bao lâu. Bài này trả lời câu hỏi còn lại: những con số đó từ đâu ra?

Cách trả lời: lấy đúng các mảnh của Qwen ở bài 2 (embedding, attention với RoPE, MLP, RMSNorm), thu nhỏ lại còn 3,2 triệu tham số, khởi tạo bằng số ngẫu nhiên, rồi huấn luyện trên Wikipedia tiếng Việt. Toàn bộ chạy trên CPU, dùng 2 luồng, trong 58 giây. Kết quả không viết được văn hay, nhưng đủ để xem từng giai đoạn: từ một mớ ký tự vô nghĩa, tới các từ hay gặp, tới những cụm từ đúng ngữ pháp.

Lộ trình: mục tiêu của việc huấn luyện, dữ liệu (và vì sao 98,5% Wikipedia tiếng Việt bị bỏ đi), tokenizer, mô hình, vòng lặp huấn luyện, xem chữ thành hình qua từng bước, đo mô hình như một thuật toán nén, rồi so với Qwen.

Mọi con số trong bài đều đo trên máy thật, với Python 3.12, PyTorch 2.14 và tokenizers 0.23, chạy bằng CPU. Thí nghiệm được cố ý làm nhỏ để chạy nhẹ trên một laptop bình thường.

Mục tiêu: bớt ngạc nhiên

Bài 2 cho thấy mô hình là một hàm: dãy token vào, mỗi vị trí ra một phân phối xác suất cho token kế tiếp. Huấn luyện chỉ có một mục tiêu: làm cho xác suất mà mô hình gán cho token thật sự đứng tiếp theo càng cao càng tốt.

Con số đo điều đó gọi là loss (cross-entropy): lấy xác suất mô hình gán cho token đúng, rồi tính −log của nó. Gán 100% cho token đúng thì loss bằng 0; gán 1% thì loss là 4,6. Có thể hiểu loss là độ ngạc nhiên của mô hình khi thấy token thật. Lấy trung bình trên mọi vị trí của mọi đoạn văn, ta có một con số duy nhất để làm cho nhỏ đi.

Một mô hình chưa biết gì sẽ chia đều xác suất cho cả bộ từ vựng. Với 8.192 token, mỗi token được 1/8.192, nên loss là ln(8192) = 9,01. Mô hình ngẫu nhiên của bài này, trước bước huấn luyện đầu tiên, có loss đo được là 9,04: đúng là chưa biết gì.

Không có ai gán nhãn ở đây. Văn bản tự nó là đáp án: ở mỗi vị trí, "câu trả lời đúng" là token đứng ngay sau. Vì vậy mô hình ngôn ngữ huấn luyện được trên bất kỳ văn bản nào, và càng nhiều văn bản càng tốt.

Dữ liệu

Wikipedia tiếng Việt được Wikimedia công bố trên Hugging Face dưới dạng file parquet. Hai trong bốn file (khoảng 120 MB) chứa 644.340 trang. Mở thử vài trang ngẫu nhiên:

Pleurostelma schimperi là một loài thực vật có hoa trong họ La bố ma. Loài này được (Vatke) Liede
miêu tả khoa học đầu tiên năm 1994.

Senecio platanifolius là một loài thực vật có hoa trong họ Cúc. Loài này được Benth. miêu tả khoa
học đầu tiên năm 1840.

Wikipedia tiếng Việt có rất nhiều bài sơ khai do bot tạo ra theo cùng một khuôn, phần lớn về các loài sinh vật. Trong một file, độ dài trung vị của một trang chỉ là 193 ký tự. Huấn luyện trên dữ liệu này, mô hình sẽ học rất giỏi đúng một câu: "… là một loài thực vật có hoa trong họ …". Nên trước khi huấn luyện, dữ liệu được lọc: chỉ giữ bài dài từ 3.000 ký tự trở lên và không mở đầu bằng "là một loài".

Số bài
Hai file ban đầu 644.340
Sau khi lọc 9.578 (1,5%)

98,5% số trang bị bỏ đi, nhưng phần còn lại vẫn là 105 MB văn bản thật: tiểu sử, lịch sử, địa lý, âm nhạc. Với các mô hình thật, lọc dữ liệu là một phần lớn của công việc, và chất lượng dữ liệu thường quan trọng hơn số lượng. 2% số bài (191 bài) được để riêng làm dữ liệu kiểm tra, không bao giờ dùng để huấn luyện.

Tokenizer

Mô hình cần một bộ từ vựng. Thay vì dùng bộ 151.665 token của Qwen (bảng embedding sẽ to gấp nhiều lần phần còn lại của một mô hình nhỏ), ta học một bộ riêng 8.192 token bằng đúng thuật toán BPE ở bài 1, lần này dùng bản Rust của thư viện tokenizers thay cho bản Python tự viết:

from tokenizers import Tokenizer, decoders, models, normalizers, pre_tokenizers, trainers

tokenizer = Tokenizer(models.BPE())
tokenizer.normalizer = normalizers.NFC()  # the lesson from part 1
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tokenizer.decoder = decoders.ByteLevel()
trainer = trainers.BpeTrainer(
    vocab_size=8192, special_tokens=["<|endoftext|>"], initial_alphabet=pre_tokenizers.ByteLevel.alphabet()
)
tokenizer.train(["train.txt"], trainer)

Học trên khoảng 100 MB chữ mất 33 giây. Câu quen thuộc "Tất nhiên, tôi sẽ giúp bạn" thành 7 token, mỗi từ một token, tốt hơn cả Qwen (8 token): một bộ từ vựng nhỏ nhưng chỉ học tiếng Việt thì dành hết chỗ cho tiếng Việt. Cả kho dữ liệu thành 23,4 triệu token để huấn luyện và 461.000 token để kiểm tra, trung bình 4,39 byte mỗi token. Giữa hai bài viết có một token <|endoftext|> để mô hình biết chỗ một bài kết thúc.

Mô hình: Qwen thu nhỏ

Mô hình dùng đúng các mảnh của bài 2, chỉ khác là bây giờ chúng là các lớp nn.Module có tham số học được, và attention dùng scaled_dot_product_attention như bài 3:

Qwen2.5-0.5B Mô hình của bài này
Bộ từ vựng 151.936 8.192
Kích thước vector 896 192
Số lớp 24 4
Head query / key-value 14 / 2 4 / 2
MLP 4.864 512
Ngữ cảnh tối đa 32.768 128
Tham số 494 triệu 3,2 triệu
import torch
import torch.nn as nn
import torch.nn.functional as F

VOCAB, D, LAYERS, H, KV, FF, CTX = 8192, 192, 4, 4, 2, 512, 128
HD = D // H


class Block(nn.Module):
    def __init__(self):
        super().__init__()
        self.norm1, self.norm2 = nn.RMSNorm(D), nn.RMSNorm(D)
        self.q, self.k, self.v = nn.Linear(D, H * HD), nn.Linear(D, KV * HD), nn.Linear(D, KV * HD)
        self.o = nn.Linear(H * HD, D, bias=False)
        self.gate, self.up = nn.Linear(D, FF, bias=False), nn.Linear(D, FF, bias=False)
        self.down = nn.Linear(FF, D, bias=False)

    def forward(self, x, cos, sin):
        b, t, _ = x.shape
        h = self.norm1(x)
        q = self.q(h).view(b, t, H, HD).transpose(1, 2)
        k = self.k(h).view(b, t, KV, HD).transpose(1, 2)
        v = self.v(h).view(b, t, KV, HD).transpose(1, 2)
        q, k = rotate(q, cos, sin), rotate(k, cos, sin)
        a = F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=True)
        x = x + self.o(a.transpose(1, 2).reshape(b, t, D))
        h = self.norm2(x)
        return x + self.down(F.silu(self.gate(h)) * self.up(h))


def rotate(x, cos, sin):
    x1, x2 = x[..., : HD // 2], x[..., HD // 2 :]
    return x * cos + torch.cat([-x2, x1], dim=-1) * sin


class GPT(nn.Module):
    """The same pieces as Qwen2.5 in the previous posts, just small, and with weights that start random."""

    def __init__(self):
        super().__init__()
        self.embed = nn.Embedding(VOCAB, D)
        self.blocks = nn.ModuleList(Block() for _ in range(LAYERS))
        self.norm = nn.RMSNorm(D)
        freqs = 1.0 / 10000 ** (torch.arange(0, HD, 2).float() / HD)
        angles = torch.arange(CTX)[:, None] * freqs[None, :]
        self.register_buffer("cos", angles.cos().repeat(1, 2), persistent=False)
        self.register_buffer("sin", angles.sin().repeat(1, 2), persistent=False)
        for p in self.parameters():
            if p.dim() == 2:
                nn.init.normal_(p, std=0.02)

    def forward(self, ids):
        t = ids.shape[1]
        x = self.embed(ids)
        for block in self.blocks:
            x = block(x, self.cos[:t], self.sin[:t])
        return self.norm(x) @ self.embed.weight.T  # tied, like Qwen

Hai khác biệt nhỏ so với bài 2: mọi thứ có thêm chiều batch (b), vì huấn luyện xử lý nhiều đoạn văn cùng lúc; và mọi ma trận khởi đầu bằng số ngẫu nhiên nhỏ (phân phối chuẩn, độ lệch 0,02). Ở mô hình nhỏ này, bảng embedding (8.192 × 192 = 1,57 triệu số) chiếm gần một nửa số tham số; ở Qwen nó là 27,6%.

Vòng lặp huấn luyện

def batches(path, batch_size, seed):
    data = np.fromfile(path, dtype=np.uint16)
    rng = np.random.default_rng(seed)
    while True:
        starts = rng.integers(0, len(data) - CTX - 1, batch_size)
        chunk = torch.from_numpy(np.stack([data[s : s + CTX + 1] for s in starts]).astype(np.int64))
        yield chunk[:, :-1], chunk[:, 1:]  # the target at each position is simply the next token


torch.set_num_threads(2)  # keep the machine responsive
model = GPT()
opt = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=0.1, betas=(0.9, 0.95))
train = batches("train.bin", 16, seed=1)

for step in range(800):
    for group in opt.param_groups:
        group["lr"] = lr_at(step)  # warm up for 50 steps, then decay along a cosine
    x, y = next(train)
    loss = F.cross_entropy(model(x).flatten(0, 1), y.flatten())  # how surprised the model is by the real next token
    opt.zero_grad()
    loss.backward()                                             # which way to nudge each of the 3.2M numbers
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step()                                                  # nudge them

Mỗi bước:

  1. Lấy một batch: 16 đoạn, mỗi đoạn 129 token liên tiếp ở một chỗ ngẫu nhiên trong kho. x là 128 token đầu, y là 128 token sau đó, lệch một vị trí. Mỗi đoạn cho ra 128 bài tập dự đoán cùng lúc, nhờ mặt nạ causal ở bài 2: vị trí 5 chỉ thấy token 1–5 và phải đoán token 6.
  2. Tính loss trên cả 16 × 128 = 2.048 vị trí.
  3. loss.backward(): PyTorch đi ngược lại qua mọi phép tính đã làm (đây là backpropagation), và cho mỗi con số trong 3,2 triệu tham số tính ra một gradient: nếu tăng con số này lên một chút, loss tăng hay giảm, và nhiều cỡ nào.
  4. opt.step(): dịch mỗi tham số một bước nhỏ về phía làm loss giảm. AdamW là một cách dịch thông minh hơn "đi ngược gradient": nó nhớ hướng đi của các bước trước để đi đều hơn, và mỗi tham số có cỡ bước riêng.

Hai chi tiết giữ cho quá trình ổn định: clip_grad_norm_ cắt gradient khi nó quá lớn (thường xảy ra ở những bước đầu), và learning rate (cỡ bước) tăng dần trong 50 bước đầu rồi giảm dần theo hình cosin tới cuối. Không có gì trong vòng lặp này nói về ngữ pháp, chính tả hay lịch sử. Mọi thứ mô hình học được đều đến từ việc lặp lại bốn bước trên 800 lần.

Xem chữ thành hình

800 bước, mỗi bước 2.048 token, tổng cộng 1,6 triệu token, tức khoảng 7% kho dữ liệu. Toàn bộ mất 58 giây. Loss trên dữ liệu kiểm tra (những bài mô hình chưa từng thấy):

Bước Loss kiểm tra Thời gian
0 9,04 0 s
50 7,46 4 s
100 7,14 8 s
200 6,76 15 s
400 6,25 29 s
600 5,97 44 s
800 5,84 58 s

Con số khó cảm nhận; văn bản thì không. Ở mỗi mốc, mô hình viết tiếp 30 token sau "Hà Nội là" và "Năm 1945," (temperature 0,8, top-p 0,9, cùng seed, hàm sample ở bài 2):

Bước 0, trọng số ngẫu nhiên:

Hà Nội là Mil lỗ dõiĐánhócTAs 978trước Mạng Bàn Div Bếnect vượt vinhр tịínhchen Jocqué cắt 11oxombus...
Năm 1945, Mil khángChủĐánhóc�As 978 xứnging Bàn 1907 Bếnect vượt chở CoulcínhchenBiến cắt 11oxombus...

Token bất kỳ trong bộ từ vựng, ghép lại không theo quy luật nào. Để ý là hai câu gần như giống hệt nhau: mô hình ngẫu nhiên gần như không để ý tới đầu vào.

Bước 50 (4 giây):

Hà Nội là Hưng,. tại giúpT đã động lãnh con. vận và được điều\n nhân khác thứ với nhưng Quốc 11, lấy có là một, không
Năm 1945, Hưng,. tại giúpT đã động lãnh con. vận và được điều\n nhân khác thứ với nhưng Quốc 11, lấy có là một, không

Toàn là từ tiếng Việt thật, và đều là những từ hay gặp nhất: "và", "được", "của", "tại", "là một". Giai đoạn đầu tiên mô hình học là thống kê đơn giản nhất: từ nào xuất hiện nhiều. Hai câu vẫn giống hệt nhau: nó chưa biết dùng ngữ cảnh.

Bước 400 (29 giây):

Hà Nội là Hưng Sùng hậu tại vùng tại châu Tân. Ông là nhân hát của điều hợp nhân khác tấn vực nhưng cắt đi...
Năm 1945, Hưng, Có tại vùng Kỳ đã nên viết con trường vận động của điều thống, khác ta với nhưng cắt đi...

Hai câu đã tách ra: mô hình bắt đầu phụ thuộc vào đầu vào. Có những cụm nhỏ đúng ngữ pháp ("Ông là", "tại vùng", "vận động"), câu có dấu chấm, chữ hoa sau dấu chấm.

Bước 800 (58 giây):

Hà Nội là phường Đông.\n\nTơn động lãnh lập\nBèo tử\n\nNăm 16%\nĐuy Mông lấy sự quân Phương hội phong
Năm 1945, Hưng Hòa.\n\nTùi Đình Hòa, Thiên Nhĩ Kim được điều hợp nhân khác của Quân Dương Quốc, Chu lấy lãnh thành một cuộc phong

"Hà Nội là phường Đông." Sau "Hà Nội", mô hình nghĩ tới đơn vị hành chính. Có dòng trống giữa các đoạn và các dòng ngắn giống tiêu đề mục của Wikipedia. Có những thứ trông như tên người Việt ("Đình Hòa", "Hưng Hòa"). Vẫn chưa có nghĩa, nhưng đã có hình dạng của một bài Wikipedia tiếng Việt.

Thứ tự đó, từ hay gặp trước, ngữ pháp cục bộ sau, cấu trúc văn bản sau nữa, nghĩa và kiến thức sau cùng, là điều thường thấy khi huấn luyện mô hình ngôn ngữ. Mô hình thật đi qua cùng những giai đoạn đó, chỉ là đi xa hơn rất, rất nhiều.

Đo bằng bit: mô hình ngôn ngữ là một thuật toán nén

Loss 5,84 nghĩa là gì? Có một cách đổi nó ra con số dễ so sánh: số bit trên mỗi byte văn bản.

Một mô hình gán xác suất p cho token kế tiếp thì có thể dùng để nén văn bản: một bộ mã hóa số học (arithmetic coding) cần đúng −log₂ p bit để ghi token đó. Tức là loss của mô hình, đổi từ cơ số e sang cơ số 2 và chia cho số byte mỗi token, chính là kích thước nén. Mô hình đoán tốt hơn thì nén nhỏ hơn. Với loss 5,84 và 4,39 byte mỗi token: 5,84 / ln 2 / 4,39 = 1,92 bit mỗi byte.

Đơn vị này không phụ thuộc vào tokenizer, nên so được với mọi thứ, kể cả các chương trình nén quen thuộc. Trên toàn bộ dữ liệu kiểm tra (2 MB):

Bit mỗi byte
gzip -9 2,54
xz -9 2,00
Mô hình của bài này, sau 58 giây 1,92

Và trên một đoạn 20.000 ký tự của dữ liệu kiểm tra, để có chỗ cho Qwen2.5-0.5B (với Qwen, phải đưa từng đoạn 512 token qua mô hình, nên chỉ đo một đoạn):

Bit mỗi byte
gzip -9 2,97
xz -9 2,81
Mô hình của bài này 1,79
Qwen2.5-0.5B 0,99

Sau một phút huấn luyện, mô hình 3 triệu tham số đã nén tiếng Việt tốt hơn xz. Qwen làm tốt gần gấp đôi nữa. (Cần nói cho công bằng: Wikipedia gần như chắc chắn nằm trong dữ liệu huấn luyện của Qwen, nên rất có thể nó đã đọc chính những bài này. Con số của Qwen ở đây là một giới hạn trên lạc quan.) Nhưng ý chính không đổi: "dự đoán token kế tiếp" và "nén văn bản" là cùng một bài toán, và một mô hình nén tốt văn bản tiếng Việt là một mô hình phải nắm được nhiều điều về tiếng Việt.

So với Qwen thật

Có một công thức ước lượng quen thuộc cho khối lượng tính toán khi huấn luyện: khoảng 6 phép tính cho mỗi tham số, cho mỗi token (2 cho lượt tính xuôi như bài 2, khoảng 4 cho lượt backward).

Tham số Token huấn luyện Phép tính (6 × N × D)
Mô hình của bài này 3,2 triệu 1,6 triệu ~3 × 10¹³
Qwen2.5-0.5B 494 triệu tới 18 nghìn tỉ (theo báo cáo kỹ thuật của Qwen2.5) ~5 × 10²²

Qwen tốn nhiều hơn khoảng 1,7 tỉ lần. Nếu chạy ở cùng tốc độ như thí nghiệm này, đó là hơn 3.000 năm; thực tế những mô hình như vậy được huấn luyện trên rất nhiều GPU chạy song song. Khoảng cách giữa "Hà Nội là phường Đông" và "Thủ đô của Việt Nam là Hà Nội" là khoảng cách đó: cùng kiến trúc, cùng vòng lặp bốn bước, khác nhau ở quy mô mô hình, quy mô dữ liệu, và chất lượng dữ liệu.

Còn một bước nữa mà mô hình của bài này không có. Huấn luyện như trên (gọi là pretraining) cho ra một mô hình đoán tiếp văn bản, như phiên bản Qwen trả lời "Thủ đô của Việt Nam là" bằng ' gì' ở bài 2. Để thành một mô hình trợ lý, nó được huấn luyện tiếp trên các đoạn hội thoại mẫu có khung <|im_start|>user … <|im_start|>assistant (gọi là supervised fine-tuning), rồi thường thêm một giai đoạn học từ đánh giá của con người hoặc từ phần thưởng tự động về câu trả lời nào tốt hơn. Vòng lặp vẫn là forward, loss, backward, step; chỉ dữ liệu và cách chấm điểm là khác.

Tóm lại

Khái niệm Thực chất
Mục tiêu huấn luyện Tăng xác suất cho token thật sự đứng tiếp theo; văn bản tự nó là đáp án
Loss −log xác suất của token đúng; mô hình ngẫu nhiên có loss ln(số token) = 9,01
Dữ liệu Lọc mạnh tay: 98,5% trang Wikipedia tiếng Việt bị bỏ vì là bài sơ khai
Backpropagation loss.backward(): với mỗi tham số, tăng nó lên thì loss đổi thế nào
AdamW Dịch mỗi tham số một bước nhỏ theo hướng giảm loss, có trớn và cỡ bước riêng
Các giai đoạn Từ hay gặp → ngữ pháp cục bộ → cấu trúc văn bản → nghĩa và kiến thức
Bit mỗi byte Loss đổi sang bit chia cho byte mỗi token; mô hình ngôn ngữ là một thuật toán nén
Quy mô 6 × tham số × token; Qwen2.5-0.5B tốn gấp khoảng 1,7 tỉ lần thí nghiệm này
Pretraining và fine-tuning Đoán tiếp văn bản trước, học làm trợ lý sau; cùng một vòng lặp

Tới đây ta đã đi hết vòng đời của một mô hình: huấn luyện ra trọng số, chữ thành token, token qua mô hình, và vì sao nó nhanh hay chậm. Bài sau quay sang phía vận hành: khi hàng trăm người cùng hỏi một lúc, server làm thế nào để phục vụ tất cả mà không cần hàng trăm bản mô hình.