Dua chapter sebelumnya memakai mask sebagai kotak hitam: DecoderBlock di chapter Transformer Decoder memanggil masked_fill tanpa menunjukkan isinya, dan model lengkap di chapter Encoder-Decoder Transformer mengandalkan mask agar tugas salin-gesernya tidak menjadi curang. Chapter ini membuka kotak hitam itu dan menghitung seluruh prosesnya dengan angka nyata: skor mentah 4x4, segitiga yang diisi -inf, softmax yang menghasilkan bobot nol persis di masa depan, hingga mask kedua yang sering bekerja berdampingan dengannya — mask padding. Kedua mask tersebut menyelesaikan dua masalah berbeda: kebocoran masa depan pada model autoregressive dan token pengisi <pad> pada batch kalimat berbeda panjang.

Mengapa Attention Harus Dibatasi #

Alasan pertama bersifat arsitektural. Model autoregressive seperti decoder Transformer memprediksi token ke-t hanya dari token 0..t-1. Selama pelatihan dengan teacher forcing, seluruh urutan target diproses sekaligus secara paralel — padahal softmax tanpa batas membiarkan token posisi 0 membaca token posisi 3. Tanpa mask, prediksi menjadi sepele: model tinggal menyalin token masa depan yang sudah terlihat, loss menuju nol tanpa satu pun aturan terpelajari. Mask kausal menutup pintu itu: token hanya boleh menoleh ke dirinya sendiri dan token-token sebelumnya, sehingga paralelisasi pelatihan tetap tercapai tanpa kebocoran.

Alasan kedua bersifat operasional. Kalimat dalam satu batch memiliki panjang berbeda, jadi urutan pendek ditambal token <pad> sampai sejajar — mekanisme yang kita bangun di chapter Vocabulary dan Token ID . Token pengisi tersebut bukan kata: isinya hanyalah angka 0 yang tidak bermakna. Jika dibiarkan, softmax menyia-nyiakan sebagian bobot pada posisi sampah itu dan representasi token nyata ikut terdilusi. Mask padding memblokir seluruh kolom <pad> sehingga bobot hanya tersebar pada token yang benar-benar ada.

Mekanisme: Isi -inf Sebelum Softmax #

Kedua mask bekerja pada titik yang sama: matriks skor Q @ K^T / sqrt(d_k) sebelum softmax. Softmax menghitung exp(skor) / jumlah exp; untuk skor -inf, hasil exp(-inf) = 0, sehingga bobot posisi terlarang menjadi nol persis — bukan sekadar kecil. Bobot sisanya tetap berjumlah 1 karena softmax menormalisasi ulang seluruh baris.

Diagram di atas menempatkan mask tepat sebelum softmax — satu-satunya titik di seluruh pipeline attention yang perlu disentuh. Banyak implementasi memakai konstanta negatif besar seperti -1e9 alih-alih -inf; dengan softmax terstabilkan (mengurangi nilai maksimum baris) keduanya memberi hasil nyaris sama, tetapi -inf lebih tepat karena menghasilkan nol eksak dan tidak bergantung pada skala skor.

Program: Empat Token, Tiga Mask #

Program berikut memakai empat token mainan berdimensi delapan. Nilai-nilainya berasal dari generator berseed sehingga seluruh angka dapat direproduksi. Empat tahap dicetak berurutan: softmax tanpa mask, mask kausal, mask padding, lalu gabungan keduanya:

python
"""Dua jenis mask pada attention: mask kausal dan mask padding.

Skor 4x4 dari token mainan dihitung tanpa mask, dengan mask kausal
(token hanya boleh menoleh ke masa lalu dan dirinya sendiri), dengan
mask padding (kolom <pad> diabaikan), lalu gabungan keduanya. Softmax
baris setelah mask memperlihatkan bobot persis nol pada posisi
terlarang, dan keluaran masked vs unmasked dibandingkan sebagai bukti
efeknya pada hasil akhir. Bagian akhir memverifikasi bahwa pola
torch.triu + masked_fill memberi bobot yang identik dengan NumPy.
"""

import numpy as np
import torch

np.set_printoptions(precision=3, suppress=True)


def softmax_baris(M):
    """Softmax per baris, stabil terhadap luapan exp."""
    e = np.exp(M - M.max(axis=1, keepdims=True))
    return e / e.sum(axis=1, keepdims=True)


rng = np.random.default_rng(7)
T, D = 4, 8

X = rng.standard_normal((T, D))             # 4 token, 8 dimensi
W = rng.standard_normal((D, D)) * 0.5
Q, K = X @ W, X @ W                          # self-attention: Q dan K dari X
V = rng.standard_normal((T, 3))

skor = Q @ K.T / np.sqrt(D)

print("== Skor mentah (4x4, sebelum mask) ==")
print("baris = token pemohon (query), kolom = token yang ditoleh (key)")
print(np.round(skor, 2))
print()

A = softmax_baris(skor)
print("== Softmax baris TANPA mask ==")
print(np.round(A, 3))
print("jumlah tiap baris :", np.round(A.sum(axis=1), 3))
print(f"contoh: baris 0 memberi bobot {A[0, 2]:.3f} pada kolom 2 (masa depan)")
print()

print("== Mask kausal: segitiga atas diisi -inf sebelum softmax ==")
kausal = np.triu(np.ones((T, T), dtype=bool), k=1)   # True = masa depan
print("pola mask (1 = masa depan, diblokir):")
print(kausal.astype(int))
skor_k = skor.copy()
skor_k[kausal] = -np.inf
print("skor dua baris pertama setelah diisi -inf:")
print(skor_k[:2])
A_k = softmax_baris(skor_k)
print("posisi masa depan (kolom > baris) diblokir:")
print(np.round(A_k, 3))
print("jumlah tiap baris :", np.round(A_k.sum(axis=1), 3))
print("-> segitiga atas persis 0.0; baris 0 hanya menoleh ke dirinya")
print()

print("== Efek pada keluaran: A @ V ==")
Z = A @ V
Z_k = A_k @ V
print("keluaran token 0 tanpa mask  :", np.round(Z[0], 3))
print("keluaran token 0 dengan mask :", np.round(Z_k[0], 3))
print(f"selisih maksimum token 0     : {np.abs(Z[0] - Z_k[0]).max():.3f}")
print("-> masked vs unmasked menghasilkan representasi berbeda")
print()

print("== Mask padding: kolom token <pad> diabaikan ==")
ada_pad = np.array([False, False, True, True])       # token 2 dan 3 = <pad>
kolom_pad = np.tile(ada_pad[None, :], (T, 1))        # blokir kolom pad
skor_p = skor.copy()
skor_p[kolom_pad] = -np.inf
A_p = softmax_baris(skor_p)
print("token 2 dan 3 adalah <pad>; kolomnya diblokir untuk semua query:")
print(np.round(A_p, 3))
print("-> kolom 2 dan 3 persis 0.0; bobot terkonsentrasi ke token nyata")
print()

print("== Gabungan: kausal + padding ==")
gabung = kausal | kolom_pad
skor_g = skor.copy()
skor_g[gabung] = -np.inf
A_g = softmax_baris(skor_g)
print("query nyata hanya melihat masa lalu yang nyata:")
print(np.round(A_g, 3))
print("-> baris 0: hanya kolom 0; baris 1: kolom 0-1; kolom pad tetap nol")
print()

print("== Verifikasi: torch.triu + masked_fill memberi hasil sama ==")
skor_t = torch.tensor(skor, dtype=torch.float64)
kausal_t = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
bobot_t = skor_t.masked_fill(kausal_t, float("-inf")).softmax(dim=-1)
selisih = np.abs(bobot_t.numpy() - A_k).max()
print(f"selisih maksimum NumPy vs torch : {selisih:.3e}")
print("-> pola inilah yang dipakai DecoderBlock di chapter transformer-decoder")

Output yang Dihasilkan:

text
== Skor mentah (4x4, sebelum mask) ==
baris = token pemohon (query), kolom = token yang ditoleh (key)
[[ 2.14  0.34  4.09  1.44]
 [ 0.34  1.01 -0.39 -0.86]
 [ 4.09 -0.39  9.19  4.  ]
 [ 1.44 -0.86  4.    4.33]]

== Softmax baris TANPA mask ==
[[0.115 0.019 0.809 0.057]
 [0.266 0.524 0.129 0.08 ]
 [0.006 0.    0.988 0.006]
 [0.031 0.003 0.405 0.561]]
jumlah tiap baris : [1. 1. 1. 1.]
contoh: baris 0 memberi bobot 0.809 pada kolom 2 (masa depan)

== Mask kausal: segitiga atas diisi -inf sebelum softmax ==
pola mask (1 = masa depan, diblokir):
[[0 1 1 1]
 [0 0 1 1]
 [0 0 0 1]
 [0 0 0 0]]
skor dua baris pertama setelah diisi -inf:
[[2.143  -inf  -inf  -inf]
 [0.336 1.013  -inf  -inf]]
posisi masa depan (kolom > baris) diblokir:
[[1.    0.    0.    0.   ]
 [0.337 0.663 0.    0.   ]
 [0.006 0.    0.994 0.   ]
 [0.031 0.003 0.405 0.561]]
jumlah tiap baris : [1. 1. 1. 1.]
-> segitiga atas persis 0.0; baris 0 hanya menoleh ke dirinya

== Efek pada keluaran: A @ V ==
keluaran token 0 tanpa mask  : [-0.09   1.863 -0.856]
keluaran token 0 dengan mask : [-1.291  0.347 -1.688]
selisih maksimum token 0     : 1.516
-> masked vs unmasked menghasilkan representasi berbeda

== Mask padding: kolom token <pad> diabaikan ==
token 2 dan 3 adalah <pad>; kolomnya diblokir untuk semua query:
[[0.859 0.141 0.    0.   ]
 [0.337 0.663 0.    0.   ]
 [0.989 0.011 0.    0.   ]
 [0.909 0.091 0.    0.   ]]
-> kolom 2 dan 3 persis 0.0; bobot terkonsentrasi ke token nyata

== Gabungan: kausal + padding ==
query nyata hanya melihat masa lalu yang nyata:
[[1.    0.    0.    0.   ]
 [0.337 0.663 0.    0.   ]
 [0.989 0.011 0.    0.   ]
 [0.909 0.091 0.    0.   ]]
-> baris 0: hanya kolom 0; baris 1: kolom 0-1; kolom pad tetap nol

== Verifikasi: torch.triu + masked_fill memberi hasil sama ==
selisih maksimum NumPy vs torch : 1.110e-16
-> pola inilah yang dipakai DecoderBlock di chapter transformer-decoder

Mari kita telaah kode di atas baris per baris:

  1. Baris 18-21: fungsi softmax_baris — softmax per baris terstabilkan dengan pengurangan nilai maksium baris sebelum np.exp, pola yang sama dengan chapter Scaled Dot-Product Attention .
  2. Baris 24-30: data mainan. Generator default_rng(7) menghasilkan empat token X berdimensi delapan; proyeksi W menghasilkan Q dan K dari sumber yang sama (self-attention), sedangkan V berdimensi tiga agar keluaran mudah dibaca.
  3. Baris 32: skor terkalakan sqrt(D) — rumus scaled dot-product persis dari chapter sebelumnya, sebelum disentuh mask apa pun.
  4. Baris 34-43: softmax tanpa mask. Baris 0 memberi bobot 0,809 pada kolom 2 — token pertama membaca token ketiga, kebocoran masa depan yang menjadi alasan utama mask ini ada.
  5. Baris 46-58: mask kausal. np.triu(..., k=1) membentuk segitiga atas bernilai True; penugasan skor_k[kausal] = -np.inf mengisi posisi tersebut (dua baris pertama dicetak agar -inf-nya terlihat langsung); hasil softmax menunjukkan segitiga atas bernilai 0,0 persis dan setiap baris tetap berjumlah 1.
  6. Baris 61-67: efek pada keluaran. Keluaran token 0 berubah dari [-0,09; 1,863; -0,856] menjadi [-1,291; 0,347; -1,688] — selisih maksimum 1,516 membuktikan mask bukan kosmetik melainkan mengubah representasi yang dipelajari model.
  7. Baris 70-78: mask padding. ada_pad menandai token 2 dan 3 sebagai <pad>; np.tile mereplikasi tanda itu ke seluruh baris sehingga kolom pad terblokir untuk semua query; hasilnya kolom 2 dan 3 bernilai 0,0 dan bobot terkonsentrasi ke token nyata.
  8. Baris 81-88: gabungan kedua mask lewat operator OR — True dari salah satu mask cukup untuk memblokir. Query nyata kini hanya melihat masa lalu yang nyata; baris 0 hanya berisi kolom 0.
  9. Baris 91-97: verifikasi PyTorch. torch.triu(..., diagonal=1) dan masked_fill(..., float("-inf")) membentuk jalur yang sama dengan DecoderBlock di chapter Transformer Decoder ; selisih maksimum terhadap NumPy hanya 1,11e-16 — setara nol pada tingkat presisi mesin.

Membaca Angka: Nol Persis, Bukan Sekadar Kecil #

Tiga pengamatan pada output di atas layak digarisbawahi. Pertama, bobot terlarang bernilai 0.0 persis — properti matematis dari exp(-inf) = 0, bukan efek pembulatan cetak. Kedua, token 0 tanpa mask menaruh 0,809 dari total bobotnya pada token 2; angka tunggal ini memperlihatkan betapa dominannya kebocoran masa depan bila mask dilupakan. Ketiga, pada blok gabungan, baris 2 dan 3 (query <pad>) tetap memperhatikan token nyata — mask padding memblokir kolom, bukan baris, dan itu memang cukup: keluaran posisi <pad> dibuang saat loss dihitung, jadi isinya tidak pernah dipakai.

Jenis maskBentuk matriks skorKapan dipakaiContoh pemakai
Kausal (look-ahead)segitiga atas -infself-attention decoder, model autoregressiveGPT, decoder Transformer
Paddingkolom <pad> -infbatch berisi urutan berbeda panjangBERT, hampir semua model praktis
Gabungan (OR)gabungan keduanyadecoder yang memproses batch ber-padterjemahan mesin, model sekuens-ke-sekuens

Dokumentasi torch.Tensor.masked_fill merinci perilaku pengisian bersyarat yang kita tiru secara manual di atas; contoh chapter ini diuji pada CPU dengan PyTorch 2.14 dan selesai dalam hitungan detik.

Mask kausal dan mask padding melengkapi perlengkapan attention kita: kini mekanisme ini tidak hanya menghubungkan token, tetapi juga tahu token mana yang boleh dihubungkan. Sisa satu pertanyaan menanti: apakah attention — yang selama ini memroses urutan kata — juga bekerja pada data yang sama sekali bukan teks? Chapter berikutnya menjawabnya dengan memotong gambar menjadi token dan menjalankan Transformer pada piksel.