PyTorch · character-level generation

NAME/FORGE

A neural network that invents fantasy names one character at a time — a miniature language model you can read in five minutes.

  • PyTorch
  • Deep Learning
  • Generative
  • One dependency
  • Zero-config static docs

01 — What it does

Show it forty invented fantasy names. It learns which letters tend to follow which, then writes names of its own that nobody has written before.

In plain terms

Read forty made-up names and you would start to notice things without trying: an "h" often follows a "t", names hardly ever end on a "q", "ae" shows up a lot. That noticing is the entire thing this program learns.

To write a new name it looks at the last three letters, guesses what letter might come next, and picks one at random — but weighted, so likely letters win more often than unlikely ones. It writes that letter down, looks at the last three letters again, and guesses again. One of the things it can guess is "this name is finished", and when that comes up, it stops.

Do that twelve times and you get twelve names nobody has written before. Nothing was memorized and nothing was copied: it only ever knows which letter tends to follow which. A chatbot works the same way, guessing the next word instead of the next letter.

There is no dictionary, no list of syllables, no rules about vowels. The model sees three characters at a time and predicts a probability for every possible next character — including a special "." that means the name is finished. Generation rolls a weighted die against those probabilities, appends the letter it lands on, slides the window forward, and repeats until the die comes up "." The model decides where names end; the program never counts letters for it.

That is the whole architecture of a large language model, shrunk until it fits on one screen: embed the tokens, predict the next one, sample from the prediction, repeat. This version plays with 27 characters and a few thousand parameters. Claude plays the same game with word pieces and roughly a trillion.

The input

The output

Twelve novel, deduplicated names per temperature setting, capitalized and guaranteed absent from the training list.

The training

The knob

Temperature — the same parameter every LLM API exposes — scales the logits before the softmax and controls how adventurous the sampling gets.

02 — Architecture

Five components, one direction of travel. Shapes below are the real tensor shapes the code produces, with N = the example count.

1 · Corpus NAMES: list[str]

Forty invented lowercase names, 4–10 characters. The vocabulary is every character that appears plus "." as the shared start/end token — 27 tokens.

2 · Sliding window X: (N, 3) · Y: (N,)

Each name is padded to "..." + name + "." and cut into (3 characters → the 4th) index pairs. Every name contributes one final example whose target is "." — this is how stopping is taught.

3 · Model nn.Module · 5,235 params

Four layers, in order:

nn.Embedding(27, 8)     (N, 3)  -> (N, 3, 8)   learned character vectors
nn.Flatten()            (N, 3, 8) -> (N, 24)   context as one row
nn.Linear(24, 96) + Tanh (N, 24) -> (N, 96)    the one hidden layer
nn.Linear(96, 27)       (N, 96) -> (N, 27)     one logit per token
4 · Training loop Adam(lr=0.01) · 400 epochs

Full-batch: every example every step. CrossEntropyLoss against the true next character, then zero_grad → forward → loss → backward → step. Loss is checkpointed at epoch 1 and every 100 epochs after.

5 · Sampler @torch.no_grad() · multinomial

Seed the context with "...", divide the logits by temperature, softmax, then torch.multinomial to sample. Append, slide the window, repeat. Stop on "." or at 12 characters.

The generation loop feeds itself. Each sampled character becomes part of the next step's context — the model's own output is its next input. That autoregressive feedback is the single structural idea shared with every production LLM.

Two supporting pieces sit outside the model: build_site.py runs the script, captures its stdout, and generates this page from site_template.html; the tests/ suite asserts the shapes, the descent of the loss, the novelty of generated names, and that this page matches the run it claims.

03 — Real output

Terminal transcript

python name_forge.py --json
NAME FORGE — 40 training names, vocabulary of 27 tokens
326 training examples (3-character context -> next character)

5235 trainable parameters

training:
  epoch    1   loss 3.2999
  epoch  100   loss 0.6331
  epoch  200   loss 0.6226
  epoch  300   loss 0.6209
  epoch  400   loss 0.6203

temperature 0.5 (cautious):
  Dorvath        Brommarra      Dra            Amberlyn
  Zoranth        Brenwynneth    Ulmar          Fenwynneth
  Kaelin         Cindra         Yrandraveth    Wynth

temperature 0.8 (balanced):
  Dra            Kaelin         Caelen         Kaelithreya
  Wynth          Pyrandravell   Orvath         Yrandra
  Brommarra      Halorin        Brenwick       Nythea

temperature 1.2 (chaotic):
  Quorin         Yrandrissa     Caelen         Orvathi
  Gwyn           Vaelith        Rhaeliana      Thalvenn
  Cindrissa      Cindra         Aelen          Caeloria

--------------------------------------------------------------------
This is the same machinery as an LLM: embed, predict, sample, repeat.
Claude plays it with words instead of letters, and ~10^12 parameters
instead of the few thousand above. The loop is identical.
--------------------------------------------------------------------

wrote results.json

Captured verbatim from the process this page was built by — stdout only. Running it yourself also prints a benign UserWarning: Failed to initialize NumPy to stderr; torch emits it because numpy is deliberately not installed, and nothing in this project uses it.

One model, three temperatures

Every name is novel — none appears in the training list, and each batch is deduplicated. The contrast here is real but modest: across 200 samples per setting the model produced 90 unique names at T=0.5 against 108 at T=1.2. 326 training examples at 400 epochs is close to memorization, so the softmax is already sharp and temperature has limited room to work. Low temperature stays near the shapes of the training names; high temperature blends them more freely.

Learning curve

    04 — Key decisions

    Rendered from docs/decisions.md in the repository, which is the running log every choice was written to as it was made.

      05 — How to run it

      One dependency. Under five seconds end to end on a laptop CPU.

      1 · Install

      git clone <this-repo> name-forge
      cd name-forge
      python3 -m venv .venv
      source .venv/bin/activate
      pip install torch

      2 · Configure

      # nothing to configure — no environment variables,
      # no API keys, no config files, no network access

      3 · Run

      python name_forge.py

      4 · Run and capture results

      python name_forge.py --json      # also writes results.json

      5 · Test

      python -m unittest discover -s tests -v

      6 · Rebuild this page

      python build_site.py             # runs the model, regenerates site/index.html

      7 · Deploy these docs

      cd site && vercel --prod

      Output is deterministic — torch.manual_seed(7) — so a fresh clone reproduces the names and the loss values on this page exactly.

      06 — Code tour

      The five files that matter, quoted from source. Every excerpt below is lifted out of the repository by symbol name at build time, so it cannot drift from the code it claims to show.

      07 — Full source

      name_forge.py — the entire model, training loop and sampler
      """
      name_forge.py — a character-level neural name generator.
      
      This is not a toy classifier. It is a *miniature language model*: it reads a
      window of characters, predicts a distribution over the next character, samples
      from it, and repeats. That is the entire architecture of a modern LLM, shrunk
      until it fits on one screen.
      
      Run:
          python name_forge.py
          python name_forge.py --json      # also writes results.json for the showcase site
      """
      
      import argparse
      import datetime
      import json
      import torch
      import torch.nn as nn
      
      # --------------------------------------------------------------------------
      # 1. DATA
      # --------------------------------------------------------------------------
      # 40 invented fantasy names. Lowercase, 4-10 characters. Nothing here is a real
      # word — the model has no dictionary to fall back on, only letter statistics.
      NAMES = [
          "aeliana", "brommar", "caelith", "dorvane", "elandra",
          "fenwick", "gorathi", "halvenn", "ithreya", "jorvath",
          "kaelen", "lysandra", "morrigan", "nythera", "orvellon",
          "pyranth", "quorien", "rhaelin", "sylvane", "thalorin",
          "ulmarra", "vaeloria", "wynthar", "xanthea", "yravelle",
          "zorander", "amberly", "brenwyn", "cindris", "draveth",
          "emberlyn", "faerion", "gwynneth", "haldric", "isolde",
          "kestrel", "marowen", "nerissa", "solvane", "tyriel",
      ]
      
      # The vocabulary is every character that appears, plus "." — a single token that
      # marks BOTH the start and the end of a name. Nothing in the loop below ever
      # says "stop after N letters": the model must LEARN when a name is finished and
      # emit "." itself. Termination is a prediction, not a rule.
      CHARS = sorted(set("".join(NAMES)))
      ITOS = ["."] + CHARS
      STOI = {c: i for i, c in enumerate(ITOS)}
      VOCAB = len(ITOS)
      
      CONTEXT = 3  # how many previous characters the model gets to look at
      
      
      def build_dataset(names):
          """Slide a CONTEXT-wide window over '...' + name + '.' -> (context, next char)."""
          xs, ys = [], []
          for name in names:
              padded = "." * CONTEXT + name + "."
              for i in range(len(padded) - CONTEXT):
                  window = padded[i:i + CONTEXT]
                  nxt = padded[i + CONTEXT]
                  xs.append([STOI[c] for c in window])
                  ys.append(STOI[nxt])
          return torch.tensor(xs), torch.tensor(ys)
      
      
      # --------------------------------------------------------------------------
      # 2. MODEL
      # --------------------------------------------------------------------------
      class NameForge(nn.Module):
          def __init__(self, vocab, context=CONTEXT, embed_dim=8, hidden=96):
              super().__init__()
              # This one line turns letters into learned vectors. It is the identical
              # move an LLM makes with word/subword tokens — the model discovers for
              # itself that vowels behave alike, that "." is special, that "th" is a
              # unit. Nobody tells it; the gradients do.
              self.embed = nn.Embedding(vocab, embed_dim)
              self.flatten = nn.Flatten()                       # 3 chars x 8 dims -> 24
              self.fc1 = nn.Linear(context * embed_dim, hidden)  # 24 -> 96
              self.act = nn.Tanh()
              self.fc2 = nn.Linear(hidden, vocab)                # 96 -> next-char logits
      
          def forward(self, x):
              return self.fc2(self.act(self.fc1(self.flatten(self.embed(x)))))
      
          def param_count(self):
              return sum(p.numel() for p in self.parameters())
      
      
      # --------------------------------------------------------------------------
      # 3. TRAINING
      # --------------------------------------------------------------------------
      def train(model, X, Y, epochs=400, lr=0.01, log_every=100, verbose=True):
          optimizer = torch.optim.Adam(model.parameters(), lr=lr)
          loss_fn = nn.CrossEntropyLoss()
          curve = []
      
          for epoch in range(1, epochs + 1):
              optimizer.zero_grad()      # 1. clear last step's gradients
              logits = model(X)          # 2. forward: contexts -> next-char scores
              loss = loss_fn(logits, Y)  # 3. loss: how wrong were those scores?
              loss.backward()            # 4. backward: blame every weight, proportionally
              optimizer.step()           # 5. step: nudge each weight down its gradient
      
              # Checkpoint every log_every epochs, plus epoch 1 so the curve records
              # where the model started (a random guess over 27 tokens ~= ln(27) = 3.30).
              if epoch % log_every == 0 or epoch == 1:
                  curve.append({"epoch": epoch, "loss": round(loss.item(), 4)})
                  if verbose:
                      print(f"  epoch {epoch:>4}   loss {loss.item():.4f}")
      
          return curve, loss.item()
      
      
      # --------------------------------------------------------------------------
      # 4. GENERATION
      # --------------------------------------------------------------------------
      @torch.no_grad()
      def generate(model, temperature=0.8, max_len=12):
          """Roll the model forward one character at a time until it predicts '.'."""
          context = [STOI["."]] * CONTEXT
          out = []
          for _ in range(max_len):
              logits = model(torch.tensor([context]))
              # Temperature divides the logits before softmax: < 1 sharpens the
              # distribution (safe, repetitive), > 1 flattens it (wild, misspelled).
              # This is exactly the `temperature` knob every LLM API exposes.
              probs = torch.softmax(logits / temperature, dim=1)
              # Sampling — not argmax — is where the creativity lives. argmax would
              # return the same single name forever; multinomial rolls a weighted die.
              idx = torch.multinomial(probs, num_samples=1).item()
              if ITOS[idx] == ".":
                  break
              out.append(ITOS[idx])
              context = context[1:] + [idx]
          return "".join(out)
      
      
      def generate_batch(model, n=12, temperature=0.8, max_attempts=200):
          """Collect n novel, deduped names. Attempt-capped so a degenerate model
          that only ever emits '.' cannot hang the loop forever."""
          found, seen = [], set()
          for _ in range(max_attempts):
              if len(found) >= n:
                  break
              name = generate(model, temperature=temperature)
              if len(name) >= 3 and name not in NAMES and name not in seen:
                  seen.add(name)
                  found.append(name.capitalize())
          return found
      
      
      # --------------------------------------------------------------------------
      # 5. MAIN
      # --------------------------------------------------------------------------
      def main():
          parser = argparse.ArgumentParser(description="Character-level neural name generator.")
          parser.add_argument("--json", action="store_true",
                              help="also write results.json (data source for the showcase site)")
          args = parser.parse_args()
      
          torch.manual_seed(7)
      
          X, Y = build_dataset(NAMES)
          print(f"NAME FORGE — {len(NAMES)} training names, vocabulary of {VOCAB} tokens")
          print(f"{len(X)} training examples ({CONTEXT}-character context -> next character)\n")
      
          model = NameForge(VOCAB)
          print(f"{model.param_count()} trainable parameters\n")
          print("training:")
          curve, final_loss = train(model, X, Y)
          print()
      
          batches = {}
          for temp, label in [(0.5, "cautious"), (0.8, "balanced"), (1.2, "chaotic")]:
              names = generate_batch(model, n=12, temperature=temp)
              batches[temp] = names
              print(f"temperature {temp} ({label}):")
              for i in range(0, len(names), 4):
                  print("  " + "   ".join(f"{n:<12}" for n in names[i:i + 4]).rstrip())
              print()
      
          print("-" * 68)
          print("This is the same machinery as an LLM: embed, predict, sample, repeat.")
          print("Claude plays it with words instead of letters, and ~10^12 parameters")
          print("instead of the few thousand above. The loop is identical.")
          print("-" * 68)
      
          if args.json:
              payload = {
                  "run_date": datetime.date.today().isoformat(),
                  "torch_version": torch.__version__,
                  "seed": 7,
                  "final_loss": round(final_loss, 4),
                  "param_count": model.param_count(),
                  "loss_curve": curve,
                  "names_t05": batches[0.5],
                  "names_t08": batches[0.8],
                  "names_t12": batches[1.2],
                  "training_names_count": len(NAMES),
                  "example_count": len(X),
              }
              with open("results.json", "w") as f:
                  json.dump(payload, f, indent=2)
              print("\nwrote results.json")
      
      
      if __name__ == "__main__":
          main()