copper-mind / app /tft_explain.py
ifieryarrows's picture
Sync from GitHub (tests passed)
beacc9c verified
Raw History Blame Contribute Delete
17.3 kB
"""Build the per-forecast TFT-ASRO explanation payload stored as ``explain_json``.
The payload enriches the raw interpretation produced by
:func:`deep_learning.models.tft_copper.interpret_batch` (VSN variable-selection
weights plus encoder attention) with human-readable feature labels, five-session
recent deltas and window dates. Everything returned is plain JSON data
(floats/strings/``None``), safe for SQLAlchemy ``JSON`` columns and for the
``/api/explain/{symbol}`` endpoint.
Building is best-effort: hot-path callers must use
:func:`try_build_explain_payload`, which never raises. An explanation is
optional and must never fail a pipeline run.
"""
import hashlib
import logging
import re
from datetime import date, datetime, timezone
from typing import Any, Optional, Sequence
import pandas as pd
from app.features import describe_feature
from app.price_utils import finite_number
logger = logging.getLogger(__name__)
EXPLAIN_SCHEMA_VERSION = 1
TOP_RECENT = 5
RECENT_BASIS_SESSIONS = 5
# Hard cap on serialized encoder entries. A full VSN list (mrmr_top_k=80 real
# features with label+description+category metadata) serializes to roughly
# 20-40 KB per snapshot row per day — stored in Postgres, returned by every
# /api/explain poll, and injected into the DOM via the "Raw JSON" details.
# The panel renders only the top five rows, so 25 caps the stored artifact
# while keeping meaningful context.
TOP_ENCODER = 25
_HORIZON_LABEL = "5-day cumulative log return (primary)"
_HORIZON_DAYS = 5
_TOP_DAYS = 5
# Features whose five-session change reads naturally as a percentage.
_LEVEL_TOKENS = frozenset(
{
"price",
"sma",
"ema",
"close",
"open",
"high",
"low",
"stock",
"tonnes",
"inventory",
"warehouse",
"volume",
}
)
_NOTES = (
"Model focus only: variable-selection (VSN) weights and attention weights show where the "
"TFT-ASRO model looked inside the latest encoder window. They are raw, pre-calibration "
"signals and are not causal: do not read them as market drivers or investment advice."
)
def _json_date(value: Any) -> Optional[str]:
"""Normalize a date-like value to an ISO string; other values pass through ``str``."""
if value is None:
return None
if isinstance(value, date):
return value.isoformat()
return str(value)
def _json_text(value: Any) -> Optional[str]:
"""Normalize an identifier value to ``str`` (JSON-safe)."""
if value is None:
return None
return str(value)
def _sanitize_weight(value: Any) -> Optional[float]:
"""Return ``value`` as a finite float rounded to 6 dp, or ``None`` when unusable."""
weight = finite_number(value)
if weight is None:
return None
return round(weight, 6)
def _layout_hash(feature_names: Sequence[str]) -> str:
"""12-char sha1 fingerprint of a model group's feature layout."""
joined = "\n".join(str(name) for name in feature_names)
return hashlib.sha1(joined.encode("utf-8")).hexdigest()[:12]
def _recent_kind(feature: str, category: str) -> Optional[str]:
"""Return ``"pct"``/``"abs"`` for a recent-delta block, or ``None`` when none applies.
Embedding features have no meaningful signed delta; level-like features
(prices, averages, physical stock levels, volume) read best as percentage
changes; everything else uses absolute changes.
"""
lowered = (feature or "").lower()
if category == "Embedding" or "emb_" in lowered:
return None
tokens = re.split(r"[^a-z0-9]+", lowered)
if any(token in _LEVEL_TOKENS for token in tokens):
return "pct"
return "abs"
def _recent_block(
feature: str,
category: str,
feature_frame: pd.DataFrame,
window_dates: Sequence[Optional[str]],
) -> Optional[dict]:
"""Five-session delta block for an encoder feature, or ``None`` when it cannot be built.
Compares the first and last finite values within the tail ``RECENT_BASIS_SESSIONS``
rows of ``feature_frame``; the block is skipped for embedding features, for
features missing from the frame, and whenever fewer than two finite values are
available. ``change`` is always the absolute first→last delta; ``change_pct``
holds the percentage delta for ``pct`` features and is ``None`` otherwise.
"""
kind = _recent_kind(feature, category)
if kind is None or not feature or feature not in feature_frame.columns:
return None
tail = feature_frame[feature].tail(RECENT_BASIS_SESSIONS)
offset = len(feature_frame) - len(tail)
finite_values: list[tuple[int, float]] = []
for position, raw in enumerate(tail.tolist()):
value = finite_number(raw)
if value is not None:
finite_values.append((offset + position, value))
if len(finite_values) < 2:
return None
last_position, last_value = finite_values[-1]
first_position, first_value = finite_values[0]
change = finite_number(last_value - first_value)
if change is None:
return None
change_pct: Optional[float] = None
if kind == "pct" and first_value != 0.0:
raw_pct = finite_number(change / abs(first_value) * 100.0)
if raw_pct is None:
return None
change_pct = round(raw_pct, 6)
# Label the actual observed span, not the nominal window: when the tail rows
# contain missing values the comparison may span fewer sessions, and the UI
# prints this label verbatim.
basis = f"{last_position - first_position + 1} sessions"
return {
"basis": basis,
"kind": kind,
"last_value": round(last_value, 6),
"change": round(change, 6),
"change_pct": change_pct,
"as_of": window_dates[last_position],
}
def _entry(feature: str, weight: float) -> dict:
"""One variable-selection entry enriched with ``describe_feature`` metadata."""
description = describe_feature(feature) or {}
return {
"feature": feature,
"weight": weight,
"label": description.get("label", feature),
"description": description.get("description", ""),
"category": description.get("category", "Other"),
"time_horizon": description.get("time_horizon", ""),
}
def _group_entries(group: Sequence[dict]) -> list[dict]:
"""Convert one interpretation group, dropping non-finite weights."""
entries: list[dict] = []
for item in group:
feature = _json_text(item.get("feature")) or ""
weight = _sanitize_weight(item.get("weight"))
if weight is None:
continue
entries.append(_entry(feature, weight))
return entries
def _layout_names(group: Sequence[dict]) -> list[str]:
"""Feature names of one interpretation group, in model order."""
return [_json_text(item.get("feature")) or "" for item in group]
def build_explain_payload(
*,
interpretation: dict,
window_dates: Sequence[Any],
feature_frame: pd.DataFrame,
reference_price_date: Any,
symbol: str,
checkpoint: Any,
trained_at: Any = None,
run_id: Any = None,
generated_at: Any = None,
attention_pred_horizon: int = 0,
) -> dict:
"""Assemble the ``explain_json`` payload for one TFT-ASRO forecast.
``interpretation`` is an :func:`interpret_batch` result; ``window_dates`` must
carry one date label per attention value and ``feature_frame`` must have the
same number of rows (positional alignment). Encoder entries are sorted by
descending weight and capped at ``TOP_ENCODER``; decoder/static entries keep
model order. Only the top ``TOP_RECENT`` encoder entries receive a
five-session ``recent`` block.
Raises ``ValueError`` on length mismatches; any other malformed input raises
the underlying error. Callers on the prediction path should prefer
:func:`try_build_explain_payload`.
"""
attention_raw = list(interpretation["attention"])
dates = [_json_date(value) for value in window_dates]
if len(dates) != len(attention_raw):
raise ValueError(
f"window_dates length ({len(dates)}) must match "
f"attention length ({len(attention_raw)})"
)
if len(feature_frame) != len(dates):
raise ValueError(
f"feature_frame length ({len(feature_frame)}) must match "
f"window_dates length ({len(dates)})"
)
encoder_entries = _group_entries(interpretation["encoder"])
encoder_entries.sort(key=lambda entry: entry["weight"], reverse=True)
for entry in encoder_entries[:TOP_RECENT]:
recent = _recent_block(entry["feature"], entry["category"], feature_frame, dates)
if recent is not None:
entry["recent"] = recent
# Cap the emitted list only after the recent blocks are attached: they belong
# to the top five, which always survives the cap.
encoder_entries = encoder_entries[:TOP_ENCODER]
decoder_entries = _group_entries(interpretation["decoder"])
static_entries = _group_entries(interpretation["static"])
series = []
for date_value, raw_weight in zip(dates, attention_raw):
weight = _sanitize_weight(raw_weight)
series.append({"date": date_value, "weight": 0.0 if weight is None else weight})
top_days = [
{"date": entry["date"], "weight": entry["weight"]}
for entry in sorted(series, key=lambda entry: entry["weight"], reverse=True)[:_TOP_DAYS]
]
if generated_at is None:
generated_at = datetime.now(timezone.utc)
return {
"schema_version": EXPLAIN_SCHEMA_VERSION,
"symbol": _json_text(symbol),
"reference_price_date": _json_date(reference_price_date),
"generated_at": _json_date(generated_at),
"run_id": _json_text(run_id),
"model": {
"checkpoint": _json_text(checkpoint),
"trained_at": _json_date(trained_at),
},
"horizon": {"label": _HORIZON_LABEL, "days": _HORIZON_DAYS},
"layout_hash": {
"encoder": _layout_hash(_layout_names(interpretation["encoder"])),
"decoder": _layout_hash(_layout_names(interpretation["decoder"])),
"static": _layout_hash(_layout_names(interpretation["static"])),
},
"variable_selection": {
"method": "vsn",
"encoder": encoder_entries,
"decoder": decoder_entries,
"static": static_entries,
},
"attention": {
"method": "tft_attention",
"step": {
"kind": "first_forecast_day",
"horizon_index": int(attention_pred_horizon),
},
"window": {
"start": dates[0] if dates else None,
"end": dates[-1] if dates else None,
"days": len(dates),
},
"top_days": top_days,
"series": series,
},
"notes": _NOTES,
}
def try_build_explain_payload(**kwargs: Any) -> Optional[dict]:
"""Best-effort wrapper around :func:`build_explain_payload`.
Never raises: any failure is logged as a warning and yields ``None`` so an
explanation can never fail the prediction pipeline.
"""
try:
return build_explain_payload(**kwargs)
except Exception as exc:
logger.warning("TFT explain payload build failed; skipping explanation: %s", exc)
return None
# ---------------------------------------------------------------------------
# Commentary digest
# ---------------------------------------------------------------------------
# Prompt-budget caps for :func:`summarize_explain_for_commentary`: the top five
# VSN encoder signals (reusing ``TOP_RECENT``, the same five entries that carry
# recent deltas) and the top three attention memory anchors.
TOP_COMMENTARY_SIGNALS = TOP_RECENT
TOP_COMMENTARY_ANCHORS = 3
def _commentary_weight_pct(value: Any) -> Optional[float]:
"""A finite model weight as a percentage rounded to one decimal, else ``None``."""
weight = finite_number(value)
if weight is None:
return None
return round(weight * 100.0, 1)
def _commentary_recent_summary(recent: Any) -> Optional[str]:
"""Human-readable recent delta (``"+2.10% over 5 sessions"``) or ``None``."""
if not isinstance(recent, dict):
return None
basis = recent.get("basis")
basis = str(basis).strip() if basis is not None else ""
if not basis:
return None
if recent.get("kind") == "pct":
change_pct = finite_number(recent.get("change_pct"))
if change_pct is None:
return None
return f"{change_pct:+.2f}% over {basis}"
if recent.get("kind") == "abs":
change = finite_number(recent.get("change"))
if change is None:
return None
return f"{change:+.3f} over {basis}"
return None
def _commentary_encoder_signals(encoder: Any) -> list[dict]:
"""Top encoder VSN signals reduced to prompt-safe primitives."""
if not isinstance(encoder, (list, tuple)):
return []
signals: list[dict] = []
for item in encoder:
if not isinstance(item, dict):
continue
feature = item.get("feature")
feature = "" if feature is None else str(feature)
if not feature:
continue
weight_pct = _commentary_weight_pct(item.get("weight"))
if weight_pct is None:
continue
label = item.get("label")
category = item.get("category")
signals.append(
{
"feature": feature,
"label": str(label) if label else feature,
"category": str(category) if category else "Other",
"weight_pct": weight_pct,
"recent_summary": _commentary_recent_summary(item.get("recent")),
}
)
if len(signals) >= TOP_COMMENTARY_SIGNALS:
break
return signals
def _commentary_memory_anchors(top_days: Any) -> list[dict]:
"""Top attention anchor dates reduced to ``{date, weight_pct}``."""
if not isinstance(top_days, (list, tuple)):
return []
anchors: list[dict] = []
for item in top_days:
if not isinstance(item, dict):
continue
date_value = item.get("date")
weight_pct = _commentary_weight_pct(item.get("weight"))
if date_value is None or weight_pct is None:
continue
anchors.append({"date": str(date_value), "weight_pct": weight_pct})
if len(anchors) >= TOP_COMMENTARY_ANCHORS:
break
return anchors
def _commentary_window_span(window: Any) -> Optional[dict]:
"""``{start, end, days}`` encoder-window span, or ``None`` when unavailable."""
if not isinstance(window, dict):
return None
days = window.get("days")
if isinstance(days, bool) or not isinstance(days, (int, float)):
days = None
else:
days = int(days)
start = window.get("start")
end = window.get("end")
return {
"start": None if start is None else str(start),
"end": None if end is None else str(end),
"days": days,
}
def summarize_explain_for_commentary(explain_payload: Optional[dict]) -> Optional[dict]:
"""Compact, prompt-safe digest of an ``explain_json`` payload for commentary.
Reduces a :func:`build_explain_payload` result to what the commentary prompt
and the UI companion surface need: the horizon label, the reference price
date, the top five VSN encoder signals (weight percentage plus recent
five-session delta), the top three attention memory anchors, and the encoder
window span.
Fail-open by contract: a non-dict payload yields ``None`` and malformed
sub-structures degrade to empty fields, never an exception, so commentary
generation can always proceed without an explanation.
"""
if not isinstance(explain_payload, dict):
return None
try:
horizon_label = _HORIZON_LABEL
horizon = explain_payload.get("horizon")
if isinstance(horizon, dict):
label = horizon.get("label")
if isinstance(label, str) and label.strip():
horizon_label = label.strip()
variable_selection = explain_payload.get("variable_selection")
encoder = (
variable_selection.get("encoder")
if isinstance(variable_selection, dict)
else None
)
attention = explain_payload.get("attention")
top_days = attention.get("top_days") if isinstance(attention, dict) else None
window = attention.get("window") if isinstance(attention, dict) else None
return {
"horizon_label": horizon_label,
"reference_price_date": _json_date(explain_payload.get("reference_price_date")),
"top_encoder_signals": _commentary_encoder_signals(encoder),
"memory_anchors": _commentary_memory_anchors(top_days),
"window_span": _commentary_window_span(window),
}
except Exception as exc: # fail-open: an explanation is never mandatory
logger.warning("TFT explain commentary summary failed; skipping XAI context: %s", exc)
return None