From dff64ca0d9a5dafed7312d2c70cd682a97b76cc0 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 10:30:07 -0400 Subject: [PATCH] feat(training): a label tier, and a refusal when the corpus cannot carry it `sds train --tier unit|force|team`, defaulting to `team`. Every label was the whole side's outcome, so in an 8v8 one machine's choice carried seven other machines' luck. A tier narrows the scope to one unit or one lance. A label with no sub-team form, or a corpus with no per-unit round tick, refuses and names what is missing rather than handing back the team's number. --- sds/cli.py | 14 +- sds/train.py | 380 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 374 insertions(+), 20 deletions(-) diff --git a/sds/cli.py b/sds/cli.py index 1f30ee5..ccbd7c1 100644 --- a/sds/cli.py +++ b/sds/cli.py @@ -34,6 +34,8 @@ from .train import CREDITS as TRAIN_CREDITS from .train import GAMMA as TRAIN_GAMMA from .train import LABEL_KIND as TRAIN_LABEL from .train import LABEL_MEANING as TRAIN_LABELS +from .train import TIER_DEFAULT as TRAIN_TIER +from .train import TIERS as TRAIN_TIERS from .viewer import ViewerError from .viewer import write as write_view @@ -423,7 +425,7 @@ def cmd_train(args: argparse.Namespace) -> int: from .train import Label, TrainingError, fit, read_corpus, report, save try: - label = Label(kind=args.label, gamma=args.gamma, credit=args.credit) + label = Label(kind=args.label, gamma=args.gamma, credit=args.credit, tier=args.tier) corpus = read_corpus(Path(args.target), imitation=args.imitation, label=label) result = fit(corpus, ridge=args.ridge, features=args.feature or None, label=label) except TrainingError as error: @@ -656,6 +658,16 @@ def main(argv: list[str] | None = None) -> int: "payoff by how outmatched we were, so a hit that changes the force " "ratio counts for more than one landed while already winning", ) + train.add_argument( + "--tier", + default=TRAIN_TIER, + choices=sorted(TRAIN_TIERS), + help="whose outcome a decision is scored by. `team` is the whole side, " + "which in an 8v8 is seven other machines' luck in every row; `unit` is " + "what that machine dealt and took, and `force` is its lance. A label or " + "a corpus that cannot carry the tier refuses rather than falling back " + "to the team's number", + ) train.add_argument( "--against", metavar="NAME", diff --git a/sds/train.py b/sds/train.py index a71b2c7..8e015a2 100644 --- a/sds/train.py +++ b/sds/train.py @@ -6,7 +6,7 @@ the weight vector is fitted. This module is the fitting half - it reads a run directory, builds a design matrix out of the logged feature vectors, solves a least squares problem and writes a weights file the bot can load. -Three things in here are load-bearing. +Four things in here are load-bearing. *A `local` feature is never fitted.* `local` means min-maxed across the candidates of one decision, so the same situation on a different board gives a @@ -33,6 +33,12 @@ learned `overkill` positive and `p_kill` negative and lost every decided game. `bv_loss_inflicted` and `damage_inflicted` count only what came off the enemy, discounted to the end of the match. `plan/training.md` has the corpus numbers. +*A tier never widens quietly.* Every label was the whole side's outcome, so in +an 8v8 one machine's choice carried seven other machines' luck. `--tier` asks +for a narrower one, and a label or a corpus that cannot give it refuses and says +what is missing. Answering with the team's number instead would read as an 8x +sharpening and be the same diluted label, and nothing would fail. + Standard library only, and deterministic: feature names sorted, rows in a fixed order, no clock and no randomness anywhere in the arithmetic. """ @@ -41,7 +47,7 @@ from __future__ import annotations import json import math -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import UTC, datetime from pathlib import Path @@ -185,6 +191,49 @@ CREDITS = (CREDIT_FLAT, CREDIT_RATIO) # replaces. `--credit flat` is the comparison, and is one flag away. CREDIT_DEFAULT = CREDIT_RATIO +# Whose outcome a label is about. +# +# Every label was team-wide, so a decision one machine made was scored by what +# its whole side achieved. In an 8v8 that is seven other machines' luck in every +# row, and the signal is diluted about eightfold before the fit sees it. +# +# `team` is what there was and is still right for a genuinely side-wide outcome. +# `unit` is the tightest signal available: what this machine dealt and took. +# `force` sits between them, for a decision a lance makes rather than a unit - +# nested the way `Forces` is, so a company's label is over its lances. +# +# The default is `team`, so nothing changes unless it is asked for. +TIER_UNIT = "unit" +TIER_FORCE = "force" +TIER_TEAM = "team" +TIERS = (TIER_UNIT, TIER_FORCE, TIER_TEAM) +TIER_DEFAULT = TIER_TEAM + +# The two round-tick fields a sub-team tier is built from, neither of which the +# host records yet. See `plan/protocol.md`. +# +# `entities` is a *level* per unit, the per-unit form of `bv`/`armor`/`internal`/ +# `potential`: `{seat: {entity id: {bv, armor, internal, potential, force}}}`, a +# destroyed unit dropping out the way it already drops out of the side totals. +# That is all a `_taken` series needs, because what a unit lost is the fall in +# its own levels. +# +# `credits` is a *flow*: `{seat: {entity id: {bv, damage, units}}}`, what that +# unit took off the enemy during the round just logged. An `_inflicted` series +# needs it and cannot be derived without it - a per-unit snapshot says which +# enemy unit lost armour, never who took it off. +FIELD_ENTITIES = "entities" +FIELD_CREDITS = "credits" + +# What a unit's entry in `entities` is keyed by, mapped to the round-tick side +# field it is the per-unit form of. +ENTITY_FIELDS = { + "bv": "bv", + "armor": "armor", + "internal": "internal", + "potential": "potential", +} + # The imitation corpus, written by the bot rather than by the host: one row per # enemy move it could reconstruct from two consecutive observations. See # `crates/sds-bot/src/imitate.rs`, which also records what cloning Princess's @@ -233,6 +282,9 @@ class Decision: # another player made, reconstructed from the observation, where `value` is # what *our* weights thought of it and is deliberately not the argmax. policy: str = POLICY_ARGMAX + # Which unit or force this row's label is about, at a sub-team tier. None at + # the team tier, which is every corpus read without `--tier`. + scope: str | None = None @property def taken(self) -> Candidate: @@ -253,6 +305,10 @@ class Corpus: # Rows whose `owner` named a player the match result does not, so the row # could not be given a seat and therefore could not be labelled. unresolved_owners: set[int] = field(default_factory=set) + # Decisions whose scope the round log could not name - a force tier over a + # unit with no force recorded. Counted rather than dropped quietly: the + # alternative is crediting a lance decision to the wrong lance. + unscoped: int = 0 matches: set[str] = field(default_factory=set) unlabelled: list[str] = field(default_factory=list) local_features: set[str] = field(default_factory=set) @@ -266,7 +322,9 @@ class Corpus: """ pairs = [] for d in self.decisions: - if (d.tag, d.seat, d.round) in self.labels: + if (d.tag, d.seat, d.scope, d.round) in self.labels: + pairs.append((d, self.labels[(d.tag, d.seat, d.scope, d.round)])) + elif (d.tag, d.seat, d.round) in self.labels: pairs.append((d, self.labels[(d.tag, d.seat, d.round)])) elif (d.tag, d.seat) in self.labels: pairs.append((d, self.labels[(d.tag, d.seat)])) @@ -473,6 +531,7 @@ class Label: kind: str = LABEL_KIND gamma: float = GAMMA credit: str = CREDIT_DEFAULT + tier: str = TIER_DEFAULT def __post_init__(self) -> None: if self.kind not in LABEL_MEANING: @@ -483,6 +542,20 @@ class Label: raise TrainingError(f"unknown credit {self.credit!r}; have {', '.join(CREDITS)}") if not 0.0 < self.gamma <= 1.0: raise TrainingError(f"gamma must be in (0, 1], not {self.gamma}") + if self.tier not in TIERS: + raise TrainingError(f"unknown tier {self.tier!r}; have {', '.join(sorted(TIERS))}") + if self.tier != TIER_TEAM and self.kind in TEAM_ONLY: + raise TrainingError( + f"{self.kind} has no per-{self.tier} form: it is a differential of two " + "side totals, and one machine's share of it is not a quantity. Refused " + "rather than answered with the team's number. The one-sided labels - " + f"{', '.join(sorted(ONE_SIDED))} - are the ones a tier can sharpen." + ) + + @property + def needs(self) -> tuple[str, ...]: + """Round-tick fields this label needs, beyond what the team tier reads.""" + return () if self.tier == TIER_TEAM else label_needs(self.kind) @property def one_sided(self) -> bool: @@ -510,6 +583,8 @@ class Label: text += ", credit `ratio` ignored (this label is already relative)" if self.sense < 0: text += "; **bigger is worse**, so every expected sign is inverted" + if self.tier != TIER_TEAM: + text += f"; scored per **{self.tier}** rather than per team" return text @@ -529,6 +604,116 @@ def _side_totals(tick: dict, seat: str, field: str) -> tuple[float, float] | Non return ours, theirs +def _entities_of(tick: dict, seat: str) -> dict[str, dict] | None: + """One seat's per-unit levels in a tick, or None if the tick has none. + + None means "this match was recorded before `entities` existed", which is + every match so far. It is not "this seat had no machines". + """ + side = tick.get(FIELD_ENTITIES) + if not isinstance(side, dict): + return None + mine = side.get(seat) + if not isinstance(mine, dict): + return None + return {str(key): value for key, value in mine.items() if isinstance(value, dict)} + + +def _credits_of(tick: dict, seat: str) -> dict[str, dict] | None: + """One seat's per-unit attribution in a tick, or None if the tick has none.""" + side = tick.get(FIELD_CREDITS) + if not isinstance(side, dict): + return None + mine = side.get(seat) + if not isinstance(mine, dict): + return None + return {str(key): value for key, value in mine.items() if isinstance(value, dict)} + + +def _members(tick: dict, seat: str, tier: str, part: str) -> list[str] | None: + """The entity ids a scope covers in this tick, or None if `entities` is absent. + + A unit scope is itself, and is empty once it is destroyed and has dropped + out of the tick. A force scope is every unit whose `force` names it, which + is how the nesting arrives: a company names its lances' units too, because + `force` is written with the leaf's full membership. + """ + units = _entities_of(tick, seat) + if units is None: + return None + if tier == TIER_UNIT: + return [part] if part in units else [] + return sorted(key for key, value in units.items() if str(value.get("force", "")) == part) + + +def _part_level(tick: dict, seat: str, tier: str, part: str, base: str) -> float | None: + """A scope's own level of one quantity, or None if the tick cannot say. + + The per-unit form of the `ours` half of `_side_totals`. `damage` is armour + plus structure, the same pair the side totals sum. `units` is how many of + the scope's machines are still in the tick, so a unit scope reads 1 while + alive and 0 once destroyed. + """ + members = _members(tick, seat, tier, part) + if members is None: + return None + units = _entities_of(tick, seat) or {} + if base == "units": + return float(len(members)) + keys = ("armor", "internal") if base == "damage" else (ENTITY_FIELDS[base],) + total = 0.0 + for member in members: + for key in keys: + total += float(units[member].get(key) or 0) + return total + + +def _part_credit(tick: dict, seat: str, tier: str, part: str, base: str) -> float | None: + """What a scope took off the enemy during the round this tick heads, or None. + + A flow rather than a level, and the reason `credits` has to exist at all: a + per-unit snapshot says which enemy machine lost armour, never who took it + off. Nothing here can be inferred from `entities`. + """ + credited = _credits_of(tick, seat) + if credited is None: + return None + members = _members(tick, seat, tier, part) + if members is None: + return None + if tier == TIER_UNIT: + # A destroyed unit is out of `entities` but may still have earned + # credit in the round it died in, so the credit map is the authority + # here rather than the membership. + members = [part] + total = 0.0 + for member in members: + entry = credited.get(member) + if entry: + total += float(entry.get(base) or 0) + return total + + +def scope_of(result: dict, seat: str, unit: int, tier: str) -> str | None: + """Which scope a decision's label belongs to, or None if the log cannot say. + + A unit is its own scope. A force scope is read from the unit's `force` in + the round log, which is the only place force membership is recorded; a + corpus without it gives every force decision None and the rows go + unlabelled rather than being credited to the wrong lance. + """ + if tier == TIER_TEAM: + return None + if tier == TIER_UNIT: + return str(unit) + for tick in result.get("rounds") or []: + units = _entities_of(tick, seat) if isinstance(tick, dict) else None + entry = (units or {}).get(str(unit)) + if entry and entry.get("force"): + return str(entry["force"]) + return None + + # The round-tick keys each raw series is summed over. # # Two keys for damage because armour and structure are both points off the @@ -557,6 +742,33 @@ LABEL_SERIES: dict[str, tuple[str, str | None]] = { LABEL_EFFICIENCY: ("damage_inflicted", "potential_ours"), } +# Labels that have no sub-team form at all. +# +# Both differentials are a side's share of a side total. "This unit's share of +# the BV differential" is not a quantity - the differential is one number over +# two whole sides, and slicing it per machine would invent an attribution that +# the arithmetic does not contain. Asked for at `unit` or `force` these refuse +# rather than quietly handing back the team's number, which is the failure mode +# worth guarding: a diluted label that looks sharpened is worse than none. +TEAM_ONLY = (LABEL_FINAL, LABEL_DELTA3) + + +def series_needs(series: str) -> str: + """The round-tick field a sub-team tier needs to build one raw series. + + `_taken` and `_ours` are levels of our own machines and come from + `entities`. `_inflicted` is what we took off them, which no snapshot of + levels can attribute to an attacker, and comes from `credits`. + """ + return FIELD_CREDITS if series.endswith("_inflicted") else FIELD_ENTITIES + + +def label_needs(kind: str) -> tuple[str, ...]: + """Which round-tick fields `kind` needs to be computed below the team tier.""" + numerator, denominator = LABEL_SERIES[kind] + parts = [numerator] + ([denominator] if denominator else []) + return tuple(sorted({series_needs(name) for name in parts})) + def force_ratio(theirs: float, ours: float) -> float: """How outmatched we were, clamped, for crediting a round's payoff. @@ -584,7 +796,13 @@ def force_ratio(theirs: float, ours: float) -> float: return min(max(theirs / ours, 1.0 / RATIO_CLAMP), RATIO_CLAMP) -def _raw_series(result: dict, seat: str, wanted: set[str]) -> dict[str, dict[int, float]] | None: +def _raw_series( + result: dict, + seat: str, + wanted: set[str], + tier: str = TIER_DEFAULT, + part: str | None = None, +) -> dict[str, dict[int, float]] | None: """Every per-round quantity a label needs, one-sided and in raw units. Keyed by the round the change is *observed* in: a tick is the top of a @@ -617,6 +835,8 @@ def _raw_series(result: dict, seat: str, wanted: set[str]) -> dict[str, dict[int ) if len(ticks) < 2: return None + if tier != TIER_TEAM: + return _part_series(ticks, seat, wanted, tier, str(part)) needed = {name.rsplit("_", 1)[0] for name in wanted} out: dict[str, dict[int, float]] = {name: {} for name in wanted} @@ -645,6 +865,43 @@ def _raw_series(result: dict, seat: str, wanted: set[str]) -> dict[str, dict[int return out +def _part_series( + ticks: list[dict], seat: str, wanted: set[str], tier: str, part: str +) -> dict[str, dict[int, float]] | None: + """The same per-round series as `_raw_series`, for one unit or one force. + + Two halves, and they come from different places. A `_taken` or `_ours` + series is a level of our own machines and is read the same way the side + totals are - the fall between two ticks. An `_inflicted` series is read + from `credits`, because attributing a fall in an enemy's armour to the + machine that caused it is not something any snapshot contains. + + Clamped at zero and keyed by the round the change is observed in, exactly as + the team tier is. Returns None the moment a tick cannot answer, so a corpus + that carries the fields for some rounds and not others refuses instead of + reading the gaps as quiet rounds. + """ + out: dict[str, dict[int, float]] = {name: {} for name in wanted} + for previous, now in zip(ticks, ticks[1:], strict=False): + at = int(now["round"]) + for name in wanted: + base, side = name.rsplit("_", 1) + if side == "inflicted": + value = _part_credit(now, seat, tier, part, base) + if value is None: + return None + out[name][at] = max(0.0, value) + continue + before = _part_level(previous, seat, tier, part, base) + after = _part_level(now, seat, tier, part, base) + if before is None or after is None: + return None + # A level, not a change, for `_ours`: what the scope could have + # fired during the round that has just been logged. + out[name][at] = before if side == "ours" else max(0.0, before - after) + return out + + def _starts(result: dict, seat: str) -> tuple[float, float] | None: """`(ours, theirs)` BV at the top of the match, for normalising a one-sided sum.""" ticks = sorted( @@ -656,12 +913,21 @@ def _starts(result: dict, seat: str) -> tuple[float, float] | None: return _side_totals(ticks[0], seat, "bv") -def _normaliser(result: dict, seat: str, series: str) -> float | None: +def _normaliser( + result: dict, seat: str, series: str, tier: str = TIER_DEFAULT, part: str | None = None +) -> float | None: """What a one-sided sum of `series` is divided by, so a lance and a company are on the same scale. The relevant side's total at the top of the match: their BV for what we took off them, our BV for what we lost, their unit count for kills. + + Below the team tier the `inflicted` half is unchanged - what one machine + took off the enemy is still a share of everything the enemy brought, and + keeping the same denominator is what makes a unit's label and its team's + label the same units. Only the `taken` half narrows, to the scope's own + starting level: a unit that lost half its armour reads 0.5 whatever its + lancemates did. """ ticks = sorted( (t for t in (result.get("rounds") or []) if isinstance(t, dict) and "round" in t), @@ -670,6 +936,9 @@ def _normaliser(result: dict, seat: str, series: str) -> float | None: if not ticks: return None base, side = series.rsplit("_", 1) + if tier != TIER_TEAM and side != "inflicted": + total = _part_level(ticks[0], seat, tier, str(part), base) + return total if total and total > 0.0 else None total = 0.0 for key in SERIES_FIELDS[base]: first = _side_totals(ticks[0], seat, key) @@ -693,7 +962,9 @@ def discounted_return(steps: dict[int, float], round_: int, gamma: float) -> flo return total -def inflicted_steps(result: dict, seat: str, spec: Label) -> dict[int, float] | None: +def inflicted_steps( + result: dict, seat: str, spec: Label, part: str | None = None +) -> dict[int, float] | None: """The per-round series a one-sided label sums, normalised and credited. Only defined for the labels that are a single sum. A ratio label has two @@ -702,10 +973,10 @@ def inflicted_steps(result: dict, seat: str, spec: Label) -> dict[int, float] | numerator, denominator = LABEL_SERIES[spec.kind] if denominator is not None: raise TrainingError(f"{spec.kind} is a ratio and has no single step series") - raw = _raw_series(result, seat, {numerator}) + raw = _raw_series(result, seat, {numerator}, spec.tier, part) if raw is None: return None - scale = _normaliser(result, seat, numerator) + scale = _normaliser(result, seat, numerator, spec.tier, part) if scale is None: return None credit = _credit_weights(result, seat) if spec.credit == CREDIT_RATIO else None @@ -744,7 +1015,9 @@ def _credit_weights(result: dict, seat: str) -> dict[int, float]: return weights -def label_value(result: dict, seat: str, round_: int, spec: Label) -> float | None: +def label_value( + result: dict, seat: str, round_: int, spec: Label, part: str | None = None +) -> float | None: """One decision's label, or None when the result cannot carry it. A one-sided label is the discounted sum of its own series. A ratio label is @@ -755,10 +1028,10 @@ def label_value(result: dict, seat: str, round_: int, spec: Label) -> float | No """ numerator, denominator = LABEL_SERIES[spec.kind] if denominator is None: - steps = inflicted_steps(result, seat, spec) + steps = inflicted_steps(result, seat, spec, part) return None if steps is None else discounted_return(steps, round_, spec.gamma) - raw = _raw_series(result, seat, {numerator, denominator}) + raw = _raw_series(result, seat, {numerator, denominator}, spec.tier, part) if raw is None: return None top = discounted_return(raw[numerator], round_, spec.gamma) @@ -783,8 +1056,9 @@ def _round_label( seat: str, round_: int, spec: Label, - cache: dict[tuple[str, str], dict[int, float] | None], + cache: dict[tuple, dict[int, float] | None], tag: str, + part: str | None = None, ) -> float | None: """The per-round label for one decision, or None if the result cannot carry it. @@ -801,12 +1075,48 @@ def _round_label( if spec.kind == LABEL_DELTA3: return label_at(result, seat, round_) if LABEL_SERIES[spec.kind][1] is None: - key = (tag, seat) + key = (tag, seat, part) if key not in cache: - cache[key] = inflicted_steps(result, seat, spec) + cache[key] = inflicted_steps(result, seat, spec, part) steps = cache[key] return None if steps is None else discounted_return(steps, round_, spec.gamma) - return label_value(result, seat, round_, spec) + return label_value(result, seat, round_, spec, part) + + +def tier_gap(result: dict, spec: Label) -> str | None: + """Why this match cannot carry the asked-for tier, or None if it can. + + Checked once per match and raised rather than worked around. The fallback + that exists for a missing label - drop to the match-wide differential - is + exactly the wrong answer for a missing tier: it would hand back the team's + number under `--tier unit`, which reads as a sharpened label and is a + diluted one. A refusal that names the field is the honest form. + """ + if spec.tier == TIER_TEAM: + return None + ticks = [t for t in (result.get("rounds") or []) if isinstance(t, dict) and "round" in t] + if len(ticks) < 2: + return "it has no per-round log" + missing = [name for name in spec.needs if not any(isinstance(t.get(name), dict) for t in ticks)] + if not missing: + return None + return ( + f"its round log carries no `{'` or `'.join(missing)}`. " + + ( + "`entities` is the per-unit form of the side totals - what each machine " + "still has - and a `taken` label is the fall in it. " + if FIELD_ENTITIES in missing + else "" + ) + + ( + "`credits` is what each machine took off the enemy in a round, and an " + "`inflicted` label cannot be derived without it: a snapshot of levels says " + "which enemy lost armour, never who took it off. " + if FIELD_CREDITS in missing + else "" + ) + + "See `plan/protocol.md`" + ) def read_corpus(target: Path, imitation: bool = False, label: Label | None = None) -> Corpus: @@ -851,7 +1161,9 @@ def read_corpus(target: Path, imitation: bool = False, label: Label | None = Non # `(tag, seat)` to the per-round one-sided payoffs, computed once per seat # rather than once per decision: a match has hundreds of decisions and one # round log. - steps: dict[tuple[str, str], dict[int, float] | None] = {} + steps: dict[tuple, dict[int, float] | None] = {} + # One tier check per match, not one per decision. + gaps: dict[str, str | None] = {} for log in sorted(logs): tag = _corpus_tag_of(log) corpus.matches.add(tag) @@ -869,21 +1181,42 @@ def read_corpus(target: Path, imitation: bool = False, label: Label | None = Non owner = record.get("owner") if isinstance(owner, int) and owner not in seats[tag]: corpus.unresolved_owners.add(owner) - corpus.decisions.append(decision) corpus.local_features.update(decision.local) result = results[tag] if result is None: + corpus.decisions.append(decision) if tag not in corpus.unlabelled: corpus.unlabelled.append(tag) continue + if tag not in gaps: + gaps[tag] = tier_gap(result, spec) + if gaps[tag] is not None: + raise TrainingError( + f"--tier {spec.tier} was asked for and {tag} cannot carry it: {gaps[tag]}." + ) + scope = scope_of(result, decision.seat, decision.unit, spec.tier) + if spec.tier != TIER_TEAM and scope is None: + # A force tier over a unit the round log gives no force. The row + # is kept out rather than credited to a lance it may not be in. + corpus.unscoped += 1 + corpus.decisions.append(decision) + continue + decision = replace(decision, scope=scope) + corpus.decisions.append(decision) # Per round where the result carries one, per match where it does # not. A corpus recorded before `rounds` existed still fits, with # the weaker label and a line in the report saying so. - local_label = _round_label(result, decision.seat, decision.round, spec, steps, tag) + local_label = _round_label( + result, decision.seat, decision.round, spec, steps, tag, scope + ) if local_label is not None: - corpus.labels[(tag, decision.seat, decision.round)] = local_label + corpus.labels[(tag, decision.seat, scope, decision.round)] = local_label corpus.label_kinds.add(spec.kind) continue + if spec.tier != TIER_TEAM: + # No falling back to the team's number under a sub-team tier. + corpus.unscoped += 1 + continue key = (tag, decision.seat) if key in corpus.labels: continue @@ -1076,6 +1409,7 @@ class Fit: rows: int labelled_seats: int unlabelled_matches: list[str] + unscoped_decisions: int r_squared: float label_mean: float label_kind: str @@ -1100,6 +1434,7 @@ class Fit: "label": self.label_kind, "gamma": self.label.gamma, "credit": self.label.credit, + "tier": self.label.tier, "framing": self.framing, "ridge": self.ridge, "matches": self.matches, @@ -1294,6 +1629,7 @@ def fit( rows=len(design), labelled_seats=len(corpus.labels), unlabelled_matches=list(corpus.unlabelled), + unscoped_decisions=corpus.unscoped, r_squared=r_squared, label_mean=sum(labels) / len(labels) if labels else 0.0, label_kind="+".join(sorted(corpus.label_kinds)) or LABEL_KIND, @@ -1356,6 +1692,12 @@ def report(result: Fit) -> str: "argmax, the log does not describe the policy that produced it, and " "nothing fitted from it means what it says." ) + if result.unscoped_decisions: + lines.append( + f"{result.unscoped_decisions} decisions had no `{result.label.tier}` the round " + "log could name and were left out. They are not fitted against the team's " + "number: a tier that silently widens is worse than one that refuses." + ) if result.unlabelled_matches: lines.append( f"{len(result.unlabelled_matches)} matches had no usable result and " -- 2.51.2