Seluruh bata sudah ada di tangan kita: multi-head attention dari chapter Multi-Head Attention
, positional encoding dari chapter Positional Encoding
, dan layer normalization dari section Training dan Optimasi
. Chapter ini mulai merangkai bata-bata tersebut menjadi lapisan utuh pertama Transformer: blok encoder. Satu blok hanya berisi dua sub-layer — multi-head self-attention dan feed-forward — tetapi tiap sub-layer dibungkus residual connection dan layer normalization sehingga tumpukan belasan blok tetap bisa dilatih. Kita membangun blok itu sebagai class PyTorch, mengujinya pada masukan (2, 5, 16), lalu membandingkan dua varian penyusunan layer normalization: post-LN dari makalah asli dan pre-LN yang menjadi standar modern.
Dua Sub-Layer dalam Satu Blok #
Tugas dua sub-layer tersebut terbagi tegas. Multi-head self-attention adalah satu-satunya tempat token bertukar informasi: bobot attention menghubungkan tiap token ke seluruh token lain dalam satu urutan. Feed-forward network (FFN) bekerja sebaliknya — tiap token diproses sendiri-sendiri tanpa melihat tetangganya, melalui dua layer linear dengan aktivasi di tengah; makalah asli memakai ReLU, sedangkan praktik modern umumnya GELU karena turunannya lebih halus di sekitar nol. FFN juga menjadi tempat tinggal mayoritas parameter: lebar tersembunyinya d_ff lazimnya empat kali d_model, sehingga pada blok kecil kita pun FFN menyumbang 1.072 dari 2.224 parameter.
Pembungkus residual dan layer normalization menjaga agar kedua sub-layer itu aman ditumpuk. Residual connection x + sublayer(x) menyediakan jalan pintas bagi gradien sehingga sinyal dari loss menjangkau lapisan awal tanpa melalui seluruh transformasi, sementara layer normalization menjaga skala tiap baris token tetap terkendali — mekanisme yang sudah kita bedah tuntas di chapter Layer Normalization
.
Diagram di atas memperlihatkan susunan pre-LN: normalisasi dilakukan sebelum sub-layer, dan penjumlahan residual mengalir bersih dari masukan ke keluaran.
Post-LN dan Pre-LN #
Makalah Attention Is All You Need menempatkan layer normalization setelah penjumlahan residual — varian post-LN. Varian pre-LN memindahkan normalisasi ke awal sub-layer dan meninggalkan jalur residual murni. Perbedaan urutan ini kecil di atas kertas tetapi besar saat pelatihan: pada post-LN, keluaran tiap blok dinormalisasi ulang sehingga gradien harus melintasi banyak normalisasi bertumpuk, dan pelatihan umumnya membutuhkan learning rate warmup; pada pre-LN, jalur residual tidak tersentuh sehingga tumpukan dalam terlatih lebih stabil — alasan utama arsitektur modern seperti GPT memilih pre-LN.
| Varian | Urutan operasi | Ciri pelatihan |
|---|---|---|
| Post-LN (makalah 2017) | LN(x + sublayer(x)) | keluaran tiap blok ter-normalisasi; membutuhkan warmup |
| Pre-LN (modern) | x + sublayer(LN(x)) | jalur residual mulus; stabil pada tumpukan dalam |
Implementasi EncoderBlock #
Program berikut membangun blok encoder dengan class MultiHeadAttention manual dari chapter sebelumnya, menambahkan FeedForward, lalu membungkus keduanya dalam EncoderBlock yang bisa memilih varian lewat flag prenorm. Konfigurasi uji: d_model = 16, empat head, d_ff = 32, masukan batch 2 berisi lima token:
"""Blok encoder Transformer: multi-head self-attention + feed-forward,
masing-masing dibungkus residual connection dan layer normalization.
Dua varian dipanggil dari class yang sama lewat flag prenorm: post-LN
(makalah asli 2017, LayerNorm SETELAH penjumlahan residual) dan pre-LN
(praktik modern, LayerNorm SEBELUM sub-layer). Diuji pada masukan
(2, 5, 16): batch 2, lima token, d_model 16, empat head. Verifikasi
tambahan: statistik LayerNorm per token, sifat setara-permutasi blok
tanpa positional encoding, serta susunan dua blok berurutan.
PyTorch 2.14, CPU.
"""
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
torch.set_printoptions(precision=3, sci_mode=False)
class MultiHeadAttention(nn.Module):
"""Multi-head attention manual dari chapter multi-head-attention."""
def __init__(self, d_model, num_heads):
super().__init__()
assert d_model % num_heads == 0
self.h = num_heads
self.d_k = d_model // num_heads
self.wq = nn.Linear(d_model, d_model)
self.wk = nn.Linear(d_model, d_model)
self.wv = nn.Linear(d_model, d_model)
self.wo = nn.Linear(d_model, d_model)
def forward(self, x):
n, panjang, d = x.shape
q = self.wq(x).view(n, panjang, self.h, self.d_k).transpose(1, 2)
k = self.wk(x).view(n, panjang, self.h, self.d_k).transpose(1, 2)
v = self.wv(x).view(n, panjang, self.h, self.d_k).transpose(1, 2)
skor = q @ k.transpose(-2, -1) / self.d_k ** 0.5
a = skor.softmax(dim=-1)
z = (a @ v).transpose(1, 2).contiguous().view(n, panjang, d)
return self.wo(z), a
class FeedForward(nn.Module):
"""Position-wise feed-forward: Linear -> GELU -> Linear."""
def __init__(self, d_model, d_ff):
super().__init__()
self.l1 = nn.Linear(d_model, d_ff)
self.l2 = nn.Linear(d_ff, d_model)
def forward(self, x):
return self.l2(F.gelu(self.l1(x)))
class EncoderBlock(nn.Module):
"""Satu blok encoder; prenorm memilih pre-LN (True) atau post-LN (False)."""
def __init__(self, d_model, num_heads, d_ff, prenorm=False):
super().__init__()
self.attn = MultiHeadAttention(d_model, num_heads)
self.ffn = FeedForward(d_model, d_ff)
self.ln1 = nn.LayerNorm(d_model)
self.ln2 = nn.LayerNorm(d_model)
self.prenorm = prenorm
def forward(self, x):
if self.prenorm:
h = x + self.attn(self.ln1(x))[0]
return h + self.ffn(self.ln2(h))
h = self.ln1(x + self.attn(x)[0])
return self.ln2(h + self.ffn(h))
def positional_encoding(panjang, d_model):
"""PE sinus-kosinus dari chapter positional-encoding."""
pos = np.arange(panjang)[:, None]
dua_i = np.arange(0, d_model, 2)[None, :]
sudut = pos / 10000 ** (dua_i / d_model)
pe = np.zeros((panjang, d_model))
pe[:, 0::2] = np.sin(sudut)
pe[:, 1::2] = np.cos(sudut)
return torch.tensor(pe, dtype=torch.float32)
D_MODEL, NUM_HEADS, D_FF = 16, 4, 32
blok = EncoderBlock(D_MODEL, NUM_HEADS, D_FF)
x = torch.randn(2, 5, D_MODEL)
print("== Blok encoder: MHA + FFN, tiap sub-layer ber-residual & LayerNorm ==")
print()
print(f"masukan x : bentuk {tuple(x.shape)}")
out = blok(x)
bobot = blok.attn(x)[1]
print(f"keluaran blok : bentuk {tuple(out.shape)}")
print(f"bobot self-attention per head : bentuk {tuple(bobot.shape)}")
print()
n_attn = sum(p.numel() for p in blok.attn.parameters())
n_ffn = sum(p.numel() for p in blok.ffn.parameters())
n_ln = sum(
p.numel() for p in list(blok.ln1.parameters()) + list(blok.ln2.parameters())
)
print("Jumlah parameter:")
print(f" multi-head attention : {n_attn}")
print(f" feed-forward : {n_ffn}")
print(f" 2 x layer norm : {n_ln}")
print(f" total : {n_attn + n_ffn + n_ln}")
print()
print("== Post-LN (makalah asli): LayerNorm jadi operasi terakhir ==")
print("mean per token (batch 0):",
[round(v, 3) + 0.0 for v in out[0].mean(dim=1).tolist()])
print("std per token (batch 0):",
[round(v, 3) for v in out[0].std(dim=1, correction=0).tolist()])
print("-> tiap baris token ter-normalisasi (mean ~0, std ~1)")
print()
print("== Blok tidak peduli urutan selama tanpa positional encoding ==")
idx = torch.tensor([2, 0, 3, 4, 1])
out_acak = blok(x[:, idx])
ikut = torch.allclose(out[:, idx], out_acak, atol=1e-5)
print(f"permutasi token {idx.tolist()}: keluaran ikut terpermutasi sama? {ikut}")
pe = positional_encoding(5, D_MODEL)
out_pe = blok(x + pe)
out_pe_acak = blok(x[:, idx] + pe[idx])
selisih_pe = (out_pe - out_pe_acak).abs().max().item()
print("dengan + PE sinus: keluaran berubah setelah permutasi token")
print(f"(selisih maksimum {selisih_pe:.3f}) -> urutan dibawa oleh PE")
print()
print("== Pre-LN (varian modern): LayerNorm pindah ke awal sub-layer ==")
blok_pre = EncoderBlock(D_MODEL, NUM_HEADS, D_FF, prenorm=True)
out_pre = blok_pre(x)
print(f"keluaran blok pre-LN : bentuk {tuple(out_pre.shape)}")
print("mean per token (batch 0):",
[round(v, 3) + 0.0 for v in out_pre[0].mean(dim=1).tolist()])
print("std per token (batch 0):",
[round(v, 3) for v in out_pre[0].std(dim=1, correction=0).tolist()])
print("-> jalur residual tidak dinormalisasi ulang di tiap sub-layer")
print()
tumpukan = nn.Sequential(
EncoderBlock(D_MODEL, NUM_HEADS, D_FF),
EncoderBlock(D_MODEL, NUM_HEADS, D_FF),
)
out2 = tumpukan(x)
print("== Susunan dua blok berurutan (stack N blok) ==")
print(f"keluaran tumpukan 2 blok : bentuk {tuple(out2.shape)}")Output yang Dihasilkan:
== Blok encoder: MHA + FFN, tiap sub-layer ber-residual & LayerNorm ==
masukan x : bentuk (2, 5, 16)
keluaran blok : bentuk (2, 5, 16)
bobot self-attention per head : bentuk (2, 4, 5, 5)
Jumlah parameter:
multi-head attention : 1088
feed-forward : 1072
2 x layer norm : 64
total : 2224
== Post-LN (makalah asli): LayerNorm jadi operasi terakhir ==
mean per token (batch 0): [0.0, 0.0, 0.0, 0.0, 0.0]
std per token (batch 0): [1.0, 1.0, 1.0, 1.0, 1.0]
-> tiap baris token ter-normalisasi (mean ~0, std ~1)
== Blok tidak peduli urutan selama tanpa positional encoding ==
permutasi token [2, 0, 3, 4, 1]: keluaran ikut terpermutasi sama? True
dengan + PE sinus: keluaran berubah setelah permutasi token
(selisih maksimum 3.784) -> urutan dibawa oleh PE
== Pre-LN (varian modern): LayerNorm pindah ke awal sub-layer ==
keluaran blok pre-LN : bentuk (2, 5, 16)
mean per token (batch 0): [-0.155, -0.171, -0.016, 0.146, 0.051]
std per token (batch 0): [0.898, 0.98, 0.82, 0.889, 1.13]
-> jalur residual tidak dinormalisasi ulang di tiap sub-layer
== Susunan dua blok berurutan (stack N blok) ==
keluaran tumpukan 2 blok : bentuk (2, 5, 16)Mari kita telaah kode di atas baris per baris:
- Baris 22-43: class
MultiHeadAttention— salinan class dari chapter Multi-Head Attention : empat proyeksinn.Linear, pemecahan head lewatviewdantranspose, skor terkalakansqrt(d_k), softmax per baris, lalu perangkaian kembali sebelum proyeksiwo. - Baris 46-55: class
FeedForward— dua layer lineard_model -> d_ff -> d_modeldenganF.geludi tengah; karena diterapkan pada tensor(batch, panjang, d_model), layer linear memroses tiap token secara terpisah (position-wise). - Baris 58-74: class
EncoderBlock— konstruktor merangkai satu MHA, satu FFN, dan duann.LayerNorm; flagprenormpada baris 61-67 memilih varian susunan; baris 73-74 adalah post-LN (ln1(x + attn(x))laluln2(h + ffn(h))) dan baris 70-72 adalah pre-LN. - Baris 77-85: fungsi
positional_encoding— rumus sinus-kosinus persis dari chapter Positional Encoding , dipakai hanya pada verifikasi permutasi. - Baris 88-99: uji bentuk. Masukan
(2, 5, 16)— batch 2, lima token, 16 dimensi — keluar tetap(2, 5, 16); bobot attention per head berbentuk(2, 4, 5, 5): satu matriks 5x5 untuk tiap kombinasi batch dan head. - Baris 102-111: penghitungan parameter — 1.088 pada attention (empat matriks 16x16 plus bias), 1.072 pada FFN, 64 pada dua layer normalization; total 2.224.
- Baris 114-119: statistik keluaran post-LN — mean 0 dan std 1 pada tiap baris token, bukti layer normalization menjadi operasi terakhir blok.
- Baris 122-132: uji permutasi. Token diacak dengan urutan
[2, 0, 3, 4, 1]; keluaran blok mengikuti permutasi yang sama (True). Setelah PE dijumlahkan, keluaran berbeda nyata (selisih maksimum 3,784). - Baris 135-143: blok pre-LN — mean dan std per token tidak lagi dipaksa ke 0 dan 1 karena jalur residual tidak dinormalisasi ulang.
- Baris 146-152: dua blok disusun dengan
nn.Sequential— keluaran tetap(2, 5, 16)karena tiap blok mempertahankan bentuk; inilah cara tumpukan N blok dibangun.
Urutan Hanya Tinggal Tugas Positional Encoding #
Hasil uji permutasi di atas layak digarisbawahi. Blok encoder tanpa PE bersifat setara-permutasi: acak urutan token pada masukan, keluaran teracak dengan pola yang sama — attention, FFN, residual, dan layer normalization semuanya bekerja per baris tanpa membaca posisi absolut. Begitu PE dijumlahkan, sifat tersebut hilang dan urutan mulai berperan. Temuan ini menegaskan pembahasan chapter Positional Encoding : pada Transformer, informasi urutan memang hanya masuk lewat satu pintu itu.
Encoder yang kita bangun memproses seluruh token sekaligus dan bebas menoleh ke dua arah — cocok untuk memahami urutan, tetapi belum bisa menghasilkan urutan baru token demi token. Untuk itu Transformer membutuhkan saudarannya: blok decoder dengan masked self-attention dan cross-attention, pokok bahasan chapter berikutnya.