decider-4b-tr-rag / decider /calibrate.py
hayriyigit's picture
decider LoRA fine-tune
8fe5d78 verified
Raw History Blame Contribute Delete
12 kB
"""Fit decider_config.json "temperature_by_type" by NLL, one temperature per answer type (decider.temperature).
python -m decider.calibrate records.jsonl [--min-rows 50]
-> {"temperature_by_type": {"choice": 1.48, "noul": 2.22, "score": 1.38}, "temperature": 1.6, "rows": {...}, "nll": {...}}
A record is one answer with its gold, read at temperature 1:
{"type": "choice" | "noul" | "score", "logits": [one value per option], "gold": option index}
{"type": "score", "level_logits": [[no, yes], one pair per level], "gold": level index} Score with isolated levels
"probs" / "level_probs" (probabilities at temperature 1) may stand in for "logits" / "level_logits": log p is the logit up to a
constant, which the softmax ignores. A noul gold is 1 for true, 0 for false. Every record is checked first (check_record):
a malformed one (gold out of range or not an integer, wrong shape, NaN, a row without a finite logit, an isolated Score whose
levels all have P(yes) = 0) raises ValueError naming its position.
An isolated-levels Score answer is fitted through the readout the server uses: every level row is softmax(row / T), and the
answer is P(yes) of each level divided by their sum (decider.systemone.combine_isolated). So the "score" temperature is fitted on
Score answers whichever readout the model serves them with; fit it on records collected with the same isolated_levels setting.
`collect(decider, examples)` produces records from a decider.infer.Decider and labelled /v1/systemone-shaped examples.
"temperature" in the output is one temperature fitted on all records together, for comparison; a type with fewer than
--min-rows records is left out of the map (it then uses "temperature" of the config).
"""
import json, math, sys
import numpy as np
from decider.temperature import TYPES
GRID = np.exp(np.linspace(math.log(0.05), math.log(20.0), 801)) # 0.05 .. 20, about 0.75 % apart
def _log(x):
x = np.asarray(x, dtype=np.float64)
with np.errstate(divide="ignore"):
return np.where(x > 0, np.log(np.clip(x, 1e-300, None)), -np.inf)
def _logits(rec, key):
if key in rec:
return np.asarray(rec[key], dtype=np.float64)
alt = {"logits": "probs", "level_logits": "level_probs"}[key]
return _log(rec[alt])
def _lse(z, axis=-1):
m = np.max(z, axis=axis, keepdims=True)
m = np.where(np.isfinite(m), m, 0.0)
with np.errstate(divide="ignore"): # an all -inf row gives -inf, handled by the callers
return (m + np.log(np.sum(np.exp(z - m), axis=axis, keepdims=True))).squeeze(axis)
def nll(rec, T):
"""Negative log-likelihood of one record's gold at temperature T."""
return float(_Batch([rec]).nll(T))
def _isolated(rec):
return "level_logits" in rec or "level_probs" in rec
def check_record(rec, i=None):
"""Raise ValueError unless `rec` is a well-formed record (module docstring): an integral gold within the options or levels,
a 1-D row of at least two options or an [levels, 2] array of at least two levels, logits that are not NaN or +inf,
probabilities in [0, 1] with positive mass, and isolated levels only on a Score record."""
where = f"record {i}" if i is not None else "record"
if not isinstance(rec, dict):
raise ValueError(f"{where}: not a JSON object")
if rec.get("type") not in TYPES:
raise ValueError(f"{where}: type {rec.get('type')!r}, expected one of {', '.join(TYPES)}")
iso = _isolated(rec)
keys = ("level_logits", "level_probs") if iso else ("logits", "probs")
if sum(k in rec for k in keys) != 1 or (iso and ("logits" in rec or "probs" in rec)):
raise ValueError(f"{where}: give exactly one of logits, probs, level_logits, level_probs")
if iso and rec["type"] != "score":
raise ValueError(f"{where}: isolated levels (level_logits / level_probs) belong to a score record, not {rec['type']!r}")
key = keys[0] if keys[0] in rec else keys[1]
try:
a = np.asarray(rec[key], dtype=np.float64)
except (TypeError, ValueError):
raise ValueError(f"{where}: {key} is not a numeric array") from None
if iso and (a.ndim != 2 or a.shape[1] != 2 or a.shape[0] < 2):
raise ValueError(f"{where}: {key} must be one [no, yes] pair per level, at least two levels; got shape {a.shape}")
if not iso and (a.ndim != 1 or a.shape[0] < 2):
raise ValueError(f"{where}: {key} must be one value per option, at least two options; got shape {a.shape}")
if key.endswith("probs"):
if not np.all(np.isfinite(a)) or np.any(a < 0) or np.any(a > 1) or np.any(a.sum(-1) <= 0):
raise ValueError(f"{where}: {key} must be probabilities in [0, 1] with positive mass per row")
elif np.any(np.isnan(a)) or np.any(a == np.inf) or not np.all(np.any(np.isfinite(a), axis=-1)):
raise ValueError(f"{where}: {key} contains NaN or +inf, or a row without a finite value")
if rec["type"] == "noul" and a.shape[0] != 2:
raise ValueError(f"{where}: a noul record has exactly two options (false, true); got {a.shape[0]}")
if iso and not np.any(a[:, 1] > 0 if key == "level_probs" else np.isfinite(a[:, 1])):
raise ValueError(f"{where}: no level has a yes probability above 0, so the served Score answer has no distribution")
g = rec.get("gold")
if isinstance(g, bool) or not isinstance(g, (int, np.integer)) or not 0 <= g < a.shape[0]:
raise ValueError(f"{where}: gold must be an integer index in 0..{a.shape[0] - 1}, got {g!r}")
return rec
class _Batch:
"""Records packed into padded arrays, so one temperature is scored over all of them at once."""
def __init__(self, records):
records = [check_record(r, i) for i, r in enumerate(records)]
lists = [r for r in records if not _isolated(r)]
isos = [r for r in records if _isolated(r)]
self.n = len(lists) + len(isos)
self.L = self.Lg = self.I = self.Im = self.Ig = None
if lists:
zs = [_logits(r, "logits") for r in lists]
K = max(len(z) for z in zs)
self.L = np.full((len(zs), K), -np.inf)
for i, z in enumerate(zs):
self.L[i, :len(z)] = z
self.Lg = np.array([int(r["gold"]) for r in lists])
if isos:
zs = [_logits(r, "level_logits").reshape(-1, 2) for r in isos]
n = max(len(z) for z in zs)
self.I = np.zeros((len(zs), n, 2)); self.Im = np.zeros((len(zs), n), dtype=bool)
for i, z in enumerate(zs):
self.I[i, :len(z)] = z; self.Im[i, :len(z)] = True
self.Ig = np.array([int(r["gold"]) for r in isos])
def nll(self, T):
"""Sum of the records' NLL at temperature T."""
tot = 0.0
if self.L is not None:
z = self.L / T
zg = z[np.arange(len(z)), self.Lg]
tot += float(np.sum(np.where(np.isfinite(zg), _lse(z) - zg, 690.0)))
if self.I is not None: # in log space: P(yes) of very confident rows underflows otherwise
z = self.I / T
with np.errstate(invalid="ignore"):
lp = np.where(self.Im, z[..., 1] - _lse(z), -np.inf) # log P(yes) per level, -inf on padding
norm = _lse(lp) # log of the summed P(yes)
lg = lp[np.arange(len(z)), self.Ig]
with np.errstate(invalid="ignore"):
per = np.where(np.isfinite(lg), norm - lg, 690.0) # check_record guarantees a finite norm
tot += float(np.sum(per))
return tot
def mean_nll(records, T):
b = records if isinstance(records, _Batch) else _Batch(records)
return b.nll(T) / b.n
def fit(records, grid=GRID):
"""The temperature with the lowest mean NLL on the grid, refined by a golden-section search between its neighbours."""
records = records if isinstance(records, _Batch) else _Batch(list(records))
if not records.n:
raise ValueError("no records to fit")
vals = [mean_nll(records, T) for T in grid]
i = int(np.argmin(vals))
lo, hi = math.log(grid[max(i - 1, 0)]), math.log(grid[min(i + 1, len(grid) - 1)])
r = (math.sqrt(5) - 1) / 2
a, b = hi - r * (hi - lo), lo + r * (hi - lo)
fa, fb = mean_nll(records, math.exp(a)), mean_nll(records, math.exp(b))
for _ in range(40):
if fa < fb:
hi, b, fb = b, a, fa; a = hi - r * (hi - lo); fa = mean_nll(records, math.exp(a))
else:
lo, a, fa = a, b, fb; b = lo + r * (hi - lo); fb = mean_nll(records, math.exp(b))
T = math.exp((lo + hi) / 2)
return T if mean_nll(records, T) <= vals[i] else float(grid[i])
def fit_by_type(records, min_rows=50):
"""-> {"temperature_by_type": {type: T}, "temperature": pooled T, "rows": {type: n}, "nll": {type: {"T=1": .., "fitted": ..}}}"""
records = [check_record(r, i) for i, r in enumerate(records)]
groups = {t: [] for t in TYPES}
for r in records:
groups[r["type"]].append(r)
out = {"temperature_by_type": {}, "temperature": round(fit(records), 3) if records else None,
"rows": {t: len(g) for t, g in groups.items()}, "nll": {}}
for t, g in groups.items():
if len(g) < max(1, min_rows):
continue
b = _Batch(g); T = fit(b)
out["temperature_by_type"][t] = round(T, 3)
out["nll"][t] = {"T=1": round(mean_nll(b, 1.0), 4), "fitted": round(mean_nll(b, T), 4)}
return out
def collect(decider, examples, independent=True):
"""Records for fit_by_type from a decider.infer.Decider, read at temperature 1 on the uncached state-first path.
examples: iterable of (state, questions, golds) with questions as /v1/systemone takes them and golds {question id: gold},
the gold being a Choice option name, a Noul true/false, or a Score level index. Questions without a gold are skipped.
Score questions are read with the Decider's isolated_levels setting, as system_one serves them."""
recs = []
for state, questions, golds in examples:
rqs, index, items = decider._system_one_items(state, questions, independent, layout="state_first")
rows = decider._system_one_probs(items, "state_first", temperature=1.0)
for k, kind, s, n in index:
if k not in golds:
continue
rq, g = rqs[k], golds[k]
if rq["type"] == "noul" and not (isinstance(g, bool) or g in (0, 1)):
raise ValueError(f"gold of noul question {k!r} must be true or false, got {g!r}")
if rq["type"] == "score" and (isinstance(g, bool) or not isinstance(g, int) or not 0 <= g < len(rq["names"])):
raise ValueError(f"gold of score question {k!r} must be a level index in 0..{len(rq['names']) - 1}, got {g!r}")
key = bool(g) if rq["type"] == "noul" else g
if key not in rq["names"]:
raise ValueError(f"gold of choice question {k!r} is not one of its options: {g!r}")
gold = rq["names"].index(key)
if kind == "iso":
recs.append({"type": rq["type"], "level_probs": [rows[s + j][:2] for j in range(n)], "gold": gold})
else:
recs.append({"type": rq["type"], "probs": rows[s][:len(rq["options"])], "gold": gold})
return recs
def main(argv=None):
import argparse
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("records", help="JSON lines, one record per line (see the module docstring)")
ap.add_argument("--min-rows", type=int, default=50, help="fit a type only when it has at least this many records")
a = ap.parse_args(argv)
with open(a.records) as f:
recs = [json.loads(line) for line in f if line.strip()]
print(json.dumps(fit_by_type(recs, a.min_rows), indent=1))
if __name__ == "__main__":
main(sys.argv[1:])