| |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import math |
| import re |
| import sys |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn.functional as F |
| from transformers import AutoModelForCausalLM, AutoTokenizer |
|
|
|
|
| def find_repo_root(start: Path) -> Path: |
| for parent in [start, *start.parents]: |
| if (parent / "flowtext_lab").is_dir(): |
| return parent |
| raise RuntimeError(f"Could not find repo root from {start}") |
|
|
|
|
| REPO_ROOT = find_repo_root(Path(__file__).resolve()) |
| if str(REPO_ROOT) not in sys.path: |
| sys.path.insert(0, str(REPO_ROOT)) |
|
|
| from flowtext_lab.genppl import filter_generated_texts, summarize_token_diversity |
| from flowtext_lab.model import EndpointPredictor |
| from flowtext_lab.tokenization import BpeTextTokenizer |
|
|
|
|
| SPECIAL_RE = re.compile(r"\s+") |
|
|
|
|
| def strip_common_special(text: str) -> str: |
| text = text.replace("[CLS]", " ").replace("[SEP]", " ").replace("[PAD]", " ") |
| text = text.replace("<|endoftext|>", " ") |
| return SPECIAL_RE.sub(" ", text).strip() |
|
|
|
|
| def write_raw_samples(path: Path, title: str, summary: dict, texts: list[str]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| with path.open("w", encoding="utf-8") as f: |
| f.write(title + "\n") |
| f.write(json.dumps(summary, ensure_ascii=False, indent=2) + "\n\n") |
| for i, text in enumerate(texts): |
| f.write(f"===== sample {i:03d} =====\n") |
| f.write(text + "\n\n") |
|
|
|
|
| def build_endpoint_model(ckpt: dict, tokenizer: BpeTextTokenizer, device: torch.device) -> EndpointPredictor: |
| train_args = ckpt.get("args", {}) |
| token_vocab = tokenizer.vocab_size |
| train_vocab = int(ckpt.get("train_vocab_size") or token_vocab + 1) |
| train_vocab = max(train_vocab, token_vocab + 1) |
| model = EndpointPredictor( |
| vocab_size=train_vocab, |
| max_len=int(train_args.get("max_len", 128)), |
| d_model=int(train_args.get("d_model", 768)), |
| n_heads=int(train_args.get("n_heads", 12)), |
| n_layers=int(train_args.get("n_layers", 12)), |
| dim_ff=int(train_args.get("dim_ff", 3072)), |
| dropout=0.0, |
| model_type=str(train_args.get("model_type", "ddit")), |
| cond_dim=int(train_args.get("cond_dim", 128)), |
| input_format=str(train_args.get("state_format", train_args.get("input_format", "prob"))), |
| ).to(device) |
| model.load_state_dict(ckpt["model"], strict=True) |
| model.eval() |
| return model |
|
|
|
|
| def log_linear_alpha(t: torch.Tensor, eps: float) -> torch.Tensor: |
| return 1.0 - (1.0 - eps) * t |
|
|
|
|
| def sample_categorical(weights: torch.Tensor) -> torch.Tensor: |
| flat = weights.to(torch.float64).clamp_min(0).reshape(-1, weights.size(-1)) |
| row_sum = flat.sum(dim=-1, keepdim=True) |
| probs = flat / row_sum.clamp_min(1e-300) |
| return torch.multinomial(probs, num_samples=1).view(*weights.shape[:-1]) |
|
|
|
|
| def mdlm_log_probs( |
| model: EndpointPredictor, |
| x: torch.Tensor, |
| sigma: torch.Tensor, |
| attn: torch.Tensor, |
| *, |
| token_vocab_size: int, |
| mask_id: int, |
| ) -> torch.Tensor: |
| logits = model(x, sigma, attn).float() |
| logits[:, :, mask_id] = -1_000_000.0 |
| log_probs = logits - torch.logsumexp(logits, dim=-1, keepdim=True) |
|
|
| |
| unmasked = x != mask_id |
| log_probs = log_probs.clone() |
| log_probs[unmasked] = -1_000_000.0 |
| log_probs[unmasked, x[unmasked].clamp_max(token_vocab_size - 1)] = 0.0 |
| return log_probs |
|
|
|
|
| @torch.inference_mode() |
| def decode_mdlm_ddpm_cache( |
| model: EndpointPredictor, |
| tokenizer: BpeTextTokenizer, |
| *, |
| n_samples: int, |
| batch_size: int, |
| max_len: int, |
| steps: int, |
| eps: float, |
| final: str, |
| time_conditioned: bool, |
| seed: int, |
| device: torch.device, |
| ) -> tuple[list[list[int]], list[str], dict]: |
| torch.manual_seed(seed) |
| token_vocab_size = tokenizer.vocab_size |
| mask_id = token_vocab_size |
| all_ids: list[list[int]] = [] |
| all_texts: list[str] = [] |
| remaining = n_samples |
| while remaining > 0: |
| bs = min(batch_size, remaining) |
| x = torch.full((bs, max_len), mask_id, dtype=torch.long, device=device) |
| attn = torch.ones((bs, max_len), dtype=torch.bool, device=device) |
| timesteps = torch.linspace(1.0, eps, steps + 1, device=device) |
| dt = (1.0 - eps) / max(steps, 1) |
| p_x0_cache: torch.Tensor | None = None |
|
|
| for step in range(steps): |
| t = torch.full((bs, 1), float(timesteps[step].item()), dtype=torch.float32, device=device) |
| alpha_t = log_linear_alpha(t, eps) |
| alpha_s = log_linear_alpha(t - dt, eps) |
| if p_x0_cache is None: |
| sigma = -torch.log(alpha_t.clamp_min(1e-8)).squeeze(-1) |
| p_x0_cache = mdlm_log_probs( |
| model, |
| x, |
| sigma, |
| attn, |
| token_vocab_size=token_vocab_size, |
| mask_id=mask_id, |
| ).exp() |
|
|
| q_xs = p_x0_cache * (alpha_s - alpha_t).clamp_min(0)[:, :, None] |
| q_xs[:, :, mask_id] = (1.0 - alpha_s).clamp_min(0).squeeze(-1)[:, None] |
| proposal = sample_categorical(q_xs) |
| x_next = torch.where(x != mask_id, x, proposal) |
|
|
| |
| |
| if time_conditioned or not torch.equal(x_next, x): |
| p_x0_cache = None |
| x = x_next |
|
|
| if final != "none" and (x == mask_id).any(): |
| t0 = torch.full((bs, 1), float(timesteps[-1].item()), dtype=torch.float32, device=device) |
| alpha_t0 = log_linear_alpha(t0, eps) |
| sigma0 = -torch.log(alpha_t0.clamp_min(1e-8)).squeeze(-1) |
| log_probs = mdlm_log_probs( |
| model, |
| x, |
| sigma0, |
| attn, |
| token_vocab_size=token_vocab_size, |
| mask_id=mask_id, |
| ) |
| if final == "greedy": |
| proposal = log_probs[:, :, :token_vocab_size].argmax(dim=-1) |
| elif final == "ancestral": |
| proposal = sample_categorical(log_probs.exp()) |
| else: |
| raise ValueError(f"unknown final={final!r}") |
| x = torch.where(x != mask_id, x, proposal) |
|
|
| x = x.clamp_max(token_vocab_size - 1) |
| rows = x.detach().cpu().tolist() |
| all_ids.extend(rows) |
| all_texts.extend(tokenizer.decode(row, stop_at_eos=False, skip_special_tokens=False) for row in rows) |
| remaining -= bs |
| print(f"[mdlm ddpm_cache final={final}] generated {n_samples - remaining}/{n_samples}", flush=True) |
|
|
| decode = { |
| "kind": "mdlm", |
| "steps": steps, |
| "decode_rule": "ddpm_cache_absorbing_state", |
| "final": final, |
| "eps": eps, |
| "time_conditioned": time_conditioned, |
| "n_samples": n_samples, |
| "seed": seed, |
| } |
| return all_ids, all_texts, decode |
|
|
|
|
| @torch.no_grad() |
| def score_with_loaded( |
| texts: list[str], |
| scorer, |
| scorer_tok, |
| *, |
| batch_size: int, |
| max_length: int, |
| device: torch.device, |
| ) -> dict: |
| total_nll = 0.0 |
| total_tokens = 0 |
| skipped = 0 |
| for start in range(0, len(texts), batch_size): |
| batch_texts = texts[start : start + batch_size] |
| enc = scorer_tok( |
| batch_texts, |
| return_tensors="pt", |
| return_token_type_ids=False, |
| return_attention_mask=True, |
| padding=True, |
| truncation=True, |
| max_length=max_length, |
| ).to(device) |
| input_ids = enc["input_ids"] |
| attention_mask = enc["attention_mask"] |
| if input_ids.size(1) < 2: |
| skipped += len(batch_texts) |
| continue |
| logits = scorer(input_ids=input_ids, attention_mask=attention_mask).logits.transpose(-1, -2) |
| token_nll = F.cross_entropy(logits[..., :-1].float(), input_ids[..., 1:], reduction="none") |
| if scorer_tok.eos_token_id is not None: |
| first_eos = (input_ids == scorer_tok.eos_token_id).cumsum(-1) == 1 |
| token_mask = input_ids != scorer_tok.eos_token_id |
| shift_mask = (first_eos[..., 1:] | token_mask[..., 1:]).contiguous() |
| else: |
| shift_mask = attention_mask[..., 1:].contiguous().bool() |
| total_nll += float(token_nll[shift_mask].sum().detach().cpu()) |
| total_tokens += int(shift_mask.sum().detach().cpu()) |
| nll = total_nll / max(total_tokens, 1) |
| return { |
| "ppl": float(math.exp(min(nll, 50.0))), |
| "nll_per_token": float(nll), |
| "tokens": total_tokens, |
| "kept_samples": len(texts), |
| "total_samples": len(texts), |
| "empty_rate": 0.0, |
| "skipped_samples": skipped, |
| } |
|
|
|
|
| def main() -> None: |
| p = argparse.ArgumentParser() |
| p.add_argument("--checkpoint", required=True) |
| p.add_argument("--tokenizer_path", required=True) |
| p.add_argument("--scorer", required=True) |
| p.add_argument("--out_dir", required=True) |
| p.add_argument("--name", default="mdlm_ddpm_cache") |
| p.add_argument("--n_samples", type=int, default=256) |
| p.add_argument("--max_len", type=int, default=128) |
| p.add_argument("--steps", type=int, default=1024) |
| p.add_argument("--decode_batch", type=int, default=32) |
| p.add_argument("--score_batch", type=int, default=8) |
| p.add_argument("--score_max_length", type=int, default=256) |
| p.add_argument("--seed", type=int, default=20260506) |
| p.add_argument("--eps", type=float, default=1e-5) |
| p.add_argument("--final", choices=["ancestral", "greedy", "none"], default="ancestral") |
| p.add_argument("--no_time_conditioned", action="store_true") |
| args = p.parse_args() |
|
|
| out_dir = Path(args.out_dir) |
| out_dir.mkdir(parents=True, exist_ok=True) |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| tokenizer = BpeTextTokenizer.from_file(args.tokenizer_path) |
|
|
| ckpt = torch.load(args.checkpoint, map_location="cpu", weights_only=False) |
| model = build_endpoint_model(ckpt, tokenizer, device) |
| ids, raw_texts, decode = decode_mdlm_ddpm_cache( |
| model, |
| tokenizer, |
| n_samples=args.n_samples, |
| batch_size=args.decode_batch, |
| max_len=args.max_len, |
| steps=args.steps, |
| eps=args.eps, |
| final=args.final, |
| time_conditioned=not args.no_time_conditioned, |
| seed=args.seed, |
| device=device, |
| ) |
| del model |
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
|
|
| scorer_tok = AutoTokenizer.from_pretrained(args.scorer) |
| if scorer_tok.pad_token_id is None: |
| scorer_tok.pad_token = scorer_tok.eos_token |
| scorer_tok.pad_token_id = scorer_tok.eos_token_id |
| scorer = AutoModelForCausalLM.from_pretrained(args.scorer).to(device).eval() |
| if getattr(scorer.config, "pad_token_id", None) is None: |
| scorer.config.pad_token_id = scorer_tok.pad_token_id |
|
|
| stripped = [strip_common_special(t) for t in raw_texts] |
| kept_raw, _ = filter_generated_texts(raw_texts, min_chars=1, normalize_whitespace=False, drop_empty=True) |
| kept_stripped, _ = filter_generated_texts(stripped, min_chars=1, normalize_whitespace=True, drop_empty=True) |
| raw_ppl = score_with_loaded( |
| kept_raw, |
| scorer, |
| scorer_tok, |
| batch_size=args.score_batch, |
| max_length=args.score_max_length, |
| device=device, |
| ) |
| stripped_ppl = score_with_loaded( |
| kept_stripped, |
| scorer, |
| scorer_tok, |
| batch_size=args.score_batch, |
| max_length=args.score_max_length, |
| device=device, |
| ) |
| diversity = summarize_token_diversity(ids).__dict__ |
| summary = { |
| "type": "summary", |
| "name": args.name, |
| "kind": "mdlm", |
| "checkpoint": args.checkpoint, |
| "step": ckpt.get("step"), |
| "decode": decode, |
| "raw_genppl": raw_ppl, |
| "stripped_genppl": stripped_ppl, |
| "diversity": diversity, |
| } |
|
|
| with (out_dir / f"{args.name}_scored.jsonl").open("w", encoding="utf-8") as f: |
| f.write(json.dumps(summary, ensure_ascii=False) + "\n") |
| for i, (raw, clean) in enumerate(zip(raw_texts, stripped)): |
| f.write( |
| json.dumps( |
| { |
| "type": "sample", |
| "name": args.name, |
| "index": i, |
| "raw_text": raw, |
| "stripped_text": clean, |
| }, |
| ensure_ascii=False, |
| ) |
| + "\n" |
| ) |
| write_raw_samples(out_dir / f"{args.name}_raw_samples.txt", args.name, summary, raw_texts) |
| with (out_dir / "summary.jsonl").open("w", encoding="utf-8") as f: |
| f.write(json.dumps(summary, ensure_ascii=False) + "\n") |
| with (out_dir / "summary.tsv").open("w", encoding="utf-8") as f: |
| f.write("name\tstep\tfinal\traw_genppl\tstripped_genppl\tentropy\tdistinct_2\ttop_token_mass\n") |
| f.write( |
| "\t".join( |
| [ |
| args.name, |
| str(ckpt.get("step")), |
| args.final, |
| f"{raw_ppl['ppl']:.6f}", |
| f"{stripped_ppl['ppl']:.6f}", |
| f"{diversity['sample_entropy']:.6f}", |
| f"{diversity['distinct_2']:.6f}", |
| f"{diversity['top_token_mass']:.6f}", |
| ] |
| ) |
| + "\n" |
| ) |
| print("[summary]", json.dumps(summary, ensure_ascii=False), flush=True) |
| print(f"[done] {out_dir}", flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|