Download scripts/chat.py from CodeSoft/MetaDiffusion-150M-ChatBase: direct link, hf CLI and curl.
- Browser
- Download file 11.6 kB
-
https://huggingface.co/CodeSoft/MetaDiffusion-150M-ChatBase/resolve/main/scripts/chat.py
- Command line
-
hf download hf://CodeSoft/MetaDiffusion-150M-ChatBase/scripts/chat.py
-
curl -L -o chat.py https://huggingface.co/CodeSoft/MetaDiffusion-150M-ChatBase/resolve/main/scripts/chat.py
11.6 kB
| #!/usr/bin/env python3 | |
| """ | |
| 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 # noqa: E402 | |
| 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)) | |
| 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)) | |
| # Never predict the mask token | |
| logits[:, :, mask_id] = -1e9 | |
| # Repetition penalty over everything already on the sequence | |
| 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(): | |
| # Left-to-right commit: fill the leftmost masked positions first | |
| # (semi-autoregressive block generation, as in LLaDA-MoE eval). | |
| # Confidence-based commit lets <|im_end|> win the race at ANY | |
| # position and commits mid-block tokens before position 0, which | |
| # produced empty responses and fragment-style output on this model. | |
| 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(): | |
| # live in-place update | |
| sys.stdout.write("\r" + line[:99].ljust(99)) | |
| sys.stdout.flush() | |
| elif i % max(1, num_steps // 8) == 0: | |
| print(line) | |
| # Stop early once the model commits a terminator in the response | |
| 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) | |
| # Retry on empty responses: small models occasionally commit <|im_end|> | |
| # as the first token. Bump temperature per attempt | |
| 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 # exported dirs keep the tokenizer at root | |
| 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() | |