microGPT

L'implémentation la plus atomique d'un GPT en Python pur, sans dépendance, par Andrej Karpathy. Ci-dessous : le code original et ses portages en Java et Ruby réalisés par la communauté.

Original — gist.github.com / karpathy Java — ani03sha/microgpt-java Ruby — khasinski/nanogpt-rb
microgpt.py
gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95
import os, math, random
random.seed(42)

if not os.path.exists('input.txt'):
    import urllib.request
    urllib.request.urlretrieve(
        'https://raw.githubusercontent.com/karpathy/makemore/988aa59/names.txt',
        'input.txt')
docs = [line.strip() for line in open('input.txt') if line.strip()]
random.shuffle(docs)
print(f"num docs: {len(docs)}")

uchars = sorted(set(''.join(docs)))
BOS = len(uchars)
vocab_size = len(uchars) + 1
print(f"vocab size: {vocab_size}")

class Value:
    __slots__ = ('data', 'grad', '_children', '_local_grads')

    def __init__(self, data, children=(), local_grads=()):
        self.data = data
        self.grad = 0
        self._children = children
        self._local_grads = local_grads

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, (self, other), (1, 1))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data * other.data, (self, other), (other.data, self.data))

    def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))
    def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
    def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
    def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))
    def __neg__(self): return self * -1
    def __radd__(self, other): return self + other
    def __sub__(self, other): return self + (-other)
    def __rsub__(self, other): return other + (-self)
    def __rmul__(self, other): return self * other
    def __truediv__(self, other): return self * other**-1
    def __rtruediv__(self, other): return other * self**-1

    def backward(self):
        topo, visited = [], set()
        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._children: build_topo(child)
                topo.append(v)
        build_topo(self)
        self.grad = 1
        for v in reversed(topo):
            for child, local_grad in zip(v._children, v._local_grads):
                child.grad += local_grad * v.grad

n_layer, n_embd, block_size, n_head = 1, 16, 16, 4
head_dim = n_embd // n_head
matrix = lambda nout, nin, std=0.08: [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]
state_dict = {'wte': matrix(vocab_size, n_embd), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd)}
for i in range(n_layer):
    state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
    state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
params = [p for mat in state_dict.values() for row in mat for p in row]
print(f"num params: {len(params)}")

def linear(x, w):
    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]

def softmax(logits):
    max_val = max(val.data for val in logits)
    exps = [(val - max_val).exp() for val in logits]
    total = sum(exps)
    return [e / total for e in exps]

def rmsnorm(x):
    ms = sum(xi * xi for xi in x) / len(x)
    scale = (ms + 1e-5) ** -0.5
    return [xi * scale for xi in x]

def gpt(token_id, pos_id, keys, values):
    x = [t + p for t, p in zip(state_dict['wte'][token_id], state_dict['wpe'][pos_id])]
    x = rmsnorm(x)
    for li in range(n_layer):
        x_residual = x
        x = rmsnorm(x)
        q = linear(x, state_dict[f'layer{li}.attn_wq'])
        k = linear(x, state_dict[f'layer{li}.attn_wk'])
        v = linear(x, state_dict[f'layer{li}.attn_wv'])
        keys[li].append(k); values[li].append(v)
        x_attn = []
        for h in range(n_head):
            hs = h * head_dim
            q_h = q[hs:hs+head_dim]
            k_h = [ki[hs:hs+head_dim] for ki in keys[li]]
            v_h = [vi[hs:hs+head_dim] for vi in values[li]]
            attn_logits = [sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5 for t in range(len(k_h))]
            attn_weights = softmax(attn_logits)
            head_out = [sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h))) for j in range(head_dim)]
            x_attn.extend(head_out)
        x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
        x = [a + b for a, b in zip(x, x_residual)]
        x_residual = x
        x = rmsnorm(x)
        x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
        x = [xi.relu() for xi in x]
        x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
        x = [a + b for a, b in zip(x, x_residual)]
    return linear(x, state_dict['lm_head'])

learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params)
v = [0.0] * len(params)

num_steps = 1000
for step in range(num_steps):
    doc = docs[step % len(docs)]
    tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
    n = min(block_size, len(tokens) - 1)
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    losses = []
    for pos_id in range(n):
        token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
        logits = gpt(token_id, pos_id, keys, values)
        probs = softmax(logits)
        losses.append(-probs[target_id].log())
    loss = (1 / n) * sum(losses)
    loss.backward()
    lr_t = learning_rate * (1 - step / num_steps)
    for i, p in enumerate(params):
        m[i] = beta1 * m[i] + (1 - beta1) * p.grad
        v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2
        m_hat = m[i] / (1 - beta1 ** (step + 1))
        v_hat = v[i] / (1 - beta2 ** (step + 1))
        p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)
        p.grad = 0
    print(f"step {step+1:4d} / {num_steps:4d} | loss {loss.data:.4f}", end='\r')

temperature = 0.5
print("\n--- inference ---")
for sample_idx in range(20):
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    token_id, sample = BOS, []
    for pos_id in range(block_size):
        logits = gpt(token_id, pos_id, keys, values)
        probs = softmax([l / temperature for l in logits])
        token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]
        if token_id == BOS: break
        sample.append(uchars[token_id])
    print(f"sample {sample_idx+1:2d}: {''.join(sample)}")

Concepts clés

Runner.java
github.com/ani03sha/microgpt-java
package com.anirudhology.microgpt;

import com.anirudhology.microgpt.data.NGramDatasetBuilder;
import com.anirudhology.microgpt.data.TextCorpus;
import com.anirudhology.microgpt.data.TrainingExample;
import com.anirudhology.microgpt.model.BaselineBigramModel;
import com.anirudhology.microgpt.model.GPTLanguageModel;
import com.anirudhology.microgpt.model.MLPLanguageModel;
import com.anirudhology.microgpt.model.NeuralBigramAutogradModel;
import com.anirudhology.microgpt.model.NeuralBigramModel;
import com.anirudhology.microgpt.optimizer.AdamOptimizer;
import com.anirudhology.microgpt.tokenizer.CharacterTokenizer;

import java.util.Collections;
import java.util.List;
import java.util.Random;

/**
 * Runs each model in sequence, from simplest to most powerful.
 *
 * Step 1: Statistical Bigram       - pure counting, no neural network
 * Step 2: Neural Bigram            - same task, learned weights, manual gradients
 * Step 3: Neural Bigram (Autograd) - same model, automatic differentiation via Value
 * Step 4: MLP Language Model       - wider context window, embeddings, hidden layer
 * Step 5: GPT Transformer          - multi-head attention, transformer blocks, Adam
 */
public class Runner {

    static void main() {
        TextCorpus textCorpus = new TextCorpus();
        final List<String> docs = textCorpus.readCorpus("input.txt");

        CharacterTokenizer tokenizer = new CharacterTokenizer();
        tokenizer.buildVocabulary(docs);

        int split = (int) (docs.size() * 0.9);
        final List<String> trainDocs = docs.subList(0, split);
        final List<String> validationDocs = docs.subList(split, docs.size());

        printStep(1, "Statistical Bigram",
                "Count character pair frequencies. No learning, no gradients. Pure statistics.");
        runBaselineBigram(tokenizer, docs);

        printStep(2, "Neural Bigram (manual gradients)",
                "Replace the count table with a learned weight matrix.\n" +
                "  Gradients computed by hand: dL/dlogit = p - 1_correct.");
        runNeuralBigram(tokenizer, trainDocs, validationDocs);

        printStep(3, "Neural Bigram (autograd)",
                "Same model, but gradients computed automatically by the Value graph.");
        runNeuralAutogradBigram(tokenizer, trainDocs, validationDocs);

        printStep(4, "MLP Language Model",
                "Wider context window (3 chars). Token + positional embeddings flattened\n" +
                "  into a hidden layer with tanh activation.");
        runMLPLanguageModel(tokenizer, docs);

        printStep(5, "GPT Transformer (single-head attention)",
                "CausalSelfAttention: one attention head over the full embedding dimension.");
        runGPTLanguageModel(tokenizer, docs, false);

        printStep(6, "GPT Transformer (multi-head attention)",
                "MultiHeadCausalSelfAttention: splits embedding into 4 heads.");
        runGPTLanguageModel(tokenizer, docs, true);
    }

    private static void runBaselineBigram(CharacterTokenizer tokenizer, List<String> docs) {
        BaselineBigramModel model = new BaselineBigramModel(tokenizer.getVocabularySize(), tokenizer.getBOSId(), 42L);
        model.fit(docs, tokenizer, 1.0);
        double nll = model.averageNegativeLogLikelihood(docs, tokenizer);
        System.out.printf("Avg NLL: %.4f%n", nll);
        for (int i = 0; i < 10; i++)
            System.out.printf("Sample %2d: %s%n", i + 1, model.sample(tokenizer, 16));
    }

    private static void runNeuralBigram(CharacterTokenizer tokenizer,
            List<String> trainDocs, List<String> validationDocs) {
        NeuralBigramModel model = new NeuralBigramModel(tokenizer.getVocabularySize(), tokenizer.getBOSId(), 42L);
        double learningRate = 0.5;
        for (int e = 1; e <= 30; e++) {
            double trainNll = model.trainEpoch(trainDocs, tokenizer, learningRate, 1000L + e);
            double valNll = model.averageNegativeLogLikelihood(validationDocs, tokenizer);
            System.out.printf("Epoch %2d | Train NLL: %.4f | Val NLL: %.4f%n", e, trainNll, valNll);
            learningRate *= 0.98;
        }
        for (int i = 0; i < 10; i++)
            System.out.printf("Sample %2d: %s%n", i + 1, model.sample(tokenizer, 16, 0.9));
    }

    private static void runNeuralAutogradBigram(CharacterTokenizer tokenizer,
            List<String> trainDocs, List<String> validationDocs) {
        NeuralBigramAutogradModel model = new NeuralBigramAutogradModel(
                tokenizer.getVocabularySize(), tokenizer.getBOSId(), 42L);
        double learningRate = 0.5;
        for (int e = 0; e < 20; e++) {
            double trainNll = model.train(trainDocs, tokenizer, learningRate, 1000L + e);
            double valNll = model.averageNegativeLogLikelihood(validationDocs, tokenizer);
            System.out.printf("Epoch %2d | Train NLL: %.4f | Val NLL: %.4f%n", e, trainNll, valNll);
            learningRate *= 0.98;
        }
        for (int i = 0; i < 10; i++)
            System.out.printf("Sample %2d: %s%n", i + 1, model.sample(tokenizer, 16, 0.9));
    }

    private static void runMLPLanguageModel(CharacterTokenizer tokenizer, List<String> documents) {
        final int blockSize = 3;
        final List<TrainingExample> examples = NGramDatasetBuilder.build(documents, tokenizer, blockSize, true);
        final MLPLanguageModel model = new MLPLanguageModel(
                tokenizer.getVocabularySize(), blockSize, 10, 100, 42L);
        double learningRate = 0.01;
        for (int epoch = 0; epoch < 10; epoch++) {
            double totalLoss = 0.0;
            Collections.shuffle(examples, new Random(42L + epoch));
            for (int i = 0; i < examples.size(); i++) {
                totalLoss += model.trainStep(examples.get(i), learningRate, true);
                if ((i + 1) % 1000 == 0)
                    System.out.printf("Epoch %d, Step %d/%d, Avg Loss: %.4f%n",
                            epoch + 1, i + 1, examples.size(), totalLoss / (i + 1));
            }
            learningRate *= 0.9;
        }
        for (int i = 0; i < 10; i++)
            System.out.printf("Sample %2d: %s%n", i + 1, model.generate(tokenizer, 20, 1.0));
    }

    private static void runGPTLanguageModel(CharacterTokenizer tokenizer,
            List<String> documents, boolean useMultiHead) {
        final int blockSize = 16, embeddingDimension = 16, numHeads = 4, numberOfLayers = 1;
        final List<TrainingExample> examples = NGramDatasetBuilder.build(documents, tokenizer, blockSize, true);
        final GPTLanguageModel model = new GPTLanguageModel(
                tokenizer.getVocabularySize(), blockSize, embeddingDimension,
                numHeads, numberOfLayers, useMultiHead, 42L);
        final AdamOptimizer optimizer = new AdamOptimizer(model.parameters());
        double initialLearningRate = 0.01;
        for (int step = 0; step < 1000; step++) {
            double learningRate = initialLearningRate * (1.0 - (double) step / 1000);
            optimizer.zeroGradient(model.parameters());
            double loss = model.trainStep(examples.get(step % examples.size()));
            optimizer.step(model.parameters(), learningRate);
            if ((step + 1) % 100 == 0)
                System.out.printf("Step %4d / 1000 | Loss: %.4f | LR: %.6f%n", step + 1, loss, learningRate);
        }
        for (int i = 0; i < 10; i++)
            System.out.printf("Sample %2d: %s%n", i + 1, model.generate(tokenizer, 20, 0.5));
    }

    private static void printStep(int step, String title, String description) {
        System.out.println("\n" + "=".repeat(70));
        System.out.printf("  Step %d: %s%n", step, title);
        System.out.println("=".repeat(70));
        System.out.println("  " + description);
        System.out.println("-".repeat(70));
    }
}

Progression pédagogique (6 étapes)

exe/nanogpt
github.com/khasinski/nanogpt-rb
#!/usr/bin/env ruby
# frozen_string_literal: true
# nanogpt — A Ruby port of Karpathy's nanoGPT
# Source: https://github.com/khasinski/nanogpt-rb

require "nano_gpt"

class NanoGPTCLI
  COMMANDS = %w[prepare train sample bench version help].freeze

  def initialize(args)
    @command = args.shift
    @args = args
  end

  def run
    case @command
    when "prepare" then prepare
    when "train"   then train
    when "sample"  then sample
    when "bench"   then bench
    when "version", "-v", "--version" then version
    when "help", "-h", "--help", nil  then help
    else
      puts "Unknown command: #{@command}"
      help; exit 1
    end
  end

  private

  def train
    config = NanoGPT::TrainConfig.load(@args)
    config[:device] = NanoGPT::Device.auto if config[:device] == "auto"

    data_dir  = File.join("data", config[:dataset])
    tokenizer = NanoGPT::Tokenizer.for_dataset(data_dir)

    model_config = NanoGPT::GPTConfig.new(
      block_size: config[:block_size],
      vocab_size: tokenizer.vocab_size,
      n_layer:    config[:n_layer],
      n_head:     config[:n_head],
      n_embd:     config[:n_embd],
      dropout:    config[:dropout],
      bias:       config[:bias]
    )

    model = NanoGPT::GPT.new(model_config)
    model.to(config[:device]) if config[:device] != "cpu"

    data_loader = NanoGPT::DataLoader.new(
      data_dir:   data_dir,
      block_size: config[:block_size],
      batch_size: config[:batch_size],
      device:     config[:device]
    )

    NanoGPT::Trainer.new(model: model, data_loader: data_loader, config: config.to_h).train
    puts "\nTraining complete! Checkpoint saved to #{config[:out_dir]}/ckpt.pt"
  end

  def sample
    config = NanoGPT::SampleConfig.load(@args)
    config[:device] = NanoGPT::Device.auto if config[:device] == "auto"
    Torch.manual_seed(config[:seed])

    checkpoint   = Torch.load(File.join(config[:out_dir], "ckpt.pt"))
    model_config = NanoGPT::GPTConfig.new(**checkpoint["model_args"].transform_keys(&:to_sym))
    model        = NanoGPT::GPT.new(model_config)
    model.load_state_dict(checkpoint["model"])
    model.to(config[:device]) if config[:device] != "cpu"
    model.eval

    tokenizer  = NanoGPT::Tokenizer.for_dataset(File.join("data", config[:dataset]))
    start_text = config[:start]
    start_text = File.read(start_text[5..]) if start_text.start_with?("FILE:")
    x = Torch.tensor([tokenizer.encode(start_text)], dtype: :long, device: config[:device])

    config[:num_samples].times do
      y      = model.generate(x, config[:max_new_tokens], temperature: config[:temperature], top_k: config[:top_k])
      output = tokenizer.decode(y[0].to_a)
      puts output
      puts "-" * 50
    end
  end

  def bench
    config = NanoGPT::BenchConfig.load(@args)
    config[:device] = NanoGPT::Device.auto if config[:device] == "auto"
    Torch.manual_seed(config[:seed])

    model_config = NanoGPT::GPTConfig.new(
      block_size: config[:block_size], vocab_size: 50304,
      n_layer: config[:n_layer], n_head: config[:n_head], n_embd: config[:n_embd],
      dropout: config[:dropout], bias: config[:bias]
    )
    model     = NanoGPT::GPT.new(model_config)
    model.to(config[:device]) if config[:device] != "cpu"
    optimizer = model.configure_optimizers(
      weight_decay: 1e-2, learning_rate: 1e-4, betas: [0.9, 0.95],
      device_type: NanoGPT::Device.type(config[:device])
    )

    get_batch = lambda do
      x = Torch.randint(50304, [config[:batch_size], config[:block_size]], dtype: :long)
      y = Torch.randint(50304, [config[:batch_size], config[:block_size]], dtype: :long)
      [x, y]
    end

    [{ name: "burn-in", steps: 10 }, { name: "benchmark", steps: 20 }].each do |phase|
      puts "\nPhase: #{phase[:name]}"
      x, y = get_batch.call
      t0 = Time.now
      phase[:steps].times do |k|
        _logits, loss = model.forward(x, targets: y)
        x, y = get_batch.call
        optimizer.zero_grad; loss.backward; optimizer.step
        puts "  #{k}/#{phase[:steps]} loss: #{format('%.4f', loss.item)}"
      end
      if phase[:name] == "benchmark"
        dt  = Time.now - t0
        mfu = model.estimate_mfu(config[:batch_size] * phase[:steps], dt)
        puts "\nTime/iter: #{format('%.2f', dt / phase[:steps] * 1000)}ms | MFU: #{format('%.2f', mfu * 100)}%"
      end
    end
  end

  def prepare
    dataset = @args.first || (puts "Usage: nanogpt prepare <dataset>"; exit 1)
    load File.join(File.dirname(__FILE__), "..", "data", dataset, "prepare.rb")
  end

  def version
    puts "nanogpt #{NanoGPT::VERSION}"
  end

  def help
    puts <<~HELP
      nanogpt — Ruby port of Karpathy's nanoGPT
      https://github.com/khasinski/nanogpt-rb

      Commands:
        prepare <dataset>   Download and prepare a dataset
        train               Train a GPT model
        sample              Generate text from a trained model
        bench               Run performance benchmarks
        version             Show version
        help                Show this help
    HELP
  end
end

NanoGPTCLI.new(ARGV).run

Fonctionnalités