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.
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.
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.
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.
nn.Module · 5,235 paramsFour 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
Adam(lr=0.01) · 400 epochsFull-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.
@torch.no_grad() · multinomialSeed 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.
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
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 — 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()