"""Taxonomies ``nsb-v1`` and ``nsb-v2``: heads, gating rules, loss masks and the gated output (pure Python). Spec: docs/sentiment-labelling.md §2.1-2.2 (options and gates) and docs/classifier-plan.md §4.2 (heads and masks). The bundle's ``taxonomy.json`` (written by ``naver-psy export-training``) holds field and option order; the rules live here, and a ``taxonomy.json`` that states different ones is refused, not silently ignored. Canonical ``taxonomy.json`` (what the export writes and every consumer reads):: {"version": "nsb-v1", "fields": {"relevance": ["market", "politics", "off_topic", "unclear"], ..., "emotion_valence": [...]}, "absence": {"relevance": "off_topic", "target": "none", ...}, "derived": {"emotion_valence": {"from": "emotion_fine", "map": {"euphoria": "positive", ...}}}, "relevant_option": "market", "gated_by_relevance": [...], "commercial_ad_options": [...], "gate_order": [...], "field_weights": {"direction": 2.0}, "class_weights": {}, "macro_f1_exclude": {}} Gates at inference (labelling §2.2, in this order; forbidden options get probability 0, the rest is renormalised): 1. top(relevance) != market -> target, direction, sarcasm, behavior, pnl, emotion_fine, emotion_valence, crowd = absence; 2. top(target) == none -> direction = none; 3./4. sarcasm and commercial do not restrict other heads (commercial is handled by the aggregation weights); 5. emotion_valence leads: emotion_fine is restricted to the options that map to top(emotion_valence). Loss masks (classifier-plan §4.2): gate-decided absences are excluded, so each head is a conditional: target and the mood heads only when relevance = market; direction only when market and target != none. ``nsb-v2`` (labelling §2.5; the nsb-v1 labels mapped at export, ``naver_psy/labelling/taxonomy_v2.py``) has the same fields, absences and gates; ``sarcasm`` is {ironic, joke_meme, none} and ``commercial`` is {commercial, none}. Two training/evaluation settings travel with the taxonomy (version defaults, overridable in ``taxonomy.json`` and on the command line): ``class_weights`` (per-option loss multipliers; nsb-v2: pnl ``breakeven`` 3.0) and ``macro_f1_exclude`` (options left out of the headline macro-F1, still reported per class; nsb-v2: emotion_fine ``mixed``). """ from __future__ import annotations import json from pathlib import Path from typing import Any, Iterable, Mapping VERSION = "nsb-v1" RELEVANT = "market" VALENCE_FIELD = "emotion_valence" FINE_FIELD = "emotion_fine" FINE_TO_VALENCE = { "euphoria": "positive", "optimism": "positive", "fomo": "positive", "relief": "positive", "anxiety": "negative", "fear_panic": "negative", "anger": "negative", "regret": "negative", "resignation": "negative", "contempt_mockery": "negative", "mixed": "mixed", "not_expressed": "none", } # The nine asked fields (labelling §2.1) plus the derived valence, in the order of the §2.3 record example. NSB_V1_FIELDS: dict[str, list[str]] = { "relevance": ["market", "politics", "off_topic", "unclear"], "target": ["this_stock", "other_stock", "sector_theme", "market_overall", "none"], "direction": ["bullish", "bearish", "neutral_range", "mixed", "none"], "sarcasm": ["ironic", "joke_meme", "unclear", "none"], "behavior": ["buy_add", "sell_reduce", "hold_wait", "none"], "pnl": ["profit", "loss", "breakeven", "none"], "emotion_fine": ["euphoria", "optimism", "fomo", "relief", "anxiety", "fear_panic", "anger", "regret", "resignation", "contempt_mockery", "mixed", "not_expressed"], "crowd": ["herding_call", "blame_actor", "crowd_reference", "none"], "commercial": ["leading_room", "pump_promo", "contact_link", "other_ad", "unclear", "none"], "emotion_valence": ["positive", "negative", "mixed", "none"], } NSB_V1_ABSENCE = { "relevance": "off_topic", "target": "none", "direction": "none", "sarcasm": "none", "behavior": "none", "pnl": "none", "emotion_fine": "not_expressed", "crowd": "none", "commercial": "none", "emotion_valence": "none", } GATED_BY_RELEVANCE = ("target", "direction", "sarcasm", "behavior", "pnl", "emotion_fine", "emotion_valence", "crowd") GATE_ORDER = ("relevance", "target", "direction", "sarcasm", "commercial", "emotion_valence", "emotion_fine") COMMERCIAL_AD = ("leading_room", "pump_promo", "contact_link", "other_ad") PRODUCT_FIELDS = ("relevance", "direction", "commercial") FIELD_WEIGHTS = {"direction": 2.0} # nsb-v2: only sarcasm and commercial change (the asked-field lists are owned by naver_psy/labelling/taxonomy_v2.py; # a test checks they agree). Absences are those of nsb-v1. NSB_V2_FIELDS: dict[str, list[str]] = dict(NSB_V1_FIELDS, sarcasm=["ironic", "joke_meme", "none"], commercial=["commercial", "none"]) SPECS: dict[str, dict[str, list[str]]] = {"nsb-v1": NSB_V1_FIELDS, "nsb-v2": NSB_V2_FIELDS} AD_OPTIONS: dict[str, tuple[str, ...]] = {"nsb-v1": COMMERCIAL_AD, "nsb-v2": ("commercial",)} # For consumers that see option names but not the version (rollups, PPI): the names never collide across versions. AD_OPTIONS_ANY = COMMERCIAL_AD + ("commercial",) # Training/evaluation defaults per version (owner-approved for nsb-v2, 2026-10-19). breakeven 3.0 = sqrt of the # mean pnl profit/loss count over the breakeven count in the teacher labels (sqrt(1051/112) = 3.06), rounded down. CLASS_WEIGHTS: dict[str, dict[str, dict[str, float]]] = {"nsb-v1": {}, "nsb-v2": {"pnl": {"breakeven": 3.0}}} MACRO_F1_EXCLUDE: dict[str, dict[str, list[str]]] = {"nsb-v1": {}, "nsb-v2": {"emotion_fine": ["mixed"]}} class TaxonomyError(ValueError): """A taxonomy definition that does not match its version (nsb-v1 or nsb-v2).""" class Taxonomy: """Field and option order of one taxonomy version, its rules, class weights and macro-F1 exclusions.""" def __init__(self, fields: Mapping[str, Iterable[str]], absence: Mapping[str, str] | None = None, version: str = VERSION, class_weights: Mapping[str, Mapping[str, float]] | None = None, macro_f1_exclude: Mapping[str, Iterable[str]] | None = None) -> None: if version not in SPECS: raise TaxonomyError(f"unknown taxonomy version {version!r} (known: {sorted(SPECS)}); " "versions are never mixed") spec = SPECS[version] self.version = version self.fields = {f: list(o) for f, o in fields.items()} self.names = list(self.fields) self.absence = dict(absence or NSB_V1_ABSENCE) if set(self.fields) != set(spec): raise TaxonomyError(f"fields differ from {version}: {sorted(set(self.fields) ^ set(spec))}") for f, options in self.fields.items(): if len(set(options)) != len(options): raise TaxonomyError(f"duplicate option in {f}") if set(options) != set(spec[f]): raise TaxonomyError(f"options of {f} differ from {version}: {sorted(set(options) ^ set(spec[f]))}") if self.absence.get(f) != NSB_V1_ABSENCE[f]: raise TaxonomyError(f"absence option of {f} differs from {version}") cw = CLASS_WEIGHTS[version] if class_weights is None else class_weights self.class_weights = {f: {o: float(w) for o, w in ow.items()} for f, ow in cw.items() if ow} mx = MACRO_F1_EXCLUDE[version] if macro_f1_exclude is None else macro_f1_exclude self.macro_f1_exclude = {f: list(opts) for f, opts in mx.items() if opts} for name, table in (("class_weights", self.class_weights), ("macro_f1_exclude", self.macro_f1_exclude)): for f, opts in table.items(): unknown = sorted(set(opts) - set(self.fields.get(f, ()))) if f not in self.fields or unknown: raise TaxonomyError(f"{name}: unknown field or options {f}: {unknown}") if any(not 0 < w < float("inf") for ow in self.class_weights.values() for w in ow.values()): raise TaxonomyError("class_weights must be positive and finite") def replace(self, *, class_weights: Mapping[str, Mapping[str, float]] | None = None, macro_f1_exclude: Mapping[str, Iterable[str]] | None = None) -> "Taxonomy": """The same taxonomy with other class weights or macro-F1 exclusions (None keeps the current ones).""" return Taxonomy(self.fields, self.absence, self.version, self.class_weights if class_weights is None else class_weights, self.macro_f1_exclude if macro_f1_exclude is None else macro_f1_exclude) @property def commercial_ad(self) -> tuple[str, ...]: """The commercial options that count as advertising (q = 1 - their probability, classifier-plan §7.2).""" return AD_OPTIONS[self.version] # ------------------------------------------------------------------ (de)serialisation @classmethod def default(cls, version: str = VERSION) -> "Taxonomy": if version not in SPECS: raise TaxonomyError(f"unknown taxonomy version {version!r}") return cls(SPECS[version], NSB_V1_ABSENCE, version) @classmethod def load(cls, path: str | Path) -> "Taxonomy": """Read a canonical taxonomy.json (a bundle or run directory is accepted too).""" path = Path(path) if path.is_dir(): path = path / "taxonomy.json" return cls.from_json(json.loads(path.read_text(encoding="utf-8"))) @classmethod def from_json(cls, value: Mapping[str, Any]) -> "Taxonomy": """Canonical form only; ``normalise_source`` handles other shapes (the labelling definition).""" version = value.get("version") derived = (value.get("derived") or {}).get(VALENCE_FIELD, {}) if derived and derived.get("map") != FINE_TO_VALENCE: raise TaxonomyError("taxonomy.json valence map differs from nsb-v1") if value.get("gate_order") not in (None, list(GATE_ORDER)): raise TaxonomyError("taxonomy.json gate order differs from nsb-v1") return cls(value["fields"], value.get("absence"), version, value.get("class_weights"), value.get("macro_f1_exclude")) def to_json(self) -> dict[str, Any]: return { "version": self.version, "fields": self.fields, "absence": self.absence, "derived": {VALENCE_FIELD: {"from": FINE_FIELD, "map": FINE_TO_VALENCE}}, "relevant_option": RELEVANT, "gated_by_relevance": list(GATED_BY_RELEVANCE), "commercial_ad_options": list(self.commercial_ad), "product_fields": list(PRODUCT_FIELDS), "gate_order": list(GATE_ORDER), "field_weights": FIELD_WEIGHTS, "class_weights": self.class_weights, "macro_f1_exclude": self.macro_f1_exclude, } # ------------------------------------------------------------------ shapes def sizes(self) -> list[int]: return [len(self.fields[f]) for f in self.names] def flat_size(self) -> int: return sum(self.sizes()) def weights(self) -> list[float]: return [FIELD_WEIGHTS.get(f, 1.0) for f in self.names] def class_weight_vectors(self) -> list[list[float] | None]: """Per field in head order: the option weights for the cross-entropy (1.0 when unlisted), None if unweighted.""" return [[self.class_weights[f].get(o, 1.0) for o in self.fields[f]] if f in self.class_weights else None for f in self.names] def index(self, field: str, option: str | None) -> int: """Option index, or -1 for a masked target.""" return -1 if option is None else self.fields[field].index(option) def encode(self, targets: Mapping[str, str | None], mask: Iterable[str] = ()) -> list[int]: """Label row for the loss: option index per field, -1 where masked or missing.""" masked = set(mask) return [-1 if f in masked else self.index(f, targets.get(f)) for f in self.names] def flatten(self, logits: Mapping[str, list[float]]) -> list[float]: """Per-field logits -> one flat list in taxonomy order (``post_predictions.logits``).""" out: list[float] = [] for f in self.names: row = logits[f] if len(row) != len(self.fields[f]): raise ValueError(f"{f}: {len(row)} logits for {len(self.fields[f])} options") out.extend(float(x) for x in row) return out def unflatten(self, flat: list[float]) -> dict[str, list[float]]: if len(flat) != self.flat_size(): raise ValueError(f"{len(flat)} logits for {self.flat_size()} options") out, i = {}, 0 for f in self.names: k = len(self.fields[f]) out[f] = [float(x) for x in flat[i:i + k]] i += k return out def normalise_source(value: Mapping[str, Any]) -> Taxonomy: """Build a Taxonomy from the labelling definition (``naver_psy/labelling/taxonomy.json``), whatever its shape. Accepted: ``fields`` as ``{name: [options]}`` or ``[{"name", "options", "absence"?}]``, options as strings or ``{"name"|"value"|"id": ...}`` objects; version under ``version`` or ``taxonomy_version``. The derived ``emotion_valence`` head is appended when the definition lists only the nine asked fields. ``class_weights`` and ``macro_f1_exclude`` are taken when present (else the version defaults). """ version = value.get("version") or value.get("taxonomy_version") or value.get("id") raw = value.get("fields") if "fields" in value else value.get("heads") if raw is None: raise TaxonomyError("taxonomy definition has no fields") def opt(o: Any) -> str: if isinstance(o, str): return o for key in ("name", "value", "id", "option"): if isinstance(o, Mapping) and key in o: return str(o[key]) raise TaxonomyError(f"cannot read option {o!r}") fields: dict[str, list[str]] = {} if isinstance(raw, Mapping): for name, spec in raw.items(): options = spec.get("options") if isinstance(spec, Mapping) else spec fields[name] = [opt(o) for o in options] else: for spec in raw: fields[spec.get("name") or spec.get("field")] = [opt(o) for o in spec["options"]] if VALENCE_FIELD not in fields: fields[VALENCE_FIELD] = list(NSB_V1_FIELDS[VALENCE_FIELD]) derived = value.get("derived") or {} if isinstance(derived, list): # the labelling definition lists derived heads as [{name, from, map, ...}] derived = {d.get("name"): d for d in derived} vmap = value.get("emotion_valence_map") or value.get("valence_map") or (derived.get(VALENCE_FIELD) or {}).get("map") if vmap is not None and dict(vmap) != FINE_TO_VALENCE: raise TaxonomyError("valence map differs from nsb-v1") return Taxonomy(fields, NSB_V1_ABSENCE, version or VERSION, value.get("class_weights"), value.get("macro_f1_exclude")) # ---------------------------------------------------------------------- labels: rules, targets and masks def valence_of(fine: str | None) -> str | None: return None if fine is None else FINE_TO_VALENCE[fine] def violations(labels: Mapping[str, str | None]) -> list[str]: """Hard rules (labelling §2.2 rules 1, 2, 5) a label set breaks; a null value is not checked.""" out = [] rel = labels.get("relevance") if rel is not None and rel != RELEVANT: for f in GATED_BY_RELEVANCE: if labels.get(f) is not None and labels[f] != NSB_V1_ABSENCE[f]: out.append(f"not_market_{f}") if labels.get("target") == "none" and labels.get("direction") not in (None, "none"): out.append("target_none_direction") fine, val = labels.get(FINE_FIELD), labels.get(VALENCE_FIELD) if fine is not None and val is not None and FINE_TO_VALENCE[fine] != val: out.append("fine_valence") return out def gate_fill(labels: Mapping[str, str | None]) -> dict[str, str | None]: """Apply the label-side gates: fields a gate decides are set to their absence; valence is derived.""" t = dict(labels) if t.get("relevance") is not None and t["relevance"] != RELEVANT: for f in GATED_BY_RELEVANCE: t[f] = NSB_V1_ABSENCE[f] if t.get("target") == "none": t["direction"] = "none" if t.get(FINE_FIELD) is not None: t[VALENCE_FIELD] = FINE_TO_VALENCE[t[FINE_FIELD]] return t def loss_mask(labels: Mapping[str, str | None], unresolved: Iterable[str] = ()) -> list[str]: """Fields excluded from the loss: gate-decided absences, plus unresolved (null) fields.""" masked = set(unresolved) rel = labels.get("relevance") if rel is None: masked.update(GATED_BY_RELEVANCE) elif rel != RELEVANT: masked.update(GATED_BY_RELEVANCE) elif labels.get("target") in (None, "none"): masked.add("direction") masked.update(f for f, v in labels.items() if v is None) return sorted(f for f in masked if f in NSB_V1_FIELDS) # ---------------------------------------------------------------------- distributions: gated output def top(dist: Mapping[str, float]) -> str: """Highest-probability option; ties go to the earlier option in taxonomy order.""" best = None for k, v in dist.items(): if best is None or v > dist[best]: best = k if best is None: raise ValueError("empty distribution") return best def _restrict(dist: Mapping[str, float], allowed: Iterable[str]) -> dict[str, float]: allowed = set(allowed) kept = {k: (v if k in allowed else 0.0) for k, v in dist.items()} total = sum(kept.values()) if total <= 0: # every allowed option had probability 0: uniform over the allowed ones return {k: (1.0 / len(allowed) if k in allowed else 0.0) for k in dist} return {k: v / total for k, v in kept.items()} def _only(dist: Mapping[str, float], option: str) -> dict[str, float]: return {k: (1.0 if k == option else 0.0) for k in dist} def gate(probs: Mapping[str, Mapping[str, float]]) -> dict[str, dict[str, float]]: """The labelling §2.2 gates applied in GATE_ORDER to per-field distributions (option order kept).""" out = {f: dict(d) for f, d in probs.items()} if top(out["relevance"]) != RELEVANT: for f in GATED_BY_RELEVANCE: out[f] = _only(out[f], NSB_V1_ABSENCE[f]) return out if top(out["target"]) == "none": out["direction"] = _only(out["direction"], "none") val = top(out[VALENCE_FIELD]) out[FINE_FIELD] = _restrict(out[FINE_FIELD], [o for o, v in FINE_TO_VALENCE.items() if v == val]) return out def argmax(probs: Mapping[str, Mapping[str, float]]) -> dict[str, str]: return {f: top(d) for f, d in probs.items()}