GPT'yi Sıfırdan Yazalım: nano-gpt
27 Haz 2026 · 13 dk okuma
Her gün en çok sohbet ettiğimiz dil modellerinden biri olan o GPT'ye bakıp hiç "bunun içinde ne dönüyor ki?" dediniz mi? Eğer dediyseniz doğru yerdesiniz çünkü GPT'nin bazen şakacı bazense hayat kurtaran o dünyasının derinlerine ineceğiz bu yazıda. Andrej Karpathy'nin nanoGPT dersinden esinlenerek hazırladığım nano-gpt reposu da bu yazıya kod kaynaklığı edecek: PyTorch'la yazılmış, karakter-seviyesinde, decoder-only minik bir Transformer (encoder, decoder gibi kavramlara küçük bir göz atmanızı da öneririm). Dediğim gibi bu yazıya ana kaynaklık eden, Andrej Karpathy'nin "Let's build GPT" dersiydi ve o dersi de ilgileniyorsanız dinlemenizi şiddetle öneririm. Gelelim şimdi bizim mini-GPT'mize. Ölçeği bir kenara bırakırsanız, bu küçük modelin mekanizması bugünün dev modellerini çalıştıran yapıyla bire bir aynı:
token + position embedding → causal self-attention → multi-head → residual bloklar → autoregressive üretim
Aşağıda bu yapının her parçasını, notebook'taki gerçek kodla birlikte tek tek kuracağız. Dersin temeline birkaç şey de ekledim: learning rate'i daha yumuşak düşüren bir cosine-annealing scheduler, işi gereksiz yere uzatmayan ve öğrenme devam etmiyorsa süreci durduran early stopping ve eğitimi canlı izlemek için Weights & Biases — birazdan göreceğiniz "HERO RUN v4" işte bu.
Önce kuşbakışı bakalım
Öncelikle karışık bir veri setinden bizimle konuşacak olan modele gideceğimiz süreci aşağıdaki gibi adımlara böldüm:
| Aşama | Fonksiyon | İşlev |
|---|---|---|
| 1. Veriyi İndir | download_dataset() | ~1.1 MB'lık Tiny Shakespeare metnini çeker. |
| 2. Tokenize et | build_tokenizer() | Her karakteri bir sayıya eşler. (Bilgiğiniz gibi bilgisayarlarımız her şeyi sayılar olarak işliyor ve bir "kelime" nedir onu bilmiyor) |
| 3. Böl | make_splits() | Metni tek bir long tensora (tensor nedir diye sorarsanız sitedeki yazıya bir göz atmanızı öneririm) çevirip %90 train / %10 val seti şeklinde ayırır. |
| 4. Batch | get_batch() | Rastgele (context, target) çiftleri toplar. |
| 5. Model | BigramLanguageModel | Embedding → N× Block → LayerNorm → lm_head. |
| 6. Üret | model.generate() | Karakter karakter, autoregressive örnekleme. |
İşe veriyle başlıyoruz
Eğitim için Tiny Shakespeare veri setini kullanıyoruz ve metni karakter seviyesinde tokenize ediyoruz. Şu anda bugünkü dil modellerinin kullandığı BPE(byte-pair encoding) gibi karmaşık tokenizerları kullanmıyoruz ki işimiz biraz daha basit olsun); burada kullandığımız tokenization sadece her karakterle bir sayı arasında mapping yapan iki yönlü minik bir sözlük. Bu sayede vocabulary'miz 65 civarında kalıyor — embedding tablosu küçük oluyor, hata ayıklamak da haliyle kolay. Ama her kolaylığın getirdiği bir bedel var burada da her kelimeyi karakter seviyesinde küçük parçalara ayırdığımız için diziler uzuyor ama şuan için bu ölçekte göze alabileceğimiz bir zorluk. Aşağıda tokenization metodumuzu görüyorsunuz:
def build_tokenizer(text: str):
chars = sorted(set(text))
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)} # string -> int (her karakter bir id)
itos = {i: ch for i, ch in enumerate(chars)} # int -> string
def encode(s: str) -> list[int]:
return [stoi[c] for c in s]
def decode(ids: list[int]) -> str:
return "".join(itos[i] for i in ids)
return chars, vocab_size, encode, decodeArdından bütün metni tek bir sayı tensore çevirip 90'a 10 bölüyoruz:
def make_splits(text: str, encode):
data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
return data[:n], data[n:]Aklınızda kalsın diye özetleyeyim: token dediğimiz şey aslında bir sayıdan ibaret;
decode da o sayıları alıp geri metne çeviriyor. Hepsi bu.
Batch'ler ve o "bir sağa kaydır" numarası
Veriyi (B, T) şeklinde, batch'ler hâlinde çekiyoruz: B tane birbirinden bağımsız
dizi, her biri T karakter uzunluğunda. İşin püf noktası, hedef y'nin aslında
girdimiz x'in tam bir karakter sağa kaymış hâli olması — çünkü modelden istediğimiz
tek şey, eldeki context'e bakıp bir sonraki karakteri tahmin edebilmesi.
def get_batch(split, train_data, val_data, block_size, batch_size, device="cpu"):
data = train_data if split == "train" else val_data
ix = torch.randint(len(data) - block_size, (batch_size,)) # rastgele başlangıçlar
x = torch.stack([data[i:i + block_size] for i in ix])
y = torch.stack([data[i + 1:i + 1 + block_size] for i in ix]) # 1 sağa kaymış hâli
return x.to(device), y.to(device)x : F i r s t C
↓ ↓ ↓ ↓ ↓ ↓ ↓
y : i r s t C i
Burada çok güzel bir şey oluyor: block_size uzunluğundaki tek bir örnek, içinde
aslında modelimiz T tane ayrı öğrenme şansı barındırıyor. F'ten i'yi, Fi'den r'yi, Fir'den
s'yi tahmin etmesi gerektiğini görüyor. Transformer'ın aynı anda bu kadar çok örnekten
öğrenebilmesinin sırrı da tam olarak burada yatıyor. Yani tek bir cümleden aslında harflerin bir dil içerisinde arka arkaya bulunabilme istetistiğini çıkarıyoruz. Şimdi yapay zekanın aslında nasıl bir istatistiki çıkarsama harikası olduğunu görebiliyor musunuz ? Bence mükemmel!
İşin kalbi: tek bir attention head
Geldik en kritik parçaya. Her token, kendisinden üç ayrı vektör(aslında kelimelerin diğer kelimelerle birlikte oluşturduğu o kelime uzayındaki temsilleri) üretiyor: Query ("ben bu konsept özelinde ne arıyorum?"), Key ("beni nasıl sorgulayabilirsin?") ve Value ("aslında ne taşıyorum?"). Sonra her query'yi her key'le karşılaştırıp bir benzerlik skoru çıkarıyoruz (yani aradığım şeyle (Q) en yakın özelliği sunan (K) kelimeyi arıyoruz), bu skoru ölçekliyor, geleceği bir casual mask ile kapatıyoruz(çünkü modelimizin tahmin yaparken geleceği görmesi kopya çekmek olurdu), softmax'le (burası için ayrı yazılarımız gelecek ama ne olduğuna bakmanızı öneririm) bir olasılık dağılımına çeviriyoruz(yani aslında benim aradığım kelimeden sonra bu dil içinde hangi kelime ne olasılıkla gelir bunu çıkarıyoruz) ve olasılıksal değerleri belli ağırlıklarla harmanlıyoruz:
Bu yapıyı bir decoder yapan şey, işte o causal mask: bir token yalnızca kendisine ve geçmişe bakabiliyor; geleceği görmesi kesinlikle yasak.
bakılan →
t0 t1 t2 t3 t4
t0 [ 1 0 0 0 0 ]
t1 [ 1 1 0 0 0 ]
t2 [ 1 1 1 0 0 ]
t3 [ 1 1 1 1 0 ]
t4 [ 1 1 1 1 1 ]
Kodda bunu alt-üçgen(lower triangular) bir matrisle hallediyoruz; üst üçgeni -inf ile dolduruyoruz ki
softmax o hücrelere sıfır ağırlık versin:
class Head(nn.Module):
""" Bir tane self-attention head """
def __init__(self, n_embed, head_size, dropout_rate, block_size):
super().__init__()
self.key = nn.Linear(n_embed, head_size, bias=False)
self.query = nn.Linear(n_embed, head_size, bias=False)
self.value = nn.Linear(n_embed, head_size, bias=False)
self.register_buffer("tril", torch.tril(torch.ones(block_size, block_size)))
self.head_size = head_size
self.dropout = nn.Dropout(dropout_rate)
def forward(self, x):
B, T, C = x.shape
k = self.key(x)
q = self.query(x)
# attention skorlarını hesapla
wei = q @ k.transpose(-2, -1) * (self.head_size ** -0.5) # ölçeklenmiş skor
wei = wei.masked_fill(self.tril[:T, :T] == 0, float("-inf")) # causal mask
wei = F.softmax(wei, dim=-1)
wei = self.dropout(wei)
v = self.value(x)
return wei @ v # ağırlıklı toplamŞu head_size ** -0.5 çarpanı küçük ama çok hayati: boyut büyüdükçe şişen iç çarpımları
biraz olsun dizginliyor. Olmasaydı softmax doyuma gider, gradyanlar da sıfıra doğru sönerdi ve bir noktadan sonra öğrenme dururdu.
Tek head biraz az olabilir: multi-head
Tek bir head yalnızca tek bir tür ilişki yakalar. Birkaç tanesini yan yana koşturursak model aynı anda birden çok örüntüye bakabilir. Çıktılarını birleştirip (concat) tekrar model boyutuna indiriyoruz:
class MultiHeadAttention(nn.Module):
""" Paralel olarak birden fazla self-attention head çalıştırma """
def __init__(self, n_embed, num_heads, head_size, dropout_rate, block_size):
super().__init__()
self.heads = nn.ModuleList(
[Head(n_embed, head_size, dropout_rate, block_size) for _ in range(num_heads)]
)
self.proj = nn.Linear(n_embed, n_embed)
self.dropout = nn.Dropout(dropout_rate)
def forward(self, x):
out = torch.cat([h(x) for h in self.heads], dim=-1)
return self.dropout(self.proj(out))Önce haberleş, sonra hesapla: Transformer bloğu
Bir blokta iki katman var: multi-head attention (token'lar burada haberleşiyor) ve bir feed-forward ağ (her token burada tek başına hesap yapıyor). İkisi de önce LayerNorm'dan geçiyor, sonra bir residual bağlantıyla girdinin üstüne geri ekleniyor — derin Transformer'ları eğitilebilir kılan tarif tam olarak bu:
x = x + Dropout( MultiHeadAttention( LayerNorm(x) ) )
x = x + Dropout( FeedForward( LayerNorm(x) ) )
class Block(nn.Module):
""" Transformer bloğu: Haberleşme (MultiHeadAttention) + Hesaplama (FFN) """
def __init__(self, n_embed, n_head, dropout_rate, block_size):
super().__init__()
head_size = n_embed // n_head
self.sa = MultiHeadAttention(n_embed, n_head, head_size, dropout_rate, block_size)
self.ffwd = nn.Sequential(
nn.Linear(n_embed, 4 * n_embed),
nn.ReLU(),
nn.Linear(4 * n_embed, n_embed),
nn.Dropout(dropout_rate),
)
self.ln1 = nn.LayerNorm(n_embed)
self.ln2 = nn.LayerNorm(n_embed)
self.dropout1 = nn.Dropout(dropout_rate)
self.dropout2 = nn.Dropout(dropout_rate)
def forward(self, x):
x = x + self.dropout1(self.sa(self.ln1(x)))
x = x + self.dropout2(self.ffwd(self.ln2(x)))
return xFeed-forward kısmı boyutu önce 4 × n_embed'e çıkarıyor, sonra geri indiriyor.
Modelin asıl "düşünme" kapasitesinin büyük kısmı, bu genişleyip daralan katmanda
saklı. Yani aslında öğrenilen vektör içinde non-linearity için yer açmış oluyoruz. Ve öğrenilecek yeni özellikleri vektöre ekleyip onu tekrardan eski boyutuna sıkıştırıyoruz.
Parçaları birleştirip modeli kuralım
Sıra bütün parçaları üst üste dizmeye geldi. Token embedding bir token'ın ne
olduğunu söylüyor, position embedding ise nerede durduğunu. İkisini toplayıp
n_layer bloktan, bir final LayerNorm'dan ve en sonunda vocab boyutunda logit üreten
bir lineer katmandan geçiriyoruz:
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size, n_embed, n_head, n_layer, dropout_rate, block_size):
super().__init__()
# Her token için bir embedding tablosu (tek başına ne ifade eder)
self.token_embedding_table = nn.Embedding(vocab_size, n_embed)
# Position embedding tablosu (hangi pozisyonda ne ifade eder)
self.position_embedding_table = nn.Embedding(block_size, n_embed)
self.tok_pos_dropout = nn.Dropout(dropout_rate)
self.blocks = nn.Sequential(
*[Block(n_embed, n_head, dropout_rate, block_size) for _ in range(n_layer)]
)
self.ln_f = nn.LayerNorm(n_embed) # final layer norm
self.lm_head = nn.Linear(n_embed, vocab_size)
self.block_size = block_size
def forward(self, idx, targets=None):
B, T = idx.shape
tok_emb = self.token_embedding_table(idx) # (B, T, C)
pos_emb = self.position_embedding_table(torch.arange(T, device=DEVICE)) # (T, C)
x = self.tok_pos_dropout(tok_emb + pos_emb)
x = self.blocks(x)
x = self.ln_f(x)
logits = self.lm_head(x) # (B, T, vocab)
if targets is None:
return logits, None
# CrossEntropy (B*T, vocab) ve (B*T) bekler
B, T, C_vocab = logits.shape
logits = logits.view(B * T, C_vocab)
targets = targets.view(B * T)
loss = F.cross_entropy(logits, targets)
return logits, lossSınıfın adı hâlâ
BigramLanguageModeldiye duruyor; bu, Karpathy'nin dersinin başlangıç noktasından kalma bir miras sadece. Buradaki kod aslında tam teşekküllü bir decoder-only Transformer — bigram ise bu yapının türediği o ilk temel. (Neden bir yere kadar idare ettiğine yazının sonunda geleceğiz.)
Sıra metin üretmekte
Üretim, autoregressive ilerliyor: her adımda context'i son block_size token'a
kırpıyoruz (position embedding ancak o kadarını tanıyor çünkü), son pozisyonun
logit'lerini alıyor, softmax'le olasılığa çeviriyor, bir token örnekleyip dizinin
sonuna ekliyoruz. Sonra baştan.
def generate(self, idx, max_new_tokens):
for _ in range(max_new_tokens):
idx_cond = idx[:, -self.block_size:] # context penceresine kırp
logits, _ = self(idx_cond)
logits = logits[:, -1, :] # sadece son adıma odaklan
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, idx_next), dim=1)
return idxcontext = torch.zeros((1, 1), dtype=torch.long, device=DEVICE)
print(decode(model.generate(context, max_new_tokens=500)[0].tolist()))Eğitim döngüsü
Eğitimin özü klasik dört satır: forward, backward, step, schedule. Etrafına da
değerlendirme, early stopping ve loglamayı ekliyoruz. Optimizer olarak AdamW
kullandım; learning rate'i 5e-4'ten 1e-5'e yavaşça indiren bir
cosine-annealing scheduler ekliyoruz.
for iter in range(cfg.epochs):
if iter % EVAL_INTERVAL == 0 or iter == cfg.epochs - 1:
losses = estimate_loss(model, train_data, val_data, encode,
cfg.block_size, cfg.batch_size)
val = losses["val"].item()
# Early stopping kontrolü
if val < best_val_loss:
best_val_loss, patience_counter = val, 0
else:
patience_counter += 1
if patience_counter >= patience:
break
xb, yb = get_batch("train", train_data, val_data,
cfg.block_size, cfg.batch_size, device=DEVICE)
logits, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
scheduler.step()İşte o HERO RUN v4 hiperparametreleri:
| Parametre | Değer |
|---|---|
block_size | 256 |
batch_size | 64 |
n_embed | 256 |
n_head | 8 |
n_layer | 6 |
dropout_rate | 0.2 |
lr | 5e-4 |
weight_decay | 1e-3 |
Peki ne çıktı ortaya?
Cross-Entropy (Çapraz Entropi) Loss
Modeli eğitirken kullandığımız kayıp fonksiyonu cross-entropy. Formülü şöyle:
Tek bir örnek için, doğru sınıf (token) için tahmin edilen olasılık ise:
Daha genel haliyle, gerçek dağılım (one-hot) ve modelin tahmini üzerinden:
burada kelime dağarcığının (vocab) boyutu, ise softmax sonrası token'ına atanan olasılıktır.
Nasıl çalışır? Model her adımda, bir sonraki token için tüm kelime dağarcığı üzerinde bir olasılık dağılımı üretir (softmax çıktısı). Cross-entropy, bu dağılımda gerçekte gelen token'a ne kadar olasılık verdiğimize bakar ve onun negatif logaritmasını alır. Model doğru token'a yüksek olasılık verdiyse (), olur ve kayıp küçülür; düşük olasılık verdiyse (), olur ve kayıp büyük bir ceza haline gelir. Yani fonksiyon, modeli "emin olduğu ama yanıldığı" tahminler için ağır şekilde cezalandırır.
Neden kullanıyoruz? Çünkü bir sonraki token'ı tahmin etmek aslında bir sınıflandırma problemi: her pozisyonda olası token arasından doğru olanı seçmeye çalışıyoruz. Cross-entropy bu tür olasılıksal sınıflandırma için doğal kayıp fonksiyonudur — modelin ürettiği olasılık dağılımını gerçek dağılıma yaklaştırır (maksimum olabilirlik tahminine eşdeğerdir). Ayrıca gradyanları temiz ve kararlıdır: softmax ile birleştiğinde türevi basitçe olur, bu da gradient descent'in verimli çalışmasını sağlar. PyTorch'ta tek satırda F.cross_entropy(logits, targets) ile hesaplanır; bu fonksiyon softmax + log + negatif log-olabilirlik adımlarını sayısal olarak kararlı biçimde tek seferde yapar.
Validation loss aslında cross-entropy, yani negatif log-olabilirlik — ne kadar düşükse o kadar iyi. Bu karakter-seviye Tiny Shakespeare kurulumu için referans değerler şöyle:
Validation loss (düşük = iyi)
Rastgele init ████████████████████████████████████ 4.17
Saf bigram ██████████████████████ 2.50
Bu Transformer █████████████ 1.50
Değerlendirme tarafına gelince: saf bir bigram, harflerin tek tek frekansına uyan ama
hiçbir zaman düzgün bir kelime kuramayan bir "harf çorbası" oluşturuyor. Bizim Transformer
ise Shakespeare'e benzeyen bir metin üretiyor: makul kelimeler, NAME: diyalog
kalıbı, satır sonları, noktalama... Üslubu insanı kandıracak kadar inandırıcı ama
çok da bir anlamı yok — ~10M parametreli bir karakter modelinden zaten daha fazlasını bekleyemiyoruz.
Bigram neden bir yere kadar sınırlıyor bizi ? — attention bu engeli nasıl aşıyor ?
Aslında bütün attention hikâyesinin sebebi, bu temelin tam olarak nerede tıkandığını
görmek. Gerçek bir bigram P(sonraki karakter | önceki karakter)'i modelliyor:
hafızası tam tamına bir karakter, konum diye bir derdi yok ve yapısı gereği içinde
hiçbir kompozisyon barındırmayan bir vocab × vocab arama tablosundan ibaret. Bu
yüzden ne kadar uğraşırsanız uğraşın, loss 2.5 dolayında bir yerde takılıp kalıyor.
Eklediğimiz her parça, bu sınırlardan birini ortadan kaldırıyor:
| Ekleme | Kaldırdığı sınır |
|---|---|
| Self-attention | bağlamı 1 token yerine block_size'a (256) çıkarır |
| Position embedding | sıra ve konum anlam kazanır |
| Multi-head attention | birden çok ilişki türünü paralel öğrenir |
| Residual bloklar + FFN | derinlik ve doğrusal olmayan, kompozisyonel hesaplama katar |
Hepsi bir araya gelince loss ~2.5'ten ~1.5'e iniyor ve o anlamsız harf çorbası, tutarlı bir Shakespeare eserine dönüşüyor. Bu projenin göstermek istediği sıçrama da işte tam olarak bu.
Hadi siz de deneyin
Bütün implementasyon, satır satır yorumladığım tek bir notebook'ta duruyor:
- Repo: github.com/gocenalper/nano-gpt
- Tarayıcıda çalıştır: Colab'da aç
GPT'nin gizemini gerçekten çözmek istiyorsanız, bunun küçük bir prototipini kendi elinizle yazmaktan — ve her parça yerine oturdukça performansın nasıl arttığını izlemekten — daha iyi bir yol olamaz heralde değil mi ?
Buraya kadar gelebildiyseniz mükemmel! Artık siz de GPT' nin içinde neler dönüyor biliyorsunuz.
Referanslar
Bu yazıdaki uygulama ve açıklamalar büyük ölçüde aşağıdaki kaynaklara dayanıyor:
- Andrej Karpathy — "Let's build GPT: from scratch, in code, spelled out" (YouTube): youtube.com/watch?v=kCc8FmEb1nY
- Andrej Karpathy — nanoGPT deposu: github.com/karpathy/nanoGPT
- Andrej Karpathy — minGPT deposu: github.com/karpathy/minGPT
- Vaswani ve diğerleri — "Attention Is All You Need" (2017): arxiv.org/abs/1706.03762
- Radford ve diğerleri — "Language Models are Unsupervised Multitask Learners" (GPT-2): cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf
- Jay Alammar — "The Illustrated Transformer": jalammar.github.io/illustrated-transformer
- Ba, Kiros ve Hinton — "Layer Normalization" (2016): arxiv.org/abs/1607.06450
- Tiny Shakespeare veri kümesi: github.com/karpathy/char-rnn