Download scripts/evaluate_nulls.py from wuhp/ghtest: direct link, hf CLI and curl.
- Browser
- Download file 4.93 kB
-
https://huggingface.co/spaces/wuhp/ghtest/resolve/main/scripts/evaluate_nulls.py
- Command line
-
hf download hf://spaces/wuhp/ghtest/scripts/evaluate_nulls.py
-
curl -L -o evaluate_nulls.py https://huggingface.co/spaces/wuhp/ghtest/resolve/main/scripts/evaluate_nulls.py
4.93 kB
| """Reproduce the null-ladder evaluation on the pinned ZL3b transcription. | |
| python scripts/evaluate_nulls.py [--corpus ZL3b.txt] [--replicates 10] [--seed 0] | |
| Prints, for every rung of the null ladder (and its per-Currier / per-quire stratified twins for the | |
| boundary-aware chain), the mean +/- sd of four diagnostic features over Monte-Carlo replicates and the | |
| observed manuscript's z-score against them; then the heterogeneity control for the block classifier. | |
| What to look for (values from the pinned corpus; see README "Null-ladder findings"): | |
| * Space-free Markov nulls (N1-N3, N6) and the block shuffle overshoot cross-boundary edge MI by 3-6x, | |
| because they model boundary transitions as if spaces did not exist; the token shuffle undershoots it. | |
| * The boundary-aware n-gram (N2b/N3b) reproduces hapax fraction / Zipf slope / mean token length that no | |
| space-free null can, and its edge MI saturates by order 2 -- but stays below the observed value. | |
| * Stratifying that chain by Currier language or quire does NOT close the edge-MI gap. | |
| * The block classifier's separation from global nulls is substantially stratum composition. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| from pathlib import Path | |
| import sys | |
| import time | |
| import urllib.request | |
| import numpy as np | |
| ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(ROOT / "src")) | |
| from voynich_lab.ivtff import parse_ivtff # noqa: E402 | |
| from voynich_lab.metrics import compute_metrics # noqa: E402 | |
| from voynich_lab.stratified import heterogeneity_control, line_strata, stratified_generator # noqa: E402 | |
| from voynich_lab.surrogates import NULL_LADDER as DEFAULT_GENERATORS, bind_generator, boundary_markov_surrogate, token_shuffle_surrogate # noqa: E402 | |
| PINNED_CORPUS_URL = ( | |
| "https://raw.githubusercontent.com/lrozanova/voynich-units/" | |
| "956a7c4fc39981f4d116fa3f4edfccce6d065571/voynich_decipherment_repro_bundle/" | |
| "voynich_calibration_sources/ZL3b.txt" | |
| ) | |
| FEATURES = { | |
| "cross_token_edge_mi_bits": "edgeMI", | |
| "conditional_entropy_order1_bits": "H(1)", | |
| "hapax_fraction": "hapax", | |
| "zipf_loglog_slope": "zipf", | |
| } | |
| def _load(path: str | None) -> str: | |
| if path: | |
| return Path(path).read_text(encoding="utf-8", errors="replace") | |
| with urllib.request.urlopen(PINNED_CORPUS_URL, timeout=60) as resp: # noqa: S310 (pinned https URL) | |
| return resp.read().decode("utf-8", errors="replace") | |
| def main() -> None: | |
| ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument("--corpus", help="local ZL3b.txt (default: download the pinned copy)") | |
| ap.add_argument("--replicates", type=int, default=10) | |
| ap.add_argument("--seed", type=int, default=0) | |
| args = ap.parse_args() | |
| doc = parse_ivtff(_load(args.corpus)) | |
| text = doc.to_analysis_text() | |
| observed = compute_metrics(text) | |
| print(f"{len(doc.lines):,} loci, {int(observed['glyph_count']):,} glyphs, {int(observed['conventional_token_count']):,} tokens") | |
| print("observed " + " ".join(f"{short}={observed[f]:.3f}" for f, short in FEATURES.items())) | |
| boundary2 = bind_generator(boundary_markov_surrogate, order=2) | |
| rungs = dict(DEFAULT_GENERATORS) | |
| rungs["N7a_boundary_markov2_per_currier"] = stratified_generator(line_strata(doc, "currier"), boundary2) | |
| rungs["N7b_boundary_markov2_per_quire"] = stratified_generator(line_strata(doc, "quire"), boundary2) | |
| print(f"\nz = (observed - null mean) / null sd over {args.replicates} replicates (+ means observed is higher)\n") | |
| header = f"{'null':<36}" + "".join(f"{short:>24}" for short in FEATURES.values()) | |
| print(header + "\n" + "-" * len(header)) | |
| for name, fn in rungs.items(): | |
| start = time.time() | |
| samples = {f: [] for f in FEATURES} | |
| for r in range(args.replicates): | |
| metrics = compute_metrics(fn(text, args.seed + r)) | |
| for f in FEATURES: | |
| samples[f].append(metrics[f]) | |
| cells = [] | |
| for f in FEATURES: | |
| arr = np.asarray(samples[f], dtype=float) | |
| sd = arr.std(ddof=1) | |
| z = (observed[f] - arr.mean()) / sd if sd > 0 else float("nan") | |
| cells.append(f"{arr.mean():8.3f}+/-{sd:5.3f} z={z:+7.1f}") | |
| print(f"{name:<36}" + "".join(f"{c:>24}" for c in cells) + f" ({time.time() - start:.0f}s)") | |
| print("\nHeterogeneity control (block classifier, global vs per-quire null):") | |
| for label, fn in [("N1 order-1 Markov", DEFAULT_GENERATORS["N1_markov1_glyph"]), ("N5 token shuffle", token_shuffle_surrogate)]: | |
| r = heterogeneity_control(text, line_strata(doc, "quire"), fn, seed=args.seed) | |
| print( | |
| f" {label:<20} global AUC={r.auc_global:.3f} per-quire AUC={r.auc_stratified:.3f} " | |
| f"drop={r.auc_drop:+.3f} stratified CI includes 0.5: {r.stratified_ci_includes_chance}" | |
| ) | |
| if __name__ == "__main__": | |
| main() | |