Inside AI Models
← Tüm makaleler
TransformerDerin ÖğrenmeLLM

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şamaFonksiyonİşlev
1. Veriyi İndirdownload_dataset()~1.1 MB'lık Tiny Shakespeare metnini çeker.
2. Tokenize etbuild_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ölmake_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. Batchget_batch()Rastgele (context, target) çiftleri toplar.
5. ModelBigramLanguageModelEmbedding → N× Block → LayerNorm → lm_head.
6. Üretmodel.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, decode

Ardı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:

Attention(Q,K,V)=softmax ⁣(QKdk)V\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V

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 x

Feed-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, loss

Sınıfın adı hâlâ BigramLanguageModel diye 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 idx
context = 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:

ParametreDeğer
block_size256
batch_size64
n_embed256
n_head8
n_layer6
dropout_rate0.2
lr5e-4
weight_decay1e-3

Peki ne çıktı ortaya?

Cross-Entropy (Çapraz Entropi) Loss

Modeli eğitirken kullandığımız kayıp fonksiyonu cross-entropy. Formülü şöyle:

L=1Ni=1Nlogpθ(yixi)\mathcal{L} = -\frac{1}{N} \sum_{i=1}^{N} \log p_{\theta}\big(y_i \mid x_i\big)

Tek bir örnek için, doğru sınıf (token) için tahmin edilen olasılık pp ise:

Li=logpθ(yixi)\mathcal{L}_i = -\log p_{\theta}(y_i \mid x_i)

Daha genel haliyle, gerçek dağılım yy (one-hot) ve modelin tahmini y^\hat{y} üzerinden:

L=c=1Vyclogy^c\mathcal{L} = -\sum_{c=1}^{V} y_c \, \log \hat{y}_c

burada VV kelime dağarcığının (vocab) boyutu, y^c\hat{y}_c ise softmax sonrası cc 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 (p1p \to 1), logp0-\log p \to 0 olur ve kayıp küçülür; düşük olasılık verdiyse (p0p \to 0), logp-\log p \to \infty 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 VV 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 y^y\hat{y} - y 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:

EklemeKaldırdığı sınır
Self-attentionbağlamı 1 token yerine block_size'a (256) çıkarır
Position embeddingsıra ve konum anlam kazanır
Multi-head attentionbirden çok ilişki türünü paralel öğrenir
Residual bloklar + FFNderinlik 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:

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:

okunma

Benzer makaleler