davanstrien's picture
download
raw
15.6 kB
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "transformers>=5.11,<6",
# "torch>=2.8,<2.10", # a100-large job image driver is CUDA 12.9; torch 2.10+ ships cu13 wheels
# "torchvision", # Gemma4Processor imports it even for text-only use
# "accelerate",
# "huggingface-hub",
# "pillow",
# "pyzipper",
# "requests",
# ]
# ///
"""Canvas-rescue sweep: can an OCR-seeded canvas be made to EDIT instead of copy?
Background (v1, experiments/2026-06-10_v1-bln600-text): seeding DiffusionGemma's
denoising canvas with the OCR text collapses to copy-through — the entropy-bound
sampler accepts the real text wholesale in 2-5 steps (fix rate 0.6%, CER worse
than doing nothing). Three rescue axes, the first two suggested by João Gante
(DeepMind):
1. t_max — raise the initial temperature of the schedule (default 0.8)
to "wiggle the model out of the initial OCR estimate"
2. entropy_bound — tighten the EntropyBoundSampler bound (default 0.1) so
fewer tokens lock early
3. noise_p — SDEdit-style: corrupt a fraction p of the seeded canvas
tokens to uniform random, putting it at an intermediate
noise level on the training distribution
GENERATION ONLY — metrics computed offline by `metrics.py sweep`.
Stages (HF Jobs, a100-large):
--stage smoke 3 passages x 3 cells (anchor / default-seeded / extreme):
verifies the config overrides actually bite before
burning a grid on silently-ignored knobs
--stage screen 20 passages x (27-cell factorial + random-canvas anchor)
--stage confirm 75 passages x top cells (--cells p0.25_t1.2_e0.03,...) x 3 sampler seeds
"""
import argparse
import copy
import difflib
import gc
import json
import random
import re
import time
from pathlib import Path
import requests
DG_MODEL = "google/diffusiongemma-26B-A4B-it"
TOK_MODEL = "google/gemma-4-E4B-it" # same tokenizer family; keeps v1 passage selection identical
BLN600_API = "https://api.figshare.com/v2/articles/25439023"
BLN600_ZIP_PASSWORD = b"BLN600"
MAX_PASSAGE_TOKENS = 220
P_VALUES = [0.0, 0.25, 0.5]
T_MAX_VALUES = [0.8, 1.2, 1.6]
ENTROPY_BOUNDS = [0.1, 0.03, 0.01]
PROMPT_TEMPLATE = """\
Correct the OCR errors in the following text from a 19th-century English newspaper.
Fix only recognition errors (wrong, missing, or extra characters). Do not modernise \
spelling, do not rephrase, and do not add or remove content. Preserve the original \
punctuation unless it is clearly an OCR error.
Output only the corrected text, with no commentary or preamble.
OCR text:
{ocr}"""
STOP_MARKERS = ("<turn|>", "<eos>", "<end_of_turn>", "<pad>")
# ------------------------------------------------- shared with benchmark.py
# (duplicated: HF Jobs uploads only this file, so the script must be self-contained)
def extract_answer(raw: str) -> tuple[str, str]:
stops = [i for m in STOP_MARKERS if (i := raw.find(m)) != -1]
if stops:
raw = raw[: min(stops)]
thought = ""
if "<channel|>" in raw:
head, _, raw = raw.rpartition("<channel|>")
m = re.search(r"<\|channel>thought(.*)$", head, flags=re.DOTALL)
if m:
thought = m.group(1).strip()
return raw.strip(), thought
def clean_output(text: str) -> str:
return re.sub(r"^\s*corrected text:?\s*", "", text.strip(), flags=re.IGNORECASE)
def count_generated_tokens(generated_ids, tokenizer) -> int:
ids = generated_ids.tolist()
stop_ids = {tokenizer.eos_token_id, tokenizer.pad_token_id}
count = 0
for tid in ids:
if tid in stop_ids:
break
count += 1
return count
def _download(url: str, dest: Path) -> Path:
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
return dest
print(f"downloading {url} -> {dest}")
with requests.get(url, stream=True, timeout=600) as r:
r.raise_for_status()
with dest.open("wb") as f:
for chunk in r.iter_content(chunk_size=1 << 20):
f.write(chunk)
return dest
def download_bln600(workdir: Path) -> list[dict]:
import pyzipper
meta = requests.get(BLN600_API, timeout=60).json()
zips = [f for f in meta["files"] if f["name"].lower().endswith(".zip")]
zip_path = _download(zips[0]["download_url"], workdir / zips[0]["name"])
extract_dir = workdir / "bln600"
if not extract_dir.exists():
with pyzipper.AESZipFile(zip_path) as zf:
zf.setpassword(BLN600_ZIP_PASSWORD)
zf.extractall(extract_dir)
ocr_files = {p.stem: p for p in extract_dir.rglob("*.txt") if "ocr" in str(p.parent).lower()}
gold_files = {
p.stem: p
for p in extract_dir.rglob("*.txt")
if "ground" in str(p.parent).lower() or "gold" in str(p.parent).lower()
}
common = sorted(set(ocr_files) & set(gold_files))
print(f"BLN600: {len(common)} aligned pairs")
return [
{
"id": f"bln600/{stem}",
"ocr_input": ocr_files[stem].read_text(errors="replace"),
"gold": gold_files[stem].read_text(errors="replace"),
}
for stem in common
]
def trim_pair(ocr: str, gold: str, n_tokens, max_tokens: int) -> tuple[str, str] | None:
if n_tokens(ocr) <= max_tokens and n_tokens(gold) <= max_tokens:
return ocr, gold
sm = difflib.SequenceMatcher(None, ocr, gold, autojunk=False)
candidates = [
(i1 + m.start(), j1 + m.start())
for op, i1, i2, j1, _j2 in sm.get_opcodes()
if op == "equal"
for m in re.finditer(r"\s", ocr[i1:i2])
]
if not candidates:
return None
def fits(idx: int) -> bool:
i_cut, j_cut = candidates[idx]
return n_tokens(ocr[:i_cut]) <= max_tokens and n_tokens(gold[:j_cut]) <= max_tokens
if not fits(0):
return None
lo, hi = 0, len(candidates) - 1
while lo < hi:
mid = (lo + hi + 1) // 2
if fits(mid):
lo = mid
else:
hi = mid - 1
i_cut, j_cut = candidates[lo]
return ocr[:i_cut].rstrip(), gold[:j_cut].rstrip()
def sample_passages(passages: list[dict], n: int, seed: int) -> list[dict]:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(TOK_MODEL)
def n_tokens(text: str) -> int:
return len(tok(text)["input_ids"])
chosen = random.Random(seed).sample(passages, len(passages))
out: list[dict] = []
for p in chosen:
if len(out) >= n:
break
trimmed = trim_pair(p["ocr_input"], p["gold"], n_tokens, MAX_PASSAGE_TOKENS)
if trimmed is None or len(trimmed[1]) < 200:
continue
out.append({"id": p["id"], "ocr_input": trimmed[0], "gold": trimmed[1]})
print(f"sampled {len(out)} passages (seed {seed})")
return out
# ------------------------------------------------- sweep
def make_conditions(stage: str, cells_arg: str | None) -> list[dict]:
"""A condition: canvas ('random' or 'ocr'), noise_p, t_max, entropy_bound, sampler_seed."""
anchor = {"key": "anchor_random_canvas", "canvas": "random", "noise_p": None,
"t_max": 0.8, "entropy_bound": 0.1, "sampler_seed": 0}
grid = [
{
"key": f"p{p}_t{t}_e{e}",
"canvas": "ocr", "noise_p": p, "t_max": t, "entropy_bound": e, "sampler_seed": 0,
}
for p in P_VALUES
for t in T_MAX_VALUES
for e in ENTROPY_BOUNDS
]
if stage == "smoke":
by_key = {c["key"]: c for c in grid}
return [anchor, by_key["p0.0_t0.8_e0.1"], by_key["p0.5_t1.6_e0.01"]]
if stage == "screen":
return [anchor] + grid
# confirm: named cells x 3 sampler seeds
if not cells_arg:
raise SystemExit("--stage confirm requires --cells key1,key2,...")
by_key = {c["key"]: c for c in grid}
out = []
for key in cells_arg.split(","):
for seed in (0, 1, 2):
c = dict(by_key[key.strip()])
c["sampler_seed"] = seed
c["key"] = f"{key.strip()}_s{seed}"
out.append(c)
return out
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--stage", choices=["smoke", "screen", "confirm"], default="smoke")
parser.add_argument("--cells", default=None, help="confirm stage: comma-separated cell keys")
parser.add_argument("--seed", type=int, default=42, help="passage sampling seed (v1 = 42)")
parser.add_argument("--out-repo", default=None)
parser.add_argument("--workdir", type=Path, default=Path("data"))
args = parser.parse_args()
import torch
import transformers
from transformers import AutoProcessor, DiffusionGemmaForBlockDiffusion, TextDiffusionStreamer
print(f"transformers {transformers.__version__}, torch {torch.__version__}, "
f"cuda: {torch.cuda.get_device_name(0)}")
n_passages = {"smoke": 3, "screen": 20, "confirm": 75}[args.stage]
passages = sample_passages(download_bln600(args.workdir), n_passages, args.seed)
conditions = make_conditions(args.stage, args.cells)
print(f"stage={args.stage}: {len(passages)} passages x {len(conditions)} conditions")
class StepCountingStreamer(TextDiffusionStreamer):
def __init__(self, tokenizer):
super().__init__(tokenizer=tokenizer)
self.n_steps = 0
def put_draft(self, value, **kwargs):
self.n_steps += 1
def put(self, value):
pass
def end(self):
pass
print(f"loading {DG_MODEL} ...")
processor = AutoProcessor.from_pretrained(DG_MODEL)
model = DiffusionGemmaForBlockDiffusion.from_pretrained(DG_MODEL, dtype="auto", device_map="auto")
tokenizer = processor.tokenizer
base_config = copy.deepcopy(model.generation_config)
canvas_length = getattr(base_config, "canvas_length", None) or 256
vocab = model.config.text_config.vocab_size
def build_canvas(ocr_text: str, noise_p: float, rng: torch.Generator) -> torch.Tensor:
ids = tokenizer(ocr_text, add_special_tokens=False)["input_ids"][:canvas_length]
ids = torch.tensor(ids, dtype=torch.long)
if noise_p > 0:
corrupt = torch.rand(len(ids), generator=rng) < noise_p
ids[corrupt] = torch.randint(vocab, (int(corrupt.sum()),), generator=rng)
pad = torch.randint(vocab, (canvas_length - len(ids),), generator=rng)
return torch.cat([ids, pad]).unsqueeze(0)
def generate(ocr_text: str, cond: dict, canvas_rng: torch.Generator) -> dict:
# mutate the live generation_config: guaranteed to be read, no reliance
# on generate() kwarg plumbing for these fields
gen_config = copy.deepcopy(base_config)
gen_config.t_max = cond["t_max"]
gen_config.sampler_config.entropy_bound = cond["entropy_bound"]
model.generation_config = gen_config
message = [{"role": "user", "content": PROMPT_TEMPLATE.format(ocr=ocr_text)}]
inputs = processor.apply_chat_template(
message, tokenize=True, add_generation_prompt=True, return_dict=True, return_tensors="pt"
).to(model.device)
input_len = inputs["input_ids"].shape[-1]
streamer = StepCountingStreamer(tokenizer)
gen_kwargs: dict = {"max_new_tokens": 256, "streamer": streamer}
if cond["canvas"] == "ocr":
gen_kwargs["decoder_input_ids"] = build_canvas(
ocr_text, cond["noise_p"], canvas_rng
).to(model.device)
torch.manual_seed(cond["sampler_seed"]) # sampler stochasticity, reproducible
torch.cuda.synchronize()
t0 = time.perf_counter()
output = model.generate(**inputs, **gen_kwargs)
torch.cuda.synchronize()
seconds = time.perf_counter() - t0
seq = output.sequences if hasattr(output, "sequences") else output
generated = seq[0][input_len:] if seq.shape[-1] > input_len else seq[0]
raw = tokenizer.decode(generated, skip_special_tokens=False)
answer, thought = extract_answer(raw)
return {
"text": clean_output(answer),
"_raw": raw,
"seconds": round(seconds, 3),
"tokens_generated": count_generated_tokens(generated, tokenizer),
"denoising_steps": streamer.n_steps,
"thought_chars": len(thought),
}
# verify the overrides bite: print the effective config per condition
print("warmup (uncounted) ...")
warm_rng = torch.Generator().manual_seed(999)
generate(passages[0]["ocr_input"], conditions[0], warm_rng)
meta = {
"date": time.strftime("%Y-%m-%d"),
"stage": args.stage,
"n_passages": len(passages),
"passage_seed": args.seed,
"prompt": PROMPT_TEMPLATE,
"grid": {"noise_p": P_VALUES, "t_max": T_MAX_VALUES, "entropy_bound": ENTROPY_BOUNDS},
"defaults": {"t_max": base_config.t_max,
"entropy_bound": base_config.sampler_config.entropy_bound,
"confidence_threshold": getattr(base_config, "confidence_threshold", None),
"max_denoising_steps": getattr(base_config, "max_denoising_steps", None)},
"transformers": transformers.__version__,
"torch": torch.__version__,
"gpu": torch.cuda.get_device_name(0),
}
out_path = Path(f"raw_outputs_canvas_sweep_{args.stage}.jsonl")
records = []
t_sweep = time.perf_counter()
for ci, cond in enumerate(conditions):
print(f"[{ci + 1}/{len(conditions)}] {cond['key']}: t_max={cond['t_max']}, "
f"entropy_bound={cond['entropy_bound']}, canvas={cond['canvas']}, p={cond['noise_p']}")
# deterministic noise per (condition, passage)
for pi, p in enumerate(passages):
canvas_rng = torch.Generator().manual_seed(ci * 10_000 + pi)
out = generate(p["ocr_input"], cond, canvas_rng)
raw = out.pop("_raw")
records.append({"id": p["id"], "ocr_input": p["ocr_input"], "gold": p["gold"],
"condition": {k: cond[k] for k in
("key", "canvas", "noise_p", "t_max", "entropy_bound",
"sampler_seed")},
"output": out})
print(f" {p['id']}: {out['seconds']}s, {out['denoising_steps']} steps, "
f"{out['tokens_generated']} tok")
if args.stage == "smoke":
print(f" OCR: {p['ocr_input'][:160]}")
print(f" OUT: {out['text'][:160]}")
print(f" RAW: {raw[:200]}")
print(f"sweep wall-clock: {time.perf_counter() - t_sweep:.0f}s")
with out_path.open("w") as f:
for i, r in enumerate(records):
if i == 0:
r = {**r, "meta": meta}
f.write(json.dumps(r) + "\n")
print(f"wrote {out_path} ({len(records)} records)")
if args.out_repo:
from huggingface_hub import HfApi
api = HfApi()
api.create_repo(args.out_repo, repo_type="dataset", private=True, exist_ok=True)
api.upload_file(path_or_fileobj=out_path, path_in_repo=out_path.name,
repo_id=args.out_repo, repo_type="dataset")
print(f"uploaded to https://huggingface.co/datasets/{args.out_repo} (private)")
model = None # noqa: F841 — drop the closure-captured ref so the GPU frees
gc.collect()
torch.cuda.empty_cache()
if __name__ == "__main__":
main()

Xet Storage Details

Size:
15.6 kB
·
Xet hash:
3033142b4f455f245dd525785b58ca63bb37cb87d0423fd2109ee437ed025d88

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.