jvonrad commited on
Commit
f0d18ec
·
verified ·
1 Parent(s): bcc57b5

Upload src/xscript/eval/bpb.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/xscript/eval/bpb.py +111 -0
src/xscript/eval/bpb.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bits-per-byte evaluation.
2
+
3
+ BPB = (sum of next-token NLL in nats) / (ln2 * total UTF-8 bytes of the text).
4
+
5
+ The byte denominator makes BPB comparable across tokenizers with different
6
+ fertilities -- the whole reason the plan reports BPB rather than per-token loss.
7
+ Each document is scored with a leading <bos>; the <bos> itself is never a target
8
+ and <eos> is included as a target (it is real signal the model must predict).
9
+ Long documents are scored in sliding windows of the model's context length.
10
+ """
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import torch
15
+
16
+ from ..tok.wrapper import BOS_ID, EOS_ID
17
+
18
+
19
+ @torch.no_grad()
20
+ def score_texts(model, tok, texts, device, seq_len: int, batch_tokens: int = 8192):
21
+ """Return (nll_nats, n_bytes, n_target_tokens) summed over `texts`."""
22
+ model.eval()
23
+ total_nll = 0.0
24
+ total_bytes = 0
25
+ total_tokens = 0
26
+ for text in texts:
27
+ b = len(text.encode("utf-8"))
28
+ if b == 0:
29
+ continue
30
+ ids = tok.encode(text, bos=True, eos=True)
31
+ total_bytes += b
32
+ # sliding, non-overlapping windows of seq_len+1 (predict positions 1..)
33
+ for st in range(0, len(ids) - 1, seq_len):
34
+ chunk = ids[st:st + seq_len + 1]
35
+ if len(chunk) < 2:
36
+ continue
37
+ x = torch.tensor(chunk[:-1], device=device).unsqueeze(0)
38
+ y = torch.tensor(chunk[1:], device=device).unsqueeze(0)
39
+ logits, _ = model(x, y)
40
+ nll = torch.nn.functional.cross_entropy(
41
+ logits.view(-1, logits.size(-1)), y.view(-1), reduction="sum")
42
+ total_nll += float(nll)
43
+ total_tokens += y.numel()
44
+ return total_nll, total_bytes, total_tokens
45
+
46
+
47
+ def bpb(nll_nats: float, n_bytes: int) -> float:
48
+ import math
49
+ return nll_nats / (math.log(2) * max(n_bytes, 1))
50
+
51
+
52
+ def eval_sources(model, tok, sources: dict[str, list[str]], device, seq_len: int) -> dict:
53
+ """sources: name -> list[str]. Returns {name: {bpb, ppl_tok, bytes, tokens}}."""
54
+ import math
55
+ out = {}
56
+ for name, texts in sources.items():
57
+ nll, nbytes, ntok = score_texts(model, tok, texts, device, seq_len)
58
+ out[name] = {
59
+ "bpb": bpb(nll, nbytes),
60
+ "ppl_token": math.exp(nll / max(ntok, 1)),
61
+ "bytes": nbytes,
62
+ "tokens": ntok,
63
+ }
64
+ return out
65
+
66
+
67
+ def run(run_name: str, tok_name: str, tag: str = "final",
68
+ langs=None, out_dir=None) -> dict:
69
+ """Re-evaluate a saved checkpoint's BPB on FLORES+ dev and holdout."""
70
+ from pathlib import Path
71
+ from ..model import ModelConfig, Transformer
72
+ from ..tok.wrapper import Tok
73
+ from ..paths import RUNS, RESULTS, tokenizer_dir, ensure
74
+ from .. import flores
75
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
76
+ ck = torch.load(RUNS / run_name / "checkpoints" / f"{tag}.pt",
77
+ map_location="cpu", weights_only=False)
78
+ model = Transformer(ModelConfig(**ck["cfg"]["model"]))
79
+ model.load_state_dict(ck["model"])
80
+ model = model.to(device).eval()
81
+ tok = Tok(tokenizer_dir(tok_name))
82
+ langs = langs or list(ck["cfg"]["langs"])
83
+ srcs = {}
84
+ for l in langs:
85
+ h = load_holdout(l)
86
+ if h:
87
+ srcs[f"holdout_{l}"] = h
88
+ for l, sents in flores.load_parallel(langs, "dev").items():
89
+ srcs[f"flores_{l}"] = sents
90
+ res = eval_sources(model, tok, srcs, device, model.cfg.max_seq_len)
91
+ out_dir = ensure(Path(out_dir) if out_dir else RESULTS / "bpb")
92
+ (out_dir / f"{run_name}_{tag}.json").write_text(json.dumps(res, indent=2))
93
+ print(f"[bpb] {run_name} ({tag}): " +
94
+ ", ".join(f"{k}={v['bpb']:.4f}" for k, v in res.items()))
95
+ return res
96
+
97
+
98
+ def load_holdout(lang: str, max_docs: int = 2000) -> list[str]:
99
+ """In-domain eval text from the reserved FineWeb holdout shard."""
100
+ from ..paths import HOLDOUT
101
+ import zstandard, io
102
+ texts = []
103
+ for p in sorted(HOLDOUT.glob(f"{lang}_*.jsonl.zst")):
104
+ with open(p, "rb") as raw:
105
+ r = zstandard.ZstdDecompressor().stream_reader(raw)
106
+ for line in io.TextIOWrapper(r, encoding="utf-8"):
107
+ if line.strip():
108
+ texts.append(json.loads(line)["text"])
109
+ if len(texts) >= max_docs:
110
+ return texts
111
+ return texts