Upload 12 files
Browse files- config.json +24 -0
- generation_config.json +13 -0
- model.safetensors +3 -0
- scripts/chat.py +284 -0
- scripts/convert_data.py +208 -0
- scripts/export_hf.py +181 -0
- scripts/model.py +526 -0
- scripts/prepare_data.py +411 -0
- scripts/requirements.txt +7 -0
- scripts/train_chat.py +698 -0
- tokenizer.json +0 -0
- tokenizer_config.json +23 -0
config.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"hidden_size": 768,
|
| 3 |
+
"intermediate_size": 2112,
|
| 4 |
+
"num_hidden_layers": 16,
|
| 5 |
+
"num_attention_heads": 12,
|
| 6 |
+
"num_key_value_heads": 6,
|
| 7 |
+
"head_dim": 64,
|
| 8 |
+
"vocab_size": 32010,
|
| 9 |
+
"mask_vocab_size": 32010,
|
| 10 |
+
"max_position_embeddings": 5120,
|
| 11 |
+
"rope_theta": 10000.0,
|
| 12 |
+
"rms_norm_eps": 1e-06,
|
| 13 |
+
"hidden_act": "silu",
|
| 14 |
+
"timestep_emb_hidden": 768,
|
| 15 |
+
"mask_token_id": 32000,
|
| 16 |
+
"pad_token_id": 1,
|
| 17 |
+
"mask_ratio_min": 0.0,
|
| 18 |
+
"mask_ratio_max": 1.0,
|
| 19 |
+
"tie_word_embeddings": false,
|
| 20 |
+
"model_type": "metadiffusion",
|
| 21 |
+
"architectures": [
|
| 22 |
+
"MetaDiffusionForCausalLM"
|
| 23 |
+
]
|
| 24 |
+
}
|
generation_config.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"bos_token_id": 0,
|
| 3 |
+
"eos_token_id": 2,
|
| 4 |
+
"pad_token_id": 1,
|
| 5 |
+
"mask_token_id": 32000,
|
| 6 |
+
"temperature": 0.7,
|
| 7 |
+
"repetition_penalty": 1.5,
|
| 8 |
+
"re_mask": 0.1,
|
| 9 |
+
"num_steps": 128,
|
| 10 |
+
"max_new_tokens": 96,
|
| 11 |
+
"use_cache": false,
|
| 12 |
+
"transformers_version": "4.40.0"
|
| 13 |
+
}
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5d7d0ce3745a3a99ff6863849dff20f2b536f0e2d2092fe263072056715c01d5
|
| 3 |
+
size 339086208
|
scripts/chat.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
chat.py: Multi-turn ChatML chat with MetaDiffusion chat-SFT models.
|
| 4 |
+
|
| 5 |
+
Works with:
|
| 6 |
+
- an exported dir (config.json + model.safetensors + tokenizer/)
|
| 7 |
+
- a training checkpoint (step_*.pt) with --tokenizer
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
Interactive:
|
| 11 |
+
python3 chat.py --model-path MetaDiffusion-150M-ChatBase/
|
| 12 |
+
python3 chat.py --model-path checkpoints_chat/best.pt \
|
| 13 |
+
--tokenizer data/tokenizer
|
| 14 |
+
|
| 15 |
+
One-shot:
|
| 16 |
+
python3 chat.py --model-path MetaDiffusion-150M-ChatBase/ \
|
| 17 |
+
--prompt "What is 2+2?" --max-new-tokens 128
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import math
|
| 23 |
+
import sys
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn.functional as F
|
| 28 |
+
from safetensors.torch import load_file
|
| 29 |
+
from transformers import AutoTokenizer
|
| 30 |
+
|
| 31 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 32 |
+
from model import MetaDiffusionLM, MetaDiffusionConfig # noqa: E402
|
| 33 |
+
|
| 34 |
+
MASK_TOKEN_ID = 32000
|
| 35 |
+
CHAT_TOKENS = ["<|im_start|>", "<|im_end|>"] + [f"<|r{i}|>" for i in range(1, 8)]
|
| 36 |
+
IM_START, IM_END = "<|im_start|>", "<|im_end|>"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def build_config(config_dict):
|
| 40 |
+
valid = {k: v for k, v in config_dict.items()
|
| 41 |
+
if k in MetaDiffusionConfig.__dataclass_fields__}
|
| 42 |
+
config = MetaDiffusionConfig(**valid)
|
| 43 |
+
config.tie_word_embeddings = False
|
| 44 |
+
return config
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_model(model_path, device):
|
| 48 |
+
path = Path(model_path)
|
| 49 |
+
if path.is_dir():
|
| 50 |
+
with open(path / "config.json") as f:
|
| 51 |
+
config = build_config(json.load(f))
|
| 52 |
+
model = MetaDiffusionLM(config).to(device)
|
| 53 |
+
sd = load_file(path / "model.safetensors")
|
| 54 |
+
sd = {k[len("model."):] if k.startswith("model.") else k: v
|
| 55 |
+
for k, v in sd.items()}
|
| 56 |
+
missing, unexpected = model.load_state_dict(sd, strict=False)
|
| 57 |
+
if missing or unexpected:
|
| 58 |
+
print(f" Warning: missing={missing[:3]} unexpected={unexpected[:3]}")
|
| 59 |
+
else:
|
| 60 |
+
ckpt = torch.load(model_path, map_location=device, weights_only=False)
|
| 61 |
+
config = build_config(ckpt["config"])
|
| 62 |
+
model = MetaDiffusionLM(config).to(device)
|
| 63 |
+
sd = {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v
|
| 64 |
+
for k, v in ckpt["model_state_dict"].items()}
|
| 65 |
+
model.load_state_dict(sd)
|
| 66 |
+
print(f" Loaded {sum(p.numel() for p in model.parameters())/1e6:.1f}M params, "
|
| 67 |
+
f"vocab={config.mask_vocab_size}")
|
| 68 |
+
return model
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def ensure_chat_tokens(tokenizer):
|
| 72 |
+
"""Add ChatML + rainbow tokens if missing (base tokenizer case).
|
| 73 |
+
|
| 74 |
+
Id 32000 is the diffusion [MASK] id, so a reserved filler token takes it
|
| 75 |
+
first; chat tokens must land at 32001..32009.
|
| 76 |
+
"""
|
| 77 |
+
if tokenizer.convert_tokens_to_ids(IM_START) == tokenizer.unk_token_id:
|
| 78 |
+
if len(tokenizer) == 32000:
|
| 79 |
+
tokenizer.add_special_tokens({"additional_special_tokens": ["<|reserved|>"]})
|
| 80 |
+
tokenizer.add_special_tokens({"additional_special_tokens": CHAT_TOKENS})
|
| 81 |
+
assert tokenizer.convert_tokens_to_ids(IM_END) == 32002, \
|
| 82 |
+
"chat token ids wrong (collide with mask id 32000)"
|
| 83 |
+
return tokenizer
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def format_messages(messages):
|
| 87 |
+
parts = []
|
| 88 |
+
for m in messages:
|
| 89 |
+
parts.append(f"{IM_START}{m['role']}\n{m['content']}{IM_END}")
|
| 90 |
+
return "\n".join(parts)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def cumulative_unmask_frac(i, N):
|
| 94 |
+
return 0.5 * (1 - math.cos(math.pi * i / N))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@torch.no_grad()
|
| 98 |
+
def generate_response(model, tokenizer, prompt_ids, gen_len, num_steps,
|
| 99 |
+
temperature, repetition_penalty, device, watch=False,
|
| 100 |
+
stop_on_end=True):
|
| 101 |
+
"""Denoise a block of [MASK] tokens after the prompt (inference.py schedule).
|
| 102 |
+
|
| 103 |
+
With stop_on_end=True, denoising halts as soon as <|im_end|> or </s> is
|
| 104 |
+
committed in the response region: committed tokens never change, so the
|
| 105 |
+
output is identical, but we skip filling garbage after the terminator.
|
| 106 |
+
"""
|
| 107 |
+
model.eval()
|
| 108 |
+
total_len = prompt_ids.shape[1] + gen_len
|
| 109 |
+
x = torch.full((1, total_len), MASK_TOKEN_ID, device=device, dtype=torch.long)
|
| 110 |
+
x[0, : prompt_ids.shape[1]] = prompt_ids
|
| 111 |
+
mask_id = MASK_TOKEN_ID
|
| 112 |
+
im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
|
| 113 |
+
eos_id = tokenizer.eos_token_id
|
| 114 |
+
prompt_len = prompt_ids.shape[1]
|
| 115 |
+
|
| 116 |
+
for i in range(num_steps):
|
| 117 |
+
frac_now = cumulative_unmask_frac(i, num_steps)
|
| 118 |
+
frac_next = cumulative_unmask_frac(i + 1, num_steps)
|
| 119 |
+
|
| 120 |
+
n_masked = (x == mask_id).sum().item()
|
| 121 |
+
n_total = int((frac_next - frac_now) * gen_len + 0.5)
|
| 122 |
+
if i == num_steps - 1:
|
| 123 |
+
n_unmask = n_masked
|
| 124 |
+
else:
|
| 125 |
+
n_unmask = max(n_total, 1) if n_masked > 0 else 0
|
| 126 |
+
|
| 127 |
+
t = 1.0 - frac_now
|
| 128 |
+
logits = model(x, torch.full((1,), t, device=device))
|
| 129 |
+
|
| 130 |
+
# Never predict the mask token
|
| 131 |
+
logits[:, :, mask_id] = -1e9
|
| 132 |
+
|
| 133 |
+
# Repetition penalty over everything already on the sequence
|
| 134 |
+
if repetition_penalty != 1.0:
|
| 135 |
+
for tok in x[0].unique():
|
| 136 |
+
ti = tok.item()
|
| 137 |
+
logits[0, :, ti] = torch.where(
|
| 138 |
+
logits[0, :, ti] < 0,
|
| 139 |
+
logits[0, :, ti] * repetition_penalty,
|
| 140 |
+
logits[0, :, ti] / repetition_penalty,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
mask_positions = x == mask_id
|
| 144 |
+
mask_logits = logits[mask_positions]
|
| 145 |
+
probs = F.softmax(mask_logits / temperature, dim=-1)
|
| 146 |
+
sampled = torch.multinomial(probs, 1).squeeze(-1)
|
| 147 |
+
mask_flat = mask_positions.nonzero(as_tuple=False)
|
| 148 |
+
|
| 149 |
+
if n_unmask < mask_positions.sum():
|
| 150 |
+
# Left-to-right commit: fill the leftmost masked positions first
|
| 151 |
+
# (semi-autoregressive block generation, as in LLaDA-MoE eval).
|
| 152 |
+
# Confidence-based commit lets <|im_end|> win the race at ANY
|
| 153 |
+
# position and commits mid-block tokens before position 0, which
|
| 154 |
+
# produced empty responses and fragment-style output on this model.
|
| 155 |
+
fill_positions = mask_flat[:n_unmask]
|
| 156 |
+
for idx, tok in zip(fill_positions, sampled[:n_unmask]):
|
| 157 |
+
x[idx[0], idx[1]] = tok
|
| 158 |
+
else:
|
| 159 |
+
x[mask_positions] = sampled
|
| 160 |
+
|
| 161 |
+
if watch:
|
| 162 |
+
remaining = (x == mask_id).sum().item()
|
| 163 |
+
live_toks = [t for t in x[0, prompt_len:].tolist() if t != mask_id]
|
| 164 |
+
partial = tokenizer.decode(
|
| 165 |
+
cut_response(live_toks, tokenizer), skip_special_tokens=True
|
| 166 |
+
).strip()
|
| 167 |
+
if len(partial) > 70:
|
| 168 |
+
partial = partial[:70] + "..."
|
| 169 |
+
line = (f"step {i+1:3d}/{num_steps} | t={t:.3f} "
|
| 170 |
+
f"| masks={remaining:3d} | {partial}")
|
| 171 |
+
if sys.stdout.isatty():
|
| 172 |
+
# live in-place update
|
| 173 |
+
sys.stdout.write("\r" + line[:99].ljust(99))
|
| 174 |
+
sys.stdout.flush()
|
| 175 |
+
elif i % max(1, num_steps // 8) == 0:
|
| 176 |
+
print(line)
|
| 177 |
+
|
| 178 |
+
# Stop early once the model commits a terminator in the response
|
| 179 |
+
if stop_on_end and ((x[0, prompt_len:] == im_end_id).any() or
|
| 180 |
+
(x[0, prompt_len:] == eos_id).any()):
|
| 181 |
+
break
|
| 182 |
+
|
| 183 |
+
if watch and sys.stdout.isatty():
|
| 184 |
+
sys.stdout.write("\n")
|
| 185 |
+
return x
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def cut_response(tokens, tokenizer):
|
| 189 |
+
"""Cut generated token list at <|im_end|> or </s>; drop rainbow/pad tokens."""
|
| 190 |
+
im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
|
| 191 |
+
eos_id = tokenizer.eos_token_id
|
| 192 |
+
rainbow_ids = {tokenizer.convert_tokens_to_ids(f"<|r{i}|>") for i in range(1, 8)}
|
| 193 |
+
out = []
|
| 194 |
+
for t in tokens:
|
| 195 |
+
if t == im_end_id or t == eos_id:
|
| 196 |
+
break
|
| 197 |
+
if t in rainbow_ids or t == tokenizer.pad_token_id:
|
| 198 |
+
continue
|
| 199 |
+
out.append(t)
|
| 200 |
+
return out
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def run_turn(model, tokenizer, messages, args, device):
|
| 204 |
+
prompt = format_messages(messages) + f"\n{IM_START}assistant\n"
|
| 205 |
+
prompt_ids = torch.tensor([tokenizer.encode(prompt, add_special_tokens=False)],
|
| 206 |
+
device=device)
|
| 207 |
+
# Retry on empty responses: small models occasionally commit <|im_end|>
|
| 208 |
+
# as the first token. Bump temperature per attempt
|
| 209 |
+
for attempt in range(3):
|
| 210 |
+
x = generate_response(model, tokenizer, prompt_ids, args.max_new_tokens,
|
| 211 |
+
args.num_steps,
|
| 212 |
+
args.temperature * (1 + 0.15 * attempt),
|
| 213 |
+
args.repetition_penalty, device, watch=args.watch)
|
| 214 |
+
response_tokens = x[0, prompt_ids.shape[1]:].tolist()
|
| 215 |
+
response_tokens = cut_response(response_tokens, tokenizer)
|
| 216 |
+
text = tokenizer.decode(response_tokens, skip_special_tokens=True).strip()
|
| 217 |
+
if text:
|
| 218 |
+
return text
|
| 219 |
+
return "(empty response)"
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def main():
|
| 223 |
+
parser = argparse.ArgumentParser(description="MetaDiffusion chat (ChatML)")
|
| 224 |
+
parser.add_argument("--model-path", required=True,
|
| 225 |
+
help="Exported dir (config+safetensors+tokenizer) or step_*.pt")
|
| 226 |
+
parser.add_argument("--tokenizer", default=None,
|
| 227 |
+
help="Tokenizer dir (needed when --model-path is a step_*.pt)")
|
| 228 |
+
parser.add_argument("--prompt", default=None, help="One-shot prompt (else REPL)")
|
| 229 |
+
parser.add_argument("--system", default="You are a helpful assistant.",
|
| 230 |
+
help="System prompt for the REPL")
|
| 231 |
+
parser.add_argument("--max-new-tokens", type=int, default=96,
|
| 232 |
+
help="Response block size)")
|
| 233 |
+
parser.add_argument("--num-steps", type=int, default=128, help="Denoising steps")
|
| 234 |
+
parser.add_argument("--temperature", type=float, default=0.7)
|
| 235 |
+
parser.add_argument("--repetition-penalty", type=float, default=1.5,
|
| 236 |
+
help="Small diffusion models loop without a strong penalty")
|
| 237 |
+
parser.add_argument("--device", default="cuda")
|
| 238 |
+
parser.add_argument("--watch", action="store_true", help="Show denoising progress")
|
| 239 |
+
args = parser.parse_args()
|
| 240 |
+
|
| 241 |
+
device = torch.device(args.device if torch.cuda.is_available() else "cpu")
|
| 242 |
+
print(f"[*] Loading model from {args.model_path}")
|
| 243 |
+
model = load_model(args.model_path, device)
|
| 244 |
+
|
| 245 |
+
model_path = Path(args.model_path)
|
| 246 |
+
tok_path = args.tokenizer
|
| 247 |
+
if tok_path is None:
|
| 248 |
+
if model_path.is_dir():
|
| 249 |
+
cand = model_path / "tokenizer"
|
| 250 |
+
if not cand.exists() and (model_path / "tokenizer.json").exists():
|
| 251 |
+
cand = model_path # exported dirs keep the tokenizer at root
|
| 252 |
+
tok_path = str(cand)
|
| 253 |
+
if tok_path is None or not Path(tok_path).exists():
|
| 254 |
+
raise Exception("No tokenizer")
|
| 255 |
+
tokenizer = AutoTokenizer.from_pretrained(str(tok_path))
|
| 256 |
+
tokenizer = ensure_chat_tokens(tokenizer)
|
| 257 |
+
print(f"[*] Tokenizer: {tok_path} (vocab {len(tokenizer)})")
|
| 258 |
+
|
| 259 |
+
if args.prompt:
|
| 260 |
+
messages = [{"role": "user", "content": args.prompt}]
|
| 261 |
+
text = run_turn(model, tokenizer, messages, args, device)
|
| 262 |
+
print(f"\nUser: {args.prompt}\nAssistant: {text}\n")
|
| 263 |
+
return
|
| 264 |
+
|
| 265 |
+
print("\nMetaDiffusion chat, type 'exit', 'quit' or Ctrl-D to leave.\n")
|
| 266 |
+
messages = [{"role": "system", "content": args.system}]
|
| 267 |
+
while True:
|
| 268 |
+
try:
|
| 269 |
+
user_input = input("You: ").strip()
|
| 270 |
+
except (EOFError, KeyboardInterrupt):
|
| 271 |
+
print()
|
| 272 |
+
break
|
| 273 |
+
if user_input.lower() in ("exit", "quit"):
|
| 274 |
+
break
|
| 275 |
+
if not user_input:
|
| 276 |
+
continue
|
| 277 |
+
messages.append({"role": "user", "content": user_input})
|
| 278 |
+
text = run_turn(model, tokenizer, messages, args, device)
|
| 279 |
+
print(f"Assistant: {text}\n")
|
| 280 |
+
messages.append({"role": "assistant", "content": text})
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
if __name__ == "__main__":
|
| 284 |
+
main()
|
scripts/convert_data.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
convert_data.py: turn local ChatML datasets into tokenized train.pt/val.pt
|
| 4 |
+
for train_chat.py.
|
| 5 |
+
|
| 6 |
+
Accepts .jsonl, .json, and .parquet files (auto-detected by extension), and
|
| 7 |
+
these record shapes (one conversation per record):
|
| 8 |
+
|
| 9 |
+
{"messages": [{"role": "user", "content": ...}, ...]} # HF ChatML style
|
| 10 |
+
[{"role": ..., "content": ...}, ...] # bare message list
|
| 11 |
+
{"conversation": [...]} or {"chat": [...]} # aliases
|
| 12 |
+
{"instruction": ..., "input": ..., "output": ...} # alpaca style (converted)
|
| 13 |
+
{"data": [...]} / {"conversations": [...]} # JSON containers of the above
|
| 14 |
+
|
| 15 |
+
Parquet rows may store the messages column as a list of dicts or as a JSON
|
| 16 |
+
string (both work).
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
python3 convert_data.py --model-path . --input my_data.jsonl \
|
| 20 |
+
--output data/my_data
|
| 21 |
+
python3 convert_data.py --model-path . --input a.jsonl b.jsonl \
|
| 22 |
+
--output data/mixed
|
| 23 |
+
python3 convert_data.py --model-path . --input ./folder \
|
| 24 |
+
--output data/folder
|
| 25 |
+
|
| 26 |
+
Then train:
|
| 27 |
+
python3 train_chat.py --model-path . --data-dir data/my_data \
|
| 28 |
+
--output-dir my_checkpoints --lr 7e-5 --epochs 3
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
import argparse
|
| 32 |
+
import json
|
| 33 |
+
import sys
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
from transformers import AutoTokenizer
|
| 38 |
+
|
| 39 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 40 |
+
|
| 41 |
+
from prepare_data import ( # noqa: E402
|
| 42 |
+
add_chat_tokens,
|
| 43 |
+
build_conv_segments,
|
| 44 |
+
save_dataset,
|
| 45 |
+
tokenize_and_split,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
SUPPORTED_EXTS = {".jsonl", ".json", ".parquet"}
|
| 49 |
+
CONTAINER_KEYS = ("conversations", "data", "rows", "examples")
|
| 50 |
+
MESSAGE_KEYS = ("messages", "conversation", "chat")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
# File reading
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
def collect_files(paths):
|
| 58 |
+
"""Expand --input args (files and/or dirs) into a sorted file list."""
|
| 59 |
+
files = []
|
| 60 |
+
for p in paths:
|
| 61 |
+
p = Path(p)
|
| 62 |
+
if p.is_dir():
|
| 63 |
+
files.extend(f for f in sorted(p.iterdir())
|
| 64 |
+
if f.suffix.lower() in SUPPORTED_EXTS)
|
| 65 |
+
elif p.suffix.lower() in SUPPORTED_EXTS:
|
| 66 |
+
files.append(p)
|
| 67 |
+
else:
|
| 68 |
+
print(f"[!] Skipping unsupported file: {p} (want .jsonl/.json/.parquet)")
|
| 69 |
+
return files
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def iter_records(path):
|
| 73 |
+
"""Yield one raw record (dict or list) per conversation from a file."""
|
| 74 |
+
ext = path.suffix.lower()
|
| 75 |
+
if ext == ".jsonl":
|
| 76 |
+
with open(path) as f:
|
| 77 |
+
for line in f:
|
| 78 |
+
line = line.strip()
|
| 79 |
+
if not line:
|
| 80 |
+
continue
|
| 81 |
+
yield json.loads(line)
|
| 82 |
+
elif ext == ".json":
|
| 83 |
+
with open(path) as f:
|
| 84 |
+
obj = json.load(f)
|
| 85 |
+
if isinstance(obj, list):
|
| 86 |
+
yield from obj
|
| 87 |
+
elif isinstance(obj, dict):
|
| 88 |
+
for key in CONTAINER_KEYS:
|
| 89 |
+
if isinstance(obj.get(key), list):
|
| 90 |
+
yield from obj[key]
|
| 91 |
+
return
|
| 92 |
+
yield obj # single-conversation file
|
| 93 |
+
elif ext == ".parquet":
|
| 94 |
+
import pandas as pd # lazy: only needed for parquet
|
| 95 |
+
df = pd.read_parquet(path)
|
| 96 |
+
for _, row in df.iterrows():
|
| 97 |
+
yield dict(row)
|
| 98 |
+
else:
|
| 99 |
+
raise ValueError(f"Unsupported extension: {path}")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def normalize_record(rec):
|
| 103 |
+
"""Turn one record into a list of {role, content} messages, or None."""
|
| 104 |
+
if isinstance(rec, list):
|
| 105 |
+
msgs = [m for m in rec
|
| 106 |
+
if isinstance(m, dict) and m.get("content")]
|
| 107 |
+
return msgs or None
|
| 108 |
+
|
| 109 |
+
if not isinstance(rec, dict):
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
# ChatML-style keys (value may be a list of dicts, a numpy array of dicts
|
| 113 |
+
# from parquet round-trips, or a JSON string)
|
| 114 |
+
for key in MESSAGE_KEYS:
|
| 115 |
+
v = rec.get(key)
|
| 116 |
+
if isinstance(v, str):
|
| 117 |
+
try:
|
| 118 |
+
v = json.loads(v)
|
| 119 |
+
except json.JSONDecodeError:
|
| 120 |
+
continue
|
| 121 |
+
if isinstance(v, (list, np.ndarray)):
|
| 122 |
+
msgs = [m for m in v
|
| 123 |
+
if isinstance(m, dict) and m.get("content")]
|
| 124 |
+
if msgs:
|
| 125 |
+
return msgs
|
| 126 |
+
|
| 127 |
+
# Alpaca-style record: instruction / input / output
|
| 128 |
+
if rec.get("instruction") and rec.get("output"):
|
| 129 |
+
user = rec["instruction"]
|
| 130 |
+
if rec.get("input"):
|
| 131 |
+
user += f"\n\n{rec['input']}"
|
| 132 |
+
return [{"role": "user", "content": user},
|
| 133 |
+
{"role": "assistant", "content": rec["output"]}]
|
| 134 |
+
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def load_convs_from_files(files):
|
| 139 |
+
"""Build (roles, segs) conversations from all files."""
|
| 140 |
+
convs = []
|
| 141 |
+
for path in files:
|
| 142 |
+
n_before = len(convs)
|
| 143 |
+
for rec in iter_records(path):
|
| 144 |
+
msgs = normalize_record(rec)
|
| 145 |
+
if msgs is None:
|
| 146 |
+
continue
|
| 147 |
+
segs, roles = build_conv_segments(msgs)
|
| 148 |
+
if not segs:
|
| 149 |
+
continue
|
| 150 |
+
convs.append((roles, segs))
|
| 151 |
+
print(f"[*] {path.name}: {len(convs) - n_before} conversations")
|
| 152 |
+
return convs
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
# Main
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
|
| 159 |
+
def main():
|
| 160 |
+
parser = argparse.ArgumentParser(
|
| 161 |
+
description="Convert local ChatML data (jsonl/json/parquet) to tokenized .pt")
|
| 162 |
+
parser.add_argument("--model-path", default=".",
|
| 163 |
+
help="Dir with tokenizer.json (the release dir works)")
|
| 164 |
+
parser.add_argument("--input", nargs="+", required=True,
|
| 165 |
+
help="File(s) or dir(s): .jsonl, .json, .parquet")
|
| 166 |
+
parser.add_argument("--output", default="data/converted",
|
| 167 |
+
help="Output dir (train.pt, val.pt, tokenizer/, stats.json)")
|
| 168 |
+
parser.add_argument("--val-size", type=int, default=500, help="Held-out examples")
|
| 169 |
+
parser.add_argument("--max-len", type=int, default=1024, help="Max tokens per example")
|
| 170 |
+
parser.add_argument("--max-resp-tokens", type=int, default=256,
|
| 171 |
+
help="Cap on target response tokens (keeps <|im_end|>)")
|
| 172 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 173 |
+
args = parser.parse_args()
|
| 174 |
+
|
| 175 |
+
out = Path(args.output)
|
| 176 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 177 |
+
|
| 178 |
+
print(f"[*] Loading tokenizer from {args.model_path}")
|
| 179 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_path)
|
| 180 |
+
add_chat_tokens(tokenizer)
|
| 181 |
+
tokenizer.save_pretrained(out / "tokenizer")
|
| 182 |
+
|
| 183 |
+
files = collect_files(args.input)
|
| 184 |
+
if not files:
|
| 185 |
+
print("[!] No .jsonl/.json/.parquet files found in the inputs.")
|
| 186 |
+
sys.exit(1)
|
| 187 |
+
print(f"[*] Files: {', '.join(f.name for f in files)}")
|
| 188 |
+
|
| 189 |
+
convs = load_convs_from_files(files)
|
| 190 |
+
if not convs:
|
| 191 |
+
print("[!] No conversations parsed (check the record shapes in the docstring).")
|
| 192 |
+
sys.exit(1)
|
| 193 |
+
print(f"[*] Total: {len(convs)} conversations")
|
| 194 |
+
|
| 195 |
+
# Auto-scale the val split: never take everything for small datasets
|
| 196 |
+
val_size = min(args.val_size, max(1, len(convs) // 10))
|
| 197 |
+
if val_size != args.val_size:
|
| 198 |
+
print(f"[*] Small dataset: using val_size={val_size}")
|
| 199 |
+
|
| 200 |
+
train_examples, val_examples, skipped, dupes = tokenize_and_split(
|
| 201 |
+
convs, set(), tokenizer, val_size, args.max_len,
|
| 202 |
+
args.max_resp_tokens, args.seed)
|
| 203 |
+
|
| 204 |
+
save_dataset(out, train_examples, val_examples, tokenizer, skipped, dupes)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
main()
|
scripts/export_hf.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
export_hf.py: Export a chat-SFT checkpoint to a HuggingFace-style dir.
|
| 4 |
+
|
| 5 |
+
Output dir contains:
|
| 6 |
+
model.safetensors (weights, "model."-prefixed keys)
|
| 7 |
+
config.json (architecture, mask_vocab_size = 32010)
|
| 8 |
+
generation_config.json (sampling defaults for chat)
|
| 9 |
+
tokenizer.json / tokenizer_config.json (with ChatML + rainbow tokens)
|
| 10 |
+
README.md (minimal)
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python3 export_hf.py \
|
| 14 |
+
--checkpoint checkpoints_chat/step_3000.pt \
|
| 15 |
+
--tokenizer data/no_robots_chatml/tokenizer \
|
| 16 |
+
--output MetaDiffusion-150M-Chat/
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import shutil
|
| 22 |
+
import sys
|
| 23 |
+
from dataclasses import asdict
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
from safetensors.torch import save_file
|
| 28 |
+
|
| 29 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 30 |
+
from model import MetaDiffusionConfig # noqa: E402
|
| 31 |
+
|
| 32 |
+
KEY_MAP = {
|
| 33 |
+
"embed_tokens.weight": "model.embed_tokens.weight",
|
| 34 |
+
"norm.weight": "model.norm.weight",
|
| 35 |
+
"lm_head.weight": "model.lm_head.weight",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
LAYER_KEY_MAP = {
|
| 39 |
+
"input_layernorm.weight": "input_layernorm.weight",
|
| 40 |
+
"self_attn.q_proj.weight": "self_attn.q_proj.weight",
|
| 41 |
+
"self_attn.k_proj.weight": "self_attn.k_proj.weight",
|
| 42 |
+
"self_attn.v_proj.weight": "self_attn.v_proj.weight",
|
| 43 |
+
"self_attn.o_proj.weight": "self_attn.o_proj.weight",
|
| 44 |
+
"post_attention_layernorm.weight": "post_attention_layernorm.weight",
|
| 45 |
+
"mlp.gate_proj.weight": "mlp.gate_proj.weight",
|
| 46 |
+
"mlp.up_proj.weight": "mlp.up_proj.weight",
|
| 47 |
+
"mlp.down_proj.weight": "mlp.down_proj.weight",
|
| 48 |
+
"timestep_residual.proj.weight": "timestep_residual.proj.weight",
|
| 49 |
+
"timestep_residual.proj.bias": "timestep_residual.proj.bias",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
TIMESTEP_KEY_MAP = {
|
| 53 |
+
"timestep_emb.mlp.0.weight": "model.timestep_emb.mlp.0.weight",
|
| 54 |
+
"timestep_emb.mlp.0.bias": "model.timestep_emb.mlp.0.bias",
|
| 55 |
+
"timestep_emb.mlp.2.weight": "model.timestep_emb.mlp.2.weight",
|
| 56 |
+
"timestep_emb.mlp.2.bias": "model.timestep_emb.mlp.2.bias",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
GENERATION_CONFIG = {
|
| 60 |
+
"bos_token_id": 0,
|
| 61 |
+
"eos_token_id": 2,
|
| 62 |
+
"pad_token_id": 1,
|
| 63 |
+
"mask_token_id": 32000,
|
| 64 |
+
"temperature": 0.7,
|
| 65 |
+
"repetition_penalty": 1.5,
|
| 66 |
+
"re_mask": 0.1,
|
| 67 |
+
"num_steps": 128,
|
| 68 |
+
"max_new_tokens": 96,
|
| 69 |
+
"use_cache": False,
|
| 70 |
+
"transformers_version": "4.40.0",
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def remap_state_dict(state_dict, half=False):
|
| 75 |
+
# Strip torch.compile's _orig_mod. prefix (old checkpoints saved from a
|
| 76 |
+
# compiled model have it; training now saves clean keys)
|
| 77 |
+
state_dict = {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v
|
| 78 |
+
for k, v in state_dict.items()}
|
| 79 |
+
new_dict = {}
|
| 80 |
+
for key, tensor in state_dict.items():
|
| 81 |
+
if key in KEY_MAP:
|
| 82 |
+
new_key = KEY_MAP[key]
|
| 83 |
+
elif key in TIMESTEP_KEY_MAP:
|
| 84 |
+
new_key = TIMESTEP_KEY_MAP[key]
|
| 85 |
+
elif key.startswith("layers."):
|
| 86 |
+
parts = key.split(".")
|
| 87 |
+
# layers.N.<component>.<rest>
|
| 88 |
+
layer_idx, component = parts[1], parts[2]
|
| 89 |
+
rest = ".".join(parts[3:])
|
| 90 |
+
comp_key = f"{component}.{rest}" if rest else component
|
| 91 |
+
new_key = f"model.layers.{layer_idx}.{comp_key}"
|
| 92 |
+
else:
|
| 93 |
+
new_key = key
|
| 94 |
+
new_dict[new_key] = tensor.half() if half else tensor.float()
|
| 95 |
+
return new_dict
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def package_scripts(out):
|
| 99 |
+
"""Copy the runnable pipeline into out/scripts/ so the release is
|
| 100 |
+
self-contained: chat, finetune, and re-export work from the artifact."""
|
| 101 |
+
src = Path(__file__).resolve().parent
|
| 102 |
+
root = src.parent # model.py lives in the project root
|
| 103 |
+
scripts_dir = out / "scripts"
|
| 104 |
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
for name in ["model.py", "chat.py", "prepare_data.py", "train_chat.py",
|
| 106 |
+
"export_hf.py", "convert_data.py"]:
|
| 107 |
+
cand = src / name if (src / name).exists() else root / name
|
| 108 |
+
if cand.exists():
|
| 109 |
+
shutil.copy2(cand, scripts_dir / name)
|
| 110 |
+
req = scripts_dir / "requirements.txt"
|
| 111 |
+
if not req.exists():
|
| 112 |
+
req.write_text("torch>=2.2\ntransformers>=4.40\nsafetensors>=0.4\n"
|
| 113 |
+
"datasets>=2.18\nnumpy>=1.26\n"
|
| 114 |
+
"pandas>=2.0\npyarrow>=14.0\n")
|
| 115 |
+
print(f"[*] Packaged scripts -> {scripts_dir}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def export(checkpoint_path, tokenizer_dir, output_dir, half=False):
|
| 119 |
+
out = Path(output_dir)
|
| 120 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 121 |
+
|
| 122 |
+
print(f"[*] Loading checkpoint {checkpoint_path}")
|
| 123 |
+
ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 124 |
+
config = MetaDiffusionConfig(
|
| 125 |
+
**{k: v for k, v in ckpt["config"].items()
|
| 126 |
+
if k in MetaDiffusionConfig.__dataclass_fields__}
|
| 127 |
+
)
|
| 128 |
+
config.tie_word_embeddings = False
|
| 129 |
+
|
| 130 |
+
print("[*] Remapping state dict...")
|
| 131 |
+
state_dict = remap_state_dict(ckpt["model_state_dict"], half=half)
|
| 132 |
+
save_file(state_dict, out / "model.safetensors")
|
| 133 |
+
print(f"[*] Saved {len(state_dict)} tensors -> {out / 'model.safetensors'} "
|
| 134 |
+
f"({'fp16' if half else 'fp32'})")
|
| 135 |
+
|
| 136 |
+
# Keep vocab fields consistent with the actual weights: the chat pipeline
|
| 137 |
+
# resizes embed_tokens/lm_head to 32010 (Supra 32000 + chat tokens), and
|
| 138 |
+
# the ckpt config still carries vocab_size=32000 from the base. Any loader
|
| 139 |
+
# using vocab_size to size embeddings would fail with a size mismatch.
|
| 140 |
+
n_vocab = state_dict["model.lm_head.weight"].shape[0]
|
| 141 |
+
config.vocab_size = n_vocab
|
| 142 |
+
config.mask_vocab_size = n_vocab
|
| 143 |
+
print(f"[*] Vocab in config: {n_vocab} (matches weights)")
|
| 144 |
+
|
| 145 |
+
config_dict = asdict(config)
|
| 146 |
+
config_dict.pop("dtype", None) # transformers chokes on "torch.float32" strings
|
| 147 |
+
config_dict["model_type"] = "metadiffusion"
|
| 148 |
+
config_dict["architectures"] = ["MetaDiffusionForCausalLM"]
|
| 149 |
+
with open(out / "config.json", "w") as f:
|
| 150 |
+
json.dump(config_dict, f, indent=2)
|
| 151 |
+
|
| 152 |
+
with open(out / "generation_config.json", "w") as f:
|
| 153 |
+
json.dump(GENERATION_CONFIG, f, indent=2)
|
| 154 |
+
|
| 155 |
+
# Copy tokenizer (has ChatML + rainbow tokens)
|
| 156 |
+
tok_dir = Path(tokenizer_dir)
|
| 157 |
+
for name in ["tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"]:
|
| 158 |
+
src = tok_dir / name
|
| 159 |
+
if src.exists():
|
| 160 |
+
shutil.copy2(src, out / name)
|
| 161 |
+
|
| 162 |
+
package_scripts(out)
|
| 163 |
+
print(f"[*] Exported to {out}")
|
| 164 |
+
print(" Vocab:", len(state_dict.get("model.lm_head.weight", [])),
|
| 165 |
+
"| step:", ckpt.get("step"))
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def main():
|
| 169 |
+
parser = argparse.ArgumentParser(description="Export chat-SFT checkpoint to HF-style dir")
|
| 170 |
+
parser.add_argument("--checkpoint", required=True, help="step_*.pt or best.pt")
|
| 171 |
+
parser.add_argument("--tokenizer", default="data/no_robots_chatml/tokenizer")
|
| 172 |
+
parser.add_argument("--output", required=True, help="Output dir")
|
| 173 |
+
parser.add_argument("--fp16", action="store_true",
|
| 174 |
+
help="Save weights as fp16 (half the size; matches the "
|
| 175 |
+
"base release format)")
|
| 176 |
+
args = parser.parse_args()
|
| 177 |
+
export(args.checkpoint, args.tokenizer, args.output, half=args.fp16)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
if __name__ == "__main__":
|
| 181 |
+
main()
|
scripts/model.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MetaDiffusion: Convert AR LLMs to Masked Diffusion LLMs
|
| 3 |
+
Based on Supra-1.5-50M-Base-exp architecture
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import math
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Optional, Tuple
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class MetaDiffusionConfig:
|
| 16 |
+
# Architecture (matching Supra-1.5-50M)
|
| 17 |
+
hidden_size: int = 512
|
| 18 |
+
intermediate_size: int = 1408
|
| 19 |
+
num_hidden_layers: int = 12
|
| 20 |
+
num_attention_heads: int = 8
|
| 21 |
+
num_key_value_heads: int = 4
|
| 22 |
+
head_dim: int = 64
|
| 23 |
+
vocab_size: int = 32000 # original vocab
|
| 24 |
+
mask_vocab_size: int = 32001 # vocab + [MASK] token
|
| 25 |
+
max_position_embeddings: int = 5120
|
| 26 |
+
rope_theta: float = 10000.0
|
| 27 |
+
rms_norm_eps: float = 1e-6
|
| 28 |
+
hidden_act: str = "silu"
|
| 29 |
+
|
| 30 |
+
# Diffusion-specific
|
| 31 |
+
timestep_emb_hidden: int = 512
|
| 32 |
+
mask_token_id: int = 32000 # index of [MASK] in embedding table
|
| 33 |
+
pad_token_id: int = 1 # Supra pad token
|
| 34 |
+
|
| 35 |
+
# Masking strategy
|
| 36 |
+
mask_ratio_min: float = 0.0
|
| 37 |
+
mask_ratio_max: float = 1.0
|
| 38 |
+
|
| 39 |
+
# Training
|
| 40 |
+
dtype: torch.dtype = torch.float32
|
| 41 |
+
tie_word_embeddings: bool = True
|
| 42 |
+
|
| 43 |
+
class RotaryEmbedding(nn.Module):
|
| 44 |
+
"""RoPE - position embeddings for the attention layers."""
|
| 45 |
+
|
| 46 |
+
def __init__(self, dim, max_position_embeddings=5120, base=10000.0, device=None):
|
| 47 |
+
super().__init__()
|
| 48 |
+
self.dim = dim
|
| 49 |
+
self.max_position_embeddings = max_position_embeddings
|
| 50 |
+
self.base = base
|
| 51 |
+
|
| 52 |
+
inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2, device=device).float() / dim))
|
| 53 |
+
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
| 54 |
+
|
| 55 |
+
@torch.no_grad()
|
| 56 |
+
def forward(self, x, position_ids):
|
| 57 |
+
# x: (batch, seq, hidden) - used for dtype/device only
|
| 58 |
+
# position_ids: (batch, seq)
|
| 59 |
+
inv_freq_expanded = self.inv_freq[None, :, None].float().expand(
|
| 60 |
+
position_ids.shape[0], -1, 1
|
| 61 |
+
)
|
| 62 |
+
position_ids_expanded = position_ids[:, None, :].float()
|
| 63 |
+
freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(1, 2)
|
| 64 |
+
emb = torch.cat((freqs, freqs), dim=-1)
|
| 65 |
+
cos = emb.cos()
|
| 66 |
+
sin = emb.sin()
|
| 67 |
+
return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def rotate_half(x):
|
| 71 |
+
x1, x2 = x.chunk(2, dim=-1)
|
| 72 |
+
return torch.cat((-x2, x1), dim=-1)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def apply_rotary_pos_emb(q, k, cos, sin):
|
| 76 |
+
cos = cos.unsqueeze(1) # (batch, 1, seq, dim)
|
| 77 |
+
sin = sin.unsqueeze(1)
|
| 78 |
+
q_embed = (q * cos) + (rotate_half(q) * sin)
|
| 79 |
+
k_embed = (k * cos) + (rotate_half(k) * sin)
|
| 80 |
+
return q_embed, k_embed
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class TimestepEmbedding(nn.Module):
|
| 84 |
+
"""Sinusoidal timestep embedding with learned projection."""
|
| 85 |
+
|
| 86 |
+
def __init__(self, hidden_size):
|
| 87 |
+
super().__init__()
|
| 88 |
+
self.hidden_size = hidden_size
|
| 89 |
+
self.mlp = nn.Sequential(
|
| 90 |
+
nn.Linear(hidden_size, hidden_size * 4),
|
| 91 |
+
nn.SiLU(),
|
| 92 |
+
nn.Linear(hidden_size * 4, hidden_size),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
def forward(self, t):
|
| 96 |
+
# t: (batch,) timesteps in [0, 1]
|
| 97 |
+
half_dim = self.hidden_size // 2
|
| 98 |
+
emb = math.log(10000.0) / (half_dim - 1)
|
| 99 |
+
emb = torch.exp(
|
| 100 |
+
torch.arange(half_dim, device=t.device, dtype=torch.float32) * -emb
|
| 101 |
+
)
|
| 102 |
+
emb = t[:, None].float() * emb[None, :]
|
| 103 |
+
emb = torch.cat([emb.sin(), emb.cos()], dim=-1) # (batch, hidden_size)
|
| 104 |
+
return self.mlp(emb).to(t.dtype) # (batch, hidden_size)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class TimestepResidual(nn.Module):
|
| 108 |
+
"""Add timestep embedding to hidden state at each block.
|
| 109 |
+
Initialized to zero so it starts as identity (no disruption to pretrained weights)."""
|
| 110 |
+
|
| 111 |
+
def __init__(self, hidden_size):
|
| 112 |
+
super().__init__()
|
| 113 |
+
self.proj = nn.Linear(hidden_size, hidden_size)
|
| 114 |
+
nn.init.zeros_(self.proj.weight)
|
| 115 |
+
nn.init.zeros_(self.proj.bias)
|
| 116 |
+
|
| 117 |
+
def forward(self, x, emb):
|
| 118 |
+
# x: (batch, seq, hidden)
|
| 119 |
+
# emb: (batch, hidden)
|
| 120 |
+
return x + self.proj(emb)[:, None, :]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class RMSNorm(nn.Module):
|
| 124 |
+
"""Llama-style RMSNorm."""
|
| 125 |
+
|
| 126 |
+
def __init__(self, hidden_size, eps=1e-6):
|
| 127 |
+
super().__init__()
|
| 128 |
+
self.weight = nn.Parameter(torch.ones(hidden_size))
|
| 129 |
+
self.eps = eps
|
| 130 |
+
|
| 131 |
+
def forward(self, x):
|
| 132 |
+
var = x.pow(2).mean(-1, keepdim=True)
|
| 133 |
+
x = x * torch.rsqrt(var + self.eps)
|
| 134 |
+
return self.weight * x
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class SelfAttention(nn.Module):
|
| 138 |
+
"""Multi-head attention with GQA and RoPE.
|
| 139 |
+
BIDIRECTIONAL (no causal mask) - this is the key difference from AR."""
|
| 140 |
+
|
| 141 |
+
def __init__(self, config):
|
| 142 |
+
super().__init__()
|
| 143 |
+
self.config = config
|
| 144 |
+
self.hidden_size = config.hidden_size
|
| 145 |
+
self.num_heads = config.num_attention_heads
|
| 146 |
+
self.num_kv_heads = config.num_key_value_heads
|
| 147 |
+
self.head_dim = config.head_dim
|
| 148 |
+
self.num_kv_groups = self.num_heads // self.num_kv_heads
|
| 149 |
+
|
| 150 |
+
self.q_proj = nn.Linear(
|
| 151 |
+
config.hidden_size, self.num_heads * config.head_dim, bias=False
|
| 152 |
+
)
|
| 153 |
+
self.k_proj = nn.Linear(
|
| 154 |
+
config.hidden_size, self.num_kv_heads * config.head_dim, bias=False
|
| 155 |
+
)
|
| 156 |
+
self.v_proj = nn.Linear(
|
| 157 |
+
config.hidden_size, self.num_kv_heads * config.head_dim, bias=False
|
| 158 |
+
)
|
| 159 |
+
self.o_proj = nn.Linear(
|
| 160 |
+
self.num_heads * config.head_dim, config.hidden_size, bias=False
|
| 161 |
+
)
|
| 162 |
+
self.rotary_emb = RotaryEmbedding(
|
| 163 |
+
config.head_dim,
|
| 164 |
+
max_position_embeddings=config.max_position_embeddings,
|
| 165 |
+
base=config.rope_theta,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
def forward(self, x, attention_mask=None, position_ids=None):
|
| 169 |
+
batch, seq, _ = x.shape
|
| 170 |
+
|
| 171 |
+
q = self.q_proj(x).view(batch, seq, self.num_heads, self.head_dim).transpose(1, 2)
|
| 172 |
+
k = self.k_proj(x).view(batch, seq, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
| 173 |
+
v = self.v_proj(x).view(batch, seq, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
| 174 |
+
|
| 175 |
+
cos, sin = self.rotary_emb(x, position_ids)
|
| 176 |
+
q, k = apply_rotary_pos_emb(q, k, cos, sin)
|
| 177 |
+
|
| 178 |
+
# GQA: repeat KV heads to match Q heads
|
| 179 |
+
if self.num_kv_groups > 1:
|
| 180 |
+
k = k.repeat_interleave(self.num_kv_groups, dim=1)
|
| 181 |
+
v = v.repeat_interleave(self.num_kv_groups, dim=1)
|
| 182 |
+
|
| 183 |
+
# Bidirectional attention - no causal mask!
|
| 184 |
+
out = F.scaled_dot_product_attention(q, k, v, attn_mask=attention_mask)
|
| 185 |
+
out = out.transpose(1, 2).contiguous().view(batch, seq, -1)
|
| 186 |
+
return self.o_proj(out)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class MLP(nn.Module):
|
| 190 |
+
"""Llama-style gated FFN (SwiGLU)."""
|
| 191 |
+
|
| 192 |
+
def __init__(self, config):
|
| 193 |
+
super().__init__()
|
| 194 |
+
self.gate_proj = nn.Linear(
|
| 195 |
+
config.hidden_size, config.intermediate_size, bias=False
|
| 196 |
+
)
|
| 197 |
+
self.up_proj = nn.Linear(
|
| 198 |
+
config.hidden_size, config.intermediate_size, bias=False
|
| 199 |
+
)
|
| 200 |
+
self.down_proj = nn.Linear(
|
| 201 |
+
config.intermediate_size, config.hidden_size, bias=False
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
def forward(self, x):
|
| 205 |
+
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class TransformerBlock(nn.Module):
|
| 209 |
+
"""Llama transformer block adapted for diffusion.
|
| 210 |
+
- Pre-norm architecture
|
| 211 |
+
- Bidirectional attention
|
| 212 |
+
- Timestep conditioning via residual addition
|
| 213 |
+
"""
|
| 214 |
+
|
| 215 |
+
def __init__(self, config):
|
| 216 |
+
super().__init__()
|
| 217 |
+
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
| 218 |
+
self.self_attn = SelfAttention(config)
|
| 219 |
+
self.post_attention_layernorm = RMSNorm(
|
| 220 |
+
config.hidden_size, eps=config.rms_norm_eps
|
| 221 |
+
)
|
| 222 |
+
self.mlp = MLP(config)
|
| 223 |
+
self.timestep_residual = TimestepResidual(config.hidden_size)
|
| 224 |
+
|
| 225 |
+
def forward(self, x, timestep_emb, attention_mask=None, position_ids=None):
|
| 226 |
+
# Pre-norm + attention + residual + timestep
|
| 227 |
+
residual = x
|
| 228 |
+
x = self.input_layernorm(x)
|
| 229 |
+
x = self.self_attn(x, attention_mask, position_ids)
|
| 230 |
+
x = residual + x
|
| 231 |
+
x = self.timestep_residual(x, timestep_emb)
|
| 232 |
+
|
| 233 |
+
# Pre-norm + FFN + residual + timestep
|
| 234 |
+
residual = x
|
| 235 |
+
x = self.post_attention_layernorm(x)
|
| 236 |
+
x = self.mlp(x)
|
| 237 |
+
x = residual + x
|
| 238 |
+
x = self.timestep_residual(x, timestep_emb)
|
| 239 |
+
|
| 240 |
+
return x
|
| 241 |
+
|
| 242 |
+
class MetaDiffusionLM(nn.Module):
|
| 243 |
+
"""Masked Diffusion Language Model.
|
| 244 |
+
|
| 245 |
+
Converts an AR Llama-style model to a masked-diffusion LM.
|
| 246 |
+
Transfers: embeddings, all transformer blocks, RoPE, norms.
|
| 247 |
+
New: timestep embedding, [MASK] token.
|
| 248 |
+
Output head is tied with embeddings by default (set tie_word_embeddings=False to untie).
|
| 249 |
+
"""
|
| 250 |
+
|
| 251 |
+
def __init__(self, config: MetaDiffusionConfig):
|
| 252 |
+
super().__init__()
|
| 253 |
+
self.config = config
|
| 254 |
+
|
| 255 |
+
# Embeddings (vocab + 1 for [MASK])
|
| 256 |
+
self.embed_tokens = nn.Embedding(
|
| 257 |
+
config.mask_vocab_size, config.hidden_size, padding_idx=config.pad_token_id
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# Timestep conditioning
|
| 261 |
+
self.timestep_emb = TimestepEmbedding(config.timestep_emb_hidden)
|
| 262 |
+
|
| 263 |
+
# Transformer stack
|
| 264 |
+
self.layers = nn.ModuleList(
|
| 265 |
+
[TransformerBlock(config) for _ in range(config.num_hidden_layers)]
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
# Final norm
|
| 269 |
+
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
| 270 |
+
|
| 271 |
+
# Output projection (can be tied with embeddings for parameter efficiency)
|
| 272 |
+
if config.tie_word_embeddings:
|
| 273 |
+
self.lm_head = None # Will use embed_tokens.weight in forward
|
| 274 |
+
else:
|
| 275 |
+
self.lm_head = nn.Linear(
|
| 276 |
+
config.hidden_size, config.mask_vocab_size, bias=False
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
self.post_init()
|
| 280 |
+
|
| 281 |
+
def post_init(self):
|
| 282 |
+
if self.lm_head is not None:
|
| 283 |
+
nn.init.normal_(self.lm_head.weight, std=0.02)
|
| 284 |
+
|
| 285 |
+
def forward(self, input_ids, timesteps, attention_mask=None):
|
| 286 |
+
"""
|
| 287 |
+
Forward pass for training.
|
| 288 |
+
|
| 289 |
+
Args:
|
| 290 |
+
input_ids: (batch, seq) - tokens with masked positions replaced by mask_token_id
|
| 291 |
+
timesteps: (batch,) - diffusion timestep in [0, 1]
|
| 292 |
+
attention_mask: optional (batch, seq) - 1 for real tokens, 0 for padding
|
| 293 |
+
|
| 294 |
+
Returns:
|
| 295 |
+
logits: (batch, seq, mask_vocab_size)
|
| 296 |
+
"""
|
| 297 |
+
batch, seq = input_ids.shape
|
| 298 |
+
|
| 299 |
+
# Position IDs (0, 1, 2, ...)
|
| 300 |
+
position_ids = (
|
| 301 |
+
torch.arange(seq, device=input_ids.device).unsqueeze(0).expand(batch, -1)
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
# Embed tokens
|
| 305 |
+
x = self.embed_tokens(input_ids)
|
| 306 |
+
|
| 307 |
+
# Get timestep embedding
|
| 308 |
+
t_emb = self.timestep_emb(timesteps)
|
| 309 |
+
|
| 310 |
+
# Convert attention mask for SDPA (0 -> keep, -inf -> mask out)
|
| 311 |
+
attn_mask = None
|
| 312 |
+
if attention_mask is not None:
|
| 313 |
+
attn_mask = ((1.0 - attention_mask[:, None, None, :].float()) * -1e9).to(
|
| 314 |
+
x.dtype
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
# Pass through transformer blocks
|
| 318 |
+
for layer in self.layers:
|
| 319 |
+
x = layer(x, t_emb, attn_mask, position_ids)
|
| 320 |
+
|
| 321 |
+
# Final norm + project to vocab
|
| 322 |
+
x = self.norm(x)
|
| 323 |
+
if self.lm_head is not None:
|
| 324 |
+
logits = self.lm_head(x)
|
| 325 |
+
else:
|
| 326 |
+
# Tied embeddings: use embed_tokens.weight transposed
|
| 327 |
+
logits = F.linear(x, self.embed_tokens.weight)
|
| 328 |
+
|
| 329 |
+
return logits
|
| 330 |
+
|
| 331 |
+
def compute_loss(self, logits, labels, mask_positions, pad_token_id=None):
|
| 332 |
+
"""
|
| 333 |
+
Compute cross-entropy loss on masked positions only.
|
| 334 |
+
|
| 335 |
+
Args:
|
| 336 |
+
logits: (batch, seq, vocab_size)
|
| 337 |
+
labels: (batch, seq) - original token IDs (before masking)
|
| 338 |
+
mask_positions: (batch, seq) - bool tensor, True where token was masked
|
| 339 |
+
pad_token_id: ignore these positions in loss
|
| 340 |
+
|
| 341 |
+
Returns:
|
| 342 |
+
loss: scalar
|
| 343 |
+
num_masked: number of positions in loss
|
| 344 |
+
"""
|
| 345 |
+
logits_masked = logits[mask_positions]
|
| 346 |
+
labels_masked = labels[mask_positions]
|
| 347 |
+
|
| 348 |
+
# Filter out padding tokens
|
| 349 |
+
if pad_token_id is not None:
|
| 350 |
+
valid = labels_masked != pad_token_id
|
| 351 |
+
logits_masked = logits_masked[valid]
|
| 352 |
+
labels_masked = labels_masked[valid]
|
| 353 |
+
|
| 354 |
+
if labels_masked.numel() == 0:
|
| 355 |
+
return torch.tensor(0.0, device=logits.device), 0
|
| 356 |
+
|
| 357 |
+
loss = F.cross_entropy(logits_masked, labels_masked)
|
| 358 |
+
return loss, labels_masked.numel()
|
| 359 |
+
|
| 360 |
+
@torch.no_grad()
|
| 361 |
+
def generate(self, batch_size, seq_len, num_steps=256, device="cuda"):
|
| 362 |
+
"""
|
| 363 |
+
Iterative denoising generation (LLaDA-style).
|
| 364 |
+
|
| 365 |
+
Starts from all-mask tokens and progressively unmaskes the most confident predictions.
|
| 366 |
+
|
| 367 |
+
Args:
|
| 368 |
+
batch_size: number of sequences to generate
|
| 369 |
+
seq_len: length of each sequence
|
| 370 |
+
num_steps: number of denoising iterations
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
tokens: (batch, seq) - generated token IDs
|
| 374 |
+
"""
|
| 375 |
+
mask_token_id = self.config.mask_token_id
|
| 376 |
+
x = torch.full(
|
| 377 |
+
(batch_size, seq_len), mask_token_id, device=device, dtype=torch.long
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
# Linear schedule from t=1 (all mask) to t=0 (no mask)
|
| 381 |
+
timesteps = torch.linspace(1.0, 0.0, num_steps + 1, device=device)
|
| 382 |
+
|
| 383 |
+
for i in range(num_steps):
|
| 384 |
+
t = timesteps[i]
|
| 385 |
+
t_next = timesteps[i + 1]
|
| 386 |
+
t_batch = torch.full((batch_size,), t, device=device)
|
| 387 |
+
|
| 388 |
+
# Get predictions
|
| 389 |
+
logits = self.forward(x, t_batch)
|
| 390 |
+
pred_tokens = logits.argmax(dim=-1)
|
| 391 |
+
|
| 392 |
+
# Confidence of predicted tokens
|
| 393 |
+
probs = F.softmax(logits, dim=-1)
|
| 394 |
+
confidence = probs.gather(-1, pred_tokens.unsqueeze(-1)).squeeze(-1)
|
| 395 |
+
|
| 396 |
+
# Number of tokens to unmask this step
|
| 397 |
+
num_unmask = max(1, int(seq_len * (t - t_next)))
|
| 398 |
+
|
| 399 |
+
# Only consider currently-masked positions
|
| 400 |
+
is_mask = x == mask_token_id
|
| 401 |
+
confidence_masked = confidence.clone()
|
| 402 |
+
confidence_masked[~is_mask] = -1.0
|
| 403 |
+
|
| 404 |
+
# Unmask the most confident predictions
|
| 405 |
+
_, top_indices = confidence_masked.topk(num_unmask, dim=-1)
|
| 406 |
+
batch_idx = (
|
| 407 |
+
torch.arange(batch_size, device=device).unsqueeze(-1).expand_as(top_indices)
|
| 408 |
+
)
|
| 409 |
+
x[batch_idx, top_indices] = pred_tokens[batch_idx, top_indices]
|
| 410 |
+
|
| 411 |
+
return x
|
| 412 |
+
|
| 413 |
+
@classmethod
|
| 414 |
+
def from_pretrained_ar(
|
| 415 |
+
cls,
|
| 416 |
+
model_name_or_path: str,
|
| 417 |
+
**kwargs,
|
| 418 |
+
):
|
| 419 |
+
"""
|
| 420 |
+
Initialize a MetaDiffusionLM from a pretrained AR Llama model.
|
| 421 |
+
|
| 422 |
+
Transfers all AR weights and initializes new diffusion components.
|
| 423 |
+
"""
|
| 424 |
+
from transformers import LlamaForCausalLM
|
| 425 |
+
|
| 426 |
+
print(f"Loading AR model: {model_name_or_path}")
|
| 427 |
+
ar_model = LlamaForCausalLM.from_pretrained(model_name_or_path)
|
| 428 |
+
ar_config = ar_model.config
|
| 429 |
+
|
| 430 |
+
# Build diffusion config from AR config
|
| 431 |
+
head_dim = getattr(
|
| 432 |
+
ar_config,
|
| 433 |
+
"head_dim",
|
| 434 |
+
ar_config.hidden_size // ar_config.num_attention_heads,
|
| 435 |
+
)
|
| 436 |
+
config = MetaDiffusionConfig(
|
| 437 |
+
hidden_size=ar_config.hidden_size,
|
| 438 |
+
intermediate_size=ar_config.intermediate_size,
|
| 439 |
+
num_hidden_layers=ar_config.num_hidden_layers,
|
| 440 |
+
num_attention_heads=ar_config.num_attention_heads,
|
| 441 |
+
num_key_value_heads=ar_config.num_key_value_heads,
|
| 442 |
+
head_dim=head_dim,
|
| 443 |
+
vocab_size=ar_config.vocab_size,
|
| 444 |
+
mask_vocab_size=ar_config.vocab_size + 1,
|
| 445 |
+
max_position_embeddings=ar_config.max_position_embeddings,
|
| 446 |
+
rope_theta=getattr(ar_config, "rope_theta", 10000.0),
|
| 447 |
+
rms_norm_eps=ar_config.rms_norm_eps,
|
| 448 |
+
mask_token_id=ar_config.vocab_size,
|
| 449 |
+
pad_token_id=getattr(ar_config, "pad_token_id", 1),
|
| 450 |
+
**kwargs,
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
model = cls(config)
|
| 454 |
+
|
| 455 |
+
# Build state dict mapping
|
| 456 |
+
state_dict = ar_model.state_dict()
|
| 457 |
+
new_state_dict = {}
|
| 458 |
+
|
| 459 |
+
# --- Embeddings ---
|
| 460 |
+
# Copy original vocab embeddings
|
| 461 |
+
ar_embeds = state_dict["model.embed_tokens.weight"]
|
| 462 |
+
mask_embed = ar_embeds.mean(dim=0, keepdim=True) # [MASK] = mean of all embeds
|
| 463 |
+
new_state_dict["embed_tokens.weight"] = torch.cat(
|
| 464 |
+
[ar_embeds, mask_embed], dim=0
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
# --- Transformer blocks ---
|
| 468 |
+
for i in range(config.num_hidden_layers):
|
| 469 |
+
ar_prefix = f"model.layers.{i}"
|
| 470 |
+
new_prefix = f"layers.{i}"
|
| 471 |
+
|
| 472 |
+
# Attention
|
| 473 |
+
new_state_dict[f"{new_prefix}.self_attn.q_proj.weight"] = state_dict[
|
| 474 |
+
f"{ar_prefix}.self_attn.q_proj.weight"
|
| 475 |
+
]
|
| 476 |
+
new_state_dict[f"{new_prefix}.self_attn.k_proj.weight"] = state_dict[
|
| 477 |
+
f"{ar_prefix}.self_attn.k_proj.weight"
|
| 478 |
+
]
|
| 479 |
+
new_state_dict[f"{new_prefix}.self_attn.v_proj.weight"] = state_dict[
|
| 480 |
+
f"{ar_prefix}.self_attn.v_proj.weight"
|
| 481 |
+
]
|
| 482 |
+
new_state_dict[f"{new_prefix}.self_attn.o_proj.weight"] = state_dict[
|
| 483 |
+
f"{ar_prefix}.self_attn.o_proj.weight"
|
| 484 |
+
]
|
| 485 |
+
|
| 486 |
+
# MLP
|
| 487 |
+
new_state_dict[f"{new_prefix}.mlp.gate_proj.weight"] = state_dict[
|
| 488 |
+
f"{ar_prefix}.mlp.gate_proj.weight"
|
| 489 |
+
]
|
| 490 |
+
new_state_dict[f"{new_prefix}.mlp.up_proj.weight"] = state_dict[
|
| 491 |
+
f"{ar_prefix}.mlp.up_proj.weight"
|
| 492 |
+
]
|
| 493 |
+
new_state_dict[f"{new_prefix}.mlp.down_proj.weight"] = state_dict[
|
| 494 |
+
f"{ar_prefix}.mlp.down_proj.weight"
|
| 495 |
+
]
|
| 496 |
+
|
| 497 |
+
# Layer norms
|
| 498 |
+
new_state_dict[f"{new_prefix}.input_layernorm.weight"] = state_dict[
|
| 499 |
+
f"{ar_prefix}.input_layernorm.weight"
|
| 500 |
+
]
|
| 501 |
+
new_state_dict[
|
| 502 |
+
f"{new_prefix}.post_attention_layernorm.weight"
|
| 503 |
+
] = state_dict[f"{ar_prefix}.post_attention_layernorm.weight"]
|
| 504 |
+
|
| 505 |
+
# --- Final norm ---
|
| 506 |
+
new_state_dict["norm.weight"] = state_dict["model.norm.weight"]
|
| 507 |
+
|
| 508 |
+
# --- Output head ---
|
| 509 |
+
# Initialize from AR embeddings (since AR used tied embeddings, E^T was the output)
|
| 510 |
+
if not config.tie_word_embeddings:
|
| 511 |
+
new_state_dict["lm_head.weight"] = torch.cat(
|
| 512 |
+
[ar_embeds, torch.zeros(1, config.hidden_size, device=ar_embeds.device)],
|
| 513 |
+
dim=0,
|
| 514 |
+
).clone()
|
| 515 |
+
|
| 516 |
+
# Load with strict=False for new diffusion params
|
| 517 |
+
missing, unexpected = model.load_state_dict(new_state_dict, strict=False)
|
| 518 |
+
|
| 519 |
+
print(f"Weights transferred from AR model")
|
| 520 |
+
print(f" Missing (new diffusion params): {len(missing)}")
|
| 521 |
+
print(f" Unexpected: {len(unexpected)}")
|
| 522 |
+
if missing:
|
| 523 |
+
for k in missing:
|
| 524 |
+
print(f" NEW: {k}")
|
| 525 |
+
|
| 526 |
+
return model
|
scripts/prepare_data.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
prepare_data.py: Download HuggingFaceH4/no_robots and pre-tokenize it as ChatML
|
| 4 |
+
for MetaDiffusion chat SFT.
|
| 5 |
+
|
| 6 |
+
Output (in --data-dir):
|
| 7 |
+
train.pt list of {"input_ids": LongTensor, "assistant_start": int,
|
| 8 |
+
"assistant_end": int}
|
| 9 |
+
val.pt same, held-out
|
| 10 |
+
tokenizer/ Supra tokenizer with ChatML + rainbow tokens added
|
| 11 |
+
stats.json counts and length stats
|
| 12 |
+
|
| 13 |
+
The assistant region (content + trailing <|im_end|>) is the only part that
|
| 14 |
+
will be masked during training; everything before it is visible prompt.
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python3 prepare_data.py --model-path ../hf_release --data-dir data/no_robots_chatml
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import random
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import torch
|
| 29 |
+
from datasets import load_dataset
|
| 30 |
+
from transformers import AutoTokenizer
|
| 31 |
+
|
| 32 |
+
IM_START = "<|im_start|>"
|
| 33 |
+
IM_END = "<|im_end|>"
|
| 34 |
+
RESERVED = "<|reserved|>" # filler so id 32000 stays free for [MASK]
|
| 35 |
+
CHAT_TOKENS = [IM_START, IM_END] + [f"<|r{i}|>" for i in range(1, 8)] # rainbow pads
|
| 36 |
+
BASE_VOCAB = 32000 # Supra tokenizer entry count (ids 0..31999)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def add_chat_tokens(tokenizer):
|
| 40 |
+
"""Add chat + rainbow tokens at ids 32001..32009.
|
| 41 |
+
|
| 42 |
+
The base tokenizer has 32000 entries, so the first added token would take
|
| 43 |
+
id 32000, which is the diffusion [MASK] id. A reserved filler token takes
|
| 44 |
+
that slot first; mask id 32000 must never exist in the tokenizer.
|
| 45 |
+
"""
|
| 46 |
+
if len(tokenizer) == BASE_VOCAB:
|
| 47 |
+
tokenizer.add_special_tokens({"additional_special_tokens": [RESERVED]})
|
| 48 |
+
n = tokenizer.add_special_tokens(
|
| 49 |
+
{"additional_special_tokens": CHAT_TOKENS}
|
| 50 |
+
)
|
| 51 |
+
im_start = tokenizer.convert_tokens_to_ids(IM_START)
|
| 52 |
+
im_end = tokenizer.convert_tokens_to_ids(IM_END)
|
| 53 |
+
assert im_start == 32001, f"im_start id {im_start} != 32001 (collides with [MASK])"
|
| 54 |
+
assert im_end == 32002, f"im_end id {im_end} != 32002"
|
| 55 |
+
print(f"[*] Added {n} special tokens, vocab now {len(tokenizer)}")
|
| 56 |
+
print(f"[*] im_start={im_start} im_end={im_end} "
|
| 57 |
+
f"rainbow={[tokenizer.convert_tokens_to_ids(f'<|r{i}|>') for i in range(1, 8)]}")
|
| 58 |
+
return n
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def format_segment(role: str, content: str) -> str:
|
| 62 |
+
return f"{IM_START}{role}\n{content}{IM_END}"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def load_messages(dataset_name: str):
|
| 66 |
+
"""Return a list of message lists for one dataset name."""
|
| 67 |
+
if dataset_name == "no_robots":
|
| 68 |
+
ds = load_dataset("HuggingFaceH4/no_robots", split="train")
|
| 69 |
+
return [list(row["messages"]) for row in ds]
|
| 70 |
+
if dataset_name == "alpaca":
|
| 71 |
+
ds = load_dataset("yahma/alpaca-cleaned", split="train")
|
| 72 |
+
out = []
|
| 73 |
+
for row in ds:
|
| 74 |
+
user = row["instruction"]
|
| 75 |
+
if row.get("input"):
|
| 76 |
+
user += f"\n\n{row['input']}"
|
| 77 |
+
out.append([{"role": "user", "content": user},
|
| 78 |
+
{"role": "assistant", "content": row["output"]}])
|
| 79 |
+
return out
|
| 80 |
+
if dataset_name == "dolly":
|
| 81 |
+
ds = load_dataset("databricks/databricks-dolly-15k", split="train")
|
| 82 |
+
out = []
|
| 83 |
+
for row in ds:
|
| 84 |
+
user = row["instruction"]
|
| 85 |
+
if row.get("context"):
|
| 86 |
+
user += f"\n\n{row['context']}"
|
| 87 |
+
out.append([{"role": "user", "content": user},
|
| 88 |
+
{"role": "assistant", "content": row["response"]}])
|
| 89 |
+
return out
|
| 90 |
+
if dataset_name == "smol-smoltalk":
|
| 91 |
+
# The actual SFT set for SmolLM2-135M-Instruct: 484K short, high-quality
|
| 92 |
+
# conversations designed for <1B models (no function calling, no
|
| 93 |
+
# advanced math). Cap with --max-examples (e.g. 60000) for a 150M model.
|
| 94 |
+
ds = load_dataset("HuggingFaceTB/smol-smoltalk", split="train")
|
| 95 |
+
return [list(row["messages"]) for row in ds]
|
| 96 |
+
if dataset_name == "math":
|
| 97 |
+
return load_math_data()
|
| 98 |
+
raise ValueError(f"Unknown dataset: {dataset_name} "
|
| 99 |
+
f"(choose from: no_robots, alpaca, dolly, smol-smoltalk, math)")
|
| 100 |
+
|
| 101 |
+
MATH_QUESTION_TEMPLATES = {
|
| 102 |
+
"add": ["What is {a} + {b}?", "What is {a} plus {b}?", "Add {a} and {b}.",
|
| 103 |
+
"What does {a} + {b} equal?"],
|
| 104 |
+
"sub": ["What is {a} - {b}?", "What is {a} minus {b}?", "Subtract {b} from {a}.",
|
| 105 |
+
"What does {a} - {b} equal?"],
|
| 106 |
+
"mul": ["What is {a} × {b}?", "What is {a} times {b}?", "Multiply {a} by {b}.",
|
| 107 |
+
"What does {a} × {b} equal?"],
|
| 108 |
+
"div": ["What is {a} ÷ {b}?", "What is {a} divided by {b}?", "Divide {a} by {b}.",
|
| 109 |
+
"What does {a} ÷ {b} equal?"],
|
| 110 |
+
}
|
| 111 |
+
MATH_ANSWER_TEMPLATES = ["The answer is {r}.", "It is {r}.", "{r}"]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def load_math_data(seed: int = 42):
|
| 115 |
+
"""Exhaustive basic-arithmetic QA pairs (add/sub/mul/div), ChatML messages.
|
| 116 |
+
|
| 117 |
+
A 150M model learns arithmetic by memorization, so cover EVERY pair in a
|
| 118 |
+
small range rather than sampling: add a<=b in 1..99, sub b<a in 1..99,
|
| 119 |
+
times tables 1..12, exact divisions 1..12.
|
| 120 |
+
"""
|
| 121 |
+
rng = random.Random(seed)
|
| 122 |
+
pairs = [] # (op, a, b, result)
|
| 123 |
+
for a in range(1, 100):
|
| 124 |
+
for b in range(a, 100):
|
| 125 |
+
pairs.append(("add", a, b, a + b))
|
| 126 |
+
for a in range(2, 100):
|
| 127 |
+
for b in range(1, a):
|
| 128 |
+
pairs.append(("sub", a, b, a - b))
|
| 129 |
+
for a in range(1, 13):
|
| 130 |
+
for b in range(1, 13):
|
| 131 |
+
pairs.append(("mul", a, b, a * b))
|
| 132 |
+
for b in range(1, 13):
|
| 133 |
+
for q in range(1, 13):
|
| 134 |
+
pairs.append(("div", b * q, b, q))
|
| 135 |
+
rng.shuffle(pairs)
|
| 136 |
+
|
| 137 |
+
out = []
|
| 138 |
+
for op, a, b, r in pairs:
|
| 139 |
+
question = rng.choice(MATH_QUESTION_TEMPLATES[op]).format(a=a, b=b)
|
| 140 |
+
answer = rng.choice(MATH_ANSWER_TEMPLATES).format(r=r)
|
| 141 |
+
out.append([{"role": "user", "content": question},
|
| 142 |
+
{"role": "assistant", "content": answer}])
|
| 143 |
+
print(f" (synthetic arithmetic: {len(out)} pairs, "
|
| 144 |
+
f"add {sum(1 for p in pairs if p[0]=='add')}, "
|
| 145 |
+
f"sub {sum(1 for p in pairs if p[0]=='sub')}, "
|
| 146 |
+
f"mul {sum(1 for p in pairs if p[0]=='mul')}, "
|
| 147 |
+
f"div {sum(1 for p in pairs if p[0]=='div')})")
|
| 148 |
+
return out
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def example_key(input_ids_tensor):
|
| 152 |
+
"""Compact dedup key: SHA-256 of token ids.
|
| 153 |
+
|
| 154 |
+
Tuple keys for 484K x 600-token conversations cost ~5 GB of set memory;
|
| 155 |
+
digests cost ~50 MB. (Hashing was never the bottleneck; per-segment
|
| 156 |
+
tokenizer.encode() calls were.)
|
| 157 |
+
"""
|
| 158 |
+
return hashlib.sha256(
|
| 159 |
+
input_ids_tensor.numpy().astype(np.uint32).tobytes()
|
| 160 |
+
).digest()
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def build_conv_segments(messages):
|
| 164 |
+
"""Split a conversation into ChatML segment strings + roles."""
|
| 165 |
+
segs, roles = [], []
|
| 166 |
+
for m in messages:
|
| 167 |
+
role = m.get("role", "user")
|
| 168 |
+
content = m.get("content", "")
|
| 169 |
+
if not content.strip():
|
| 170 |
+
continue
|
| 171 |
+
segs.append(format_segment(role, content))
|
| 172 |
+
roles.append(role)
|
| 173 |
+
return segs, roles
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def reconstruct_example(seg_ids, roles, tokenizer, max_len, max_resp):
|
| 177 |
+
"""Rebuild a conversation from pre-encoded segments (tokenize_example
|
| 178 |
+
logic, but with tokenization already done in batch)."""
|
| 179 |
+
ids = []
|
| 180 |
+
assistant_ranges = []
|
| 181 |
+
for role, seg in zip(roles, seg_ids):
|
| 182 |
+
start = len(ids)
|
| 183 |
+
ids.extend(seg)
|
| 184 |
+
if role == "assistant":
|
| 185 |
+
assistant_ranges.append((start, len(ids)))
|
| 186 |
+
|
| 187 |
+
if not ids or not assistant_ranges:
|
| 188 |
+
return None
|
| 189 |
+
a0, a1 = assistant_ranges[-1]
|
| 190 |
+
if a1 <= a0:
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
# Cap the response length, always keeping the trailing <|im_end|>
|
| 194 |
+
if a1 - a0 > max_resp:
|
| 195 |
+
im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
|
| 196 |
+
ids = ids[:a0] + ids[a0:a0 + max_resp - 1] + [im_end_id]
|
| 197 |
+
a1 = a0 + max_resp
|
| 198 |
+
|
| 199 |
+
# Truncate from the front (history), keep the target response intact
|
| 200 |
+
resp = ids[a0:a1]
|
| 201 |
+
if len(resp) > max_len:
|
| 202 |
+
resp = resp[:max_len]
|
| 203 |
+
a1 = a0 + len(resp)
|
| 204 |
+
hist = ids[:a0]
|
| 205 |
+
room = max_len - len(resp)
|
| 206 |
+
if len(hist) > room:
|
| 207 |
+
hist = hist[len(hist) - room:] if room > 0 else []
|
| 208 |
+
ids = hist + resp
|
| 209 |
+
return {
|
| 210 |
+
"input_ids": torch.tensor(ids, dtype=torch.long),
|
| 211 |
+
"assistant_start": len(hist),
|
| 212 |
+
"assistant_end": len(ids),
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def tokenize_and_split(convs, math_indices, tokenizer, val_size, max_len,
|
| 217 |
+
max_resp, seed=42):
|
| 218 |
+
"""Batched tokenize + reconstruct + dedup + val split.
|
| 219 |
+
|
| 220 |
+
convs: list of (roles, segs) from build_conv_segments.
|
| 221 |
+
math_indices: conversation indices exempt from dedup (math repeats are
|
| 222 |
+
intentional). Returns (train_examples, val_examples, skipped, dupes).
|
| 223 |
+
"""
|
| 224 |
+
all_seg_strs = [s for _, segs in convs for s in segs]
|
| 225 |
+
print(f"[*] Encoding {len(all_seg_strs)} segments (batched)...")
|
| 226 |
+
encoded = []
|
| 227 |
+
CHUNK = 200_000
|
| 228 |
+
for i in range(0, len(all_seg_strs), CHUNK):
|
| 229 |
+
chunk = all_seg_strs[i:i + CHUNK]
|
| 230 |
+
encoded.extend(tokenizer(chunk, add_special_tokens=False)["input_ids"])
|
| 231 |
+
|
| 232 |
+
rng = random.Random(seed)
|
| 233 |
+
idxs = list(range(len(convs)))
|
| 234 |
+
rng.shuffle(idxs)
|
| 235 |
+
val_idxs = set(idxs[:val_size])
|
| 236 |
+
|
| 237 |
+
train_examples, val_examples = [], []
|
| 238 |
+
skipped = dupes = 0
|
| 239 |
+
seen = set()
|
| 240 |
+
ptr = 0
|
| 241 |
+
for i, (roles, segs) in enumerate(convs):
|
| 242 |
+
seg_ids = encoded[ptr:ptr + len(segs)]
|
| 243 |
+
ptr += len(segs)
|
| 244 |
+
ex = reconstruct_example(seg_ids, roles, tokenizer, max_len, max_resp)
|
| 245 |
+
if ex is None:
|
| 246 |
+
skipped += 1
|
| 247 |
+
continue
|
| 248 |
+
if i not in math_indices:
|
| 249 |
+
key = example_key(ex["input_ids"])
|
| 250 |
+
if key in seen:
|
| 251 |
+
dupes += 1
|
| 252 |
+
continue
|
| 253 |
+
seen.add(key)
|
| 254 |
+
(val_examples if i in val_idxs else train_examples).append(ex)
|
| 255 |
+
return train_examples, val_examples, skipped, dupes
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def save_dataset(out_dir, train_examples, val_examples, tokenizer,
|
| 259 |
+
skipped, dupes):
|
| 260 |
+
"""Save train.pt / val.pt / stats.json and print the summary."""
|
| 261 |
+
out = Path(out_dir)
|
| 262 |
+
torch.save({"examples": train_examples}, out / "train.pt")
|
| 263 |
+
torch.save({"examples": val_examples}, out / "val.pt")
|
| 264 |
+
|
| 265 |
+
lens = [e["input_ids"].numel() for e in train_examples]
|
| 266 |
+
stats = {
|
| 267 |
+
"train": len(train_examples),
|
| 268 |
+
"val": len(val_examples),
|
| 269 |
+
"skipped": skipped,
|
| 270 |
+
"dupes": dupes,
|
| 271 |
+
"avg_tokens": sum(lens) / len(lens) if lens else 0,
|
| 272 |
+
"max_tokens": max(lens) if lens else 0,
|
| 273 |
+
"vocab_size": len(tokenizer),
|
| 274 |
+
"im_start_id": tokenizer.convert_tokens_to_ids(IM_START),
|
| 275 |
+
"im_end_id": tokenizer.convert_tokens_to_ids(IM_END),
|
| 276 |
+
"rainbow_ids": [tokenizer.convert_tokens_to_ids(f"<|r{i}|>") for i in range(1, 8)],
|
| 277 |
+
"mask_token_id": 32000,
|
| 278 |
+
}
|
| 279 |
+
with open(out / "stats.json", "w") as f:
|
| 280 |
+
json.dump(stats, f, indent=2)
|
| 281 |
+
|
| 282 |
+
print(f"[*] Done: {stats['train']} train / {stats['val']} val "
|
| 283 |
+
f"(skipped {skipped}, dupes {dupes})")
|
| 284 |
+
print(f"[*] Avg tokens per example: {stats['avg_tokens']:.0f} (max {stats['max_tokens']})")
|
| 285 |
+
print(f"[*] im_start={stats['im_start_id']} im_end={stats['im_end_id']} "
|
| 286 |
+
f"rainbow={stats['rainbow_ids']}")
|
| 287 |
+
return stats
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def tokenize_example(tokenizer, messages, max_len: int, max_resp: int = 256):
|
| 291 |
+
"""Tokenize a conversation segment-wise; return ids + assistant bounds.
|
| 292 |
+
|
| 293 |
+
The target (last assistant response) is capped at `max_resp` tokens
|
| 294 |
+
including its trailing <|im_end|>: long web-text targets teach rambling,
|
| 295 |
+
and the terminator must stay in the target so the model learns to emit it.
|
| 296 |
+
"""
|
| 297 |
+
ids = []
|
| 298 |
+
assistant_ranges = [] # (start, end) per message, in token space
|
| 299 |
+
for m in messages:
|
| 300 |
+
role = m.get("role", "user")
|
| 301 |
+
content = m.get("content", "")
|
| 302 |
+
if not content.strip():
|
| 303 |
+
continue
|
| 304 |
+
seg = format_segment(role, content)
|
| 305 |
+
seg_ids = tokenizer.encode(seg, add_special_tokens=False)
|
| 306 |
+
start = len(ids)
|
| 307 |
+
ids.extend(seg_ids)
|
| 308 |
+
if role == "assistant":
|
| 309 |
+
assistant_ranges.append((start, len(ids)))
|
| 310 |
+
|
| 311 |
+
if not ids or not assistant_ranges:
|
| 312 |
+
return None
|
| 313 |
+
|
| 314 |
+
# Target turn = the LAST assistant response (LLaDA-MoE style)
|
| 315 |
+
a0, a1 = assistant_ranges[-1]
|
| 316 |
+
if a1 <= a0:
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
# Cap the response length, always keeping the trailing <|im_end|>
|
| 320 |
+
if a1 - a0 > max_resp:
|
| 321 |
+
im_end_id = tokenizer.convert_tokens_to_ids(IM_END)
|
| 322 |
+
ids = ids[:a0] + ids[a0:a0 + max_resp - 1] + [im_end_id]
|
| 323 |
+
a1 = a0 + max_resp
|
| 324 |
+
|
| 325 |
+
# Truncate from the front (history), keep the target response intact
|
| 326 |
+
resp = ids[a0:a1]
|
| 327 |
+
if len(resp) > max_len:
|
| 328 |
+
resp = resp[:max_len]
|
| 329 |
+
a1 = a0 + len(resp)
|
| 330 |
+
hist = ids[:a0]
|
| 331 |
+
room = max_len - len(resp)
|
| 332 |
+
if len(hist) > room:
|
| 333 |
+
hist = hist[len(hist) - room:] if room > 0 else []
|
| 334 |
+
ids = hist + resp
|
| 335 |
+
return {
|
| 336 |
+
"input_ids": torch.tensor(ids, dtype=torch.long),
|
| 337 |
+
"assistant_start": len(hist),
|
| 338 |
+
"assistant_end": len(ids),
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def main():
|
| 343 |
+
parser = argparse.ArgumentParser(description="Prepare no_robots as ChatML for MetaDiffusion SFT")
|
| 344 |
+
parser.add_argument("--model-path", default="../hf_release", help="Dir with tokenizer.json (base model)")
|
| 345 |
+
parser.add_argument("--data-dir", default="data/no_robots_chatml", help="Output dir")
|
| 346 |
+
parser.add_argument("--datasets", default="no_robots",
|
| 347 |
+
help="Comma list: no_robots, alpaca, dolly, smol-smoltalk, "
|
| 348 |
+
"math (e.g. no_robots,alpaca,dolly,smol-smoltalk,math)")
|
| 349 |
+
parser.add_argument("--max-examples", type=int, default=0,
|
| 350 |
+
help="Cap per downloaded dataset (0 = no cap). smol-smoltalk "
|
| 351 |
+
"is 484K; use ~60000 for a 150M model. Does not apply "
|
| 352 |
+
"to synthetic math (exhaustive coverage is the point).")
|
| 353 |
+
parser.add_argument("--val-size", type=int, default=500, help="Held-out examples")
|
| 354 |
+
parser.add_argument("--max-len", type=int, default=1024, help="Max tokens per example")
|
| 355 |
+
parser.add_argument("--max-resp-tokens", type=int, default=256,
|
| 356 |
+
help="Cap on target response tokens (keeps <|im_end|>)")
|
| 357 |
+
parser.add_argument("--math-repeat", type=int, default=1,
|
| 358 |
+
help="Upsample synthetic math: N passes over all pairs "
|
| 359 |
+
"(different phrasings per pass; 3-4 helps a 150M "
|
| 360 |
+
"model memorize the mapping)")
|
| 361 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 362 |
+
args = parser.parse_args()
|
| 363 |
+
|
| 364 |
+
out = Path(args.data_dir)
|
| 365 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 366 |
+
|
| 367 |
+
print(f"[*] Loading tokenizer from {args.model_path}")
|
| 368 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_path)
|
| 369 |
+
add_chat_tokens(tokenizer)
|
| 370 |
+
tokenizer.save_pretrained(out / "tokenizer")
|
| 371 |
+
|
| 372 |
+
convs = [] # (roles, segs) per conversation
|
| 373 |
+
math_indices = set()
|
| 374 |
+
for name in args.datasets.split(","):
|
| 375 |
+
name = name.strip()
|
| 376 |
+
print(f"[*] Loading dataset: {name}")
|
| 377 |
+
if name == "math":
|
| 378 |
+
# Upsample: several passes over the same pairs, each pass re-rolling
|
| 379 |
+
# phrasings (fresh seed). Math repeats are NOT deduped: identical
|
| 380 |
+
# copies are intentional extra gradient steps (memorization needs
|
| 381 |
+
# repetition, not variety).
|
| 382 |
+
start = len(convs)
|
| 383 |
+
for rep in range(args.math_repeat):
|
| 384 |
+
msgs = load_math_data(seed=args.seed + rep)
|
| 385 |
+
for messages in msgs:
|
| 386 |
+
segs, roles = build_conv_segments(messages)
|
| 387 |
+
convs.append((roles, segs))
|
| 388 |
+
print(f" pass {rep + 1}/{args.math_repeat}: {len(msgs)}")
|
| 389 |
+
math_indices.update(range(start, len(convs)))
|
| 390 |
+
else:
|
| 391 |
+
msgs = load_messages(name)
|
| 392 |
+
if args.max_examples > 0 and len(msgs) > args.max_examples:
|
| 393 |
+
rng_cap = random.Random(args.seed)
|
| 394 |
+
rng_cap.shuffle(msgs)
|
| 395 |
+
msgs = msgs[: args.max_examples]
|
| 396 |
+
print(f" capped to {len(msgs)}")
|
| 397 |
+
for messages in msgs:
|
| 398 |
+
segs, roles = build_conv_segments(messages)
|
| 399 |
+
convs.append((roles, segs))
|
| 400 |
+
print(f" {len(msgs)} conversations")
|
| 401 |
+
print(f"[*] Total: {len(convs)} conversations")
|
| 402 |
+
|
| 403 |
+
train_examples, val_examples, skipped, dupes = tokenize_and_split(
|
| 404 |
+
convs, math_indices, tokenizer, args.val_size, args.max_len,
|
| 405 |
+
args.max_resp_tokens, args.seed)
|
| 406 |
+
|
| 407 |
+
save_dataset(out, train_examples, val_examples, tokenizer, skipped, dupes)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
if __name__ == "__main__":
|
| 411 |
+
main()
|
scripts/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.2
|
| 2 |
+
transformers>=4.40
|
| 3 |
+
safetensors>=0.4
|
| 4 |
+
datasets>=2.18
|
| 5 |
+
numpy>=1.26
|
| 6 |
+
pandas>=2.0
|
| 7 |
+
pyarrow>=14.0
|
scripts/train_chat.py
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
train_chat.py: Chat SFT for MetaDiffusion-150M-exp (LLaDA Algorithm 2 style).
|
| 4 |
+
|
| 5 |
+
Checkpoints use the same format as train.py:
|
| 6 |
+
{step, model_state_dict, optimizer_state_dict, scheduler_state_dict, config}
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python3 train_chat.py \
|
| 10 |
+
--model-path ../hf_release \
|
| 11 |
+
--data-dir data/no_robots_chatml \
|
| 12 |
+
--output-dir checkpoints_chat \
|
| 13 |
+
--epochs 8
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import glob
|
| 18 |
+
import heapq
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import math
|
| 22 |
+
import os
|
| 23 |
+
import re
|
| 24 |
+
import shutil
|
| 25 |
+
import sys
|
| 26 |
+
import time
|
| 27 |
+
from dataclasses import asdict
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
import torch.nn as nn
|
| 32 |
+
import torch.nn.functional as F
|
| 33 |
+
from safetensors.torch import load_file
|
| 34 |
+
from torch.optim import AdamW
|
| 35 |
+
from torch.optim.lr_scheduler import LambdaLR
|
| 36 |
+
from torch.utils.data import DataLoader, Dataset
|
| 37 |
+
|
| 38 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 39 |
+
from model import MetaDiffusionLM, MetaDiffusionConfig # noqa: E402
|
| 40 |
+
|
| 41 |
+
MASK_TOKEN_ID = 32000
|
| 42 |
+
BASE_VOCAB = 32000 # Supra tokenizer entry count
|
| 43 |
+
RESERVED = "<|reserved|>" # filler so id 32000 stays free for [MASK]
|
| 44 |
+
BASE_VOCAB_WITH_RESERVED = BASE_VOCAB + 1
|
| 45 |
+
CHAT_TOKENS = ["<|im_start|>", "<|im_end|>"] + [f"<|r{i}|>" for i in range(1, 8)]
|
| 46 |
+
CHAT_VOCAB = BASE_VOCAB + 1 + len(CHAT_TOKENS) # 32010
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def ensure_chat_tokens(tokenizer):
|
| 50 |
+
"""Make sure chat tokens live at ids 32001..32009
|
| 51 |
+
|
| 52 |
+
Handles both a fresh base tokenizer (adds <|reserved|> at 32000 first) and
|
| 53 |
+
an already-prepared one (no-op).
|
| 54 |
+
"""
|
| 55 |
+
if tokenizer.convert_tokens_to_ids("<|im_start|>") == tokenizer.unk_token_id:
|
| 56 |
+
if len(tokenizer) == BASE_VOCAB:
|
| 57 |
+
tokenizer.add_special_tokens({"additional_special_tokens": [RESERVED]})
|
| 58 |
+
tokenizer.add_special_tokens({"additional_special_tokens": CHAT_TOKENS})
|
| 59 |
+
return tokenizer
|
| 60 |
+
|
| 61 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 62 |
+
logger = logging.getLogger(__name__)
|
| 63 |
+
|
| 64 |
+
def format_bytes(b):
|
| 65 |
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
| 66 |
+
if b < 1024:
|
| 67 |
+
return f"{b:.1f} {unit}"
|
| 68 |
+
b /= 1024
|
| 69 |
+
return f"{b:.1f} PB"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def format_duration(seconds):
|
| 73 |
+
seconds = max(0, int(seconds))
|
| 74 |
+
hours, rem = divmod(seconds, 3600)
|
| 75 |
+
minutes, secs = divmod(rem, 60)
|
| 76 |
+
if hours > 0:
|
| 77 |
+
return f"{hours}h{minutes:02d}m{secs:02d}s"
|
| 78 |
+
if minutes > 0:
|
| 79 |
+
return f"{minutes}m{secs:02d}s"
|
| 80 |
+
return f"{secs}s"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def get_gpu_memory_info():
|
| 84 |
+
if not torch.cuda.is_available():
|
| 85 |
+
return None, None
|
| 86 |
+
torch.cuda.synchronize()
|
| 87 |
+
free, total = torch.cuda.mem_get_info()
|
| 88 |
+
return total, free
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def detect_max_batch_size(model, seq_len, device, keep_free_fraction=0.1,
|
| 92 |
+
amp_dtype=None):
|
| 93 |
+
"""Largest batch size that fits in VRAM with headroom (from train.py)."""
|
| 94 |
+
if not torch.cuda.is_available():
|
| 95 |
+
return 8
|
| 96 |
+
total_mem, free_mem = get_gpu_memory_info()
|
| 97 |
+
if total_mem is None:
|
| 98 |
+
return 8
|
| 99 |
+
logger.info(f"GPU memory: {format_bytes(total_mem)} total, {format_bytes(free_mem)} free")
|
| 100 |
+
model = model.to(device).train()
|
| 101 |
+
mem_limit = total_mem - int(total_mem * keep_free_fraction)
|
| 102 |
+
last_working, first_oom = 1, None
|
| 103 |
+
for bs in [1, 2, 4, 8, 16, 32, 64, 128, 256]:
|
| 104 |
+
torch.cuda.synchronize()
|
| 105 |
+
if torch.cuda.memory_allocated() >= mem_limit:
|
| 106 |
+
first_oom = bs
|
| 107 |
+
break
|
| 108 |
+
try:
|
| 109 |
+
input_ids = torch.randint(0, 32000, (bs, seq_len), device=device)
|
| 110 |
+
labels = torch.randint(0, 32000, (bs, seq_len), device=device)
|
| 111 |
+
mask_positions = torch.rand(bs, seq_len, device=device) < 0.5
|
| 112 |
+
timesteps = torch.rand(bs, device=device)
|
| 113 |
+
with torch.autocast("cuda", dtype=amp_dtype or torch.float16):
|
| 114 |
+
logits = model(input_ids, timesteps)
|
| 115 |
+
loss, num_masked = model.compute_loss(logits, labels, mask_positions)
|
| 116 |
+
if num_masked > 0:
|
| 117 |
+
(loss / 4).backward()
|
| 118 |
+
torch.cuda.synchronize()
|
| 119 |
+
peak_mem = torch.cuda.max_memory_allocated()
|
| 120 |
+
logger.info(f" batch_size={bs:>3d}: peak VRAM={format_bytes(peak_mem)} "
|
| 121 |
+
f"(limit={format_bytes(mem_limit)})")
|
| 122 |
+
if peak_mem >= mem_limit:
|
| 123 |
+
first_oom = bs
|
| 124 |
+
break
|
| 125 |
+
last_working = bs
|
| 126 |
+
except RuntimeError as e:
|
| 127 |
+
if "out of memory" in str(e).lower():
|
| 128 |
+
first_oom = bs
|
| 129 |
+
break
|
| 130 |
+
raise
|
| 131 |
+
finally:
|
| 132 |
+
model.zero_grad(set_to_none=True)
|
| 133 |
+
torch.cuda.empty_cache()
|
| 134 |
+
if first_oom is not None and last_working < first_oom - 1:
|
| 135 |
+
lo, hi = last_working, first_oom
|
| 136 |
+
while lo + 1 < hi:
|
| 137 |
+
mid = (lo + hi) // 2
|
| 138 |
+
try:
|
| 139 |
+
input_ids = torch.randint(0, 32000, (mid, seq_len), device=device)
|
| 140 |
+
labels = torch.randint(0, 32000, (mid, seq_len), device=device)
|
| 141 |
+
mask_positions = torch.rand(mid, seq_len, device=device) < 0.5
|
| 142 |
+
timesteps = torch.rand(mid, device=device)
|
| 143 |
+
with torch.autocast("cuda", dtype=amp_dtype or torch.float16):
|
| 144 |
+
logits = model(input_ids, timesteps)
|
| 145 |
+
loss, num_masked = model.compute_loss(logits, labels, mask_positions)
|
| 146 |
+
if num_masked > 0:
|
| 147 |
+
(loss / 4).backward()
|
| 148 |
+
torch.cuda.synchronize()
|
| 149 |
+
if torch.cuda.max_memory_allocated() < mem_limit:
|
| 150 |
+
lo = mid
|
| 151 |
+
else:
|
| 152 |
+
hi = mid
|
| 153 |
+
except RuntimeError as e:
|
| 154 |
+
if "out of memory" in str(e).lower():
|
| 155 |
+
hi = mid
|
| 156 |
+
else:
|
| 157 |
+
raise
|
| 158 |
+
finally:
|
| 159 |
+
model.zero_grad(set_to_none=True)
|
| 160 |
+
torch.cuda.empty_cache()
|
| 161 |
+
last_working = lo
|
| 162 |
+
model.zero_grad(set_to_none=True)
|
| 163 |
+
torch.cuda.empty_cache()
|
| 164 |
+
logger.info(f"Detected max batch_size: {last_working}")
|
| 165 |
+
return last_working
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def get_step_from_filename(filename):
|
| 169 |
+
basename = os.path.basename(filename)
|
| 170 |
+
m = re.match(r"step_(\d+)(?:_\w+)?\.pt$", basename)
|
| 171 |
+
return int(m.group(1)) if m else None
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def load_best_steps(stats_path, max_n):
|
| 175 |
+
if not os.path.exists(stats_path):
|
| 176 |
+
return set()
|
| 177 |
+
entries = []
|
| 178 |
+
with open(stats_path) as f:
|
| 179 |
+
for line in f:
|
| 180 |
+
line = line.strip()
|
| 181 |
+
if not line:
|
| 182 |
+
continue
|
| 183 |
+
try:
|
| 184 |
+
entry = json.loads(line)
|
| 185 |
+
if "step" in entry and "loss" in entry:
|
| 186 |
+
entries.append((entry["loss"], entry["step"]))
|
| 187 |
+
except json.JSONDecodeError:
|
| 188 |
+
continue
|
| 189 |
+
return {step for _, step in heapq.nsmallest(max_n, entries)}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def cleanup_checkpoints(output_dir, keep_first_n, keep_last_n, keep_best_n, stats_path):
|
| 193 |
+
all_ckpts = sorted(glob.glob(os.path.join(output_dir, "step_*.pt")))
|
| 194 |
+
if len(all_ckpts) <= keep_first_n + keep_last_n + keep_best_n:
|
| 195 |
+
return
|
| 196 |
+
first_steps = {get_step_from_filename(c) for c in all_ckpts[:keep_first_n]}
|
| 197 |
+
last_steps = {get_step_from_filename(c) for c in all_ckpts[-keep_last_n:]}
|
| 198 |
+
best_steps = load_best_steps(stats_path, keep_best_n)
|
| 199 |
+
keep_steps = (first_steps | last_steps | best_steps) - {None}
|
| 200 |
+
for ckpt in all_ckpts:
|
| 201 |
+
s = get_step_from_filename(ckpt)
|
| 202 |
+
if s is not None and s not in keep_steps:
|
| 203 |
+
try:
|
| 204 |
+
os.remove(ckpt)
|
| 205 |
+
except OSError:
|
| 206 |
+
pass
|
| 207 |
+
logger.info(f"Cleaned old checkpoints (kept {len(keep_steps)}: "
|
| 208 |
+
f"{len(first_steps)} first, {len(last_steps)} last, {len(best_steps)} best)")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def get_free_disk_space(path):
|
| 212 |
+
return shutil.disk_usage(path).free
|
| 213 |
+
|
| 214 |
+
def build_config(config_dict):
|
| 215 |
+
"""Build MetaDiffusionConfig, ignoring non-dataclass keys (model_type, ...)."""
|
| 216 |
+
valid = {k: v for k, v in config_dict.items() if k in MetaDiffusionConfig.__dataclass_fields__}
|
| 217 |
+
config = MetaDiffusionConfig(**valid)
|
| 218 |
+
config.tie_word_embeddings = False # released checkpoint has an untied lm_head
|
| 219 |
+
return config
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def load_model(model_path, device):
|
| 223 |
+
"""Load MetaDiffusionLM from a dir (config.json + model.safetensors) or a step_*.pt."""
|
| 224 |
+
path = Path(model_path)
|
| 225 |
+
if path.is_dir():
|
| 226 |
+
with open(path / "config.json") as f:
|
| 227 |
+
config = build_config(json.load(f))
|
| 228 |
+
model = MetaDiffusionLM(config).to(device)
|
| 229 |
+
sd = load_file(path / "model.safetensors")
|
| 230 |
+
sd = {k[len("model."):] if k.startswith("model.") else k: v for k, v in sd.items()}
|
| 231 |
+
missing, unexpected = model.load_state_dict(sd, strict=False)
|
| 232 |
+
if missing or unexpected:
|
| 233 |
+
logger.warning(f"missing={missing[:5]} unexpected={unexpected[:5]}")
|
| 234 |
+
else:
|
| 235 |
+
ckpt = torch.load(path, map_location=device, weights_only=False)
|
| 236 |
+
config = build_config(ckpt["config"])
|
| 237 |
+
model = MetaDiffusionLM(config).to(device)
|
| 238 |
+
model.load_state_dict(clean_state_dict(ckpt["model_state_dict"]))
|
| 239 |
+
return model, config
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def clean_state_dict(state_dict):
|
| 243 |
+
"""Strip torch.compile's _orig_mod. prefix from checkpoint keys."""
|
| 244 |
+
return {k.replace("_orig_mod.", "", 1) if k.startswith("_orig_mod.") else k: v
|
| 245 |
+
for k, v in state_dict.items()}
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def expand_embeddings(model, new_vocab):
|
| 249 |
+
"""Mean-init new rows (ChatML + rainbow tokens) in embed_tokens and lm_head.
|
| 250 |
+
|
| 251 |
+
New modules are created on the model's device/dtype: nn.Embedding/nn.Linear
|
| 252 |
+
default to CPU, which would crash the first forward ("Tensor device
|
| 253 |
+
mismatch") unless something else moves the model afterwards.
|
| 254 |
+
"""
|
| 255 |
+
old_vocab = model.config.mask_vocab_size
|
| 256 |
+
if new_vocab <= old_vocab:
|
| 257 |
+
return
|
| 258 |
+
device = model.embed_tokens.weight.device
|
| 259 |
+
dtype = model.embed_tokens.weight.dtype
|
| 260 |
+
mean_emb = model.embed_tokens.weight.data.mean(dim=0, keepdim=True)
|
| 261 |
+
n_new = new_vocab - old_vocab
|
| 262 |
+
|
| 263 |
+
emb = torch.cat([model.embed_tokens.weight.data, mean_emb.expand(n_new, -1)], dim=0)
|
| 264 |
+
model.embed_tokens = nn.Embedding(new_vocab, model.config.hidden_size,
|
| 265 |
+
padding_idx=model.config.pad_token_id).to(device, dtype)
|
| 266 |
+
model.embed_tokens.weight.data.copy_(emb)
|
| 267 |
+
|
| 268 |
+
head = torch.cat([model.lm_head.weight.data, mean_emb.expand(n_new, -1)], dim=0)
|
| 269 |
+
model.lm_head = nn.Linear(model.config.hidden_size, new_vocab, bias=False).to(device, dtype)
|
| 270 |
+
model.lm_head.weight.data.copy_(head)
|
| 271 |
+
|
| 272 |
+
model.config.mask_vocab_size = new_vocab
|
| 273 |
+
logger.info(f"Expanded embeddings {old_vocab} -> {new_vocab} (mean init)")
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class ChatDataset(Dataset):
|
| 277 |
+
"""no_robots ChatML examples; masks ONLY the last assistant response.
|
| 278 |
+
|
| 279 |
+
Examples are stored as plain int lists (NOT tensors): with forkserver
|
| 280 |
+
workers (torch's default once CUDA is initialized), every tensor in the
|
| 281 |
+
dataset is transferred through shared memory at worker spawn, and 9000
|
| 282 |
+
tensors blows the open-file limit. Lists pickle as bytes.
|
| 283 |
+
"""
|
| 284 |
+
|
| 285 |
+
def __init__(self, data_path, tokenizer, seq_len, seed=42):
|
| 286 |
+
raw = torch.load(data_path, weights_only=True)["examples"]
|
| 287 |
+
self.examples = [
|
| 288 |
+
{
|
| 289 |
+
"ids": ex["input_ids"].tolist(),
|
| 290 |
+
"a0": int(ex["assistant_start"]),
|
| 291 |
+
"a1": int(ex["assistant_end"]),
|
| 292 |
+
}
|
| 293 |
+
for ex in raw
|
| 294 |
+
]
|
| 295 |
+
self.seq_len = seq_len
|
| 296 |
+
self.mask_id = MASK_TOKEN_ID
|
| 297 |
+
self.rainbow_ids = [
|
| 298 |
+
tokenizer.convert_tokens_to_ids(f"<|r{i}|>") for i in range(1, 8)
|
| 299 |
+
]
|
| 300 |
+
self.seed = seed
|
| 301 |
+
logger.info(f"Loaded {len(self.examples)} examples from {data_path}")
|
| 302 |
+
|
| 303 |
+
def __len__(self):
|
| 304 |
+
return len(self.examples)
|
| 305 |
+
|
| 306 |
+
def __getitem__(self, idx):
|
| 307 |
+
ex = self.examples[idx]
|
| 308 |
+
ids = ex["ids"]
|
| 309 |
+
a0, a1 = ex["a0"], ex["a1"]
|
| 310 |
+
|
| 311 |
+
# Guard truncation
|
| 312 |
+
if len(ids) > self.seq_len:
|
| 313 |
+
resp = ids[a0:a1]
|
| 314 |
+
if len(resp) > self.seq_len:
|
| 315 |
+
resp = resp[: self.seq_len]
|
| 316 |
+
room = self.seq_len - len(resp)
|
| 317 |
+
hist = ids[:a0]
|
| 318 |
+
hist = hist[len(hist) - room:] if room > 0 else []
|
| 319 |
+
ids = hist + resp
|
| 320 |
+
a0, a1 = len(hist), len(ids)
|
| 321 |
+
|
| 322 |
+
# Rainbow padding (cyclic, never masked, never in loss)
|
| 323 |
+
n = len(ids)
|
| 324 |
+
pad = self.seq_len - n
|
| 325 |
+
full = ids + [self.rainbow_ids[j % 7] for j in range(pad)]
|
| 326 |
+
|
| 327 |
+
can_mask = torch.zeros(self.seq_len, dtype=torch.bool)
|
| 328 |
+
can_mask[a0:a1] = True
|
| 329 |
+
|
| 330 |
+
t = torch.rand(1).item()
|
| 331 |
+
rand = torch.rand(self.seq_len)
|
| 332 |
+
mask_pos = (rand < t) & can_mask
|
| 333 |
+
|
| 334 |
+
input_ids = torch.tensor(full, dtype=torch.long)
|
| 335 |
+
input_ids[mask_pos] = self.mask_id
|
| 336 |
+
attention = torch.ones(self.seq_len, dtype=torch.long)
|
| 337 |
+
attention[n:] = 0
|
| 338 |
+
|
| 339 |
+
return {
|
| 340 |
+
"input_ids": input_ids,
|
| 341 |
+
"labels": torch.tensor(full, dtype=torch.long),
|
| 342 |
+
"mask_positions": mask_pos,
|
| 343 |
+
"timesteps": torch.tensor(t, dtype=torch.float32),
|
| 344 |
+
"attention_mask": attention,
|
| 345 |
+
"resp_len": torch.tensor(max(a1 - a0, 1), dtype=torch.float32),
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def collate_fn(batch):
|
| 350 |
+
return {
|
| 351 |
+
k: torch.stack([b[k] for b in batch]) for k in batch[0]
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def worker_init_fn(worker_id):
|
| 356 |
+
torch.manual_seed(42 + worker_id)
|
| 357 |
+
|
| 358 |
+
def get_cosine_schedule_with_warmup(optimizer, num_warmup_steps, num_training_steps,
|
| 359 |
+
min_lr_ratio=0.1):
|
| 360 |
+
def lr_lambda(current_step):
|
| 361 |
+
if current_step < num_warmup_steps:
|
| 362 |
+
return float(current_step) / float(max(1, num_warmup_steps))
|
| 363 |
+
progress = float(current_step - num_warmup_steps) / float(
|
| 364 |
+
max(1, num_training_steps - num_warmup_steps)
|
| 365 |
+
)
|
| 366 |
+
return max(min_lr_ratio, 0.5 * (1.0 + math.cos(math.pi * progress)))
|
| 367 |
+
return LambdaLR(optimizer, lr_lambda)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
@torch.no_grad()
|
| 371 |
+
def evaluate(model, val_dataset, batch_size, device, dtype, n_max=100):
|
| 372 |
+
model.eval()
|
| 373 |
+
losses, n_seen = [], 0
|
| 374 |
+
for start in range(0, min(len(val_dataset), n_max), batch_size):
|
| 375 |
+
idxs = list(range(start, min(start + batch_size, n_max)))
|
| 376 |
+
batch = collate_fn([val_dataset[i] for i in idxs])
|
| 377 |
+
input_ids = batch["input_ids"].to(device)
|
| 378 |
+
labels = batch["labels"].to(device)
|
| 379 |
+
mask_positions = batch["mask_positions"].to(device)
|
| 380 |
+
timesteps = batch["timesteps"].to(device)
|
| 381 |
+
attention = batch["attention_mask"].to(device)
|
| 382 |
+
resp_len = batch["resp_len"].to(device)
|
| 383 |
+
with torch.autocast("cuda", dtype=dtype):
|
| 384 |
+
logits = model(input_ids, timesteps, attention_mask=attention)
|
| 385 |
+
if mask_positions.any():
|
| 386 |
+
ce = F.cross_entropy(logits.float()[mask_positions],
|
| 387 |
+
labels[mask_positions], reduction="none")
|
| 388 |
+
w = (1.0 / (timesteps * resp_len)).unsqueeze(1).expand_as(labels)
|
| 389 |
+
loss = (ce * w[mask_positions]).sum() / input_ids.shape[0]
|
| 390 |
+
losses.append(loss.item())
|
| 391 |
+
n_seen += 1
|
| 392 |
+
model.train()
|
| 393 |
+
return sum(losses) / len(losses) if losses else float("nan"), n_seen
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def main():
|
| 397 |
+
parser = argparse.ArgumentParser(description="Chat SFT for MetaDiffusion (LLaDA Algorithm 2)")
|
| 398 |
+
parser.add_argument("--model-path", default="../hf_release",
|
| 399 |
+
help="Dir with config.json + model.safetensors, or a step_*.pt")
|
| 400 |
+
parser.add_argument("--data-dir", default="data/no_robots_chatml")
|
| 401 |
+
parser.add_argument("--output-dir", default="checkpoints_chat")
|
| 402 |
+
parser.add_argument("--seq-len", type=int, default=512)
|
| 403 |
+
parser.add_argument("--batch-size", type=int, default=0, help="0 = auto-detect")
|
| 404 |
+
parser.add_argument("--grad-accum-steps", type=int, default=4)
|
| 405 |
+
parser.add_argument("--num-workers", type=int, default=4,
|
| 406 |
+
help="DataLoader workers (0 if forkserver shm issues)")
|
| 407 |
+
parser.add_argument("--lr", type=float, default=3e-5)
|
| 408 |
+
parser.add_argument("--min-lr-ratio", type=float, default=0.1)
|
| 409 |
+
parser.add_argument("--warmup-steps", type=int, default=100)
|
| 410 |
+
parser.add_argument("--weight-decay", type=float, default=0.1)
|
| 411 |
+
parser.add_argument("--epochs", type=int, default=8)
|
| 412 |
+
parser.add_argument("--max-steps", type=int, default=0, help="0 = epochs only")
|
| 413 |
+
parser.add_argument("--transferred-lr-mult", type=float, default=0.33)
|
| 414 |
+
parser.add_argument("--new-lr-mult", type=float, default=1.0)
|
| 415 |
+
parser.add_argument("--max-grad-norm", type=float, default=1.0)
|
| 416 |
+
parser.add_argument("--save-every", type=int, default=500)
|
| 417 |
+
parser.add_argument("--log-every", type=int, default=50)
|
| 418 |
+
parser.add_argument("--val-every", type=int, default=200)
|
| 419 |
+
parser.add_argument("--patience", type=int, default=3,
|
| 420 |
+
help="Early stop after N val checks without improvement (0 = off)")
|
| 421 |
+
parser.add_argument("--min-delta", type=float, default=0.001,
|
| 422 |
+
help="Relative val-loss improvement required to count as progress")
|
| 423 |
+
parser.add_argument("--resume-from", type=str, default=None)
|
| 424 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 425 |
+
parser.add_argument("--device", default="cuda", help="cuda, cuda:1, cpu")
|
| 426 |
+
parser.add_argument("--bf16", action="store_true", help="Use bf16 instead of fp16")
|
| 427 |
+
parser.add_argument("--no-compile", action="store_true")
|
| 428 |
+
parser.add_argument("--keep-free-vram", type=float, default=0.1)
|
| 429 |
+
parser.add_argument("--keep-first-n", type=int, default=2)
|
| 430 |
+
parser.add_argument("--keep-last-n", type=int, default=2)
|
| 431 |
+
parser.add_argument("--keep-best-n", type=int, default=2)
|
| 432 |
+
parser.add_argument("--disk-min-gb", type=float, default=5.0)
|
| 433 |
+
parser.add_argument("--export-dir", type=str, default=None,
|
| 434 |
+
help="Export final dir (config+safetensors+tokenizer)")
|
| 435 |
+
args = parser.parse_args()
|
| 436 |
+
|
| 437 |
+
device = torch.device(args.device if torch.cuda.is_available() else "cpu")
|
| 438 |
+
torch.manual_seed(args.seed)
|
| 439 |
+
|
| 440 |
+
model, config = load_model(args.model_path, device)
|
| 441 |
+
logger.info(f"Loaded: {config.num_hidden_layers}L x {config.hidden_size}W, "
|
| 442 |
+
f"vocab={config.mask_vocab_size}")
|
| 443 |
+
|
| 444 |
+
if args.resume_from is None:
|
| 445 |
+
expand_embeddings(model, CHAT_VOCAB)
|
| 446 |
+
else:
|
| 447 |
+
logger.info(f"Resuming: keeping expanded vocab {config.mask_vocab_size}")
|
| 448 |
+
|
| 449 |
+
from transformers import AutoTokenizer
|
| 450 |
+
tokenizer = AutoTokenizer.from_pretrained(os.path.join(args.data_dir, "tokenizer"))
|
| 451 |
+
ensure_chat_tokens(tokenizer)
|
| 452 |
+
im_end = tokenizer.convert_tokens_to_ids("<|im_end|>")
|
| 453 |
+
assert im_end == 32002, (
|
| 454 |
+
f"Tokenizer has im_end={im_end}, expected 32002. "
|
| 455 |
+
f"Data dir is stale (pre-fix ids): re-run prepare_data.py first."
|
| 456 |
+
)
|
| 457 |
+
logger.info(f"Tokenizer vocab: {len(tokenizer)} | im_end={im_end}")
|
| 458 |
+
|
| 459 |
+
if args.bf16:
|
| 460 |
+
model = model.to(torch.bfloat16)
|
| 461 |
+
amp_dtype = torch.bfloat16
|
| 462 |
+
else:
|
| 463 |
+
# fp16 AMP: keep fp32 master weights, autocast does the fp16 compute.
|
| 464 |
+
# GradScaler requires fp32 gradients; fp16 weights would produce fp16
|
| 465 |
+
# grads and unscale_ raises "Attempting to unscale FP16 gradients".
|
| 466 |
+
amp_dtype = torch.float16
|
| 467 |
+
|
| 468 |
+
train_ds = ChatDataset(os.path.join(args.data_dir, "train.pt"), tokenizer,
|
| 469 |
+
args.seq_len, seed=args.seed)
|
| 470 |
+
val_ds = ChatDataset(os.path.join(args.data_dir, "val.pt"), tokenizer,
|
| 471 |
+
args.seq_len, seed=args.seed)
|
| 472 |
+
|
| 473 |
+
transferred_names, new_names = set(), set()
|
| 474 |
+
for name, p in model.named_parameters():
|
| 475 |
+
if any(k in name for k in ["timestep_emb", "timestep_residual", "lm_head",
|
| 476 |
+
"embed_tokens.weight"]):
|
| 477 |
+
new_names.add(name)
|
| 478 |
+
else:
|
| 479 |
+
transferred_names.add(name)
|
| 480 |
+
param_groups = [
|
| 481 |
+
{"params": [p for n, p in model.named_parameters() if n in transferred_names],
|
| 482 |
+
"lr": args.lr * args.transferred_lr_mult, "name": "transferred"},
|
| 483 |
+
{"params": [p for n, p in model.named_parameters() if n in new_names],
|
| 484 |
+
"lr": args.lr * args.new_lr_mult, "name": "new"},
|
| 485 |
+
]
|
| 486 |
+
for pg in param_groups:
|
| 487 |
+
logger.info(f" {pg['name']}: {sum(p.numel() for p in pg['params']):,} params, "
|
| 488 |
+
f"lr={pg['lr']:.2e}")
|
| 489 |
+
|
| 490 |
+
optimizer = AdamW(param_groups, weight_decay=args.weight_decay)
|
| 491 |
+
|
| 492 |
+
batch_size = args.batch_size
|
| 493 |
+
if batch_size <= 0 and torch.cuda.is_available():
|
| 494 |
+
batch_size = detect_max_batch_size(model, args.seq_len, device,
|
| 495 |
+
args.keep_free_vram, amp_dtype)
|
| 496 |
+
if batch_size <= 0:
|
| 497 |
+
batch_size = 8
|
| 498 |
+
eff_batch = batch_size * args.grad_accum_steps
|
| 499 |
+
steps_per_epoch = max(1, math.ceil(len(train_ds) / eff_batch))
|
| 500 |
+
total_steps = args.max_steps if args.max_steps > 0 else steps_per_epoch * args.epochs
|
| 501 |
+
logger.info(f"batch={batch_size} accum={args.grad_accum_steps} "
|
| 502 |
+
f"eff={eff_batch} steps/epoch={steps_per_epoch} total={total_steps}")
|
| 503 |
+
|
| 504 |
+
# Compile AFTER batch detection + resume (avoids recompiles per probe
|
| 505 |
+
# batch size, and lets resume load clean keys into a plain nn.Module)
|
| 506 |
+
scheduler = get_cosine_schedule_with_warmup(
|
| 507 |
+
optimizer, args.warmup_steps, total_steps, args.min_lr_ratio
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
dataloader = DataLoader(train_ds, batch_size=batch_size, shuffle=True,
|
| 511 |
+
num_workers=args.num_workers, pin_memory=True,
|
| 512 |
+
drop_last=False, collate_fn=collate_fn,
|
| 513 |
+
worker_init_fn=worker_init_fn)
|
| 514 |
+
|
| 515 |
+
global_step = 0
|
| 516 |
+
if args.resume_from:
|
| 517 |
+
ckpt = torch.load(args.resume_from, map_location=device, weights_only=False)
|
| 518 |
+
model.load_state_dict(clean_state_dict(ckpt["model_state_dict"]))
|
| 519 |
+
optim_state = ckpt.get("optimizer_state_dict", {})
|
| 520 |
+
if optim_state and "param_groups" in optim_state and "state" in optim_state:
|
| 521 |
+
try:
|
| 522 |
+
optimizer.load_state_dict(optim_state)
|
| 523 |
+
except (ValueError, KeyError) as e:
|
| 524 |
+
logger.warning(f"Optimizer state not loaded: {e}")
|
| 525 |
+
sched_state = ckpt.get("scheduler_state_dict", {})
|
| 526 |
+
if sched_state and sched_state.get("last_epoch", 0) == ckpt.get("step", 0):
|
| 527 |
+
try:
|
| 528 |
+
scheduler.load_state_dict(sched_state)
|
| 529 |
+
except (ValueError, KeyError) as e:
|
| 530 |
+
logger.warning(f"Scheduler state not loaded: {e}")
|
| 531 |
+
global_step = ckpt.get("step", 0)
|
| 532 |
+
logger.info(f"Resumed from step {global_step}")
|
| 533 |
+
|
| 534 |
+
if not args.no_compile:
|
| 535 |
+
logger.info("Compiling model...")
|
| 536 |
+
model = torch.compile(model)
|
| 537 |
+
|
| 538 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 539 |
+
stats_path = os.path.join(args.output_dir, "stats.jsonl")
|
| 540 |
+
stats_file = open(stats_path, "a")
|
| 541 |
+
with open(os.path.join(args.output_dir, "config.json"), "w") as f:
|
| 542 |
+
json.dump(asdict(model.config), f, indent=2, default=str)
|
| 543 |
+
|
| 544 |
+
scaler = torch.amp.GradScaler("cuda", enabled=not args.bf16)
|
| 545 |
+
free_disk = get_free_disk_space(args.output_dir)
|
| 546 |
+
if free_disk < args.disk_min_gb * 1e9:
|
| 547 |
+
stats_file.close()
|
| 548 |
+
raise RuntimeError(f"Insufficient disk space: {format_bytes(free_disk)}")
|
| 549 |
+
|
| 550 |
+
model.train()
|
| 551 |
+
optimizer.zero_grad()
|
| 552 |
+
loss_total, loss_count = 0.0, 0
|
| 553 |
+
start_time = time.time()
|
| 554 |
+
last_log_time = start_time
|
| 555 |
+
data_iter = iter(dataloader)
|
| 556 |
+
epoch = 0
|
| 557 |
+
best_val = float("inf")
|
| 558 |
+
no_improve = 0
|
| 559 |
+
early_stopped = False
|
| 560 |
+
|
| 561 |
+
while global_step < total_steps:
|
| 562 |
+
if global_step % steps_per_epoch == 0 and global_step > 0:
|
| 563 |
+
epoch += 1
|
| 564 |
+
try:
|
| 565 |
+
batch = next(data_iter)
|
| 566 |
+
except StopIteration:
|
| 567 |
+
epoch += 1
|
| 568 |
+
data_iter = iter(dataloader)
|
| 569 |
+
batch = next(data_iter)
|
| 570 |
+
|
| 571 |
+
input_ids = batch["input_ids"].to(device)
|
| 572 |
+
labels = batch["labels"].to(device)
|
| 573 |
+
mask_positions = batch["mask_positions"].to(device)
|
| 574 |
+
timesteps = batch["timesteps"].to(device)
|
| 575 |
+
attention = batch["attention_mask"].to(device)
|
| 576 |
+
resp_len = batch["resp_len"].to(device)
|
| 577 |
+
|
| 578 |
+
with torch.autocast("cuda", dtype=amp_dtype):
|
| 579 |
+
logits = model(input_ids, timesteps, attention_mask=attention)
|
| 580 |
+
|
| 581 |
+
num_masked = mask_positions.sum().item()
|
| 582 |
+
if num_masked > 0:
|
| 583 |
+
# LLaDA GUIDELINES loss: CE/(t * response_len) summed over masked
|
| 584 |
+
# response tokens, mean over batch. Expected value ~ per-token CE.
|
| 585 |
+
ce = F.cross_entropy(logits.float()[mask_positions],
|
| 586 |
+
labels[mask_positions], reduction="none")
|
| 587 |
+
w = (1.0 / (timesteps * resp_len)).unsqueeze(1).expand_as(labels)
|
| 588 |
+
loss = (ce * w[mask_positions]).sum() / input_ids.shape[0]
|
| 589 |
+
scaler.scale(loss / args.grad_accum_steps).backward()
|
| 590 |
+
loss_total += loss.item()
|
| 591 |
+
else:
|
| 592 |
+
loss = torch.tensor(0.0, device=device)
|
| 593 |
+
|
| 594 |
+
global_step += 1
|
| 595 |
+
|
| 596 |
+
if global_step % args.grad_accum_steps == 0:
|
| 597 |
+
scaler.unscale_(optimizer)
|
| 598 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), args.max_grad_norm)
|
| 599 |
+
skipped = scaler.step(optimizer) # True when grads had inf/nan
|
| 600 |
+
scaler.update()
|
| 601 |
+
if not skipped:
|
| 602 |
+
scheduler.step() # don't advance LR on skipped steps
|
| 603 |
+
optimizer.zero_grad()
|
| 604 |
+
|
| 605 |
+
loss_count += 1
|
| 606 |
+
|
| 607 |
+
if global_step % args.log_every == 0:
|
| 608 |
+
now = time.time()
|
| 609 |
+
elapsed = now - start_time
|
| 610 |
+
avg_loss = loss_total / max(1, loss_count)
|
| 611 |
+
ppl = math.exp(min(avg_loss, 20))
|
| 612 |
+
lr = scheduler.get_last_lr()[0]
|
| 613 |
+
steps_per_sec = args.log_every / max(now - last_log_time, 1e-6)
|
| 614 |
+
eta = format_duration((total_steps - global_step) / steps_per_sec)
|
| 615 |
+
logger.info(f"Step {global_step:>6d} | epoch {epoch:.1f} | loss={avg_loss:.4f} | "
|
| 616 |
+
f"ppl={ppl:.1f} | lr={lr:.2e} | {steps_per_sec:.1f} steps/s | "
|
| 617 |
+
f"elapsed={elapsed:.0f}s | eta={eta}")
|
| 618 |
+
loss_total, loss_count = 0.0, 0
|
| 619 |
+
last_log_time = now
|
| 620 |
+
|
| 621 |
+
stats_entry = {"step": global_step, "epoch": round(epoch, 2),
|
| 622 |
+
"loss": round(avg_loss, 4), "ppl": round(ppl, 1), "lr": lr}
|
| 623 |
+
stats_file.write(json.dumps(stats_entry) + "\n")
|
| 624 |
+
stats_file.flush()
|
| 625 |
+
|
| 626 |
+
# Validation + early stopping (independent of log cadence)
|
| 627 |
+
if global_step % args.val_every == 0:
|
| 628 |
+
val_loss, _ = evaluate(model, val_ds, batch_size, device, amp_dtype)
|
| 629 |
+
logger.info(f" val_loss={val_loss:.4f}")
|
| 630 |
+
stats_file.write(json.dumps({"step": global_step,
|
| 631 |
+
"val_loss": round(val_loss, 4)}) + "\n")
|
| 632 |
+
stats_file.flush()
|
| 633 |
+
if val_loss < best_val * (1.0 - args.min_delta):
|
| 634 |
+
best_val = val_loss
|
| 635 |
+
no_improve = 0
|
| 636 |
+
ckpt_path = os.path.join(args.output_dir, "best.pt")
|
| 637 |
+
torch.save({"step": global_step,
|
| 638 |
+
"model_state_dict": clean_state_dict(model.state_dict()),
|
| 639 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 640 |
+
"scheduler_state_dict": scheduler.state_dict(),
|
| 641 |
+
"config": asdict(model.config)}, ckpt_path)
|
| 642 |
+
logger.info(f" Best val loss, saved {ckpt_path}")
|
| 643 |
+
else:
|
| 644 |
+
no_improve += 1
|
| 645 |
+
logger.info(f" No val improvement ({no_improve}/{args.patience} checks, "
|
| 646 |
+
f"best={best_val:.4f})")
|
| 647 |
+
if args.patience > 0 and no_improve >= args.patience:
|
| 648 |
+
logger.info(f"Early stopping at step {global_step}: no val loss "
|
| 649 |
+
f"improvement for {args.patience} checks "
|
| 650 |
+
f"(best={best_val:.4f})")
|
| 651 |
+
ckpt_path = os.path.join(args.output_dir, f"step_{global_step}.pt")
|
| 652 |
+
torch.save({"step": global_step,
|
| 653 |
+
"model_state_dict": clean_state_dict(model.state_dict()),
|
| 654 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 655 |
+
"scheduler_state_dict": scheduler.state_dict(),
|
| 656 |
+
"config": asdict(model.config)}, ckpt_path)
|
| 657 |
+
logger.info(f"Saved final checkpoint: {ckpt_path}")
|
| 658 |
+
stats_file.write(json.dumps(
|
| 659 |
+
{**stats_entry, "best_val": round(best_val, 4),
|
| 660 |
+
"early_stopped": True}) + "\n")
|
| 661 |
+
stats_file.flush()
|
| 662 |
+
stats_file.close()
|
| 663 |
+
early_stopped = True
|
| 664 |
+
break
|
| 665 |
+
|
| 666 |
+
if global_step % args.save_every == 0:
|
| 667 |
+
ckpt_path = os.path.join(args.output_dir, f"step_{global_step}.pt")
|
| 668 |
+
torch.save({"step": global_step,
|
| 669 |
+
"model_state_dict": clean_state_dict(model.state_dict()),
|
| 670 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 671 |
+
"scheduler_state_dict": scheduler.state_dict(),
|
| 672 |
+
"config": asdict(model.config)}, ckpt_path)
|
| 673 |
+
logger.info(f"Saved checkpoint: {ckpt_path}")
|
| 674 |
+
cleanup_checkpoints(args.output_dir, args.keep_first_n,
|
| 675 |
+
args.keep_last_n, args.keep_best_n, stats_path)
|
| 676 |
+
if get_free_disk_space(args.output_dir) < args.disk_min_gb * 1e9:
|
| 677 |
+
logger.warning("Low disk after save; stopping")
|
| 678 |
+
stats_file.close()
|
| 679 |
+
return
|
| 680 |
+
|
| 681 |
+
stats_file.close()
|
| 682 |
+
if early_stopped:
|
| 683 |
+
logger.info(f"Early stopping triggered; best val loss {best_val:.4f} "
|
| 684 |
+
f"saved as best.pt")
|
| 685 |
+
else:
|
| 686 |
+
logger.info(f"Training complete at step {global_step}")
|
| 687 |
+
|
| 688 |
+
if args.export_dir:
|
| 689 |
+
from export_hf import export
|
| 690 |
+
export(os.path.join(args.output_dir, f"step_{global_step}.pt")
|
| 691 |
+
if not os.path.exists(os.path.join(args.output_dir, "best.pt"))
|
| 692 |
+
else os.path.join(args.output_dir, "best.pt"),
|
| 693 |
+
os.path.join(args.data_dir, "tokenizer"),
|
| 694 |
+
args.export_dir)
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
if __name__ == "__main__":
|
| 698 |
+
main()
|
tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backend": "tokenizers",
|
| 3 |
+
"bos_token": "<s>",
|
| 4 |
+
"clean_up_tokenization_spaces": false,
|
| 5 |
+
"eos_token": "</s>",
|
| 6 |
+
"extra_special_tokens": [
|
| 7 |
+
"<|im_start|>",
|
| 8 |
+
"<|im_end|>",
|
| 9 |
+
"<|r1|>",
|
| 10 |
+
"<|r2|>",
|
| 11 |
+
"<|r3|>",
|
| 12 |
+
"<|r4|>",
|
| 13 |
+
"<|r5|>",
|
| 14 |
+
"<|r6|>",
|
| 15 |
+
"<|r7|>"
|
| 16 |
+
],
|
| 17 |
+
"is_local": true,
|
| 18 |
+
"local_files_only": false,
|
| 19 |
+
"model_max_length": 5120,
|
| 20 |
+
"pad_token": "<pad>",
|
| 21 |
+
"tokenizer_class": "TokenizersBackend",
|
| 22 |
+
"unk_token": "<unk>"
|
| 23 |
+
}
|