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

attention & transformer — ให้ token คุย​กัน แล้ว​ต่าง​คน​ต่าง​คิด

บท6 เรา graduate จาก​เขียน autograd ด้วย​มือ มา​เป็น PyTorch — MLP ของ​บท4 กลาย​เป็น nn.Module สั้นๆ ที่ loss.backward() เอง​ได้ แต่ MLP นั้น​ยัง​มี​จุด​อ่อน​ใหญ่​ข้อ​หนึ่ง: มัน​มอง context เป็น “ก้อน​แบนๆ” — เอา embedding ของ 3 ตัว​อักษร​มา​ต่อ​กัน​แล้ว​ยัด​เข้า Linear ชั้น​เดียว มัน​ไม่มี​กลไก​ให้ token แต่ละ​ตัว เลือก ว่า​จะ​สนใจ​ตัว​ไหน​ใน​อดีต​เป็น​พิเศษ ทุก token ถูก​ปฏิบัติ​เท่า​กัน​หมด​ตาม​ตำแหน่ง​ตายตัว

บท​นี้​คือ​หัวใจ​ของ​ทั้ง​คอร์ส เรา​จะ​สร้าง​กลไก​ที่​แก้​จุด​อ่อน​นั้น — Self-AttentionSelf-Attentionกลไก​ให้​แต่ละ token "มอง​ย้อน" ไป token อื่น​แล้ว​ชั่ง​น้ำหนัก​ว่า​อัน​ไหน​สำคัญ (Vaswani 2017)Architecture — แล้ว​ประกอบ​มัน​ขึ้น​เป็น TransformerTransformerสถาปัตยกรรม​ที่​ซ้อน self-attention เป็น​ชั้น ๆ — โครง​ที่ LLM จริง​ใช้Architecture ตัว​จิ๋ว ซึ่ง​เป็น​โครง​เดียว​กับ​ที่ LLM จริง​หลัง API ใช้ ไอเดีย​หลัก​พูด​เป็น​ประโยค​เดียว​ได้​ว่า: ให้​แต่ละ token “มอง​ย้อน” ไป token ก่อนหน้า​ทั้งหมด แล้ว​ชั่ง​น้ำหนัก​เอง​ว่า​อัน​ไหน​สำคัญ​กับ​มัน​ตอน​นี้ — token คำ​ว่า “กิน” อาจ​อยาก​ดึง​ข้อมูล​จาก​คำ​ว่า “ฉัน” ที่​อยู่​ห่าง​ไป 5 ตำแหน่ง​มากกว่า​คำ​ที่​ติด​กัน attention คือ​กลไก​ที่​ทำให้ “การ​เลือก​ดึง​ข้อมูล​ข้าม​ระยะ” นั้น​เรียนรู้​ได้​ด้วย gradient descent

📦 kaen-nn-from-scratch

code บท​นี้​อยู่​ใน repo kaen-nn-from-scratch (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​ใช้ PyTorch (pin torch==2.13.0, ดึง​เมื่อ 2026-07-23 · ⚠️ ตรวจ pypi ก่อน rebuild ทุก​ครั้ง) ต่อ​จาก​บท6 โดย​มิ​เรอร์ gpt.py ตัว​อ่าน​ง่าย​จาก ng-video-lecture ของ Karpathy (ตรง​กับ​ต้นฉบับ ไม่ใช่ deviate แบบ​บท3–5) code ใน​บท​นี้​เป็น “ชิ้น​ส่วน” — HeadMultiHeadAttentionBlock → การ​วาง​สาย​ทั้ง model — ส่วน loop เทรน + generate() ที่​รัน​จริง​บน CPU อยู่​ใน​บท8 (capstone)

เริ่ม​จาก​หน่วย​เล็ก​ที่สุด — attention หัว​เดียว จาก input ตัว​เดียว x ที่​มี shape (B, T, C) คือ (batch, time = จำนวน token ใน​ลำดับ, channels = n_embd) แต่ละ token จะ​ยิง projection สาม​เส้น​ออก​มา​จาก x ก้อน​เดียวกัน:

  • query (q) — “ฉัน​กำลัง​มอง​หา​อะไร”
  • key (k) — “ฉัน​มี​อะไร​ให้”
  • value (v) — “ถ้า​เลือก​ฉัน จะ​ได้​ข้อมูล​นี้​ไป”

หัวใจ​คือ: เอา query ของ​ทุก token ไป “จิ้ม” (dot product) กับ key ของ​ทุก token ได้​ตาราง affinity (T, T) = คะแนน​ว่า token คู่​ไหน​เข้า​กัน​แค่​ไหน แล้ว​เอา​คะแนน​นั้น​ไป​ถ่วง​น้ำหนัก value เพื่อ​รวม​ข้อมูล​กลับ​มา

import torch
import torch.nn as nn
from torch.nn import functional as F
# hyperparameters — ค่าเต็มของ config อยู่ในบท8; ที่นี่ตั้งพอให้ code มีความหมาย
n_embd = 128 # มิติเวกเตอร์ต่อ token
block_size = 64 # context ยาวสุดที่มองย้อนได้
n_head = 4
dropout = 0.0
class Head(nn.Module):
"""self-attention หัวเดียว แบบ causal (มองย้อนได้อย่างเดียว)"""
def __init__(self, head_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
# tril เป็น BUFFER ไม่ใช่ parameter — ไม่มี gradient แต่ย้ายไป device
# และถูกบันทึกใน state_dict ไปพร้อม model
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
def forward(self, x):
B, T, C = x.shape # (batch, time, channels = n_embd)
k = self.key(x) # (B, T, head_size)
q = self.query(x) # (B, T, head_size)
# affinity: query ทุกตัวจิ้มกับ key ทุกตัว -> คะแนนความเข้ากัน (B, T, T)
# transpose(-2, -1) สลับ 'สองแกนท้ายเท่านั้น' (T กับ head_size) ไม่ยุ่งแกน batch
wei = q @ k.transpose(-2, -1) * k.shape[-1]**-0.5 # สเกลด้วย √head_size
# causal mask: กลบอนาคตเป็น -inf *ก่อน* softmax
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf')) # (B, T, T)
wei = F.softmax(wei, dim=-1) # normalize over keys -> แต่ละแถวรวมได้ 1
v = self.value(x) # (B, T, head_size)
return wei @ v # (B, T, head_size) ถัวเฉลี่ย value ตามน้ำหนัก

code 7 บรรทัด​ใน forward นี้​คือ​ทั้งหมด​ของ self-attention — แต่​มี​กับดัก​ที่ ฆ่า model เงียบๆ ซ่อน​อยู่​หลาย​จุด มา​ไล่​ที​ละ​อัน

กับดัก #1 (ตัว​ฆ่า​อันดับ​หนึ่ง): mask ต้อง​มา ก่อน softmax และ​ต้อง​เป็น tril

หัวข้อ​ที่​มีชื่อ​ว่า “กับดัก #1 (ตัว​ฆ่า​อันดับ​หนึ่ง): mask ต้อง​มา ก่อน softmax และ​ต้อง​เป็น tril”

นี่​เป็น model ภาษา​ที่​ทาย “token ถัด​ไป” ตอน​เทรน token ตำแหน่ง​ที่ 3 ต้อง​ทาย​ตำแหน่ง​ที่ 4 โดย ห้าม​เห็น​ตำแหน่ง​ที่ 4 เป็นต้น​ไป ถ้า​มัน​แอบ​เห็น​อนาคต​ได้ งาน​ทาย​ก็​กลาย​เป็น “ลอก​คำ​ตอบ” — train loss จะ​ร่วง​เข้า​ใกล้ 0 อย่าง​สวยงาม แต่​พอ​เอา​ไป generate จริง (ตอน​นั้น ไม่มี อนาคต​ให้​ลอก) มัน​คาย​ขยะ​ออก​มา นี่​คือ bug ที่​เจ็บ​ที่สุด​เพราะ loss ดู “ดี​เกิน​จริง” จน​คุณ​ไม่​เอะใจ

# ❌ version ดิบที่พังเงียบ ๆ — สองแบบที่รั่วอนาคต
# แบบ A: mask ผิดด้าน (ใช้ triu แทน tril) -> เปิดครึ่งบน = เห็นอนาคต
triu = torch.triu(torch.ones(block_size, block_size))
wei = wei.masked_fill(triu[:T, :T] == 0, float('-inf'))
# แบบ B: mask 'หลัง' softmax -> softmax เฉลี่ยรวมอนาคตไปแล้ว น้ำหนักที่เหลือไม่รวมเป็น 1 อีก
wei = F.softmax(wei, dim=-1)
wei = wei.masked_fill(self.tril[:T, :T] == 0, 0.0)

ท่า​ที่​ถูก​คือ: ใส่ -inf ที่​ตำแหน่ง​อนาคต ก่อน แล้ว​ค่อย softmax — เพราะ exp(-inf) = 0 พอดี softmax จะ​ดัน​น้ำหนัก​อนาคต​เป็น 0 เป๊ะ แล้ว renormalize เฉพาะ​อดีต​ให้​รวม​ได้ 1 ใช้ torch.tril (เก็บ​สามเหลี่ยม​ล่าง + เส้น​ทแยง = อดีต + ตัวเอง)

F.softmax(wei, dim=-1) normalize ตาม​แกน​สุดท้าย = แกน “keys” แปล​ว่า​น้ำหนัก​ของ​แต่ละ query รวม​ได้ 1 (แต่ละ​แถว​ของ​ตาราง (T, T)) ถ้า​เผลอ​ใส่ dim=-2 จะไป normalize ผิด​แกน — น้ำหนัก​ที่ query แต่ละ​ตัว​แจก​ให้ key จะ​ไม่ใช่​การกระจาย​ความ​น่า​จะ​เป็น​อีก​ต่อ​ไป model เทรน​ไม่​ลง

ทำไม​ต้อง​คูณ k.shape[-1]**-0.5 (= 1/√head_size)? ลอง​คิด​แบบ​สัญชาตญาณ: q กับ k เริ่ม​มา​แบบ variance ≈ 1 ต่อ​ช่อง เมื่อ​จิ้ม dot product กัน head_size ช่อง ผล​รวม​จะ​มี variance ≈ head_size (ผล​บวก​ของ​ตัวแปร​อิสระ variance บวก​กัน) ยิ่ง head_size ใหญ่ คะแนน​ดิบ​ยิ่ง​กระจาย​กว้าง — พอ​เข้า softmax คะแนน​ที่​โดด​ออก​มา​จะ​ถูก​ดัน​เป็น “แหลม” เกือบ one-hot ตั้งแต่​ก้าว​แรก token จะ​จ้อง​ตัว​เดียว​แข็งๆ และ gradient แทบ​หาย การ​หาร​ด้วย √head_size ดึง variance กลับ​มา ~1 ให้ softmax “นุ่ม” พอที่​จะ​เรียนรู้​ได้

เส้น D — คณิต​เป็น​พื้น​ให้​ยืน ไม่ใช่​กำแพง​กั้น​ทาง

“เรา​สอน ‘สัญชาตญาณ’ ของ gradient กับ chain rule พอ​ให้ code มี​ความหมาย ไม่ใช่​พิสูจน์​ทฤษฎีบท — อยาก​ลึก​เชิง​พิสูจน์ เปิด Goodfellow บท​ที่​อ้าง​ไว้; คณิต​ตรง​นี้​เป็น​พื้น​ให้​ยืน ไม่ใช่​กำแพง​กั้น​ทาง”

แปล​ว่า: ข้อความ “variance ≈ head_size” ข้าง​บน​ไม่ใช่​ตัวเลข​ที่​ผม​กุ — มัน​คือ​เหตุผล​ที่​เปเปอร์ Attention Is All You Need (Vaswani et al., 2017) ให้​ไว้​เป๊ะ​ใน §3.2.1 เชิงอรรถ 4: dot product ของ​เวกเตอร์ unit-variance สอง​ตัว​บน​มิติ d_k มี variance เท่ากับ d_k เขา​จึง​หาร​ด้วย √d_k อยาก​ได้​ความ​เข้มงวด​เชิง​พิสูจน์ ก็​เปิด​เปเปอร์​ตรง​นั้น — และ​ย้ำ​ว่า​สเกล​นี้​คิด​จาก​มิติ​ของ หัว​นั้น​เอง (head_size, per-head) ไม่ใช่ n_embd รวม​ทั้ง model

tril คือ mask คงที่ ไม่มี​อะไร​ให้​เรียนรู้ — จึง​ลง​ทะเบียน​ด้วย register_buffer ไม่ใช่ nn.Parameter ความ​ต่าง​สำคัญ: buffer ไม่มี gradient (backward() ไม่​แตะ) แต่​ยัง “เดินทาง” ไป​กับ model (ย้าย .to(device) และ​บันทึก​ใน state_dict) และ​เรา slice self.tril[:T, :T] เสมอ เพราะ​ตอน generate context จะ​ค่อยๆ ยาว​ขึ้น​จาก 1 จน block_size — เรา​ต้อง​ตัด mask ให้​พอดี​กับ T ปัจจุบัน

หัว​เดียว​เรียน​ความ​สัมพันธ์​ได้ แบบ​เดียว ใน​ทาง​ปฏิบัติ​เรา​อยาก​ให้ model มอง​หลาย​มุม​พร้อม​กัน — หัว​หนึ่ง​อาจ​ตาม​เรื่อง​ไวยากรณ์ อีก​หัว​ตาม​หัวข้อ​ระยะ​ไกล (นี่​เป็น​สัญชาตญาณ ไม่ใช่​กฎ​ตายตัว) วิธี​คือ​รัน​หลาย Head ขนาน​กัน แล้ว​เอา​ผล​มา concat ตาม​แกน channel จาก​นั้น project กลับ​เป็น n_embd

class MultiHeadAttention(nn.Module):
"""หลาย Head ทำงานขนานกัน แล้ว concat + project กลับเป็น n_embd"""
def __init__(self, num_heads, head_size):
super().__init__()
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
self.proj = nn.Linear(num_heads * head_size, n_embd)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
out = torch.cat([h(x) for h in self.heads], dim=-1) # concat ตามแกน channel
return self.dropout(self.proj(out)) # (B, T, n_embd)

เคล็ด​ของ​ขนาด: เรา​ตั้ง head_size = n_embd // n_head เสมอ ดังนั้น num_heads * head_size = n_embd พอดี — concat หลาย​หัว​กลับ​มา​ได้​มิติ​เท่า​เดิม ไม่​บวม​ขึ้น

หนึ่ง​ชั้น​ของ transformer (Block) ประกอบ​จาก​สอง​งาน​ที่​สลับ​กัน:

  1. สื่อสาร (communication) = self-attention — token คุย​กัน แลก​ข้อมูล​ข้าม​ตำแหน่ง
  2. คำนวณ (computation) = feed-forward MLP — หลัง​คุย​เสร็จ แต่ละ token คิด​เอง แยก​กัน​ที​ละ​ตำแหน่ง

feed-forward เป็น MLP ที่ “ขยาย 4 เท่า​แล้ว​หด​กลับ” (n_embd → 4·n_embd → n_embd) — ตัวเลข 4× มา​จาก​เปเปอร์ Vaswani โดยตรง (d_ff = 4·d_model) มัน​คือ​พื้นที่​ให้​แต่ละ token ประมวล​ผล​สิ่ง​ที่​เพิ่ง​รวบรวม​มา

class FeedForward(nn.Module):
"""MLP ต่อ token: ขยาย 4× แล้วหดกลับ — ส่วน 'คิดเอง' ของแต่ละ token"""
def __init__(self, n_embd):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_embd, 4 * n_embd), # กว้าง 4× (ตามเปเปอร์ Vaswani)
nn.ReLU(),
nn.Linear(4 * n_embd, n_embd),
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
class Block(nn.Module):
"""หนึ่งชั้น transformer: attention (คุยกัน) + feed-forward (คิดเอง)"""
def __init__(self, n_embd, n_head):
super().__init__()
head_size = n_embd // n_head
self.sa = MultiHeadAttention(n_head, head_size)
self.ffwd = FeedForward(n_embd)
self.ln1 = nn.LayerNorm(n_embd)
self.ln2 = nn.LayerNorm(n_embd)
def forward(self, x):
x = x + self.sa(self.ln1(x)) # pre-norm + residual: normalize ก่อน แล้วบวกทางลัดกลับ
x = x + self.ffwd(self.ln2(x))
return x

สังเกต​สอง​อย่าง​ในforward:

  • residual (x = x + ...) — บวก​ทาง​ลัด​ของ input กลับ​เข้าไป ทำให้ gradient ไหล​ย้อนได้ตรงๆ ผ่าน​หลาย​ชั้น​โดย​ไม่​จาง​หาย (เป็น​เหตุผล​หลัก​ที่ transformer เทรนลึกๆ ได้)
  • pre-norm (sa(self.ln1(x))) — normalize ก่อน เข้า sublayer แล้ว​บวก residual รอบ​นอก จำ​ท่า​นี้​ไว้ เดี๋ยว​เรา​จะ​เทียบ​กับ​เปเปอร์​ปี 2017 ที่​ทำ ตรง​ข้าม
flowchart TB
  x["x เข้า Block<br/>(B, T, n_embd)"]
  subgraph COMM["สื่อสาร — token คุยกัน (self-attention)"]
    ln1["LayerNorm (ln1)"]
    sa["Multi-Head Attention<br/>causal: มองย้อนได้อย่างเดียว"]
  end
  add1(("+"))
  subgraph COMP["คำนวณ — แต่ละ token คิดเอง (MLP 4×)"]
    ln2["LayerNorm (ln2)"]
    ff["FeedForward<br/>Linear 4× → ReLU → Linear"]
  end
  add2(("+"))
  out["x ออก Block<br/>(มิติเท่าเดิม)"]

  x --> ln1 --> sa --> add1
  x -->|"residual (ทางลัด)"| add1
  add1 --> ln2 --> ff --> add2
  add1 -->|"residual (ทางลัด)"| add2
  add2 --> out

  classDef base fill:#fde68a,stroke:#92400e,color:#451a03;
  classDef accent fill:#7c2d12,stroke:#431407,color:#fed7aa;
  class x,out,ln1,ln2,sa,ff base;
  class add1,add2 accent;

คำ​บรรยาย​ภาพ: 1 Block = 2 sublayer ที่​ต่อ​กัน​แบบ pre-norm + residual ครึ่ง​แรก​คือ “การ​สื่อสาร” (attention: token มอง​ย้อน​แล้ว​ชั่ง​น้ำหนัก​กันเอง) ครึ่ง​หลัง​คือ “การ​คำนวณ” (แต่ละ token คิด​เอง​ผ่าน MLP ที่​กว้าง 4 เท่า) LayerNorm วาง​อยู่ ก่อน แต่ละ sublayer (pre-norm) และ​เส้น ”+” (residual) พา​ค่า input อ้อม​ไป​บวก​ปลายทาง​เสมอ เพื่อ​ให้ gradient ไหล​ย้อน​ได้​ไม่​จาง — นี่​คือ​รูปทรง​สมัย​ใหม่ ซึ่ง​ต่าง​จาก​ไดอะแกรม​ปี 2017 ที่​วาง norm ไว้​หลัง sublayer

ประกอบ​ทุก​ชิ้น​เป็น model เต็ม ลำดับ​คือ token embedding + position embedding → หลาย Block → LayerNorm ปิดท้าย → lm_head Positional EncodingPositional Encodingตัว​บอก​ลำดับ​ของ token ให้ transformer รู้​ว่า​อะไร​มา​ก่อน​หลัง (ไม่มี​มัน = ถุง​คำ​ไร้​ลำดับ)Architecture เข้า​มา​ตรง​นี้ — เรา​ต้อง​บอก model ว่า token ไหน​มา​ก่อน​หลัง

vocab_size = 65 # ตัวอย่าง tinyshakespeare (char-level); ตัวจริงคำนวณจากข้อมูลในบท8
n_layer = 4
class GPTLanguageModel(nn.Module):
def __init__(self):
super().__init__()
self.token_embedding_table = nn.Embedding(vocab_size, n_embd) # wte
self.position_embedding_table = nn.Embedding(block_size, n_embd) # wpe (learned!)
self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)])
self.ln_f = nn.LayerNorm(n_embd) # LayerNorm ปิดท้ายก่อน head
self.lm_head = nn.Linear(n_embd, vocab_size) # แปลงกลับเป็น logits ต่อ vocab
def forward(self, idx, targets=None):
B, T = idx.shape
tok_emb = self.token_embedding_table(idx) # (B, T, n_embd)
pos_emb = self.position_embedding_table(torch.arange(T, device=idx.device)) # (T, n_embd)
x = tok_emb + pos_emb # *บวก* ตำแหน่งเข้าไป ไม่ใช่ concat (broadcast (T,C) ทุก batch)
x = self.blocks(x) # (B, T, n_embd)
x = self.ln_f(x)
logits = self.lm_head(x) # (B, T, vocab_size) — RAW logits
if targets is None:
return logits
B, T, C = logits.shape
loss = F.cross_entropy(logits.view(B * T, C), targets.view(B * T))
return logits, loss

จุด​นี้​เป็น invariant สำคัญ: x = tok_emb + pos_emb attention เพียวๆ นั้น permutation-invariant — สลับ​ลำดับ token แล้ว​ผล​ไม่​เปลี่ยน เพราะ​มัน​มอง​ทุก​ตำแหน่ง​เป็น “ถุง​คำ” ไร้​ลำดับ ถ้า​ไม่​ฉีด​ข้อมูล​ตำแหน่ง​เข้าไป model จะ​แยก “หมา​กัด​คน” กับ “คน​กัด​หมา” ไม่​ออก เรา​จึง​ใส่ nn.Embedding(block_size, n_embd) = ตาราง​เวกเตอร์​ประจำ “slot ที่ 0, 1, 2, …” แล้ว บวกเข้า​กับ token embedding

ทำไม​บวก​ไม่​ต่อ​ท้าย (concat)? บวก​คง​มิติ​ไว้​ที่ n_embd เท่า​เดิม — ทุก matmul ข้าง​ใน​ไม่​ต้อง​โต​ตาม model เรียน​เอง​ว่า​จะ “แบ่ง​พื้นที่” ใน​เวกเตอร์​ให้​ข้อมูล​ตำแหน่ง​อย่างไร ถ้า concat มิติ​จะ​เบิ้ล​โดย​ไม่​จำเป็น เปลือง​ทั้ง parameter และ​การ​คำนวณ (และ​เพราะ position_embedding_table นิยาม​ไว้​แค่ 0..block_size-1 ตอน generate จึง​ต้อง crop context ให้​ไม่​เกิน block_size — รายละเอียด​อยู่​บท8)

code ข้าง​บน​ไม่​ได้​ลอก​เปเปอร์ Attention Is All You Need (2017) มา​เป๊ะ — มัน​คือ version “สมัย​ใหม่” ที่​ต่าง​ไป​สอง​จุด ถ้า​คุณ​เอา forward() ของ​เรา​ไป​เทียบ​ลูกศร​ใน​รูป​ที่ 1 ของ​เปเปอร์ มัน​จะ​ไม่​ตรง และ​นั่น ตั้งใจ:

รอย​ต่อเปเปอร์ 2017 (S14)code สมัย​ใหม่ (บท​นี้)
ข้อมูล​ตำแหน่งsinusoidal — สูตร sine/cosine คงที่ ไม่มี parameter เรียนรู้learned nn.Embedding(block_size, n_embd) — ตาราง​ที่ gradient descent ขยับ (เหมือน GPT-2) เขียน​ง่าย​กว่า​และ​ใช้​กัน​ทั่วไป
ตำแหน่ง LayerNormpost-normx = LayerNorm(x + sublayer(x)) (norm หลัง residual)pre-normx = x + sublayer(LayerNorm(x)) (norm ก่อน sublayer) เทรน​นิ่ง​กว่า gradient ไหล​ผ่าน residual ได้​สะอาด​กว่า จึง​เป็น default ของ GPT สมัย​ใหม่

ทั้ง​สอง​แบบ “ถูก” ทั้ง​คู่​ใน​ความหมาย​ที่​มัน​ทำงาน​ได้ — แต่​ของ​สมัย​ใหม่​เทรน​ง่าย​กว่า​ใน​ทาง​ปฏิบัติ นี่​คือ​เหตุผล​ที่​เรา​อ้าง Vaswani สำหรับ ที่มา​ของ​ไอเดีย แต่​ไม่​ลอก​ทุก​ดีเทล​ของ​ปี 2017 มา

ในบท5 เรา​เจอ Batch NormalizationBatch Normalizationปรับ​สเกล​ค่า​กลาง​ชั้น​ให้​นิ่ง เทรน​ง่าย​ขึ้น (ป้าย: practice สมัย​ใหม่​มี​ทาง​เลือก​อื่น เช่น LayerNorm)Process และ​กับดัก​คลาสสิก​ของ​มัน: BatchNorm normalize แต่ละ feature ตาม แกน batch — ค่า​ของ​ตัวอย่าง​หนึ่ง​จึง​ขึ้น​กับ “เพื่อน​ร่วม batch” ของ​มัน พอ eval ต้อง​สลับ​ไป​ใช้ running stats และ​ถ้า batch มี​ตัวอย่าง​เดียว variance = 0 → หาร​ด้วย​เกือบ​ศูนย์ → ระเบิด

nn.LayerNorm แก้​ปัญหา​นี้ตรงๆ: มัน normalize ตาม แกน feature ของ แต่ละ token แยก​กัน — ไม่​พึ่งพา token อื่น​ใน​ลำดับ ไม่​พึ่ง batch เลย จึงไม่มี​ความ​ต่าง​ระหว่าง train กับ eval (ไม่​ต้อง​เก็บ running stats) สำหรับ transformer ที่​ประมวล​ผล​ลำดับ​ความ​ยาว​ไม่​เท่า​กัน​และ​อยาก​ให้ token แต่ละ​ตัว​เป็น​อิสระ​ต่อ​กัน นี่​คือ​คุณสมบัติ​ที่​ต้องการ​พอดี — โครงสร้าง γ/β (scale/shift ที่​เรียนรู้​ได้) ยัง​เหมือน BatchNorm เป๊ะ ต่าง​แค่ normalize คนละ​แกน นี่​คือ “ผล​ตอบแทน” ของ​หมายเหตุ​ที่​เรา​ติด​ค้าง​ไว้​ตั้งแต่​บท5

เส้น C — นี่​คือ​เครื่อง​ที่ agent ของ​คุณ​เรียก​ใช้

“backprop ตัว​เดียวกัน​นี้​แหละ​ที่​หมุน​อยู่​ข้าง​ใน model ที่ agent ของ​คุณ​ใน #15-#18 เรียก​ผ่าน API — เข้าใจ​มัน​แล้ว​จะ​เห็น​ทั้ง​กอง​ตั้งแต่ neuron ยัน tool-call; แต่​พูดตรงๆ: ไม่รู้ backprop ก็​สร้าง agent ได้”

แปล​ว่า: GPTLanguageModel.forward ที่​คุณ​เพิ่ง​ประกอบ​ด้วย​มือ — token+position embedding → หลาย Block ของ attention+MLP → LayerNorm → lm_head → logits — คือ เส้นทาง​เดียวกัน (แค่​สเกล​ใหญ่​กว่า​มาก) ที่​หมุน​อยู่​หลัง client.messages.create(...) ทุก​ครั้ง​ที่ agent ของ​คุณ​ใน #15-#18 เรียก model จริง​แค่​มี n_layer/n_embd/vocab_size โต​ขึ้น​เป็น​หลัก​หมื่น​เท่า แล้ว​วน forward นี้​แบบ autoregressive (loop generate อยู่​บท8) — แต่​ย้ำ: นี่​คือ สะพาน​ความ​เข้าใจ ไม่ใช่ prerequisite ของ​การ​สร้าง agent

self-attention คือ​กลไก​ให้ token “มอง​ย้อน” แล้ว​ชั่ง​น้ำหนัก​กันเอง — สร้าง​จาก Q/K/V ของ x ก้อน​เดียว, affinity q @ kᵀ, สเกล √head_size (per-head, เหตุผล​จาก Vaswani §3.2.1), mask ก่อน softmax ด้วย tril (ตัว​ฆ่า​อันดับ​หนึ่ง​ถ้า​ทำ​ผิด), softmax(dim=-1), แล้ว​ถ่วง value; tril เป็น buffer slice [:T,:T] ต่อ​หัว​เดียว​เป็น multi-head, ประกอบ​เป็น Block (attention “คุย​กัน” + MLP 4× “คิด​เอง”, pre-norm + residual) แล้ว​วาง​สาย​ทั้ง model โดย บวก position embedding (ไม่ใช่ concat) เพื่อ​กัน permutation-invariance; code เรา​เป็น version สมัย​ใหม่ (learned positions + pre-norm) ที่​ต่าง​จาก​ไดอะแกรม​ปี 2017 อย่าง​จงใจ และ​ใช้ LayerNorm แทน BatchNorm เพราะ normalize per-token ไม่​ผูก​กับ batch

บท8 (capstone) เรา​จะ​เอา model นี้​ไป เทรน​จริง​บน CPU ด้วย config จิ๋ว เขียน loop generate() (crop context, sample ไม่ argmax, temperature), เพิ่ม BPE tokenizer, แล้ว​ลอง​ป้อน​ภาษา​ไทย​ดู — พร้อม​ปิด​คอร์ส​ด้วย​เส้น​ความ​ซื่อสัตย์​ครบ​ทั้ง​สี่​เส้น


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • Vaswani et al. — Attention Is All You Need (2017) (S14) — ต้นฉบับ​ของ self-attention/transformer; §3.2.1 เชิงอรรถ 4 คือ​ที่มา​ของ​การ​หาร​ด้วย √d_k (dot product ของ​เวกเตอร์ unit-variance บน​มิติ d_k มี variance = d_k); รูป​ที่ 1 คือ​ไดอะแกรม post-norm + sinusoidal ดั้งเดิม​ที่ code สมัย​ใหม่​ของ​เรา​ต่าง​ออก​ไป​อย่าง​จงใจ — อ้าง​เปเปอร์​นี้​สำหรับ ที่มา​ของ​ไอเดีย ไม่ใช่​ทุก​ดีเทล​ของ​ปี 2017
  • Karpathy — ng-video-lecture (gpt.py, from-scratch GPT ตัว​อ่าน​ง่าย) (S7, commit 52201428ed7b46804849dea0b3ccf0de9df1a5c3, 2023-02-07; เข้าถึง 2026-07-23) — Head/Block/การ​วาง​สาย​ใน​บท​นี้​มิ​เรอร์ file นี้ (repo ที่​ยัง​มี​ชีวิต จึง pin ด้วย SHA + วัน​ที่​ดึง — เส้น A; หมายเหตุ: HEAD ปัจจุบัน​ขยับ​ไป 4c8e902 ซึ่ง​แก้​แค่ comment อธิบาย shape code จริง​เท่า​เดิม)
  • Karpathy — nanoGPT (GPT สเกล​ใหญ่​ขึ้น) (S4) — version สเกล​ที่​บท8 ยืม CPU recipe มา​ใช้ ถูก deprecate พ.ย. 2025 → ผู้​สืบทอด​คือ nanochat (S18); ตัว​อย่างสดๆ ของ​เส้น A: repo ขยับ​ใต้เท้า​เรา จึง​ต้อง pin + ระบุ​วัน​ที่​เสมอ

เช็กความเข้าใจ — บทที่ 7

ข้อ 1 / 3

ใน code causal Head ทำไม 'ต้อง' mask ด้วย tril *ก่อน* softmax และจะเกิดอะไรถ้าสลับด้าน (เช่นใช้ triu)?