| |
| """PALIMPSESTE — Train a conversational chat model. |
| |
| Trains on Q/A pairs (from corpus_chat.py or a JSON file) so the model learns |
| to respond to user messages. Each pair is encoded as a self-contained episode: |
| BOS question EOS BOS answer EOS. |
| |
| Usage: |
| # train on the built-in corpus |
| python examples/train_chat.py --preset small --output ./chat_model |
| |
| # train on custom Q/A pairs from a JSON file: [{"q": "...", "a": "..."}, ...] |
| python examples/train_chat.py --qa-file my_pairs.json --output ./chat_model |
| |
| # train at 1B-scale capacity |
| python examples/train_chat.py --preset 1b --output ./chat_model_1b |
| |
| # train and push to HF Hub |
| python examples/train_chat.py --preset small --output ./chat_model --push-to-hub user/palimpseste-chat |
| |
| # then chat interactively: |
| python examples/chat.py --model ./chat_model |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import sys |
| from pathlib import Path |
|
|
| import numpy as np |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
|
|
| from palimseste.lm import PalimpsesteConfig, PRESETS |
| from palimseste.hf import HFPalimpsesteLM |
| from corpus_chat import get_corpus |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| p = argparse.ArgumentParser( |
| description="Train a PALIMPSESTE conversational chat model.", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=__doc__, |
| ) |
| p.add_argument("--output", "-o", type=str, required=True, |
| help="output directory for the saved model") |
| p.add_argument("--preset", type=str, default="small", |
| choices=list(PRESETS.keys()), |
| help="capacity preset (default: small)") |
| p.add_argument("--qa-file", type=str, default=None, |
| help="JSON file with [{q, a}, ...]; default: built-in corpus") |
| p.add_argument("--D", type=int, default=None, help="override D") |
| p.add_argument("--context-window", type=int, default=None, |
| help="override context window (default: 64 for chat)") |
| p.add_argument("--kernel-radius", type=int, default=None, |
| help="override kernel radius") |
| p.add_argument("--temperature", type=float, default=0.3, |
| help="sampling temperature (default: 0.3 for focused answers)") |
| p.add_argument("--push-to-hub", type=str, default=None) |
| p.add_argument("--hub-token", type=str, default=None) |
| p.add_argument("--seed", type=int, default=42) |
| p.add_argument("--epochs", type=int, default=1, |
| help="number of passes over the corpus (default: 1; " |
| "PALIMPSESTE is append-only so >1 reinforces)") |
| return p.parse_args() |
|
|
|
|
| def load_pairs(args: argparse.Namespace) -> list[tuple[str, str]]: |
| if args.qa_file: |
| with open(args.qa_file, "r", encoding="utf-8") as f: |
| data = json.load(f) |
| return [(d["q"], d["a"]) for d in data] |
| return get_corpus() |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| pairs = load_pairs(args) |
|
|
| |
| config = PRESETS[args.preset] |
| if args.D is not None: |
| config = PalimpsesteConfig(D=args.D, context_window=config.context_window, |
| kernel_radius=config.kernel_radius) |
| if args.context_window is not None: |
| config.context_window = args.context_window |
| elif args.context_window is None and config.context_window < 48: |
| config.context_window = 64 |
| if args.kernel_radius is not None: |
| config.kernel_radius = args.kernel_radius |
| config.temperature = args.temperature |
|
|
| print("=" * 70) |
| print("PALIMPSESTE — conversational chat training") |
| print("=" * 70) |
| print(f"config: D={config.D:,} context_window={config.context_window} " |
| f"kernel_radius={config.kernel_radius} temperature={config.temperature}") |
| print(f"Q/A pairs: {len(pairs)}") |
|
|
| rng = np.random.default_rng(args.seed) |
| lm = HFPalimpsesteLM(config=config, rng=rng) |
|
|
| |
| full_text = "".join(q + a for q, a in pairs) |
| lm.build_tokenizer(full_text) |
| print(f"vocab: {lm.tokenizer.vocab_size} chars") |
|
|
| |
| import time |
| total = 0 |
| for epoch in range(args.epochs): |
| t0 = time.perf_counter() |
| n = lm.train_on_qa_pairs(pairs, verbose=(args.epochs == 1)) |
| dt = time.perf_counter() - t0 |
| total += n |
| print(f" epoch {epoch+1}/{args.epochs}: {n} tokens in {dt:.2f}s " |
| f"({n/max(dt,1e-9):,.0f} tok/s), |M|={len(lm.mem)}") |
|
|
| print(f"\ntotal: {total} tokens, |M| = {len(lm.mem):,}") |
|
|
| |
| print("\n--- sample responses ---") |
| from palimseste.chat import Conversation |
| conv = Conversation(model=lm, learn_live=True) |
| conv.register_questions(pairs) |
| test_inputs = [ |
| "bonjour", |
| "qui es-tu", |
| "comment tu apprends", |
| "tu utilises un gpu", |
| "tu utilise un gpu", |
| "who are you", |
| "how do you learn", |
| "c quoi palimpseste", |
| "merci", |
| ] |
| for q in test_inputs: |
| resp = conv.respond(q, temperature=0.0, seed=0) |
| print(f" Q: {q}") |
| print(f" A: {resp}") |
| print() |
| conv.reset() |
|
|
| |
| out_dir = Path(args.output) |
| print(f"saving to {out_dir} ...") |
| lm.save_pretrained(out_dir) |
|
|
| |
| if args.push_to_hub: |
| print(f"\npushing to HF Hub: {args.push_to_hub} ...") |
| url = lm.push_to_hub(args.push_to_hub, token=args.hub_token) |
| print(f"pushed: {url}") |
|
|
| print("\n" + "=" * 70) |
| print("done. Chat with:") |
| print(f" python examples/chat.py --model {out_dir}") |
| print("=" * 70) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|