blob: fd93ccdd1af646114283b6ba33d421d4b8701849 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Trains a tiny char-level language model on the bundled public-domain
corpus and emits the Sourcelume records for the run.
Outputs (all under model/):
- training-run.json the honest training summary (real numbers)
- provenance-record.json a TrainingProvenanceRecord filled from the run
- tiny-lm-v1.npz the model weights
The record's numbers come from THIS run — that is the demo's whole point:
the record is born from the training, not authored by hand.
"""
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
BASE = Path(__file__).resolve().parent.parent
DATA = BASE / "data"
MODEL = BASE / "model"
CORPUS = DATA / "corpus" / "alice-in-wonderland-1865.txt"
DATASET_ID = "https://sourcelume.apache.org/records/sample/gutenberg-alice-1865"
MODEL_ID = "https://sourcelume.apache.org/records/sample/tiny-lm-v1"
def train(text: str, chars: int, epochs: int, window: int, hidden: int,
batch: int, lr: float, seed: int):
slice_ = text[:chars]
vocab = sorted(set(slice_))
v = len(vocab)
char_index = {c: i for i, c in enumerate(vocab)}
encoded = np.array([char_index[c] for c in slice_], dtype=np.int64)
rng = np.random.default_rng(seed)
w1 = rng.normal(0, 0.1, (window * v, hidden))
b1 = np.zeros(hidden)
w2 = rng.normal(0, 0.1, (hidden, v))
b2 = np.zeros(v)
positions = np.arange(len(encoded) - window)
history = []
for epoch in range(epochs):
rng.shuffle(positions)
losses = []
for start in range(0, len(positions) - batch + 1, batch):
idx = positions[start:start + batch]
x = np.zeros((batch, window * v))
for k in range(window):
x[np.arange(batch), k * v + encoded[idx + k]] = 1.0
y = encoded[idx + window]
h = np.tanh(x @ w1 + b1)
z = h @ w2 + b2
z -= z.max(axis=1, keepdims=True)
p = np.exp(z)
p /= p.sum(axis=1, keepdims=True)
losses.append(-np.log(p[np.arange(batch), y]).mean())
dz = p
dz[np.arange(batch), y] -= 1.0
dz /= batch
dw2 = h.T @ dz
db2 = dz.sum(axis=0)
dh = (dz @ w2.T) * (1 - h * h)
dw1 = x.T @ dh
db1 = dh.sum(axis=0)
w1 -= lr * dw1
b1 -= lr * db1
w2 -= lr * dw2
b2 -= lr * db2
history.append(float(np.mean(losses)))
print(f"epoch {epoch + 1}/{epochs}: loss {history[-1]:.4f}")
params = w1.size + b1.size + w2.size + b2.size
return slice_, vocab, history, params, (w1, b1, w2, b2)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--epochs", type=int, default=3)
parser.add_argument("--chars", type=int, default=60_000)
parser.add_argument("--window", type=int, default=8)
parser.add_argument("--hidden", type=int, default=96)
parser.add_argument("--batch", type=int, default=64)
parser.add_argument("--lr", type=float, default=0.15)
parser.add_argument("--seed", type=int, default=42)
args = parser.parse_args()
text = CORPUS.read_text(encoding="utf-8")
if args.chars > len(text):
print(f"corpus has only {len(text)} chars; using all of them")
args.chars = len(text)
slice_, vocab, history, params, weights = train(
text, args.chars, args.epochs, args.window, args.hidden,
args.batch, args.lr, args.seed)
ran_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
MODEL.mkdir(exist_ok=True)
# Tokens follow the Mímir convention: per epoch. One token = one character.
tokens_per_epoch = len(slice_)
run = {
"dataset": DATASET_ID,
"ranAt": ran_at,
"epochs": args.epochs,
"tokensPerEpoch": tokens_per_epoch,
"window": args.window,
"hidden": args.hidden,
"vocabSize": len(vocab),
"parameters": int(params),
"lossHistory": history,
"finalLoss": history[-1],
"modelFile": "model/tiny-lm-v1.npz",
}
(MODEL / "training-run.json").write_text(json.dumps(run, indent=2) + "\n")
np.savez_compressed(MODEL / "tiny-lm-v1.npz", *weights)
record = {
"@context": "https://sourcelume.apache.org/context/0.0.1/sourcelume.jsonld",
"id": MODEL_ID,
"type": "TrainingProvenanceRecord",
"identifier": "tiny-lm",
"name": "Tiny char-LM (Apache Sourcelume training demo)",
"version": "v1",
"creator": [
{
"type": "schema:Organization",
"name": "Apache Sourcelume training demo",
"role": "originator"
}
],
"created": ran_at,
"disclosure": "complete",
"trainingData": [
{
"dataset": DATASET_ID,
"phase": "pretraining",
"tokens": tokens_per_epoch,
"share": 1.0
}
],
"note": (
f"Generated by src/train.py in apache/sourcelume-sample from a real "
f"run on {ran_at} — an illustrative demo model, not a published model. "
f"Character-level tokens: one token = one character; tokens are per epoch, "
f"and this run trained for {args.epochs} epochs over a "
f"{tokens_per_epoch}-character slice of the referenced corpus "
f"({len(vocab)}-character vocabulary, {params} parameters, "
f"final loss {history[-1]:.4f}, see model/training-run.json). "
f"The referenced dataset record is published by this demo itself "
f"(data/provenance-record.json). Unsigned producer claim — see README."
),
}
(MODEL / "provenance-record.json").write_text(json.dumps(record, indent=2) + "\n")
print()
print(f"dataset record : {DATA / 'provenance-record.json'} (authored, static)")
print(f"training run : {MODEL / 'training-run.json'}")
print(f"model record : {MODEL / 'provenance-record.json'} (born from this run)")
print(f"weights : {MODEL / 'tiny-lm-v1.npz'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())