ข้ามไปยังเนื้อหาหลัก

[LLM 1/10] Continue Pretraining: สอนความรู้ใหม่ให้ LLM ภาษาไทย

· อ่าน 13 นาที
Kobkrit Viriyayudhakorn
CEO, iApp Technology

โมเดลภาษาขนาดใหญ่ที่เก่งภาษาไทยระดับหนึ่ง มักจะ "ไม่รู้จัก" ความรู้เฉพาะทางขององค์กรคุณเลย — ไม่รู้ระเบียบราชการไทย ไม่รู้ศัพท์เฉพาะในวงการของคุณ ไม่รู้เอกสารภายในบริษัท บทความนี้จะสอนวิธีแก้ที่ตรงที่สุด คือ Continue Pretraining (CPT) ตั้งแต่สมการ ไปจนถึงโค้ดที่รันจบได้จริงบน Colab ฟรีภายในราว 15 นาที

Open in Colab01_continue_pretraining.ipynb

1. ปัญหา (Problem statement)

ลองนึกภาพว่าคุณเอา Qwen3-0.6B มาถามว่า "ตามระเบียบสำนักนายกรัฐมนตรี การจัดซื้อจัดจ้างโดยวิธีเฉพาะเจาะจงทำได้เมื่อใด" โมเดลจะตอบได้อย่างมั่นใจ และตอบผิด เพราะมันไม่เคยเห็นเอกสารราชการไทยมากพอ

หลายคนพยายามแก้ด้วยการทำ fine-tuning ด้วยคู่ถาม-ตอบไม่กี่พันตัวอย่าง แล้วพบว่าไม่ได้ผล เหตุผลคือ SFT สอน "รูปแบบการตอบ" ไม่ได้สอน "ความรู้" ถ้าโมเดลไม่เคยมีความรู้นั้นอยู่ในน้ำหนัก (weights) การสอนให้มันตอบด้วยน้ำเสียงที่ถูกต้องก็แค่ทำให้มันมั่นใจเวลาโกหกเท่านั้น

ความรู้ใหม่เข้าสู่โมเดลได้ 3 ทาง และเลือกผิดคือสาเหตุที่โปรเจกต์ LLM ส่วนใหญ่ล้มเหลว:

วิธีเหมาะกับต้นทุนตอนใช้งาน
RAGความรู้ที่เปลี่ยนบ่อย ต้องอ้างอิงแหล่งที่มาค้นทุกครั้ง + prompt ยาว
Continue Pretrainingความรู้เฉพาะทางจำนวนมาก ที่ค่อนข้างนิ่งไม่มี (อยู่ในน้ำหนักแล้ว)
SFTรูปแบบ น้ำเสียง โครงสร้างคำตอบไม่มี

บทความนี้คือทางที่สอง

2. เราจะทำอะไร (Solution)

เราจะเอาโมเดล base (ยังไม่ผ่าน instruction tuning) มาเทรนต่อด้วย objective เดียวกับตอน pretrain เป๊ะ ๆ คือทายคำถัดไป บนข้อความดิบภาษาไทยในโดเมนที่เราสนใจ ไม่มี label ไม่มีคู่ถาม-ตอบ มีแค่ข้อความล้วน ๆ

แต่หัวใจของบทความนี้ไม่ใช่ "เทรนแล้วเก่งขึ้น" — มันคือสิ่งที่แลกมา:

แนวคิดหลักของบทนี้

CPT ซื้อความแม่นในโดเมน ด้วยการจ่ายความสามารถทั่วไปที่หายไป มันคือการแลกเปลี่ยน ไม่ใช่ของฟรี และ "อัตราแลกเปลี่ยน" ถูกควบคุมด้วยตัวเลขตัวเดียวชื่อ replay ratio

ปรากฏการณ์ที่โมเดลลืมสิ่งที่เคยทำได้ เรียกว่า catastrophic forgetting เราจะไม่พูดลอย ๆ แต่จะวัดมันออกมาเป็นตัวเลข แล้วหาจุดที่ยอมรับได้

3. สมการ (Equation)

3.1 Objective ของ CPT

LCPT(θ)=ExDdomain[t=1xlogpθ(xtx<t)]\mathcal{L}_{\text{CPT}}(\theta) = -\mathbb{E}_{x\sim\mathcal{D}_{\text{domain}}}\left[\sum_{t=1}^{|x|}\log p_\theta(x_t \mid x_{<t})\right]
  • xtx_t = token ตำแหน่งที่ tt
  • x<tx_{<t} = token ทั้งหมดก่อนหน้า
  • pθp_\theta = ความน่าจะเป็นที่โมเดลทำนาย

สมการนี้เหมือนกับตอน pretrain ทุกประการ สิ่งเดียวที่เปลี่ยนคือข้อมูล นี่คือเหตุผลที่ CPT ไม่ต้องการ label — ข้อความเองคือเฉลย

3.2 Perplexity: หน่วยวัดของเรา

PPL(D)=exp ⁣(1NiLCPT(x(i)))\text{PPL}(\mathcal{D}) = \exp\!\left(\frac{1}{N}\sum_{i}\mathcal{L}_{\text{CPT}}(x^{(i)})\right)

แปลเป็นภาษาคน: "โดยเฉลี่ยแล้ว โมเดลกำลังลังเลอยู่ระหว่างกี่ตัวเลือก" PPL = 20 คือลังเลประมาณ 20 คำ, PPL = 5 คือมั่นใจกว่ามาก ยิ่งต่ำยิ่งดี

3.3 สมการที่สำคัญที่สุดในบทนี้ — Replay Mixing

Dmix=λDdomain+(1λ)Dgeneral\mathcal{D}_{\text{mix}} = \lambda\,\mathcal{D}_{\text{domain}} + (1-\lambda)\,\mathcal{D}_{\text{general}}

λ\lambda คือสัดส่วนข้อมูลโดเมนในแต่ละ batch

  • λ=1.0\lambda = 1.0 → ข้อมูลโดเมนล้วน → เก่งโดเมนเร็วที่สุด และลืมเร็วที่สุด
  • λ=0.5\lambda = 0.5 → ผสมครึ่งต่อครึ่ง → ช้ากว่าแต่ลืมน้อยกว่ามาก

อย่า hardcode ค่านี้ จงกวาดหาค่ามัน แล้วเลือกจุดที่คุณยอมรับได้

4. เห็นภาพสมการ (Visualize)

Perplexity บอกอะไรเรากันแน่

ความสัมพันธ์ระหว่าง cross-entropy loss กับ perplexity และความหมายของการลด PPL 5 หน่วยจากจุดเริ่มต้นต่าง ๆความสัมพันธ์ระหว่าง cross-entropy loss กับ perplexity และความหมายของการลด PPL 5 หน่วยจากจุดเริ่มต้นต่าง ๆ

Figure 1.1PPL คือ exp ของ loss — และการลดลง 5 หน่วยมีความหมายต่างกันมาก ขึ้นกับว่าคุณเริ่มจากตรงไหน

กราฟขวาคือสิ่งที่คนมักพลาด: ถ้ามีคนบอกว่า "ลด perplexity ได้ 5 หน่วย" แล้วไม่บอกว่าเริ่มจากเท่าไหร่ ประโยคนั้นแทบไม่มีความหมาย เพราะ 80 → 75 คือดีขึ้น 6% แต่ 10 → 5 คือดีขึ้น 50%

การแลกเปลี่ยนที่ replay ratio ควบคุม

กราฟแสดงว่าเมื่อ lambda เพิ่มขึ้น perplexity ของโดเมนลดลงแต่ perplexity ทั่วไปเพิ่มขึ้น พร้อม Pareto frontierกราฟแสดงว่าเมื่อ lambda เพิ่มขึ้น perplexity ของโดเมนลดลงแต่ perplexity ทั่วไปเพิ่มขึ้น พร้อม Pareto frontier

Figure 1.2รูปทรงของการแลกเปลี่ยนที่เกิดจากสมการ replay mixing (ภาพประกอบกลไก ไม่ใช่ผลการวัดจริง — ผลจริงอยู่ในหัวข้อที่ 8)

5. เตรียมสภาพแวดล้อม (Environment)

เปิด Colab เลือก Runtime → Change runtime type → T4 GPU (ใช้แผนฟรีได้)

จุดตายที่ทำให้โน้ตบุ๊ก LLM ส่วนใหญ่พังบน Colab ฟรี

Colab T4 เป็นสถาปัตยกรรม Turing (SM 7.5) ซึ่ง ไม่รองรับ bfloat16 และ ไม่รองรับ FlashAttention-2

แต่ไฟล์ config.json ของ Qwen3-0.6B ระบุว่า torch_dtype: bfloat16 ดังนั้นถ้าคุณเขียน torch_dtype="auto" ตามที่เห็นในบทความทั่วไป โค้ดจะพังหรือช้าผิดปกติ

ในซีรีส์นี้เราจะระบุชัดเจนทุกครั้งว่า:

torch_dtype=torch.float16      # ไม่ใช่ bfloat16
attn_implementation="sdpa" # ไม่ใช่ flash_attention_2
fp16=True # ใน TrainingArguments (ไม่ใช่ bf16=True)

เซลล์แรกของทุกโน้ตบุ๊กในซีรีส์จะพิมพ์บรรทัดนี้ออกมาให้เห็นกับตา:

cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (นับ emulation!)
is_bf16_supported() โกหกคุณบน T4

torch รุ่นใหม่ตอบ True บน T4 เพราะนับ การจำลอง (emulation) ว่ารองรับด้วย ซึ่งช้ากว่า fp16 มาก ให้เช็ค compute capability ≥ 8.0 (Ampere ขึ้นไป) แทน — นี่คือบั๊กจริงที่เจอตอนรันโน้ตบุ๊กบน Colab จริง ๆ

งบ VRAM มีเท่าไหร่ และหมดไปกับอะไร

แผนภูมิแท่งแสดงการใช้ VRAM แยกตาม weights, gradients, fp32 master, Adam states และ activations เทียบระหว่าง adamw fp32 กับ 8-bitแผนภูมิแท่งแสดงการใช้ VRAM แยกตาม weights, gradients, fp32 master, Adam states และ activations เทียบระหว่าง adamw fp32 กับ 8-bit

Figure 1.3ที่มาของ VRAM ตอนเทรนทั้งโมเดล คำนวณจากค่าจริงใน config ของ Qwen3-0.6B (596M พารามิเตอร์)

สังเกตว่า optimizer state กินที่มากกว่าตัวโมเดลเอง — Adam เก็บ mm และ vv อย่างละหนึ่งชุดเท่าจำนวนพารามิเตอร์ การเปลี่ยนไปใช้ adamw_bnb_8bit ประหยัดได้ 3.3 GB ซึ่งแปลว่าคุณเพิ่ม batch size หรือความยาว sequence ได้อีกมาก

ลองเล่นกับงบ VRAM ด้วยตัวเอง — ปรับค่าแล้วดูว่าเมื่อไหร่จะ OOM:

596.0M parameters, derived from config.json
Weight dtype
Run mode
Trades about 30% more compute for a large drop in activation memory.
weights: 1.13 GiBgradients: 19.25 MiBoptimizer: 115.50 MiBactivations: 170.00 MiB16 GB — Colab T40481216GiB
  • Weights1.13 GiB
  • Gradients19.25 MiB
  • Optimizer state115.50 MiB
  • Activations170.00 MiB
  • KV cache
Total VRAM1.43 GiB14.57 GiB to spare
Trainable params10.1M1.69%
KV cache per token112 KiB2 x 28 x 8 x 128
Full context KV4.38 GiB41.0K tok

It fits.This run needs 1.43 GiB and leaves 14.57 GiB of headroom on a free Colab T4.

6. เตรียมข้อมูล (Data)

เราใช้ pythainlp/thaigov-v2-corpus-22032023 — คลังข่าวและเอกสารราชการไทย (สาธารณสมบัติ) เป็นตัวแทนของ "ความรู้เฉพาะทางที่โมเดลไม่เคยเห็นมากพอ"

และใช้ข้อความภาษาไทยทั่วไปอีกชุดเป็น replay data เพื่อกันการลืม

from datasets import load_dataset

domain = load_dataset("pythainlp/thaigov-v2-corpus-22032023", split="train")
domain = domain.shuffle(seed=42).select(range(8000))

Packing: อย่าปล่อยให้ padding กินงบ

ถ้าเอาแต่ละเอกสารมา pad ให้ยาวเท่ากัน คุณจะเสีย compute ไปกับ <pad> มหาศาล วิธีที่ถูกคือ ต่อทุกเอกสารเข้าด้วยกันแล้วหั่นเป็นบล็อกยาว 512 token เท่า ๆ กัน

def pack(examples, block_size=512):
ids = []
for text in examples["context"]:
ids.extend(tokenizer(text + tokenizer.eos_token).input_ids)
n = (len(ids) // block_size) * block_size
return {"input_ids": [ids[i:i+block_size] for i in range(0, n, block_size)]}
ทำไมภาษาไทยแพงกว่าภาษาอังกฤษ

tokenizer ของโมเดลส่วนใหญ่ถูกฝึกด้วยข้อมูลภาษาอังกฤษเป็นหลัก ข้อความไทยจึงถูกหั่นเป็น token ถี่กว่า — ประโยคเดียวกันอาจใช้ token มากกว่า 2–3 เท่า แปลว่าค่า API แพงกว่า, context เต็มเร็วกว่า, และเทรนช้ากว่า โน้ตบุ๊กจะวัดตัวเลขนี้ให้ดู

7. โค้ดหลัก (Main code)

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer

model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B-Base", # base ไม่ใช่ instruct — CPT ต้องเริ่มจาก base
torch_dtype=torch.float16, # T4 ไม่มี bf16
attn_implementation="sdpa", # T4 ไม่มี FlashAttention-2
).cuda()

model = model.float() # ต้อง cast เป็น fp32 ก่อนเทรน — ดูกล่องด้านล่าง

args = TrainingArguments(
output_dir="cpt-out",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
num_train_epochs=1,
learning_rate=2e-5, # ต่ำกว่า SFT 10 เท่า — ดูคำเตือนด้านล่าง
lr_scheduler_type="cosine",
warmup_steps=50,
optim="adamw_bnb_8bit", # ประหยัด 3.3 GB
gradient_checkpointing=True,
max_grad_norm=1.0, # กัน fp16 ระเบิด
fp16=True, # ไม่ใช่ bf16
logging_steps=10,
)
กับดัก fp16 ที่ทำให้ full fine-tune พังทันที

fp16=True ไม่ได้แปลว่าน้ำหนักเป็น fp16 — มันคือ mixed precision: การคูณเมทริกซ์ทำใน fp16 แต่ น้ำหนักหลัก (master weights) ต้องเป็น fp32 เพราะ optimizer ต้องบวกค่าที่เล็กมาก (lr = 2e-5) เข้าไป ซึ่ง fp16 ละเอียดไม่พอจะเก็บ

ถ้าโหลดโมเดลเป็น fp16 แล้วเทรนทั้งโมเดลด้วย fp16=True ตรง ๆ คุณจะเจอ:

ValueError: Attempting to unscale FP16 gradients.

เพราะ max_grad_norm=1.0 บังคับให้ต้อง unscale gradient ก่อน clip แต่ gradient นั้นเป็น fp16 วิธีแก้คือ model.float() ก่อนเทรน แล้ว cast กลับเป็น fp16 ตอนวัดผล (สำหรับ LoRA ในบทที่ 2 และ 4 จะ cast เฉพาะพารามิเตอร์ของ adapter เท่านั้น)

learning rate คือจุดที่พังบ่อยที่สุด

ถ้าคุณใช้ learning_rate=2e-4 (ค่าที่คนมักใช้กับ LoRA) มาทำ CPT แบบเต็มโมเดล คุณจะลบความสามารถของโมเดลทิ้งภายในไม่กี่ร้อย step CPT ต้องการ LR ต่ำกว่า SFT ประมาณ 10–50 เท่า เพราะเรากำลังขยับน้ำหนักทุกตัว

8. ผลลัพธ์ (Results)

โน้ตบุ๊กจะวัด 3 อย่างก่อนและหลังเทรน แล้วเขียนลง results.json:

  1. Domain held-out PPL — ควรลดลงชัดเจน (นี่คือสิ่งที่เราจ่ายเงินซื้อ)
  2. General held-out PPL — ควรเพิ่มขึ้นบ้าง (นี่คือราคาที่จ่าย)
  3. TH-KNOW accuracy จากชุดวัด KobEval-TH พร้อม Wilson 95% CI
ทำไมต้องมี confidence interval เสมอ

ถ้าชุดทดสอบมี 100 ข้อ ช่วงความเชื่อมั่น 95% จะกว้างประมาณ ±10 จุด แปลว่า "78% เทียบกับ 74%" มักจะแยกไม่ออกจากความบังเอิญ ตัวเลข accuracy ที่ไม่มี CI ไม่ใช่ผลการทดลอง มันคือข่าวลือ — เราจะลงลึกเรื่องนี้ในบทที่ 9

Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

9. เปรียบเทียบ (Comparison)

โน้ตบุ๊กเทรน 3 แบบบนข้อมูลชุดเดียวกัน เพื่อให้เห็นการแลกเปลี่ยนเป็นตัวเลข:

โมเดลDomain PPL ↓General PPL ↓TH-DOMAINเวลาเทรน
Base (ยังไม่เทรน)4.835.8827.3%
CPT, λ = 1.0 (โดเมนล้วน)4.07 (−0.76)6.72 (+0.85)8.0 นาที
CPT, λ = 0.5 (ผสม replay)4.29 (−0.53)4.98 (−0.90)36.4%8.0 นาที
วัดจริงบน Colab T4 (sm_75, 14.56 GB) — VRAM peak 10.50 GB, Qwen3-0.6B-Base, 100 optimizer step ต่อรอบ ตัวเลขทั้งหมดมาจาก results.json ที่โน้ตบุ๊กเขียนเอง

อ่านตารางนี้ให้ออกคือหัวใจของบทนี้:

  • λ = 1.0 ชนะ domain PPL (4.07 ต่ำสุด) แต่ general PPL แย่ลง จาก 5.88 เป็น 6.72 — นี่คือ catastrophic forgetting ที่วัดออกมาเป็นตัวเลขได้จริง ไม่ใช่คำเล่าลือ
  • λ = 0.5 ยอมเสีย domain นิดหน่อย (4.29) แต่ general กลับดีขึ้น เป็น 4.98 — replay ไม่ได้แค่กันลืม มันช่วยให้โมเดลอ่านภาษาไทยทั่วไปดีขึ้นด้วย
TH-DOMAIN ขึ้น แต่ยัง "สรุปไม่ได้"

27.3% → 36.4% ดูน่าดีใจ แต่ Wilson 95% CI คือ 13.2–48.2 กับ 19.7–57.0 ซึ่งซ้อนทับกันเกือบทั้งช่วง ที่ n=22 ข้อ ผลนี้จึงเป็นได้แค่ สัญญาณ ไม่ใช่ข้อสรุป

หลักฐานที่หนักแน่นจริงคือ PPL เพราะวัดจาก token หลายหมื่นตัว ไม่ใช่ 22 ข้อ ถ้าอยากให้ TH-DOMAIN สรุปได้ ต้องเพิ่มจำนวนข้อเป็นหลักร้อย — เราจะลงลึกเรื่องนี้ในบทที่ 9

10. สรุป (Summary)

  • CPT ใส่ความรู้เข้าไปในน้ำหนัก ด้วย objective เดียวกับ pretraining ไม่ต้องมี label
  • มันคือการแลกเปลี่ยนเสมอ ความแม่นในโดเมนแลกมาด้วยความสามารถทั่วไปที่หายไป
  • replay ratio λ\lambda คือปุ่มควบคุมอัตราแลกเปลี่ยน — กวาดหาค่า อย่าเดา
  • learning rate ต่ำ ๆ คือเส้นแบ่งระหว่าง CPT กับการทำลายโมเดล
  • ทุกตัวเลขต้องมาพร้อม confidence interval
ข้อจำกัดของการทดลองนี้

เราเทรนด้วยเอกสารราว 8,000 ชิ้น ขณะที่ CPT จริงระดับ OpenThaiGPT ใช้ข้อมูลระดับหมื่นล้าน token ต่างกันประมาณ 6 ระดับขนาด (order of magnitude)

การทดลองนี้พิสูจน์ "กลไก" และ "การแลกเปลี่ยน" ได้จริง แต่ไม่ได้ทำให้ได้โมเดลที่ดีขึ้นสำหรับใช้งานจริง อย่าเอาผลนี้ไปอ้างว่าสร้างโมเดลไทยที่ดีกว่าเดิม สิ่งที่คุณได้คือความเข้าใจว่าปุ่มแต่ละปุ่มทำอะไร ซึ่งจะโอนไปใช้กับงานสเกลจริงได้

บทต่อไป: SFT และ LoRA — เมื่อโมเดลมีความรู้แล้ว เราจะสอนให้มันตอบอย่างไร และทำไมการเทรนแค่ 1.7% ของพารามิเตอร์ถึงเกือบดีเท่าเทรนทั้งหมด

อ้างอิง (References)

  1. Gururangan et al. (2020). Don't Stop Pretraining: Adapt Language Models to Domains and Tasks — ต้นตำรับของ domain-adaptive pretraining ที่บทนี้ทำตาม
  2. Ibrahim et al. (2024). Simple and Scalable Strategies to Continually Pre-train Large Language Models — กลยุทธ์ replay และ LR ที่ทำให้ CPT ไม่ทำลายโมเดล
  3. Gupta et al. (2023). Continual Pre-Training of Large Language Models: How to (re)warm your model? — ทำไม learning rate warmup ถึงสำคัญมากตอนเทรนต่อ
  4. Luo et al. (2023). An Empirical Study of Catastrophic Forgetting in Large Language Models During Continual Fine-tuning — การวัด catastrophic forgetting อย่างเป็นระบบ
  5. Kaplan et al. (2020). Scaling Laws for Neural Language Models — scaling laws -- ที่มาของคำว่า "ข้อมูล 8,000 ชิ้นน้อยเกินไป"
  6. Hoffmann et al. (2022). Training Compute-Optimal Large Language Models — Chinchilla: สัดส่วนข้อมูลต่อพารามิเตอร์ที่เหมาะสม
  7. Yuenyong et al. (2025). OpenThaiGPT 1.6 and R1: Thai-Centric Open Source and Reasoning Large Language Models — CPT ภาษาไทยระดับจริง เทียบกับสเกลของบทนี้
  8. Lowphansirikul et al. (2021). WangchanBERTa: Pretraining transformer-based Thai Language Models — โมเดลภาษาไทยรุ่นบุกเบิกและวิธีเตรียมคอร์ปัสไทย

บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)