sumitk's picture
Upload folder using huggingface_hub
4e70c09 verified
Raw History Blame Contribute Delete
23.2 kB
from __future__ import annotations
import argparse
from collections import Counter
import json
import math
from pathlib import Path
from typing import Any
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader
from conveyer.checkpoint import load_checkpoint
from conveyer.config import ConveyerConfig
from conveyer.data import build_dataset_and_tokenizer, collate_conveyer_batch
from conveyer.infer import generate_conveyer
from conveyer.losses import compute_unrolled_conveyer_loss
from conveyer.model import ConveyerDiffusionLM
# ---------------------------------------------------------------------------
# Benchmark 3: Zero-Shot Commonsense / Story-Coherence Multiple Choice (2-way)
# ---------------------------------------------------------------------------
# Model scores log P(option | context) on unseen story pairs testing:
# - Physical commonsense (heavy vs light, hot vs cold, broken vs whole)
# - Emotional / social cause-and-effect (lost toy -> sad, gift -> happy)
# - Narrative entity & object permanence (tracking who holds what, where they are)
COMMONSENSE_CLOZE_PAIRS: list[tuple[str, str, str, str]] = [
(
"emotion_cause",
"Once upon a time, Tom lost his favorite red ball in the dark woods. Because he lost his ball, Tom felt very",
" sad and started to cry.",
" happy and started to laugh.",
),
(
"emotion_cause",
"Lily worked all morning to bake a sweet cake for her mom. When her mom smiled and hugged her, Lily felt very",
" proud and happy.",
" angry and mean.",
),
(
"physical_weight",
"The little mouse tried to lift the huge heavy rock, but the rock was much too",
" heavy for the tiny mouse to move.",
" light and floated into the sky.",
),
(
"physical_temperature",
"Ben went outside in the snow without his coat or gloves. Soon his hands felt very",
" cold and freezing.",
" hot and burning.",
),
(
"physical_temperature",
"The soup had just come off the stove and was boiling. Mom told Mia to wait because the soup was too",
" hot to eat right now.",
" cold and frozen like ice.",
),
(
"physical_fragile",
"The glass cup fell off the high table onto the hard stone floor. With a loud crash, the glass",
" broke into many small pieces.",
" bounced high like a rubber ball.",
),
(
"object_use",
"The wooden door was locked tight. To unlock the door, Anna took out her small brass",
" key and turned it in the lock.",
" spoon and stirred the soup.",
),
(
"object_use",
"It started to rain hard outside. Before walking to school, Max opened his big",
" umbrella to stay dry.",
" pillow to go to sleep.",
),
(
"biological_need",
"The little puppy had been running in the park all day and had not eaten anything. His tummy rumbled because he was very",
" hungry and wanted some food.",
" sleepy and wanted a haircut.",
),
(
"biological_need",
"After playing all day until night came and the stars shone in the sky, the tired children went to bed to",
" sleep until morning.",
" swim in the cold ocean.",
),
(
"entity_tracking",
"Sam had a blue car and Lucy had a yellow doll. Sam gave his blue car to Lucy. Now Lucy had the yellow doll and the",
" blue car.",
" green bicycle.",
),
(
"entity_tracking",
" Lily put her shiny coin inside the small red box and closed the lid. When she opened the red box later, she saw her",
" shiny coin inside.",
" big brown horse inside.",
),
(
"danger_reaction",
"Lily and a little bunny walked into the green forest. Suddenly a big fierce lion jumped out and roared loudly. The little bunny was",
" scared and hid behind a tree.",
" bored and read a newspaper.",
),
(
"social_repair",
"Tom accidentally knocked over Mia's tall block tower. Tom felt bad about the accident, so he said,",
' "I am sorry, Mia. Let me help you build it again."',
' "Thank you for the delicious birthday cake."',
),
(
"spatial_logic",
"The bird wanted to reach the nest at the very top of the tall tree, so it flapped its wings and",
" flew up high into the branches.",
" dug a deep hole under the mud.",
),
(
"weather_visibility",
"At midnight all the lights went out in the room. Because it was pitch dark, Ben could not",
" see anything around him.",
" hear the loud thunder outside.",
),
]
NOVEL_STORY_PROMPTS: list[tuple[str, list[str]]] = [
(
"Once upon a time, Lily and a little bunny walked into the green forest to find the golden key near the locked treasure chest. Suddenly a big lion",
["lion", "Lily", "bunny", "key", "chest", "forest", "scared", "roar", "said"],
),
(
"Once upon a time, there was a little robot named Beep who lived in a noisy workshop. One rainy morning, Beep found a tiny lost kitten by the door. Beep",
["Beep", "kitten", "robot", "door", "rain", "warm", "milk", "help"],
),
(
"Sam and his sister Mia built a huge sandcastle on the beach near the waves. Suddenly, a big wave splashed toward their castle. Sam quickly",
["Sam", "Mia", "sand", "castle", "wave", "beach", "water", "wall"],
),
(
"In a quiet village, an old baker dropped his round loaf of bread and it rolled out the door down the hill. A hungry puppy saw the bread and",
["bread", "puppy", "baker", "hill", "chased", "ran", "ate", "mouth"],
),
(
"Lucy had a box of bright crayons, but she could not find the blue crayon to color the sky. She looked under her bed and",
["Lucy", "crayon", "blue", "bed", "sky", "found", "draw", "paper"],
),
(
"One winter day, Max made a snowman in the yard with a carrot nose. When the warm sun came out in the afternoon, the snowman began to",
["Max", "snowman", "sun", "melt", "carrot", "snow", "warm", "water"],
),
]
@torch.no_grad()
def evaluate_zero_shot_commonsense(
model: ConveyerDiffusionLM,
tokenizer: Any,
device: torch.device,
) -> dict[str, Any]:
"""
Evaluates zero-shot 2-way multiple-choice accuracy (BLiMP / StoryCloze style) using both:
1. Causal LM log-probability per token over the continuation.
2. Conveyor Slot-0 first-token preference on the continuation.
"""
model.eval()
correct_lm = 0
correct_s0 = 0
margin_sum = 0.0
details: list[dict[str, Any]] = []
for category, ctx, good_cont, bad_cont in COMMONSENSE_CLOZE_PAIRS:
ctx_ids = tokenizer.encode(ctx.strip())
good_ids = tokenizer.encode(ctx.strip() + good_cont)
bad_ids = tokenizer.encode(ctx.strip() + bad_cont)
plen = len(ctx_ids)
def _score_seq(full_ids: list[int]) -> float:
inp = torch.tensor([full_ids], dtype=torch.long, device=device)
logits, _ = model.forward_causal_and_cache(inp, prefix_len=len(full_ids))
log_probs = F.log_softmax(logits[0, :-1, :].float(), dim=-1)
targets = inp[0, 1:]
# Mean log-prob over the continuation tokens [plen-1 : len-1]
start_idx = max(0, plen - 1)
cont_lp = log_probs[start_idx:, :].gather(1, targets[start_idx:].unsqueeze(1)).squeeze(1)
return float(cont_lp.mean().item())
score_good = _score_seq(good_ids)
score_bad = _score_seq(bad_ids)
is_ok = score_good > score_bad
correct_lm += int(is_ok)
margin_sum += score_good - score_bad
# Also check Conveyor Slot-0 log-prob on the first continuation token
if len(good_ids) > plen and len(bad_ids) > plen:
p_tensor = torch.tensor([ctx_ids], dtype=torch.long, device=device)
kv = model.prefill_prefix(p_tensor)
belt = model.init_empty_belt(1, tick=plen, device=device, deterministic=True)
out = model.forward_belt(belt, kv_cache=kv)
s0_lp = F.log_softmax(out.slot0_logits[0].float(), dim=-1)
s0_good = float(s0_lp[good_ids[plen]].item())
s0_bad = float(s0_lp[bad_ids[plen]].item())
correct_s0 += int(s0_good > s0_bad)
details.append(
{
"category": category,
"correct": is_ok,
"logprob_good": round(score_good, 3),
"logprob_bad": round(score_bad, 3),
}
)
n = max(1, len(COMMONSENSE_CLOZE_PAIRS))
return {
"commonsense_lm_acc": correct_lm / n,
"commonsense_slot0_acc": correct_s0 / n,
"mean_logprob_margin": margin_sum / n,
"num_pairs": len(COMMONSENSE_CLOZE_PAIRS),
"details": details,
}
@torch.no_grad()
def evaluate_prefix_sensitivity_and_generation_quality(
model: ConveyerDiffusionLM,
tokenizer: Any,
device: torch.device,
max_new_tokens: int = 80,
) -> dict[str, Any]:
"""
Measures:
1. Prefix Sensitivity KL(P(slot0 | prompt) || P(slot0 | shuffled_prompt)):
In the broken template model this was 0.03 nats (ignored word order).
A real language model should have KL >> 0.5 nats.
2. Open-Ended Generation Quality on 6 unseen prompts:
- Distinct-2 / Distinct-3 n-gram diversity (1.0 = no repetition, <0.5 = looping)
- 4-gram repetition rate (0.0 = clean prose)
- Prompt entity/topic retention rate (% of continuations that reference the prompt's new entities/events)
"""
model.eval()
kl_list: list[float] = []
dist2_list: list[float] = []
dist3_list: list[float] = []
rep4_list: list[float] = []
entity_hits = 0
samples: list[dict[str, str]] = []
g = torch.Generator().manual_seed(123)
for prompt_text, expected_keywords in NOVEL_STORY_PROMPTS:
ids = tokenizer.encode(prompt_text.strip())
p_true = torch.tensor([ids], dtype=torch.long, device=device)
kv_true = model.prefill_prefix(p_true)
belt_true = model.init_empty_belt(1, tick=len(ids), device=device, deterministic=True)
d_true = F.softmax(model.forward_belt(belt_true, kv_cache=kv_true).slot0_logits[0].float(), dim=-1)
perm = torch.randperm(len(ids) - 1, generator=g) + 1
shuf_ids = [ids[0]] + [ids[i] for i in perm.tolist()]
p_shuf = torch.tensor([shuf_ids], dtype=torch.long, device=device)
kv_shuf = model.prefill_prefix(p_shuf)
belt_shuf = model.init_empty_belt(1, tick=len(ids), device=device, deterministic=True)
d_shuf = F.softmax(model.forward_belt(belt_shuf, kv_cache=kv_shuf).slot0_logits[0].float(), dim=-1)
kl = float((d_true * (d_true.clamp_min(1e-9).log() - d_shuf.clamp_min(1e-9).log())).sum().item())
kl_list.append(kl)
out_ids, _ = generate_conveyer(
model=model,
prompt_tokens=p_true,
max_new_tokens=max_new_tokens,
prompt_overlap=10,
temperature=0.0,
eos_token_id=tokenizer.eos_id,
deterministic_spawn=True,
)
gen_toks = out_ids[0, len(ids) :].tolist()
gen_text = tokenizer.decode(gen_toks).strip()
words = [w.strip(".,!?;:\"'()").lower() for w in gen_text.split() if w.strip()]
if len(words) >= 4:
bigrams = list(zip(words[:-1], words[1:]))
trigrams = list(zip(words[:-2], words[1:-1], words[2:]))
fourgrams = list(zip(words[:-3], words[1:-2], words[2:-1], words[3:]))
dist2_list.append(len(set(bigrams)) / max(1, len(bigrams)))
dist3_list.append(len(set(trigrams)) / max(1, len(trigrams)))
c4 = Counter(fourgrams)
repeated_4 = sum(v - 1 for v in c4.values() if v > 1)
rep4_list.append(repeated_4 / max(1, len(fourgrams)))
low_gen = gen_text.lower()
hit = any(kw.lower() in low_gen for kw in expected_keywords[:4])
entity_hits += int(hit)
samples.append({"prompt": prompt_text, "continuation": gen_text})
return {
"prefix_word_order_kl": sum(kl_list) / max(1, len(kl_list)),
"distinct_2": sum(dist2_list) / max(1, len(dist2_list)),
"distinct_3": sum(dist3_list) / max(1, len(dist3_list)),
"repeat_4gram_rate": sum(rep4_list) / max(1, len(rep4_list)),
"prompt_entity_retention": entity_hits / max(1, len(NOVEL_STORY_PROMPTS)),
"samples": samples,
}
@torch.no_grad()
def evaluate_crystallization_curve(
model: ConveyerDiffusionLM,
cfg: ConveyerConfig,
val_loader: DataLoader[dict[str, torch.Tensor]],
device: torch.device,
pad_id: int = 0,
max_batches: int = 20,
) -> dict[str, Any]:
"""
Computes the Per-Slot Accuracy, Entropy, and Pause Rate across the 64-slot oven belt
plus held-out real TinyStories Perplexity (Causal LM & Conveyor Slot-0).
"""
model.eval()
B = model.belt_size
acc_sum = torch.zeros(B, device=device)
ent_sum = torch.zeros(B, device=device)
s0_loss_sum = 0.0
lm_loss_sum = 0.0
lm_acc_sum = 0.0
pause_sum = 0.0
count = 0
for i, batch in enumerate(val_loader):
if i >= max_batches:
break
seq = batch["input_ids"].to(device)
plen = batch["prompt_lengths"].to(device)
use_amp = device.type == "cuda" and torch.cuda.is_bf16_supported()
with torch.autocast(device_type=device.type, dtype=torch.bfloat16 if use_amp else torch.float32, enabled=use_amp):
m = compute_unrolled_conveyer_loss(
model=model,
seq_tokens=seq,
cfg=cfg,
prompt_lengths=plen,
tick_start=int(plen.min().item()),
pad_token_id=pad_id,
)
acc_sum += m.per_slot_acc
ent_sum += m.per_slot_entropy
s0_loss_sum += m.slot0_loss
lm_loss_sum += m.prefix_lm_loss
lm_acc_sum += m.prefix_lm_acc
pause_sum += m.mean_plan_pause
count += 1
denom = max(count, 1)
acc_curve = (acc_sum / denom).cpu().tolist()
ent_curve = (ent_sum / denom).cpu().tolist()
s0_ce = s0_loss_sum / denom
lm_ce = lm_loss_sum / denom
return {
"slot0_ce": s0_ce,
"slot0_ppl": math.exp(min(s0_ce, 20.0)),
"slot0_acc": acc_curve[0],
"causal_lm_ce": lm_ce,
"causal_lm_ppl": math.exp(min(lm_ce, 20.0)),
"causal_lm_acc": lm_acc_sum / denom,
"mean_plan_pause": pause_sum / denom,
"per_slot_acc": acc_curve,
"per_slot_entropy": ent_curve,
}
@torch.no_grad()
def evaluate_closed_loop_generation(
model: ConveyerDiffusionLM,
val_ds: Any,
tokenizer: Any,
device: torch.device,
num_episodes: int = 32,
prompt_overlap: int = 12,
bake_steps: int = 64,
max_new_tokens: int = 24,
) -> dict[str, float]:
model.eval()
n_eval = min(num_episodes, len(val_ds))
first_tok_correct = 0
total_tok_correct = 0
total_tok_count = 0
exact_match = 0
for i in range(n_eval):
item = val_ds[i]
seq = item["input_ids"].to(device)
plen = int(item["prompt_len"])
prompt = seq[:plen].unsqueeze(0)
gt_full = seq[plen:].tolist()
gt_target: list[int] = []
for tid in gt_full:
if tid == tokenizer.pad_id:
break
gt_target.append(tid)
if tid == tokenizer.eos_id:
break
if not gt_target:
continue
gen_len = min(max_new_tokens, len(gt_target))
full_out, _ = generate_conveyer(
model=model,
prompt_tokens=prompt,
max_new_tokens=gen_len,
prompt_overlap=prompt_overlap,
bake_steps=bake_steps,
temperature=0.0,
eos_token_id=tokenizer.eos_id,
deterministic_spawn=True,
)
pred_ids = full_out[0, plen : plen + gen_len].tolist()
gt_slice = gt_target[:gen_len]
if len(pred_ids) > 0 and pred_ids[0] == gt_slice[0]:
first_tok_correct += 1
matches = sum(1 for p, g in zip(pred_ids, gt_slice) if p == g)
total_tok_correct += matches
total_tok_count += len(gt_slice)
if pred_ids == gt_slice:
exact_match += 1
return {
"first_token_acc": first_tok_correct / max(1, n_eval),
"token_acc": total_tok_correct / max(1, total_tok_count),
"exact_seq_acc": exact_match / max(1, n_eval),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Comprehensive Benchmark Suite for Conveyor-Belt Diffusion LLM")
parser.add_argument("--ckpt", type=str, default="runs/tinystories/best.pt", help="Path to checkpoint (.pt)")
parser.add_argument("--device", type=str, default="auto", help="'cpu', 'cuda', or 'auto'")
parser.add_argument("--episodes", type=int, default=32, help="Number of closed-loop episodes")
parser.add_argument("--bake-steps", type=int, nargs="+", default=[16, 64], help="Bake depths K to evaluate")
parser.add_argument("--overlaps", type=int, nargs="+", default=[0, 10], help="Prompt-overlap windows W_pre")
parser.add_argument("--out-json", type=str, default=None, help="Optional path to save evaluation JSON")
args = parser.parse_args()
if args.device == "auto":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else:
device = torch.device(args.device)
model, cfg, ckpt_meta = load_checkpoint(args.ckpt, device=device)
_, val_ds, tokenizer = build_dataset_and_tokenizer(cfg)
val_loader = DataLoader(
val_ds,
batch_size=cfg.train.batch_size,
shuffle=False,
collate_fn=lambda b: collate_conveyer_batch(b, pad_id=tokenizer.pad_id),
)
step_num = ckpt_meta.get("step", "?")
elapsed_h = float(ckpt_meta.get("elapsed_sec", 0.0)) / 3600.0
cryst = evaluate_crystallization_curve(model, cfg, val_loader, device, pad_id=tokenizer.pad_id)
acc_c = cryst["per_slot_acc"]
ent_c = cryst["per_slot_entropy"]
B = len(acc_c)
print("=" * 92)
print(
f"CONVEYOR-BELT DIFFUSION LLM BENCHMARK SUITE | Ckpt: {args.ckpt} (step {step_num}, {elapsed_h:.2f}h) "
f"| Params: {model.num_parameters()/1e6:.2f}M | Device: {device}"
)
print("=" * 92)
print(
f"[1] Held-Out Real TinyStories LM Metrics (10,000 unseen stories, 0% templates):\n"
f" - Conveyor Slot-0 Perplexity : {cryst['slot0_ppl']:6.2f} | Top-1 Next-Token Acc: {cryst['slot0_acc']*100:5.2f}%\n"
f" - Causal Prefix LM Perplexity: {cryst['causal_lm_ppl']:6.2f} | Top-1 Next-Token Acc: {cryst['causal_lm_acc']*100:5.2f}%\n"
f" - Back-75% Planning Pause : {cryst['mean_plan_pause']*100:5.1f}%"
)
print("-" * 92)
print("[2] Per-Slot Crystallization Curve along the 64-Slot Oven Belt (Back k=63 -> Front k=0):")
sample_slots = [B - 1, (3 * B) // 4, B // 2, B // 4, min(8, B - 1), min(3, B - 1), 0]
seen: set[int] = set()
for k in sample_slots:
if k in seen:
continue
seen.add(k)
zone = "Back (75% Plan) " if k >= 16 else "Front (25% Stream)"
print(f" Slot {k:2d} [{zone}] | Top-1 Acc: {acc_c[k]*100:6.2f}% | Entropy: {ent_c[k]:.3f} nats")
if cfg.data.dataset == "tinystories":
cs = evaluate_zero_shot_commonsense(model, tokenizer, device)
gen_q = evaluate_prefix_sensitivity_and_generation_quality(model, tokenizer, device)
print("-" * 92)
print(
f"[3] Zero-Shot StoryCloze / Commonsense 2-Way Multiple Choice ({cs['num_pairs']} unseen pairs, random=50%):\n"
f" - Causal LM Choice Accuracy : {cs['commonsense_lm_acc']*100:6.2f}% (mean log-prob margin: {cs['mean_logprob_margin']:+.3f} nats)\n"
f" - Conveyor Slot-0 Choice Acc : {cs['commonsense_slot0_acc']*100:6.2f}%"
)
print("-" * 92)
print(
f"[4] Prompt Responsiveness & Open-Ended Generation Quality (6 novel story prompts):\n"
f" - Word-Order Sensitivity KL : {gen_q['prefix_word_order_kl']:6.3f} nats (broken model was 0.032; >0.50 = strong)\n"
f" - Prompt Entity Retention : {gen_q['prompt_entity_retention']*100:6.1f}%\n"
f" - Lexical Diversity : Distinct-2 = {gen_q['distinct_2']*100:5.1f}% | Distinct-3 = {gen_q['distinct_3']*100:5.1f}%\n"
f" - 4-gram Repetition Rate : {gen_q['repeat_4gram_rate']*100:5.2f}% (lower is better; 0% = zero loops)"
)
print("-" * 92)
print("[5] Live Novel Prompt Continutations:")
for s in gen_q["samples"][:3]:
print(f" PROMPT : {s['prompt']}")
print(f" OUTPUT : {s['continuation']}\n")
else:
cs = {}
gen_q = {}
print("-" * 92)
print(f"{'Bake Depth K':<15} | {'Prompt Overlap W':<18} | {'Pos-0 Acc':<12} | {'Token Acc':<12} | {'Exact Seq Acc':<14}")
print("-" * 92)
sweep_results: list[dict[str, Any]] = []
for k_bake in args.bake_steps:
for w_ov in args.overlaps:
res = evaluate_closed_loop_generation(
model=model,
val_ds=val_ds,
tokenizer=tokenizer,
device=device,
num_episodes=args.episodes,
prompt_overlap=w_ov,
bake_steps=k_bake,
)
print(
f"K = {k_bake:<11d} | W_pre = {w_ov:<10d} | "
f"{res['first_token_acc']*100:9.2f}% | {res['token_acc']*100:9.2f}% | {res['exact_seq_acc']*100:10.2f}%"
)
sweep_results.append({"bake_steps": k_bake, "prompt_overlap": w_ov, **res})
if args.out_json:
Path(args.out_json).parent.mkdir(parents=True, exist_ok=True)
Path(args.out_json).write_text(
json.dumps(
{
"step": step_num,
"crystallization": cryst,
"commonsense": cs,
"generation_quality": gen_q,
"closed_loop": sweep_results,
},
indent=2,
),
encoding="utf-8",
)
if __name__ == "__main__":
main()