Download source/tests/test_null_margin.py from gcoderw/DEG-5B-E2B-1018: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/gcoderw/DEG-5B-E2B-1018/resolve/main/source/tests/test_null_margin.py
- Command line
-
hf download hf://gcoderw/DEG-5B-E2B-1018/source/tests/test_null_margin.py
-
curl -L -o test_null_margin.py https://huggingface.co/gcoderw/DEG-5B-E2B-1018/resolve/main/source/tests/test_null_margin.py
11.4 kB
| """Extract null margin (options.null_margin): request parsing, the decode rule, server | |
| default, determinism / batch invariance on a tiny model, and the calibration tooling | |
| (deg.bench.null_margin).""" | |
| from __future__ import annotations | |
| import dataclasses | |
| import json | |
| import math | |
| from types import SimpleNamespace | |
| import pytest | |
| from deg.bench import null_margin as nm | |
| from deg.request import FillOptions, RequestError, parse_fill_request | |
| from deg.schema import compile_schema | |
| torch = pytest.importorskip("torch") | |
| BASE = {"context": "Order AB-1 shipped.", "schema": { | |
| "type": "object", "properties": {"x": {"type": ["string", "null"]}}, "required": ["x"]}} | |
| def test_option_parses_and_defaults_to_none(): | |
| assert parse_fill_request(BASE).options.null_margin is None | |
| for v in (0, 1.5, -2, 0.0): | |
| assert parse_fill_request({**BASE, "options": {"null_margin": v}}).options.null_margin == v | |
| for bad in (True, "1", float("inf"), [1]): | |
| with pytest.raises(RequestError): | |
| parse_fill_request({**BASE, "options": {"null_margin": bad}}) | |
| def test_decision_api_accepts_null_margin(): | |
| from deg.api import REQUEST_OPTION_KEYS | |
| assert "null_margin" in REQUEST_OPTION_KEYS | |
| # --------------------------------------------------------------------- decode rule | |
| CTX = "alpha beta gamma delta" | |
| OFFSETS = [(0, 5), (5, 10), (10, 16), (16, 22)] # " beta" etc.: trim_span strips spaces | |
| def _logits(span_lp: float, null_lp: float) -> tuple[torch.Tensor, torch.Tensor]: | |
| """start/end logits [T+1] whose best span (token 1..1 = "beta") has log-prob ~span_lp | |
| and the null outcome ~null_lp (the rest share what is left).""" | |
| def row(a: float, b: float) -> torch.Tensor: | |
| p = torch.full((5,), 1e-6, dtype=torch.float64) | |
| p[1], p[4] = math.exp(a), math.exp(b) | |
| p[0] = p[2] = p[3] = max(1e-6, (1 - p[1] - p[4]) / 3) | |
| return p.log() | |
| return row(span_lp / 2, null_lp / 2), row(span_lp / 2, null_lp / 2) | |
| def _extract(schema_type: object, span_lp: float, null_lp: float, margin: float | None, | |
| required: bool = True): | |
| from deg.infer import extract_result | |
| schema = {"type": "object", "properties": {"x": {"type": schema_type}}, | |
| "required": ["x"] if required else []} | |
| fld = compile_schema(schema).fields[0] | |
| start, end = _logits(span_lp, null_lp) | |
| return extract_result(fld, CTX, SimpleNamespace(offsets=OFFSETS), start, end, | |
| torch.tensor(1.0, dtype=torch.float64), | |
| FillOptions(null_margin=margin)) | |
| def test_margin_shifts_the_null_decision(): | |
| # span log-prob -1.0 > null -1.6: margin 0 / None take the span | |
| assert _extract(["string", "null"], -1.0, -1.6, None).value == "beta" | |
| assert _extract(["string", "null"], -1.0, -1.6, 0.0).value == "beta" | |
| assert _extract(["string", "null"], -1.0, -1.6, 0.5).value == "beta" | |
| # null wins once span < null + margin | |
| assert _extract(["string", "null"], -1.0, -1.6, 0.7).value is None | |
| # a negative margin keeps a span null would beat at 0 | |
| assert _extract(["string", "null"], -1.6, -1.0, 0.0).value is None | |
| assert _extract(["string", "null"], -1.6, -1.0, -0.7).value == "beta" | |
| def test_margin_omits_optional_and_never_touches_forbid(): | |
| omitted = _extract("string", -1.0, -1.6, 2.0, required=False) | |
| assert omitted.value is None and omitted.omit | |
| assert _extract("string", -1.0, -1.6, 50.0, required=True).value == "beta" # forbid | |
| def test_server_default_applies_only_when_unset(): | |
| from deg.server import with_null_margin | |
| assert with_null_margin(FillOptions(), 1.5).null_margin == 1.5 | |
| assert with_null_margin(FillOptions(null_margin=0.0), 1.5).null_margin == 0.0 | |
| assert with_null_margin(FillOptions(null_margin=-1), 1.5).null_margin == -1 | |
| def test_bind_model_passes_default(monkeypatch): | |
| from deg import infer, server | |
| seen = [] | |
| monkeypatch.setattr(infer, "fill", lambda m, c, s, o: seen.append(o.null_margin) or {}) | |
| monkeypatch.setattr(infer, "fill_many", | |
| lambda m, jobs: [seen.append(o.null_margin) for _, _, o in jobs]) | |
| fill, many = server.bind_model(object(), null_margin=2.0) | |
| fill("c", {}, FillOptions()) | |
| fill("c", {}, FillOptions(null_margin=0.5)) | |
| many([("c", {}, FillOptions()), ("c", {}, FillOptions(null_margin=0.0))]) | |
| assert seen == [2.0, 0.5, 2.0, 0.0] | |
| # ------------------------------------------------------------ tiny model (end to end) | |
| def model(): | |
| pytest.importorskip("transformers") | |
| from test_model import SNAPSHOT, tiny_lm | |
| from deg.model import load_model, load_tokenizer | |
| if SNAPSHOT is None: | |
| pytest.skip("Qwen3.5-0.8B tokenizer not cached") | |
| m = load_model(None, device="cpu", dtype=torch.float32, lm=tiny_lm(), | |
| tokenizer=load_tokenizer(SNAPSHOT)) | |
| torch.manual_seed(3) | |
| with torch.no_grad(): | |
| for p in m.heads.parameters(): | |
| p.normal_(0.0, 0.5) | |
| return m | |
| QSCHEMA = {"type": "object", "properties": { | |
| f"q{i}": {"type": ["string", "null"], "description": d} for i, d in enumerate( | |
| ["Who signed?", "Where?", "When was it paid?", "What is the color of the car?", | |
| "Which team won?", "What was the amount?"])}, | |
| "required": [f"q{i}" for i in range(6)]} | |
| QCTX = ("The contract was signed by Jane Roe in Paris on 3 May 2024. The fee of 400 euros " | |
| "was paid by Globex. The Red team beat the Blue team in the final.") | |
| def test_fill_margin_monotone_deterministic_and_batch_invariant(model): | |
| from deg.infer import fill, fill_many | |
| outs = {m: fill(model, QCTX, QSCHEMA, FillOptions(null_margin=m))["value"] | |
| for m in (-50.0, -1.0, 0.0, 1.0, 50.0)} | |
| nulls = [sum(v is None for v in outs[m].values()) for m in sorted(outs)] | |
| assert nulls == sorted(nulls) # larger margin -> never fewer nulls | |
| assert nulls[-1] == 6 and nulls[0] < 6 | |
| assert fill(model, QCTX, QSCHEMA)["value"] == outs[0.0] # None = 0 | |
| assert fill(model, QCTX, QSCHEMA, FillOptions(null_margin=1.0))["value"] == outs[1.0] | |
| batched = fill_many(model, [(QCTX, QSCHEMA, FillOptions(null_margin=m)) for m in sorted(outs)]) | |
| assert [b["value"] for b in batched] == [outs[m] for m in sorted(outs)] | |
| def test_leaf_probe_reproduces_margin0(model): | |
| from deg.infer import fill | |
| with nm.LeafProbe() as probe: | |
| out = fill(model, QCTX, QSCHEMA, FillOptions())["value"] | |
| assert [r["ptr"] for r in probe.rows] == [f"/q{i}" for i in range(6)] | |
| leaves = [{**r, "gold": "ans", "alts": []} for r in probe.rows] | |
| assert nm.check_margin0(leaves) == 0 | |
| for r in probe.rows: | |
| assert r["value"] == out[r["ptr"][1:]] | |
| # the sweep rule at any margin = the real decode at that margin | |
| for m in (-1.0, 1.0): | |
| real = fill(model, QCTX, QSCHEMA, FillOptions(null_margin=m))["value"] | |
| assert {r["ptr"][1:]: (None if nm.is_null(r, m) else r["span_value"]) | |
| for r in probe.rows} == real | |
| # --------------------------------------------------------------- calibration tooling | |
| def _leaf(gold: str, span_lp: float, null_lp: float, value: object = "x", | |
| alts: list | None = None) -> dict: | |
| return {"ptr": "/q", "gold": gold, "alts": alts if alts is not None else ( | |
| ["x"] if gold == "ans" else []), "span_value": value, "span_lp": span_lp, | |
| "null_lp": null_lp, "value": value if span_lp >= null_lp else None} | |
| def test_point_rates_and_cost(): | |
| leaves = [_leaf("ans", -0.1, -3.0), _leaf("ans", -1.0, -1.5, "y"), | |
| _leaf("unans", -0.5, -1.0), _leaf("unans", -2.0, -1.0)] | |
| p = nm.point(leaves, 0.0) | |
| assert p["made_up_rate"] == 0.5 and p["omission_rate"] == 0.0 | |
| assert p["answerable_correct"] == 0.5 and p["answerable_wrong_span"] == 0.5 | |
| assert p["cost"] == pytest.approx(1.0) | |
| q = nm.point(leaves, 1.0) # drops the -0.5/-1.0 made-up one and the -1.0/-1.5 answer | |
| assert q["made_up_rate"] == 0.0 and q["omission_rate"] == 0.5 and q["cost"] == 0.5 | |
| def test_choose_refuses_always_null_collapse(): | |
| def pt(m, cost, omit): | |
| return {"margin": m, "cost": cost, "sets": {"a": {"omission_rate": omit}}} | |
| curve = [pt(0.0, 0.8, 0.2), pt(1.0, 0.6, 0.3), pt(9.0, 0.5, 0.9)] | |
| assert nm.choose(curve)["margin"] == 1.0 | |
| assert nm.choose([pt(-1.0, 0.6, 0.1), pt(1.0, 0.6, 0.1), pt(0.5, 0.6, 0.1)])["margin"] == 0.5 | |
| def test_squad_records_exclude_suite_contexts(): | |
| rows = [{"context": f"Paragraph {i} text.", "id": f"{i}-{j}", "question": f"Q{j}?", | |
| "title": "T", "answers": ["text"] if j % 2 else []} | |
| for i in range(5) for j in range(4)] | |
| recs, skipped = nm.squad_records(rows, {"Paragraph 2 text."}, None, 10) | |
| assert len(recs) == 4 and skipped["suite_context"] == 1 | |
| assert all(r["context"] != "Paragraph 2 text." for r in recs) | |
| r = recs[0] | |
| assert set(r["schema"]["properties"]) == {"q1", "q2", "q3", "q4"} | |
| assert sum(v is None for v in r["value"].values()) == 2 | |
| assert nm.leaf_gold(r, "/q1")[0] == ("ans" if r["alternatives"]["/q1"] else "unans") | |
| def test_leaf_gold_nested_and_regold(tmp_path): | |
| rec = {"uid": "r", "value": {"a": {"b": "x", "c": None}, "d": "y"}} | |
| assert nm.leaf_gold(rec, "/a/b") == ("ans", ["x"]) | |
| assert nm.leaf_gold(rec, "/a/c") == ("unans", []) | |
| assert nm.leaf_gold(rec, "/a/zz") == ("unans", []) | |
| assert nm.leaf_gold(rec, "/d") == ("ans", ["y"]) | |
| nm.write_jsonl(tmp_path / "h.jsonl", [rec]) | |
| rows = [{"uid": "r", "error": None, "leaves": [{"ptr": "/a/b", "gold": "unans"}]}, | |
| {"uid": "r", "error": "boom", "leaves": [{"ptr": "/d"}]}] | |
| assert nm.regold(rows, tmp_path / "h.jsonl") == [{"ptr": "/a/b", "gold": "ans", | |
| "alts": ["x"]}] | |
| def test_extract_null_rates(): | |
| from deg.bench.score import Prediction | |
| recs = [{"uid": "a", "schema": {"type": "object", "properties": { | |
| "p": {"type": ["string", "null"]}, "q": {"type": ["string", "null"]}, | |
| "r": {"type": "string"}, "s": {"type": "string"}}, "required": ["p", "q", "r"]}, | |
| "value": {"p": "x", "q": None, "r": "z"}}] | |
| preds = {"a": Prediction("a", value={"p": None, "q": "made up", "r": "z", "s": "w"})} | |
| out = nm.extract_null_rates(recs, preds) | |
| # p answerable->null (wrong null); q, s(optional, absent gold) unanswerable->answered; | |
| # r is forbid (required, non-nullable): not counted | |
| assert out == {"unanswerable_n": 2, "answerable_n": 1, "made_up_rate": 1.0, | |
| "wrong_null_rate": 1.0} | |
| def test_suite_label_gets_margin_suffix(monkeypatch): | |
| from deg.bench.systems import deg as ds | |
| class M: | |
| generate_with, meta = "base", {} | |
| monkeypatch.setattr("deg.model.load_model", lambda *a, **k: M()) | |
| monkeypatch.setattr(ds.DegSystem, "_warm", lambda self: None) | |
| import pathlib | |
| monkeypatch.setattr(pathlib.Path, "exists", lambda self: True) | |
| s = ds.DegSystem("/x/run/step-1", null_margin=1.5) | |
| assert s.name.endswith("-basegen-nm1.5") and s.options.null_margin == 1.5 | |
| assert s.info["null_margin"] == 1.5 | |
| assert ds.DegSystem("/x/run/step-1").options == FillOptions() | |
| def test_dataclass_replace_keeps_other_options(): | |
| o = FillOptions(array_decode="hybrid", seed=3) | |
| r = dataclasses.replace(o, null_margin=1.0) | |
| assert (r.array_decode, r.seed, r.null_margin) == ("hybrid", 3, 1.0) | |
| json.dumps(dataclasses.asdict(r)) | |