| |
| """ |
| chat.py: Multi-turn ChatML chat with MetaDiffusion chat-SFT models. |
| |
| Works with: |
| - an exported dir (config.json + model.safetensors + tokenizer/) |
| - a training checkpoint (step_*.pt) with --tokenizer |
| |
| Usage: |
| Interactive: |
| python3 chat.py --model-path MetaDiffusion-150M-ChatBase/ |
| python3 chat.py --model-path checkpoints_chat/best.pt \ |
| --tokenizer data/tokenizer |
| |
| One-shot: |
| python3 chat.py --model-path MetaDiffusion-150M-ChatBase/ \ |
| --prompt "What is 2+2?" --max-new-tokens 128 |
| """ |
|
|
| import argparse |
| import json |
| import math |
| import sys |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn.functional as F |
| from safetensors.torch import load_file |
| from transformers import AutoTokenizer |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
| from model import MetaDiffusionLM, MetaDiffusionConfig |
|
|
| MASK_TOKEN_ID = 32000 |
| CHAT_TOKENS = ["<|im_start|>", "<|im_end|>"] + [f"<|r{i}|>" for i in range(1, 8)] |
| IM_START, IM_END = "<|im_start|>", "<|im_end|>" |
|
|
|
|
| def build_config(config_dict): |
| valid = {k: v for k, v in config_dict.items() |
| if k in MetaDiffusionConfig.__dataclass_fields__} |
| config = MetaDiffusionConfig(**valid) |
| config.tie_word_embeddings = False |
| return config |
|
|
|
|
| def load_model(model_path, device): |
| path = Path(model_path) |
| if path.is_dir(): |
| with open(path / "config.json") as f: |
| config = build_config(json.load(f)) |
| model = MetaDiffusionLM(config).to(device) |
| sd = load_file(path / "model.safetensors") |
| sd = {k[len("model."):] if k.startswith("model.") else k: v |
| for k, v in sd.items()} |
| missing, unexpected = model.load_state_dict(sd, strict=False) |
| if missing or unexpected: |
| print(f" Warning: missing={missing[:3]} unexpected={unexpected[:3]}") |
| else: |
| ckpt = torch.load(model_path, map_location=device, weights_only=False) |
| config = build_config(ckpt["config"]) |
| model = MetaDiffusionLM(config).to(device) |
| sd = {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v |
| for k, v in ckpt["model_state_dict"].items()} |
| model.load_state_dict(sd) |
| print(f" Loaded {sum(p.numel() for p in model.parameters())/1e6:.1f}M params, " |
| f"vocab={config.mask_vocab_size}") |
| return model |
|
|
|
|
| def ensure_chat_tokens(tokenizer): |
| """Add ChatML + rainbow tokens if missing (base tokenizer case). |
| |
| Id 32000 is the diffusion [MASK] id, so a reserved filler token takes it |
| first; chat tokens must land at 32001..32009. |
| """ |
| if tokenizer.convert_tokens_to_ids(IM_START) == tokenizer.unk_token_id: |
| if len(tokenizer) == 32000: |
| tokenizer.add_special_tokens({"additional_special_tokens": ["<|reserved|>"]}) |
| tokenizer.add_special_tokens({"additional_special_tokens": CHAT_TOKENS}) |
| assert tokenizer.convert_tokens_to_ids(IM_END) == 32002, \ |
| "chat token ids wrong (collide with mask id 32000)" |
| return tokenizer |
|
|
|
|
| def format_messages(messages): |
| parts = [] |
| for m in messages: |
| parts.append(f"{IM_START}{m['role']}\n{m['content']}{IM_END}") |
| return "\n".join(parts) |
|
|
|
|
| def cumulative_unmask_frac(i, N): |
| return 0.5 * (1 - math.cos(math.pi * i / N)) |
|
|
|
|
| @torch.no_grad() |
| def generate_response(model, tokenizer, prompt_ids, gen_len, num_steps, |
| temperature, repetition_penalty, device, watch=False, |
| stop_on_end=True): |
| """Denoise a block of [MASK] tokens after the prompt (inference.py schedule). |
| |
| With stop_on_end=True, denoising halts as soon as <|im_end|> or </s> is |
| committed in the response region: committed tokens never change, so the |
| output is identical, but we skip filling garbage after the terminator. |
| """ |
| model.eval() |
| total_len = prompt_ids.shape[1] + gen_len |
| x = torch.full((1, total_len), MASK_TOKEN_ID, device=device, dtype=torch.long) |
| x[0, : prompt_ids.shape[1]] = prompt_ids |
| mask_id = MASK_TOKEN_ID |
| im_end_id = tokenizer.convert_tokens_to_ids(IM_END) |
| eos_id = tokenizer.eos_token_id |
| prompt_len = prompt_ids.shape[1] |
|
|
| for i in range(num_steps): |
| frac_now = cumulative_unmask_frac(i, num_steps) |
| frac_next = cumulative_unmask_frac(i + 1, num_steps) |
|
|
| n_masked = (x == mask_id).sum().item() |
| n_total = int((frac_next - frac_now) * gen_len + 0.5) |
| if i == num_steps - 1: |
| n_unmask = n_masked |
| else: |
| n_unmask = max(n_total, 1) if n_masked > 0 else 0 |
|
|
| t = 1.0 - frac_now |
| logits = model(x, torch.full((1,), t, device=device)) |
|
|
| |
| logits[:, :, mask_id] = -1e9 |
|
|
| |
| if repetition_penalty != 1.0: |
| for tok in x[0].unique(): |
| ti = tok.item() |
| logits[0, :, ti] = torch.where( |
| logits[0, :, ti] < 0, |
| logits[0, :, ti] * repetition_penalty, |
| logits[0, :, ti] / repetition_penalty, |
| ) |
|
|
| mask_positions = x == mask_id |
| mask_logits = logits[mask_positions] |
| probs = F.softmax(mask_logits / temperature, dim=-1) |
| sampled = torch.multinomial(probs, 1).squeeze(-1) |
| mask_flat = mask_positions.nonzero(as_tuple=False) |
|
|
| if n_unmask < mask_positions.sum(): |
| |
| |
| |
| |
| |
| fill_positions = mask_flat[:n_unmask] |
| for idx, tok in zip(fill_positions, sampled[:n_unmask]): |
| x[idx[0], idx[1]] = tok |
| else: |
| x[mask_positions] = sampled |
|
|
| if watch: |
| remaining = (x == mask_id).sum().item() |
| live_toks = [t for t in x[0, prompt_len:].tolist() if t != mask_id] |
| partial = tokenizer.decode( |
| cut_response(live_toks, tokenizer), skip_special_tokens=True |
| ).strip() |
| if len(partial) > 70: |
| partial = partial[:70] + "..." |
| line = (f"step {i+1:3d}/{num_steps} | t={t:.3f} " |
| f"| masks={remaining:3d} | {partial}") |
| if sys.stdout.isatty(): |
| |
| sys.stdout.write("\r" + line[:99].ljust(99)) |
| sys.stdout.flush() |
| elif i % max(1, num_steps // 8) == 0: |
| print(line) |
|
|
| |
| if stop_on_end and ((x[0, prompt_len:] == im_end_id).any() or |
| (x[0, prompt_len:] == eos_id).any()): |
| break |
|
|
| if watch and sys.stdout.isatty(): |
| sys.stdout.write("\n") |
| return x |
|
|
|
|
| def cut_response(tokens, tokenizer): |
| """Cut generated token list at <|im_end|> or </s>; drop rainbow/pad tokens.""" |
| im_end_id = tokenizer.convert_tokens_to_ids(IM_END) |
| eos_id = tokenizer.eos_token_id |
| rainbow_ids = {tokenizer.convert_tokens_to_ids(f"<|r{i}|>") for i in range(1, 8)} |
| out = [] |
| for t in tokens: |
| if t == im_end_id or t == eos_id: |
| break |
| if t in rainbow_ids or t == tokenizer.pad_token_id: |
| continue |
| out.append(t) |
| return out |
|
|
|
|
| def run_turn(model, tokenizer, messages, args, device): |
| prompt = format_messages(messages) + f"\n{IM_START}assistant\n" |
| prompt_ids = torch.tensor([tokenizer.encode(prompt, add_special_tokens=False)], |
| device=device) |
| |
| |
| for attempt in range(3): |
| x = generate_response(model, tokenizer, prompt_ids, args.max_new_tokens, |
| args.num_steps, |
| args.temperature * (1 + 0.15 * attempt), |
| args.repetition_penalty, device, watch=args.watch) |
| response_tokens = x[0, prompt_ids.shape[1]:].tolist() |
| response_tokens = cut_response(response_tokens, tokenizer) |
| text = tokenizer.decode(response_tokens, skip_special_tokens=True).strip() |
| if text: |
| return text |
| return "(empty response)" |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="MetaDiffusion chat (ChatML)") |
| parser.add_argument("--model-path", required=True, |
| help="Exported dir (config+safetensors+tokenizer) or step_*.pt") |
| parser.add_argument("--tokenizer", default=None, |
| help="Tokenizer dir (needed when --model-path is a step_*.pt)") |
| parser.add_argument("--prompt", default=None, help="One-shot prompt (else REPL)") |
| parser.add_argument("--system", default="You are a helpful assistant.", |
| help="System prompt for the REPL") |
| parser.add_argument("--max-new-tokens", type=int, default=96, |
| help="Response block size)") |
| parser.add_argument("--num-steps", type=int, default=128, help="Denoising steps") |
| parser.add_argument("--temperature", type=float, default=0.7) |
| parser.add_argument("--repetition-penalty", type=float, default=1.5, |
| help="Small diffusion models loop without a strong penalty") |
| parser.add_argument("--device", default="cuda") |
| parser.add_argument("--watch", action="store_true", help="Show denoising progress") |
| args = parser.parse_args() |
|
|
| device = torch.device(args.device if torch.cuda.is_available() else "cpu") |
| print(f"[*] Loading model from {args.model_path}") |
| model = load_model(args.model_path, device) |
|
|
| model_path = Path(args.model_path) |
| tok_path = args.tokenizer |
| if tok_path is None: |
| if model_path.is_dir(): |
| cand = model_path / "tokenizer" |
| if not cand.exists() and (model_path / "tokenizer.json").exists(): |
| cand = model_path |
| tok_path = str(cand) |
| if tok_path is None or not Path(tok_path).exists(): |
| raise Exception("No tokenizer") |
| tokenizer = AutoTokenizer.from_pretrained(str(tok_path)) |
| tokenizer = ensure_chat_tokens(tokenizer) |
| print(f"[*] Tokenizer: {tok_path} (vocab {len(tokenizer)})") |
|
|
| if args.prompt: |
| messages = [{"role": "user", "content": args.prompt}] |
| text = run_turn(model, tokenizer, messages, args, device) |
| print(f"\nUser: {args.prompt}\nAssistant: {text}\n") |
| return |
|
|
| print("\nMetaDiffusion chat, type 'exit', 'quit' or Ctrl-D to leave.\n") |
| messages = [{"role": "system", "content": args.system}] |
| while True: |
| try: |
| user_input = input("You: ").strip() |
| except (EOFError, KeyboardInterrupt): |
| print() |
| break |
| if user_input.lower() in ("exit", "quit"): |
| break |
| if not user_input: |
| continue |
| messages.append({"role": "user", "content": user_input}) |
| text = run_turn(model, tokenizer, messages, args, device) |
| print(f"Assistant: {text}\n") |
| messages.append({"role": "assistant", "content": text}) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|