CoolFace
Modelpublic

criptic1/ctrader-ml-systems

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes
calibrated_uncertainty.py463 linesDownload Raw Back to uncertainty
1"""2Calibrated uncertainty quantification pipeline for the Aurea allocator.3 4Produces σ̂ (the uncertainty penalty term) from three sources:51. Ensemble disagreement across TimesFM, Chronos-2, Kronos, LightGBM/XGBoost62. Probabilistic model outputs (Chronos-2 calibrated quantiles, Kronos MC samples)73. Conformal prediction intervals (SFOCP: scale-free online conformal prediction)8 9The combined σ̂ feeds directly into the allocator's scoring:10    net_utility = edge - κ_u * σ̂ - friction11 12References:13- arxiv:2302.07869 — SAOCP / Scale-Free Online Conformal Prediction14- arxiv:2212.03463 — SPCI (Sequential Predictive Conformal Inference)15- arxiv:2508.02686 — MoE volatility-sensitive routing16"""17 18import logging19from typing import Optional, Dict, List, Tuple20import numpy as np21from dataclasses import dataclass, field22 23from ..utils.types import ForecastResult, UncertaintyEstimate24 25logger = logging.getLogger(__name__)26 27 28class ScaleFreeOnlineConformal:29    """30    Adaptive Online Conformal Prediction (ACI variant).31    32    Inspired by arxiv:2302.07869 (SAOCP) but uses the practical ACI33    algorithm from Gibbs & Candes (2021) which has better finite-sample34    convergence. The key idea:35    36        α̂_{t+1} = α̂_t + γ * (α - 𝟙[y_t ∉ C_t(x_t)])37        38    where α̂_t is the adaptive miscoverage level and C_t is the prediction39    set at the α̂_t quantile of recent residuals. This drives empirical40    coverage toward (1-α) without the gradient-magnitude asymmetry of41    raw pinball-loss OGD.42    43    For the radius-based formulation:44        s_hat tracks the (1-α̂_t) quantile of a rolling window of residuals,45        with α̂ adapted online to match the target coverage.46    """47 48    def __init__(self, alpha: float = 0.1, eta: float = 0.05, window: int = 200):49        """50        Args:51            alpha: miscoverage rate (0.1 = 90% coverage target)52            eta: step size for α̂ adaptation (0.01-0.1 typical)53            window: rolling window for residual quantile estimation54        """55        self.alpha = alpha56        self.eta = eta57        self.window = window58        self.s_hat = 0.0               # predicted radius59        self.alpha_hat = alpha          # adaptive miscoverage level60        self.n_updates = 061        self._residuals: List[float] = []62        self._coverage_history: List[bool] = []63        self._width_history: List[float] = []64 65    def predict_interval(self, y_hat: float) -> Tuple[float, float]:66        """Return (lower, upper) prediction interval centered on y_hat."""67        return y_hat - self.s_hat, y_hat + self.s_hat68 69    def predict_interval_array(self, y_hat: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:70        """Return (lower, upper) arrays for a forecast vector."""71        return y_hat - self.s_hat, y_hat + self.s_hat72 73    def update(self, y_true: float, y_hat: float):74        """75        Online update after observing true value.76        77        Two-step process:78        1. Record residual and whether current interval covered y_true79        2. Adapt α̂ and recompute s_hat from residual quantile80        """81        S_t = abs(y_true - y_hat)  # absolute residual82 83        # Track coverage BEFORE update84        covered = S_t <= self.s_hat85        self._coverage_history.append(covered)86        self._width_history.append(2 * self.s_hat)87 88        # Store residual89        self._residuals.append(S_t)90        if len(self._residuals) > self.window:91            self._residuals = self._residuals[-self.window:]92 93        # ACI update (Gibbs & Candes 2021):94        # α̂_{t+1} = α̂_t + γ * (α - miss_t)95        # miss_t = 1 if y_t NOT covered, 0 if covered96        # When missing too often: α̂ decreases → quantile level (1-α̂) increases → wider interval97        # When covering too much: α̂ increases → quantile level (1-α̂) decreases → narrower interval98        miss_t = 0.0 if covered else 1.099        self.alpha_hat = self.alpha_hat + self.eta * (self.alpha - miss_t)100        self.alpha_hat = np.clip(self.alpha_hat, 0.001, 0.999)101 102        # Recompute s_hat as (1 - α̂) quantile of recent residuals103        if len(self._residuals) >= 5:104            self.s_hat = float(np.quantile(105                self._residuals, 1 - self.alpha_hat106            ))107        else:108            # Warmup: use the max residual seen so far109            self.s_hat = max(self._residuals) if self._residuals else 0.0110 111        self.n_updates += 1112 113    def batch_update(self, y_true_arr: np.ndarray, y_hat_arr: np.ndarray):114        """Update with a batch of observations."""115        for yt, yh in zip(y_true_arr, y_hat_arr):116            self.update(float(yt), float(yh))117 118    @property119    def empirical_coverage(self) -> float:120        """Empirical coverage rate over update history."""121        if not self._coverage_history:122            return 1.0 - self.alpha123        return float(np.mean(self._coverage_history[-100:]))124 125    @property126    def avg_width(self) -> float:127        """Average interval width over recent history."""128        if not self._width_history:129            return 0.0130        return float(np.mean(self._width_history[-100:]))131 132    @property133    def calibration_error(self) -> float:134        """|empirical_coverage - (1-alpha)| — 0 is perfect."""135        return abs(self.empirical_coverage - (1 - self.alpha))136 137 138class EnsembleDisagreement:139    """140    Compute uncertainty from disagreement across multiple forecast models.141    142    Uses normalized standard deviation of forecasts, weighted by143    recent model performance (inverse MSE weighting).144    """145 146    def __init__(self, normalize: bool = True):147        self.normalize = normalize148 149    def compute(150        self,151        forecasts: Dict[str, np.ndarray],152        weights: Optional[Dict[str, float]] = None,153    ) -> Tuple[float, np.ndarray]:154        """155        Compute ensemble disagreement.156        157        Args:158            forecasts: model_name -> point_forecast array159            weights: model_name -> weight (optional, uniform if None)160            161        Returns:162            (scalar_disagreement, per_step_disagreement)163        """164        if len(forecasts) < 2:165            return 0.0, np.zeros(1)166 167        # Align lengths168        min_len = min(len(f) for f in forecasts.values())169        aligned = {k: v[:min_len] for k, v in forecasts.items()}170 171        if weights is None:172            weights = {k: 1.0 / len(forecasts) for k in forecasts}173 174        # Normalize weights175        total_w = sum(weights.get(k, 1.0) for k in aligned)176        norm_weights = {k: weights.get(k, 1.0) / total_w for k in aligned}177 178        # Weighted mean179        stacked = np.array(list(aligned.values()))180        w_arr = np.array([norm_weights[k] for k in aligned])181        weighted_mean = np.average(stacked, axis=0, weights=w_arr)182 183        # Weighted std (disagreement)184        sq_diffs = w_arr[:, None] * (stacked - weighted_mean[None, :]) ** 2185        per_step_var = np.sum(sq_diffs, axis=0)186        per_step_std = np.sqrt(per_step_var)187 188        if self.normalize and np.mean(np.abs(weighted_mean)) > 1e-10:189            # Coefficient of variation (normalized by level)190            per_step_std = per_step_std / (np.abs(weighted_mean) + 1e-10)191 192        scalar = float(np.mean(per_step_std))193        return scalar, per_step_std194 195 196class QuantileCalibrator:197    """198    Online calibration of model-produced quantile forecasts.199    200    Tracks empirical coverage for each quantile level and adjusts201    the prediction intervals via isotonic regression / histogram binning.202    """203 204    def __init__(self, quantile_levels: List[float] = None):205        self.quantile_levels = quantile_levels or [0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95]206        self._hits: Dict[float, List[bool]] = {q: [] for q in self.quantile_levels}207        self._adjustments: Dict[float, float] = {q: 0.0 for q in self.quantile_levels}208 209    def update(self, y_true: float, quantile_forecasts: Dict[float, float]):210        """211        Update calibration with observed value.212        213        Args:214            y_true: realized value215            quantile_forecasts: q -> forecast_at_q216        """217        for q in self.quantile_levels:218            if q in quantile_forecasts:219                hit = y_true <= quantile_forecasts[q]220                self._hits[q].append(hit)221                # Keep rolling window222                if len(self._hits[q]) > 200:223                    self._hits[q] = self._hits[q][-200:]224 225                # Compute adjustment (simple additive correction)226                empirical_q = np.mean(self._hits[q])227                self._adjustments[q] = q - empirical_q  # positive = need wider228 229    def adjust_quantiles(230        self, quantile_forecasts: Dict[float, np.ndarray]231    ) -> Dict[float, np.ndarray]:232        """Apply calibration adjustments to quantile forecasts."""233        adjusted = {}234        for q, forecast in quantile_forecasts.items():235            if q in self._adjustments:236                # Scale adjustment by the median level237                median = quantile_forecasts.get(0.5, forecast)238                scale = np.abs(median) * 0.01 + 1e-6239                adjusted[q] = forecast + self._adjustments[q] * scale240            else:241                adjusted[q] = forecast242        return adjusted243 244    def get_calibration_scores(self) -> Dict[float, float]:245        """Return calibration error for each quantile level."""246        scores = {}247        for q in self.quantile_levels:248            if self._hits[q]:249                empirical = np.mean(self._hits[q])250                scores[q] = abs(empirical - q)251            else:252                scores[q] = 0.0253        return scores254 255    @property256    def mean_calibration_error(self) -> float:257        """Mean absolute calibration error across all quantile levels."""258        scores = self.get_calibration_scores()259        if not scores:260            return 0.0261        return float(np.mean(list(scores.values())))262 263 264class CalibratedUncertaintyPipeline:265    """266    Full uncertainty quantification pipeline.267    268    Combines:269    1. Ensemble disagreement (across TimesFM/Chronos-2/Kronos/LightGBM)270    2. Probabilistic model quantiles (Chronos-2, Kronos MC)271    3. Conformal prediction (SFOCP for online adaptation)272    4. Quantile calibration (online isotonic correction)273    274    Produces a single σ̂ per symbol for the Aurea allocator, plus275    detailed prediction intervals for risk management.276    """277 278    def __init__(279        self,280        alpha: float = 0.1,281        ensemble_weight: float = 0.3,282        quantile_weight: float = 0.35,283        conformal_weight: float = 0.35,284    ):285        """286        Args:287            alpha: miscoverage rate for prediction intervals288            ensemble_weight: weight for ensemble disagreement in σ̂289            quantile_weight: weight for model quantile width in σ̂290            conformal_weight: weight for conformal interval width in σ̂291        """292        self.alpha = alpha293        self.w_ensemble = ensemble_weight294        self.w_quantile = quantile_weight295        self.w_conformal = conformal_weight296 297        # Per-symbol conformal predictors298        self._conformal: Dict[str, ScaleFreeOnlineConformal] = {}299        self._calibrator = QuantileCalibrator()300        self._disagreement = EnsembleDisagreement(normalize=True)301 302    def _get_conformal(self, symbol: str) -> ScaleFreeOnlineConformal:303        """Get or create conformal predictor for a symbol."""304        if symbol not in self._conformal:305            self._conformal[symbol] = ScaleFreeOnlineConformal(306                alpha=self.alpha, eta=1.0307            )308        return self._conformal[symbol]309 310    def compute_uncertainty(311        self,312        symbol: str,313        primary_forecast: ForecastResult,314        ensemble_forecasts: Optional[List[ForecastResult]] = None,315        model_weights: Optional[Dict[str, float]] = None,316    ) -> UncertaintyEstimate:317        """318        Compute calibrated uncertainty estimate for a symbol.319        320        This is the main entry point. Call once per symbol per cycle.321        322        Args:323            symbol: ticker324            primary_forecast: the forecast from the regime-gated model325            ensemble_forecasts: forecasts from all models (for disagreement)326            model_weights: adaptive weights from performance tracking327            328        Returns:329            UncertaintyEstimate with σ̂ and prediction intervals330        """331        components = {}332        horizon = primary_forecast.horizon333 334        # ─── 1. Ensemble disagreement ──────────────────────────────335        ensemble_disagreement = 0.0336        if ensemble_forecasts and len(ensemble_forecasts) > 0:337            forecasts_dict = {338                primary_forecast.model.value: primary_forecast.point_forecast339            }340            for ef in ensemble_forecasts:341                forecasts_dict[ef.model.value] = ef.point_forecast342 343            ensemble_disagreement, per_step_disagree = self._disagreement.compute(344                forecasts_dict, model_weights345            )346        components["ensemble_disagreement"] = ensemble_disagreement347 348        # ─── 2. Model quantile width ──────────────────────────────349        quantile_width = 0.0350        if primary_forecast.quantile_forecasts:351            # Apply calibration adjustment352            adjusted_q = self._calibrator.adjust_quantiles(353                primary_forecast.quantile_forecasts354            )355 356            # Compute interval width at the target alpha357            lo_key = min(adjusted_q.keys(), key=lambda q: abs(q - self.alpha / 2))358            hi_key = min(adjusted_q.keys(), key=lambda q: abs(q - (1 - self.alpha / 2)))359 360            lo_arr = adjusted_q[lo_key]361            hi_arr = adjusted_q[hi_key]362 363            width_arr = hi_arr - lo_arr364            quantile_width = float(np.mean(np.abs(width_arr)))365 366            # Normalize by price level367            price_level = np.abs(np.mean(primary_forecast.point_forecast))368            if price_level > 1e-10:369                quantile_width = quantile_width / price_level370 371        components["quantile_width"] = quantile_width372 373        # ─── 3. Conformal prediction interval ─────────────────────374        conformal = self._get_conformal(symbol)375        conformal_width = conformal.avg_width376 377        # Normalize by price level378        price_level = np.abs(np.mean(primary_forecast.point_forecast))379        if price_level > 1e-10:380            conformal_width_norm = conformal_width / price_level381        else:382            conformal_width_norm = conformal_width383 384        components["conformal_width"] = conformal_width_norm385        components["conformal_coverage"] = conformal.empirical_coverage386        components["conformal_calibration_error"] = conformal.calibration_error387 388        # ─── 4. Combined σ̂ ──────────────────────────────────────389        sigma_hat = (390            self.w_ensemble * ensemble_disagreement391            + self.w_quantile * quantile_width392            + self.w_conformal * conformal_width_norm393        )394 395        # ─── 5. Build prediction intervals ────────────────────────396        prediction_intervals = {}397        for test_alpha in [0.05, 0.1, 0.2]:398            lo, hi = conformal.predict_interval_array(primary_forecast.point_forecast)399            # Scale by alpha ratio400            scale = (self.alpha / test_alpha) if test_alpha < self.alpha else 1.0401            center = primary_forecast.point_forecast402            prediction_intervals[test_alpha] = (403                center - (center - lo) * scale,404                center + (hi - center) * scale,405            )406 407        # ─── 6. Overall calibration score ─────────────────────────408        calibration_score = (409            conformal.calibration_error * 0.5410            + self._calibrator.mean_calibration_error * 0.5411        )412 413        return UncertaintyEstimate(414            symbol=symbol,415            sigma_hat=float(sigma_hat),416            prediction_intervals=prediction_intervals,417            ensemble_disagreement=float(ensemble_disagreement),418            conformal_width=float(conformal_width),419            calibration_score=float(calibration_score),420            components=components,421        )422 423    def update_with_realized(424        self,425        symbol: str,426        y_true: float,427        y_predicted: float,428        quantile_forecasts: Optional[Dict[float, float]] = None,429    ):430        """431        Update all calibration components with realized values.432        Call this after each cycle when actual prices are known.433        434        Args:435            symbol: ticker436            y_true: realized price437            y_predicted: point forecast that was made438            quantile_forecasts: quantile forecasts that were made (for calibrator)439        """440        # Update conformal predictor441        conformal = self._get_conformal(symbol)442        conformal.update(y_true, y_predicted)443 444        # Update quantile calibrator445        if quantile_forecasts:446            self._calibrator.update(y_true, quantile_forecasts)447 448    def get_diagnostics(self) -> Dict:449        """Return diagnostic information for monitoring."""450        diag = {451            "n_symbols_tracked": len(self._conformal),452            "quantile_calibration": self._calibrator.get_calibration_scores(),453            "mean_calibration_error": self._calibrator.mean_calibration_error,454        }455        for symbol, conf in self._conformal.items():456            diag[f"conformal_{symbol}"] = {457                "coverage": conf.empirical_coverage,458                "avg_width": conf.avg_width,459                "calibration_error": conf.calibration_error,460                "n_updates": conf.n_updates,461            }462        return diag463