Skip to content

SplitConformalForecaster

yohou.interval.SplitConformalForecaster

Bases: BaseIntervalForecaster

Split conformal forecaster implementation.

Wraps a point forecaster and calibrates prediction intervals using split conformal prediction. A held-out calibration set is used to compute conformity scores whose quantiles define the interval width.

Parameters

Name Type Description Default
point_forecaster BasePointForecaster

Point forecaster used to generate point predictions.

SeasonalNaive()
calibration_size int >= 1

Number of observations to use for calibration.

100
conformity_scorer BaseConformityScorer

Scorer used to compute conformity scores.

Residual()
similarity BaseSimilarity or None

Similarity measure used to weight conformity scores.

None
panel_strategy ('global', multivariate)

How to handle panel data. See BaseForecaster for details.

"global"

Attributes

Name Type Description
fit_coverage_rates_ list of float

Coverage rates used during fit.

Notes

The data is split into a training portion and a calibration portion of size calibration_size. The point forecaster is fit on the training portion, then conformity scores are computed on the calibration portion. At prediction time, interval bounds are derived from the empirical quantiles of these scores.

See Also

Source Code

Source code in src/yohou/interval/split_conformal.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
class SplitConformalForecaster(BaseIntervalForecaster):
    """Split conformal forecaster implementation.

    Wraps a point forecaster and calibrates prediction intervals using
    split conformal prediction.  A held-out calibration set is used to
    compute conformity scores whose quantiles define the interval width.

    Parameters
    ----------
    point_forecaster : BasePointForecaster, default=SeasonalNaive()
        Point forecaster used to generate point predictions.
    calibration_size : int >= 1, default=100
        Number of observations to use for calibration.
    conformity_scorer : BaseConformityScorer, default=Residual()
        Scorer used to compute conformity scores.
    similarity : BaseSimilarity or None, default=None
        Similarity measure used to weight conformity scores.
    panel_strategy : {"global", "multivariate"}, default="global"
        How to handle panel data. See `BaseForecaster` for details.

    Attributes
    ----------
    fit_coverage_rates_ : list of float
        Coverage rates used during fit.

    Notes
    -----
    The data is split into a training portion and a calibration portion
    of size ``calibration_size``.  The point forecaster is fit on the
    training portion, then conformity scores are computed on the
    calibration portion.  At prediction time, interval bounds are
    derived from the empirical quantiles of these scores.

    See Also
    --------
    - [`BaseSimilarity`][yohou.interval.base.BaseSimilarity] : Similarity weighting for adaptive intervals.
    - [`Residual`][yohou.metrics.conformity.Residual] : Default conformity scorer.
    - [`IntervalReductionForecaster`][yohou.interval.reduction.IntervalReductionForecaster] : Alternative interval forecaster.

    """

    _parameter_constraints: dict = {
        **BaseIntervalForecaster._parameter_constraints,
        "point_forecaster": [BasePointForecaster],
        "calibration_size": [Interval(numbers.Integral, 1, None, closed="left")],
        "conformity_scorer": [BaseConformityScorer],
        "similarity": [BaseSimilarity, None],
    }

    def __sklearn_tags__(self) -> Tags:
        """Get estimator tags.

        Returns
        -------
        Tags
            Estimator tags with forecaster_type set to POINT_INTERVAL since this
            forecaster produces both point predictions and intervals.

        """
        tags = super().__sklearn_tags__()
        assert tags.forecaster_tags is not None
        # SplitConformal wraps a point forecaster and adds intervals
        tags.forecaster_tags.forecaster_type = POINT_INTERVAL
        return tags

    def __init__(
        self,
        point_forecaster: BasePointForecaster = SeasonalNaive(),
        calibration_size: StrictInt = 100,
        conformity_scorer: BaseConformityScorer = Residual(),
        similarity: BaseSimilarity | None = None,
        panel_strategy: Literal["global", "multivariate"] = "global",
    ):
        BaseIntervalForecaster.__init__(self, panel_strategy=panel_strategy)

        self.point_forecaster = point_forecaster
        self.conformity_scorer = conformity_scorer
        self.similarity = similarity
        self.calibration_size = calibration_size

    @_fit_context(prefer_skip_nested_validation=True)
    def fit(
        self,
        y: pl.DataFrame,
        X_actual: pl.DataFrame | None = None,
        forecasting_horizon: StrictInt = 1,
        coverage_rates: list[StrictFloat] | None = None,
        X_future: pl.DataFrame | None = None,
        X_forecast: pl.DataFrame | None = None,
        **params,
    ) -> "SplitConformalForecaster":
        """Fit the forecaster to historical data.

        Trains the wrapped point forecaster, calibrates conformity scores
        on a held-out calibration set, and optionally fits similarity
        weights.

        Parameters
        ----------
        y : pl.DataFrame
            Target time series with a ``"time"`` column (datetime) and one
            or more numeric value columns.
        X_actual : pl.DataFrame or None, default=None
            Actual feature observations with a ``"time"`` column aligned
            with ``y``. Passed directly to the wrapped
            ``point_forecaster_``; any feature transformation is the
            responsibility of that inner estimator. If ``None``, only
            target-derived features are used.
        forecasting_horizon : int, default=1
            Number of time steps to forecast into the future.
        coverage_rates : list of float or None, default=None
            Coverage levels for prediction intervals (e.g., ``[0.9, 0.95]``
            for 90 % and 95 % intervals).  If ``None``, defaults to
            ``[0.95]``.
        X_future : pl.DataFrame or None, default=None
            Known future features with a ``"time"`` column. Deterministic
            values available for past and future dates.
        X_forecast : pl.DataFrame or None, default=None
            External forecasts with ``"vintage_time"`` and ``"time"``
            columns. Passed to the wrapped ``point_forecaster_``; any
            feature transformation is the responsibility of that inner
            estimator, reachable as ``point_forecaster__forecast_transformer``.
        **params : dict
            Metadata to route to nested estimators.

        Returns
        -------
        self
            The fitted forecaster instance.

        """
        # Validate data and set interval
        y, X_actual, _ = validate_forecaster_data(self, y, X_actual, reset=True)

        # _pre_fit: set schemas/panel attributes, fit transformers
        # (target_transformer=None, actual_transformer=None → no-ops),
        # and populate observation buffers (observed_time_, _y_observed,
        # _X_t_observed).  Called on the full y before the
        # train/calibration split so base-class state reflects the
        # complete training history.
        self._pre_fit(y, X_actual, forecasting_horizon, X_future=X_future, X_forecast=X_forecast)

        # Validate interval-specific parameters (coverage rates)
        _, self.fit_coverage_rates_ = self._validate_interval_fit_params(self.fit_forecasting_horizon_, coverage_rates)

        # Handle splitting with optional X
        if X_actual is None:
            y_train, y_calib = train_test_split(y, test_size=self.calibration_size, shuffle=False)
            X_actual_train, X_actual_calib = None, None
        else:
            y_train, y_calib, X_actual_train, X_actual_calib = train_test_split(
                y, X_actual, test_size=self.calibration_size, shuffle=False
            )

        self.point_forecaster_ = clone(self.point_forecaster).fit(
            y=y_train,
            X_actual=X_actual_train,
            forecasting_horizon=forecasting_horizon,
            X_future=X_future,
            X_forecast=X_forecast,
        )

        # TODO: Reconsider
        # stride=1: each row of y_calib produces one prediction window of length
        # forecasting_horizon.  This yields calibration_size - step + 1 conformity
        # scores for each horizon step k, instead of the ~2-3 scores that result
        # from stride=forecasting_horizon.  More calibration scores per step gives
        # quantiles that are stable and well-separated.
        y_pred_calib = self.point_forecaster_.observe_predict(
            y=y_calib,
            X_actual=X_actual_calib,
            forecasting_horizon=None,
            stride=1,
            predict_transformed=False,
        )

        conformity_scorers = {}
        conformity_scores_list: list[pl.DataFrame] = []
        similarities = {}

        for step in range(1, 1 + forecasting_horizon):
            y_pred_calib_step = y_pred_calib[step - 1 :: forecasting_horizon]
            y_truth_step = y_calib

            conformity_scorer_step = clone(self.conformity_scorer).fit(y_calib, forecaster=self.point_forecaster_)
            conformity_scores_step = conformity_scorer_step.score(y_truth_step, y_pred_calib_step)

            conformity_scores_step = conformity_scores_step.with_columns(step=step)
            conformity_scores_list.append(conformity_scores_step)

            conformity_scorers[f"step_{step}"] = conformity_scorer_step

            # Fit similarity on the same scored subset to ensure length alignment
            if self.similarity is not None:
                scored_times_df = conformity_scores_step.drop("step").select("time")
                y_pred_for_sim = y_pred_calib_step.drop("vintage_time", strict=False).join(
                    scored_times_df, on="time", how="semi"
                )

                similarity_step = clone(self.similarity)
                similarity_step.fit(y=y_calib, y_pred=y_pred_for_sim)

                similarities[f"step_{step}"] = similarity_step

        conformity_scores = pl.concat(conformity_scores_list)

        self.conformity_scorers_ = conformity_scorers
        self.conformity_scores_ = conformity_scores

        if self.similarity is not None:
            self.similarities_ = similarities
            # Track fit-time counts for correct rewind arithmetic
            self._fit_score_counts_ = {}
            for step in range(1, 1 + forecasting_horizon):
                key = f"step_{step}"
                self._fit_score_counts_[key] = conformity_scores.filter(pl.col("step") == step).height

        return self

    def _observe_conformity(
        self,
        y: pl.DataFrame,
        X_future: pl.DataFrame | None = None,
        X_forecast: pl.DataFrame | None = None,
    ) -> None:
        """Update similarity state and conformity scores for new observations.

        This conformity/similarity update is panel-agnostic: it operates on
        the full-frame ``y`` and delegates to ``point_forecaster_.predict``
        (which handles panel dispatch internally). It is therefore called
        unconditionally by ``observe()`` *before* the panel/standard branch,
        so the panel path does not silently skip it.

        Called *before* the point forecaster absorbs the new data so that
        ``predict()`` still reflects the pre-observe state.

        Parameters
        ----------
        y : pl.DataFrame
            New target observations.
        X_future : pl.DataFrame or None, default=None
            Known future features for the point forecaster's pre-observe
            ``predict`` call. Note that ``observe()`` does not forward its
            own ``X_future`` here, so this argument is ``None`` in practice;
            the caller's ``X_future`` is applied only by the subsequent
            ``point_forecaster_.observe()`` call.
        X_forecast : pl.DataFrame or None, default=None
            External forecasts for the point forecaster's pre-observe
            ``predict`` call. As with ``X_future``, ``observe()`` does not
            forward its own ``X_forecast`` here, so this argument is ``None``
            in practice.

        """
        if not hasattr(self, "similarities_"):
            return

        # Generate predictions *before* the point forecaster is updated
        y_pred = self.point_forecaster_.predict(
            forecasting_horizon=self.fit_forecasting_horizon_,
            X_future=X_future,
            X_forecast=X_forecast,
        )

        for step in range(1, 1 + self.fit_forecasting_horizon_):
            key = f"step_{step}"
            similarity_step = self.similarities_[key]
            conformity_scorer_step = self.conformity_scorers_[key]

            y_pred_step = y_pred[step - 1 :: self.fit_forecasting_horizon_]
            # Drop vintage_time if present (conformity scorer expects time + value cols)
            y_pred_step = y_pred_step.drop("vintage_time", strict=False)

            conformity_scores_step = conformity_scorer_step.score(y, y_pred_step)

            # Observe the similarity on the scored subset, the same alignment ``fit``
            # performs. Scoring aligns y and y_pred by an inner join on time, so it
            # can return fewer rows than it was given, and none at all for a step
            # whose prediction reaches past the observed truth. Observing the full
            # y_pred_step then grew the similarity's state faster than the conformity
            # scores, and predict_interval later paired an N-weight array with fewer
            # than N scores. A step that scored nothing observes nothing, so the two
            # stay in step.
            if conformity_scores_step.height > 0:
                scored_times = conformity_scores_step.select("time")
                y_pred_for_sim = y_pred_step.join(scored_times, on="time", how="semi")
                similarity_step.observe(y=y, y_pred=y_pred_for_sim)

            conformity_scores_step = conformity_scores_step.with_columns(step=step)
            self.conformity_scores_ = pl.concat([self.conformity_scores_, conformity_scores_step])

    def _rewind_conformity(self, y: pl.DataFrame, X_actual: pl.DataFrame | None) -> None:
        """Rewind similarity state and conformity scores.

        Removes post-fit observations from similarity and conformity
        score state.  The number of rows removed equals the number
        added by ``_observe_conformity`` since fit (capped so we never
        remove fit-time data).

        Like ``_observe_conformity``, this is panel-agnostic and is called
        unconditionally by ``rewind()`` before the panel/standard branch, so
        the panel path does not skip the score rollback.

        Parameters
        ----------
        y : pl.DataFrame
            Target observations to rewind (used for row count).
        X_actual : pl.DataFrame or None
            Exogenous features (passed through to similarity rewind).

        """
        if not hasattr(self, "similarities_"):
            return

        n_rewind = len(y)

        for step in range(1, 1 + self.fit_forecasting_horizon_):
            key = f"step_{step}"
            fit_count = self._fit_score_counts_[key]
            step_scores = self.conformity_scores_.filter(pl.col("step") == step)
            n_post_fit = len(step_scores) - fit_count
            n_remove = min(n_rewind, n_post_fit)

            if n_remove > 0:
                # Remove the last n_remove rows for this step
                other_steps = self.conformity_scores_.filter(pl.col("step") != step)
                kept = step_scores.head(len(step_scores) - n_remove)
                self.conformity_scores_ = pl.concat([other_steps, kept])

                # Rewind similarity state
                similarity_step = self.similarities_[key]
                # Build a dummy y/y_pred with correct length for rewind
                y_rewind = y.head(n_remove)
                similarity_step.rewind(y=y_rewind, y_pred=y_rewind, X_actual=X_actual)

    def observe(
        self,
        y: pl.DataFrame,
        X_actual: pl.DataFrame | None = None,
        groups: list[str] | None = None,
        X_future: pl.DataFrame | None = None,
        X_forecast: pl.DataFrame | None = None,
    ) -> "SplitConformalForecaster":
        """Observe new data and update conformity scores and the point forecaster.

        Updates conformity scores and similarity weights with the new
        observations, then advances the wrapped point forecaster's
        observation buffers without refitting.

        Parameters
        ----------
        y : pl.DataFrame
            Target time series with a ``"time"`` column (datetime) and one
            or more numeric value columns.
        X_actual : pl.DataFrame or None, default=None
            Actual feature observations with a ``"time"`` column aligned
            with ``y``. Passed directly to the wrapped
            ``point_forecaster_``; any feature transformation is the
            responsibility of that inner estimator. If ``None``, only
            target-derived features are used.
        groups : list of str or None, default=None
            Panel group prefixes to operate on.  If ``None``, all groups
            are used.  Ignored when the forecaster was not fitted on panel
            data.
        X_future : pl.DataFrame or None, default=None
            Known future features with a ``"time"`` column.
        X_forecast : pl.DataFrame or None, default=None
            External forecasts with ``"vintage_time"`` and ``"time"``
            columns. Passed to the wrapped ``point_forecaster_``; any
            feature transformation is the responsibility of that inner
            estimator, reachable as ``point_forecaster__forecast_transformer``.

        Returns
        -------
        self
            The forecaster with updated observation buffers.

        """
        check_is_fitted(
            self,
            ["point_forecaster_", "local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
        )

        y, X_actual, groups = validate_forecaster_data(self, y, X_actual, reset=False, groups=groups)

        # Update similarity / conformity scores *before* the point forecaster
        # absorbs the new data so we can still call predict() to obtain the
        # prediction-vs-actual residual. This update is panel-agnostic, so it
        # runs unconditionally ahead of the panel/standard branch; routing it
        # through the standard branch alone would silently skip it on panel
        # data.
        self._observe_conformity(y)

        if self.groups_ is None:
            super()._observe_standard(y, X_actual=X_actual)
        else:
            BasePanelForecaster._observe_panel(self, y, X_actual=X_actual, groups=groups)

        self.point_forecaster_.observe(y=y, X_actual=X_actual, groups=groups, X_future=X_future, X_forecast=X_forecast)
        self.observed_time_ = self.point_forecaster_.observed_time_
        return self

    def rewind(
        self,
        y: pl.DataFrame,
        X_actual: pl.DataFrame | None = None,
        groups: list[str] | None = None,
        X_future: pl.DataFrame | None = None,
        X_forecast: pl.DataFrame | None = None,
    ) -> "SplitConformalForecaster":
        """Rewind conformity scores, similarity state, and the point forecaster.

        Removes the most recently observed conformity scores and similarity
        state, then rewinds the wrapped point forecaster's observation
        buffers, mirroring the order used by ``observe()``.

        Parameters
        ----------
        y : pl.DataFrame
            Target time series with a ``"time"`` column (datetime) and one
            or more numeric value columns.
        X_actual : pl.DataFrame or None, default=None
            Actual feature observations to restore the observation
            state to. Must align with ``y``.
        groups : list of str or None, default=None
            Panel group prefixes to operate on.  If ``None``, all groups
            are used.  Ignored when the forecaster was not fitted on panel
            data.
        X_future : pl.DataFrame or None, default=None
            Known future features with a ``"time"`` column.
        X_forecast : pl.DataFrame or None, default=None
            External forecasts with ``"vintage_time"`` and ``"time"``
            columns.

        Returns
        -------
        self
            The forecaster with rewound observation buffers.

        """
        check_is_fitted(
            self,
            ["point_forecaster_", "local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
        )

        y, X_actual, groups = validate_forecaster_data(self, y, X_actual, reset=False, groups=groups)

        # Rewind conformity / similarity state *before* the point forecaster
        # rolls back, mirroring the order used by observe() so both methods
        # maintain the same state invariant. As in observe(), this rollback is
        # panel-agnostic and runs unconditionally ahead of the branch.
        self._rewind_conformity(y, X_actual=X_actual)

        if self.groups_ is None:
            super()._rewind_standard(y, X_actual=X_actual)
        else:
            BasePanelForecaster._rewind_panel(self, y, X_actual=X_actual, groups=groups)

        self.point_forecaster_.rewind(y=y, X_actual=X_actual, groups=groups, X_future=X_future, X_forecast=X_forecast)
        self.observed_time_ = self.point_forecaster_.observed_time_
        return self

    def predict(
        self,
        forecasting_horizon: StrictInt | None = None,
        groups: list[str] | None = None,
        predict_transformed: bool = False,
        X_future: pl.DataFrame | None = None,
        X_forecast: pl.DataFrame | None = None,
        **params,
    ) -> pl.DataFrame:
        """Generate point forecasts.

        Delegates to the wrapped point forecaster.

        Parameters
        ----------
        forecasting_horizon : int or None, default=None
            Number of time steps to forecast into the future.  If ``None``,
            uses the horizon specified at fit time.
        groups : list of str or None, default=None
            Panel group prefixes to operate on.  If ``None``, all groups
            are used.  Ignored when the forecaster was not fitted on panel
            data.
        predict_transformed : bool, default=False
            If ``True``, return predictions in the transformed space without
            applying inverse target transformation.
        X_future : pl.DataFrame or None, default=None
            Known future features override. Re-derives step columns
            without mutating forecaster state.
        X_forecast : pl.DataFrame or None, default=None
            External forecast override with ``"vintage_time"`` and
            ``"time"`` columns. Re-derives step columns without mutating
            forecaster state.
        **params : dict
            Metadata to route to nested estimators.

        Returns
        -------
        pl.DataFrame
            Point predictions with ``"vintage_time"``, ``"time"``, and one
            column per target variable.

        """
        check_is_fitted(
            self,
            ["local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
        )

        _, _, groups = validate_forecaster_data(
            self,
            y=None,
            X_actual=None,
            reset=False,
            groups=groups,
        )

        return self.point_forecaster_.predict(
            forecasting_horizon=forecasting_horizon,
            groups=groups,
            predict_transformed=predict_transformed,
            X_future=X_future,
            X_forecast=X_forecast,
        )

    def observe_predict(
        self,
        y: pl.DataFrame,
        X_actual: pl.DataFrame | None = None,
        forecasting_horizon: StrictInt | None = None,
        groups: list[str] | None = None,
        stride: StrictInt | None = None,
        predict_transformed: bool = False,
        X_future: pl.DataFrame | None = None,
        X_forecast: pl.DataFrame | None = None,
        **params,
    ) -> pl.DataFrame:
        """Alternate recursive observe and predict.

        Produces a rolling sequence of predictions, observing ``stride``
        rows between each. Returns the concatenation of the initial
        prediction and one prediction per stride step.

        Parameters
        ----------
        y : pl.DataFrame
            Target time series with a ``"time"`` column (datetime) and one
            or more numeric value columns.
        X_actual : pl.DataFrame or None, default=None
            Actual feature observations with a ``"time"`` column aligned
            with ``y``. Sliced and observed incrementally at each step
            of the rolling loop.
        forecasting_horizon : int or None, default=None
            Number of time steps to forecast into the future.  If ``None``,
            uses the horizon specified at fit time.
        groups : list of str or None, default=None
            Panel group prefixes to operate on.  If ``None``, all groups
            are used.  Ignored when the forecaster was not fitted on panel
            data.
        stride : int or None, default=None
            Step size for rolling update-predict.  If ``None``, defaults to
            the forecasting horizon used at fit time
            (``fit_forecasting_horizon_``).
        predict_transformed : bool, default=False
            If ``True``, return predictions in the transformed space without
            applying inverse target transformation.
        X_future : pl.DataFrame or None, default=None
            Known future features with a ``"time"`` column.
        X_forecast : pl.DataFrame or None, default=None
            External forecasts with ``"vintage_time"`` and ``"time"``
            columns.
        **params : dict
            Metadata to route to nested estimators.

        Returns
        -------
        pl.DataFrame
            Point predictions with ``"vintage_time"``, ``"time"``, and one
            column per target variable.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If the forecaster has not been fitted yet.
        ValueError
            If ``y`` / ``X`` have invalid structure or ``groups``
            contains names not seen during fit.

        """
        check_is_fitted(
            self,
            ["local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
        )

        y, X_actual, groups = validate_forecaster_data(
            self,
            y=y,
            X_actual=X_actual,
            reset=False,
            groups=groups,
            X_future=X_future,
            X_forecast=X_forecast,
        )

        forecasting_horizon, _ = self._validate_predict_params(forecasting_horizon)
        if stride is None:
            stride = self.fit_forecasting_horizon_

        return self._observe_predict_loop(
            predict_fn=self.predict,
            y=y,
            X_actual=X_actual,
            X_future=X_future,
            X_forecast=X_forecast,
            groups=groups,
            stride=stride,
            observe_fn=self.observe,
            forecasting_horizon=forecasting_horizon,
            predict_transformed=predict_transformed,
            **params,
        )

    def observe_predict_interval(
        self,
        y: pl.DataFrame,
        X_actual: pl.DataFrame | None = None,
        forecasting_horizon: StrictInt | None = None,
        coverage_rates: list[float] | None = None,
        strategy: Literal["mean", "median", "point"] | None = None,
        groups: list[str] | None = None,
        stride: StrictInt | None = None,
        X_future: pl.DataFrame | None = None,
        X_forecast: pl.DataFrame | None = None,
        **params,
    ) -> pl.DataFrame:
        """Alternate recursive observe and predict_interval.

        Equivalent to calling ``observe(y, X_actual)`` then
        ``predict_interval()``.  Returns interval predictions.

        Overrides the parent implementation to pass ``observe_fn`` so that
        the wrapped ``point_forecaster_`` observation state is correctly
        advanced at each stride step.

        Parameters
        ----------
        y : pl.DataFrame
            Target time series with a ``"time"`` column (datetime) and one
            or more numeric value columns.
        X_actual : pl.DataFrame or None, default=None
            Actual feature observations with a ``"time"`` column aligned
            with ``y``. Sliced and observed incrementally at each step
            of the rolling loop.
        forecasting_horizon : int or None, default=None
            Number of time steps to forecast into the future.  If ``None``,
            uses the horizon specified at fit time.
        coverage_rates : list of float or None, default=None
            Coverage levels for prediction intervals (e.g., ``[0.9, 0.95]``
            for 90 % and 95 % intervals).  If ``None``, defaults to the rates
            used at fit time.
        strategy : {"mean", "median", "point"} or None, default=None
            Strategy for deriving point predictions from prediction intervals
            during recursive multi-step forecasting:

            - ``"mean"``: use the mean of the interval bounds
            - ``"median"``: use the median of the interval bounds
            - ``"point"``: use the point forecast directly (if available)

            If ``None``, defaults to ``"mean"``.
        groups : list of str or None, default=None
            Panel group prefixes to operate on.  If ``None``, all groups
            are used.
        stride : int or None, default=None
            Step size for rolling update-predict.  If ``None``, defaults to
            the forecasting horizon used at fit time
            (``fit_forecasting_horizon_``).
        X_future : pl.DataFrame or None, default=None
            Known future features with a ``"time"`` column.
        X_forecast : pl.DataFrame or None, default=None
            External forecasts with ``"vintage_time"`` and ``"time"``
            columns.
        **params : dict
            Metadata to route to nested estimators.

        Returns
        -------
        pl.DataFrame
            Interval predictions with ``"vintage_time"``, ``"time"``, and
            lower/upper bound columns for each target at each coverage rate.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If the forecaster has not been fitted yet.
        ValueError
            If ``y`` / ``X_actual`` have invalid structure, ``coverage_rates`` not in
            [0, 1], or ``groups`` contains names not seen during fit.

        """
        check_is_fitted(
            self,
            ["local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
        )

        y, X_actual, groups = validate_forecaster_data(
            self,
            y=y,
            X_actual=X_actual,
            reset=False,
            groups=groups,
            X_future=X_future,
            X_forecast=X_forecast,
        )

        forecasting_horizon, _ = self._validate_predict_params(forecasting_horizon, coverage_rates)

        if stride is None:
            stride = self.fit_forecasting_horizon_

        return self._observe_predict_loop(
            predict_fn=self.predict_interval,
            y=y,
            X_actual=X_actual,
            X_future=X_future,
            X_forecast=X_forecast,
            groups=groups,
            stride=stride,
            observe_fn=self.observe,
            forecasting_horizon=forecasting_horizon,
            coverage_rates=coverage_rates,
            strategy=strategy,
            **params,
        )

    def _weighted_inverse_score(
        self,
        y_pred_step: pl.DataFrame,
        conformity_scores_step: pl.DataFrame,
        coverage_rate: float,
        weights: np.ndarray,
        conformity_scorer_step: BaseConformityScorer,
    ) -> pl.DataFrame:
        """Compute prediction intervals using similarity-weighted quantiles.

        Parameters
        ----------
        y_pred_step : pl.DataFrame
            Point predictions for one step (single row) with ``"time"``
            and value columns.
        conformity_scores_step : pl.DataFrame
            Conformity scores for this step with ``"time"`` and value
            columns.
        coverage_rate : float
            Target coverage probability.
        weights : np.ndarray
            Similarity weights of shape ``(n_calibration,)``.
        conformity_scorer_step : BaseConformityScorer
            Fitted conformity scorer (used for type detection and
            formatting).

        Returns
        -------
        pl.DataFrame
            Interval columns (no ``"time"`` column).

        """
        value_cols = [c for c in conformity_scores_step.columns if c != "time"]
        scores_no_time = conformity_scores_step.drop("time", strict=False)
        y_pred_values = y_pred_step.drop("time")

        # Read scorer characteristics from tags instead of checking types
        tags = conformity_scorer_step.__sklearn_tags__()
        assert tags.scorer_tags is not None
        symmetric = tags.scorer_tags.symmetric
        multiplicative = tags.scorer_tags.multiplicative
        epsilon = conformity_scorer_step.get_params().get("epsilon", 0.0)

        lower_data: dict[str, list[float]] = {}
        upper_data: dict[str, list[float]] = {}

        for col in value_cols:
            scores_col = scores_no_time[col].to_numpy().astype(np.float64)
            pred_val = float(y_pred_values[col][0])
            scale = (pred_val + epsilon) if multiplicative else 1.0

            if symmetric:
                q = weighted_quantile(scores_col, 1.0 - coverage_rate, weights)
                lower_data[col] = [pred_val - q * scale]
                upper_data[col] = [pred_val + q * scale]
            else:
                alpha = 1.0 - coverage_rate
                lower_q = weighted_quantile(scores_col, 1.0 - alpha / 2.0, weights)
                upper_q = weighted_quantile(scores_col, alpha / 2.0, weights)
                lower_data[col] = [pred_val + lower_q * scale]
                upper_data[col] = [pred_val + upper_q * scale]

        lower_bound = pl.DataFrame(lower_data)
        upper_bound = pl.DataFrame(upper_data)
        return conformity_scorer_step._format_y_pred_interval(lower_bound, upper_bound, coverage_rate)

    def predict_interval(  # ty: ignore[invalid-method-override]
        self,
        forecasting_horizon: StrictInt | None = None,
        coverage_rates: list[float] | None = None,
        strategy: Literal["mean", "median", "point"] | None = None,
        groups: list[str] | None = None,
        X_future: pl.DataFrame | None = None,
        X_forecast: pl.DataFrame | None = None,
        **params,
    ) -> pl.DataFrame:
        """Generate interval forecasts.

        Uses calibrated conformity scores to construct prediction intervals
        around point forecasts.

        Parameters
        ----------
        forecasting_horizon : int or None, default=None
            Number of time steps to forecast into the future.  If ``None``,
            uses the horizon specified at fit time.
        coverage_rates : list of float or None, default=None
            Coverage levels for prediction intervals (e.g., ``[0.9, 0.95]``
            for 90 % and 95 % intervals).  If ``None``, defaults to the rates
            used at fit time.
        strategy : {"mean", "median", "point"} or None, default=None
            Strategy for deriving point predictions from prediction intervals
            during recursive multi-step forecasting.  If ``None``, defaults
            to ``"mean"``.
        groups : list of str or None, default=None
            Panel group prefixes to operate on.  If ``None``, all groups
            are used.  Ignored when the forecaster was not fitted on panel
            data.
        X_future : pl.DataFrame or None, default=None
            Known future features override. Re-derives step columns
            without mutating forecaster state.
        X_forecast : pl.DataFrame or None, default=None
            External forecast override with ``"vintage_time"`` and
            ``"time"`` columns. Re-derives step columns without mutating
            forecaster state.
        **params : dict
            Metadata to route to nested estimators.

        Returns
        -------
        pl.DataFrame
            Interval predictions with ``"vintage_time"``, ``"time"``, and
            lower/upper bound columns for each target at each coverage rate.

        """
        check_is_fitted(
            self,
            ["local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
        )

        _, _, groups = validate_forecaster_data(
            self,
            y=None,
            X_actual=None,
            reset=False,
            groups=groups,
        )

        forecasting_horizon, coverage_rates = self._validate_predict_params(forecasting_horizon, coverage_rates)

        y_pred_full = self.point_forecaster_.predict(X_future=X_future, X_forecast=X_forecast)
        has_vintage_time = "vintage_time" in y_pred_full.columns
        if has_vintage_time:
            vintage_time_col = y_pred_full["vintage_time"]
            y_pred = y_pred_full.drop("vintage_time")
        else:
            y_pred = y_pred_full

        # Extract time for later reconstruction
        y_pred_time = y_pred.select("time")
        y_pred_values = y_pred.drop("time")

        y_pred_intervals_list: list[pl.DataFrame] = []
        for step in range(1, 1 + forecasting_horizon):
            # Get step predictions
            y_pred_step_values = y_pred_values.slice(step - 1, 1)
            y_pred_step_time = y_pred_time.slice(step - 1, 1)

            # Combine time and values for inverse_score (conformity scorers need time)
            y_pred_step = y_pred_step_time.hstack(y_pred_step_values)

            conformity_scorer_step = self.conformity_scorers_[f"step_{step}"]
            conformity_scores_step = self.conformity_scores_.filter(pl.col("step") == step).drop("step")

            rate_parts: list[pl.DataFrame] = []
            for coverage_rate in coverage_rates:
                if self.similarity is not None and hasattr(self, "similarities_"):
                    similarity_step = self.similarities_[f"step_{step}"]
                    weights_array = similarity_step.predict(y_pred=y_pred_step)
                    step_weights = weights_array[0].astype(np.float64)

                    y_pred_interval_rate_step = self._weighted_inverse_score(
                        y_pred_step=y_pred_step,
                        conformity_scores_step=conformity_scores_step,
                        coverage_rate=coverage_rate,
                        weights=step_weights,
                        conformity_scorer_step=conformity_scorer_step,
                    )
                else:
                    y_pred_interval_rate_step = conformity_scorer_step.inverse_score(
                        y_pred=y_pred_step,
                        conformity_scores=conformity_scores_step,
                        coverage_rate=coverage_rate,
                    ).drop("time")

                rate_parts.append(y_pred_interval_rate_step)

            # Add time column once at the front
            y_pred_intervals_step = pl.concat([y_pred_step_time, *rate_parts], how="horizontal")

            y_pred_intervals_list.append(y_pred_intervals_step)

        y_pred_intervals = pl.concat(y_pred_intervals_list)

        if has_vintage_time:
            return y_pred_intervals.insert_column(0, vintage_time_col.head(len(y_pred_intervals)))
        return y_pred_intervals

Methods

__sklearn_tags__()

Get estimator tags.

Returns
Type Description
Tags

Estimator tags with forecaster_type set to POINT_INTERVAL since this forecaster produces both point predictions and intervals.

Source Code
Source code in src/yohou/interval/split_conformal.py
def __sklearn_tags__(self) -> Tags:
    """Get estimator tags.

    Returns
    -------
    Tags
        Estimator tags with forecaster_type set to POINT_INTERVAL since this
        forecaster produces both point predictions and intervals.

    """
    tags = super().__sklearn_tags__()
    assert tags.forecaster_tags is not None
    # SplitConformal wraps a point forecaster and adds intervals
    tags.forecaster_tags.forecaster_type = POINT_INTERVAL
    return tags

fit(y, X_actual=None, forecasting_horizon=1, coverage_rates=None, X_future=None, X_forecast=None, **params)

Fit the forecaster to historical data.

Trains the wrapped point forecaster, calibrates conformity scores on a held-out calibration set, and optionally fits similarity weights.

Parameters
Name Type Description Default
y DataFrame

Target time series with a "time" column (datetime) and one or more numeric value columns.

required
X_actual DataFrame or None

Actual feature observations with a "time" column aligned with y. Passed directly to the wrapped point_forecaster_; any feature transformation is the responsibility of that inner estimator. If None, only target-derived features are used.

None
forecasting_horizon int

Number of time steps to forecast into the future.

1
coverage_rates list of float or None

Coverage levels for prediction intervals (e.g., [0.9, 0.95] for 90 % and 95 % intervals). If None, defaults to [0.95].

None
X_future DataFrame or None

Known future features with a "time" column. Deterministic values available for past and future dates.

None
X_forecast DataFrame or None

External forecasts with "vintage_time" and "time" columns. Passed to the wrapped point_forecaster_; any feature transformation is the responsibility of that inner estimator, reachable as point_forecaster__forecast_transformer.

None
**params dict

Metadata to route to nested estimators.

{}
Returns
Type Description
self

The fitted forecaster instance.

Source Code
Source code in src/yohou/interval/split_conformal.py
@_fit_context(prefer_skip_nested_validation=True)
def fit(
    self,
    y: pl.DataFrame,
    X_actual: pl.DataFrame | None = None,
    forecasting_horizon: StrictInt = 1,
    coverage_rates: list[StrictFloat] | None = None,
    X_future: pl.DataFrame | None = None,
    X_forecast: pl.DataFrame | None = None,
    **params,
) -> "SplitConformalForecaster":
    """Fit the forecaster to historical data.

    Trains the wrapped point forecaster, calibrates conformity scores
    on a held-out calibration set, and optionally fits similarity
    weights.

    Parameters
    ----------
    y : pl.DataFrame
        Target time series with a ``"time"`` column (datetime) and one
        or more numeric value columns.
    X_actual : pl.DataFrame or None, default=None
        Actual feature observations with a ``"time"`` column aligned
        with ``y``. Passed directly to the wrapped
        ``point_forecaster_``; any feature transformation is the
        responsibility of that inner estimator. If ``None``, only
        target-derived features are used.
    forecasting_horizon : int, default=1
        Number of time steps to forecast into the future.
    coverage_rates : list of float or None, default=None
        Coverage levels for prediction intervals (e.g., ``[0.9, 0.95]``
        for 90 % and 95 % intervals).  If ``None``, defaults to
        ``[0.95]``.
    X_future : pl.DataFrame or None, default=None
        Known future features with a ``"time"`` column. Deterministic
        values available for past and future dates.
    X_forecast : pl.DataFrame or None, default=None
        External forecasts with ``"vintage_time"`` and ``"time"``
        columns. Passed to the wrapped ``point_forecaster_``; any
        feature transformation is the responsibility of that inner
        estimator, reachable as ``point_forecaster__forecast_transformer``.
    **params : dict
        Metadata to route to nested estimators.

    Returns
    -------
    self
        The fitted forecaster instance.

    """
    # Validate data and set interval
    y, X_actual, _ = validate_forecaster_data(self, y, X_actual, reset=True)

    # _pre_fit: set schemas/panel attributes, fit transformers
    # (target_transformer=None, actual_transformer=None → no-ops),
    # and populate observation buffers (observed_time_, _y_observed,
    # _X_t_observed).  Called on the full y before the
    # train/calibration split so base-class state reflects the
    # complete training history.
    self._pre_fit(y, X_actual, forecasting_horizon, X_future=X_future, X_forecast=X_forecast)

    # Validate interval-specific parameters (coverage rates)
    _, self.fit_coverage_rates_ = self._validate_interval_fit_params(self.fit_forecasting_horizon_, coverage_rates)

    # Handle splitting with optional X
    if X_actual is None:
        y_train, y_calib = train_test_split(y, test_size=self.calibration_size, shuffle=False)
        X_actual_train, X_actual_calib = None, None
    else:
        y_train, y_calib, X_actual_train, X_actual_calib = train_test_split(
            y, X_actual, test_size=self.calibration_size, shuffle=False
        )

    self.point_forecaster_ = clone(self.point_forecaster).fit(
        y=y_train,
        X_actual=X_actual_train,
        forecasting_horizon=forecasting_horizon,
        X_future=X_future,
        X_forecast=X_forecast,
    )

    # TODO: Reconsider
    # stride=1: each row of y_calib produces one prediction window of length
    # forecasting_horizon.  This yields calibration_size - step + 1 conformity
    # scores for each horizon step k, instead of the ~2-3 scores that result
    # from stride=forecasting_horizon.  More calibration scores per step gives
    # quantiles that are stable and well-separated.
    y_pred_calib = self.point_forecaster_.observe_predict(
        y=y_calib,
        X_actual=X_actual_calib,
        forecasting_horizon=None,
        stride=1,
        predict_transformed=False,
    )

    conformity_scorers = {}
    conformity_scores_list: list[pl.DataFrame] = []
    similarities = {}

    for step in range(1, 1 + forecasting_horizon):
        y_pred_calib_step = y_pred_calib[step - 1 :: forecasting_horizon]
        y_truth_step = y_calib

        conformity_scorer_step = clone(self.conformity_scorer).fit(y_calib, forecaster=self.point_forecaster_)
        conformity_scores_step = conformity_scorer_step.score(y_truth_step, y_pred_calib_step)

        conformity_scores_step = conformity_scores_step.with_columns(step=step)
        conformity_scores_list.append(conformity_scores_step)

        conformity_scorers[f"step_{step}"] = conformity_scorer_step

        # Fit similarity on the same scored subset to ensure length alignment
        if self.similarity is not None:
            scored_times_df = conformity_scores_step.drop("step").select("time")
            y_pred_for_sim = y_pred_calib_step.drop("vintage_time", strict=False).join(
                scored_times_df, on="time", how="semi"
            )

            similarity_step = clone(self.similarity)
            similarity_step.fit(y=y_calib, y_pred=y_pred_for_sim)

            similarities[f"step_{step}"] = similarity_step

    conformity_scores = pl.concat(conformity_scores_list)

    self.conformity_scorers_ = conformity_scorers
    self.conformity_scores_ = conformity_scores

    if self.similarity is not None:
        self.similarities_ = similarities
        # Track fit-time counts for correct rewind arithmetic
        self._fit_score_counts_ = {}
        for step in range(1, 1 + forecasting_horizon):
            key = f"step_{step}"
            self._fit_score_counts_[key] = conformity_scores.filter(pl.col("step") == step).height

    return self

observe(y, X_actual=None, groups=None, X_future=None, X_forecast=None)

Observe new data and update conformity scores and the point forecaster.

Updates conformity scores and similarity weights with the new observations, then advances the wrapped point forecaster's observation buffers without refitting.

Parameters
Name Type Description Default
y DataFrame

Target time series with a "time" column (datetime) and one or more numeric value columns.

required
X_actual DataFrame or None

Actual feature observations with a "time" column aligned with y. Passed directly to the wrapped point_forecaster_; any feature transformation is the responsibility of that inner estimator. If None, only target-derived features are used.

None
groups list of str or None

Panel group prefixes to operate on. If None, all groups are used. Ignored when the forecaster was not fitted on panel data.

None
X_future DataFrame or None

Known future features with a "time" column.

None
X_forecast DataFrame or None

External forecasts with "vintage_time" and "time" columns. Passed to the wrapped point_forecaster_; any feature transformation is the responsibility of that inner estimator, reachable as point_forecaster__forecast_transformer.

None
Returns
Type Description
self

The forecaster with updated observation buffers.

Source Code
Source code in src/yohou/interval/split_conformal.py
def observe(
    self,
    y: pl.DataFrame,
    X_actual: pl.DataFrame | None = None,
    groups: list[str] | None = None,
    X_future: pl.DataFrame | None = None,
    X_forecast: pl.DataFrame | None = None,
) -> "SplitConformalForecaster":
    """Observe new data and update conformity scores and the point forecaster.

    Updates conformity scores and similarity weights with the new
    observations, then advances the wrapped point forecaster's
    observation buffers without refitting.

    Parameters
    ----------
    y : pl.DataFrame
        Target time series with a ``"time"`` column (datetime) and one
        or more numeric value columns.
    X_actual : pl.DataFrame or None, default=None
        Actual feature observations with a ``"time"`` column aligned
        with ``y``. Passed directly to the wrapped
        ``point_forecaster_``; any feature transformation is the
        responsibility of that inner estimator. If ``None``, only
        target-derived features are used.
    groups : list of str or None, default=None
        Panel group prefixes to operate on.  If ``None``, all groups
        are used.  Ignored when the forecaster was not fitted on panel
        data.
    X_future : pl.DataFrame or None, default=None
        Known future features with a ``"time"`` column.
    X_forecast : pl.DataFrame or None, default=None
        External forecasts with ``"vintage_time"`` and ``"time"``
        columns. Passed to the wrapped ``point_forecaster_``; any
        feature transformation is the responsibility of that inner
        estimator, reachable as ``point_forecaster__forecast_transformer``.

    Returns
    -------
    self
        The forecaster with updated observation buffers.

    """
    check_is_fitted(
        self,
        ["point_forecaster_", "local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
    )

    y, X_actual, groups = validate_forecaster_data(self, y, X_actual, reset=False, groups=groups)

    # Update similarity / conformity scores *before* the point forecaster
    # absorbs the new data so we can still call predict() to obtain the
    # prediction-vs-actual residual. This update is panel-agnostic, so it
    # runs unconditionally ahead of the panel/standard branch; routing it
    # through the standard branch alone would silently skip it on panel
    # data.
    self._observe_conformity(y)

    if self.groups_ is None:
        super()._observe_standard(y, X_actual=X_actual)
    else:
        BasePanelForecaster._observe_panel(self, y, X_actual=X_actual, groups=groups)

    self.point_forecaster_.observe(y=y, X_actual=X_actual, groups=groups, X_future=X_future, X_forecast=X_forecast)
    self.observed_time_ = self.point_forecaster_.observed_time_
    return self

rewind(y, X_actual=None, groups=None, X_future=None, X_forecast=None)

Rewind conformity scores, similarity state, and the point forecaster.

Removes the most recently observed conformity scores and similarity state, then rewinds the wrapped point forecaster's observation buffers, mirroring the order used by observe().

Parameters
Name Type Description Default
y DataFrame

Target time series with a "time" column (datetime) and one or more numeric value columns.

required
X_actual DataFrame or None

Actual feature observations to restore the observation state to. Must align with y.

None
groups list of str or None

Panel group prefixes to operate on. If None, all groups are used. Ignored when the forecaster was not fitted on panel data.

None
X_future DataFrame or None

Known future features with a "time" column.

None
X_forecast DataFrame or None

External forecasts with "vintage_time" and "time" columns.

None
Returns
Type Description
self

The forecaster with rewound observation buffers.

Source Code
Source code in src/yohou/interval/split_conformal.py
def rewind(
    self,
    y: pl.DataFrame,
    X_actual: pl.DataFrame | None = None,
    groups: list[str] | None = None,
    X_future: pl.DataFrame | None = None,
    X_forecast: pl.DataFrame | None = None,
) -> "SplitConformalForecaster":
    """Rewind conformity scores, similarity state, and the point forecaster.

    Removes the most recently observed conformity scores and similarity
    state, then rewinds the wrapped point forecaster's observation
    buffers, mirroring the order used by ``observe()``.

    Parameters
    ----------
    y : pl.DataFrame
        Target time series with a ``"time"`` column (datetime) and one
        or more numeric value columns.
    X_actual : pl.DataFrame or None, default=None
        Actual feature observations to restore the observation
        state to. Must align with ``y``.
    groups : list of str or None, default=None
        Panel group prefixes to operate on.  If ``None``, all groups
        are used.  Ignored when the forecaster was not fitted on panel
        data.
    X_future : pl.DataFrame or None, default=None
        Known future features with a ``"time"`` column.
    X_forecast : pl.DataFrame or None, default=None
        External forecasts with ``"vintage_time"`` and ``"time"``
        columns.

    Returns
    -------
    self
        The forecaster with rewound observation buffers.

    """
    check_is_fitted(
        self,
        ["point_forecaster_", "local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
    )

    y, X_actual, groups = validate_forecaster_data(self, y, X_actual, reset=False, groups=groups)

    # Rewind conformity / similarity state *before* the point forecaster
    # rolls back, mirroring the order used by observe() so both methods
    # maintain the same state invariant. As in observe(), this rollback is
    # panel-agnostic and runs unconditionally ahead of the branch.
    self._rewind_conformity(y, X_actual=X_actual)

    if self.groups_ is None:
        super()._rewind_standard(y, X_actual=X_actual)
    else:
        BasePanelForecaster._rewind_panel(self, y, X_actual=X_actual, groups=groups)

    self.point_forecaster_.rewind(y=y, X_actual=X_actual, groups=groups, X_future=X_future, X_forecast=X_forecast)
    self.observed_time_ = self.point_forecaster_.observed_time_
    return self

predict(forecasting_horizon=None, groups=None, predict_transformed=False, X_future=None, X_forecast=None, **params)

Generate point forecasts.

Delegates to the wrapped point forecaster.

Parameters
Name Type Description Default
forecasting_horizon int or None

Number of time steps to forecast into the future. If None, uses the horizon specified at fit time.

None
groups list of str or None

Panel group prefixes to operate on. If None, all groups are used. Ignored when the forecaster was not fitted on panel data.

None
predict_transformed bool

If True, return predictions in the transformed space without applying inverse target transformation.

False
X_future DataFrame or None

Known future features override. Re-derives step columns without mutating forecaster state.

None
X_forecast DataFrame or None

External forecast override with "vintage_time" and "time" columns. Re-derives step columns without mutating forecaster state.

None
**params dict

Metadata to route to nested estimators.

{}
Returns
Type Description
DataFrame

Point predictions with "vintage_time", "time", and one column per target variable.

Source Code
Source code in src/yohou/interval/split_conformal.py
def predict(
    self,
    forecasting_horizon: StrictInt | None = None,
    groups: list[str] | None = None,
    predict_transformed: bool = False,
    X_future: pl.DataFrame | None = None,
    X_forecast: pl.DataFrame | None = None,
    **params,
) -> pl.DataFrame:
    """Generate point forecasts.

    Delegates to the wrapped point forecaster.

    Parameters
    ----------
    forecasting_horizon : int or None, default=None
        Number of time steps to forecast into the future.  If ``None``,
        uses the horizon specified at fit time.
    groups : list of str or None, default=None
        Panel group prefixes to operate on.  If ``None``, all groups
        are used.  Ignored when the forecaster was not fitted on panel
        data.
    predict_transformed : bool, default=False
        If ``True``, return predictions in the transformed space without
        applying inverse target transformation.
    X_future : pl.DataFrame or None, default=None
        Known future features override. Re-derives step columns
        without mutating forecaster state.
    X_forecast : pl.DataFrame or None, default=None
        External forecast override with ``"vintage_time"`` and
        ``"time"`` columns. Re-derives step columns without mutating
        forecaster state.
    **params : dict
        Metadata to route to nested estimators.

    Returns
    -------
    pl.DataFrame
        Point predictions with ``"vintage_time"``, ``"time"``, and one
        column per target variable.

    """
    check_is_fitted(
        self,
        ["local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
    )

    _, _, groups = validate_forecaster_data(
        self,
        y=None,
        X_actual=None,
        reset=False,
        groups=groups,
    )

    return self.point_forecaster_.predict(
        forecasting_horizon=forecasting_horizon,
        groups=groups,
        predict_transformed=predict_transformed,
        X_future=X_future,
        X_forecast=X_forecast,
    )

observe_predict(y, X_actual=None, forecasting_horizon=None, groups=None, stride=None, predict_transformed=False, X_future=None, X_forecast=None, **params)

Alternate recursive observe and predict.

Produces a rolling sequence of predictions, observing stride rows between each. Returns the concatenation of the initial prediction and one prediction per stride step.

Parameters
Name Type Description Default
y DataFrame

Target time series with a "time" column (datetime) and one or more numeric value columns.

required
X_actual DataFrame or None

Actual feature observations with a "time" column aligned with y. Sliced and observed incrementally at each step of the rolling loop.

None
forecasting_horizon int or None

Number of time steps to forecast into the future. If None, uses the horizon specified at fit time.

None
groups list of str or None

Panel group prefixes to operate on. If None, all groups are used. Ignored when the forecaster was not fitted on panel data.

None
stride int or None

Step size for rolling update-predict. If None, defaults to the forecasting horizon used at fit time (fit_forecasting_horizon_).

None
predict_transformed bool

If True, return predictions in the transformed space without applying inverse target transformation.

False
X_future DataFrame or None

Known future features with a "time" column.

None
X_forecast DataFrame or None

External forecasts with "vintage_time" and "time" columns.

None
**params dict

Metadata to route to nested estimators.

{}
Returns
Type Description
DataFrame

Point predictions with "vintage_time", "time", and one column per target variable.

Raises
Type Description
NotFittedError

If the forecaster has not been fitted yet.

ValueError

If y / X have invalid structure or groups contains names not seen during fit.

Source Code
Source code in src/yohou/interval/split_conformal.py
def observe_predict(
    self,
    y: pl.DataFrame,
    X_actual: pl.DataFrame | None = None,
    forecasting_horizon: StrictInt | None = None,
    groups: list[str] | None = None,
    stride: StrictInt | None = None,
    predict_transformed: bool = False,
    X_future: pl.DataFrame | None = None,
    X_forecast: pl.DataFrame | None = None,
    **params,
) -> pl.DataFrame:
    """Alternate recursive observe and predict.

    Produces a rolling sequence of predictions, observing ``stride``
    rows between each. Returns the concatenation of the initial
    prediction and one prediction per stride step.

    Parameters
    ----------
    y : pl.DataFrame
        Target time series with a ``"time"`` column (datetime) and one
        or more numeric value columns.
    X_actual : pl.DataFrame or None, default=None
        Actual feature observations with a ``"time"`` column aligned
        with ``y``. Sliced and observed incrementally at each step
        of the rolling loop.
    forecasting_horizon : int or None, default=None
        Number of time steps to forecast into the future.  If ``None``,
        uses the horizon specified at fit time.
    groups : list of str or None, default=None
        Panel group prefixes to operate on.  If ``None``, all groups
        are used.  Ignored when the forecaster was not fitted on panel
        data.
    stride : int or None, default=None
        Step size for rolling update-predict.  If ``None``, defaults to
        the forecasting horizon used at fit time
        (``fit_forecasting_horizon_``).
    predict_transformed : bool, default=False
        If ``True``, return predictions in the transformed space without
        applying inverse target transformation.
    X_future : pl.DataFrame or None, default=None
        Known future features with a ``"time"`` column.
    X_forecast : pl.DataFrame or None, default=None
        External forecasts with ``"vintage_time"`` and ``"time"``
        columns.
    **params : dict
        Metadata to route to nested estimators.

    Returns
    -------
    pl.DataFrame
        Point predictions with ``"vintage_time"``, ``"time"``, and one
        column per target variable.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If the forecaster has not been fitted yet.
    ValueError
        If ``y`` / ``X`` have invalid structure or ``groups``
        contains names not seen during fit.

    """
    check_is_fitted(
        self,
        ["local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
    )

    y, X_actual, groups = validate_forecaster_data(
        self,
        y=y,
        X_actual=X_actual,
        reset=False,
        groups=groups,
        X_future=X_future,
        X_forecast=X_forecast,
    )

    forecasting_horizon, _ = self._validate_predict_params(forecasting_horizon)
    if stride is None:
        stride = self.fit_forecasting_horizon_

    return self._observe_predict_loop(
        predict_fn=self.predict,
        y=y,
        X_actual=X_actual,
        X_future=X_future,
        X_forecast=X_forecast,
        groups=groups,
        stride=stride,
        observe_fn=self.observe,
        forecasting_horizon=forecasting_horizon,
        predict_transformed=predict_transformed,
        **params,
    )

observe_predict_interval(y, X_actual=None, forecasting_horizon=None, coverage_rates=None, strategy=None, groups=None, stride=None, X_future=None, X_forecast=None, **params)

Alternate recursive observe and predict_interval.

Equivalent to calling observe(y, X_actual) then predict_interval(). Returns interval predictions.

Overrides the parent implementation to pass observe_fn so that the wrapped point_forecaster_ observation state is correctly advanced at each stride step.

Parameters
Name Type Description Default
y DataFrame

Target time series with a "time" column (datetime) and one or more numeric value columns.

required
X_actual DataFrame or None

Actual feature observations with a "time" column aligned with y. Sliced and observed incrementally at each step of the rolling loop.

None
forecasting_horizon int or None

Number of time steps to forecast into the future. If None, uses the horizon specified at fit time.

None
coverage_rates list of float or None

Coverage levels for prediction intervals (e.g., [0.9, 0.95] for 90 % and 95 % intervals). If None, defaults to the rates used at fit time.

None
strategy (mean, median, point)

Strategy for deriving point predictions from prediction intervals during recursive multi-step forecasting:

  • "mean": use the mean of the interval bounds
  • "median": use the median of the interval bounds
  • "point": use the point forecast directly (if available)

If None, defaults to "mean".

"mean"
groups list of str or None

Panel group prefixes to operate on. If None, all groups are used.

None
stride int or None

Step size for rolling update-predict. If None, defaults to the forecasting horizon used at fit time (fit_forecasting_horizon_).

None
X_future DataFrame or None

Known future features with a "time" column.

None
X_forecast DataFrame or None

External forecasts with "vintage_time" and "time" columns.

None
**params dict

Metadata to route to nested estimators.

{}
Returns
Type Description
DataFrame

Interval predictions with "vintage_time", "time", and lower/upper bound columns for each target at each coverage rate.

Raises
Type Description
NotFittedError

If the forecaster has not been fitted yet.

ValueError

If y / X_actual have invalid structure, coverage_rates not in [0, 1], or groups contains names not seen during fit.

Source Code
Source code in src/yohou/interval/split_conformal.py
def observe_predict_interval(
    self,
    y: pl.DataFrame,
    X_actual: pl.DataFrame | None = None,
    forecasting_horizon: StrictInt | None = None,
    coverage_rates: list[float] | None = None,
    strategy: Literal["mean", "median", "point"] | None = None,
    groups: list[str] | None = None,
    stride: StrictInt | None = None,
    X_future: pl.DataFrame | None = None,
    X_forecast: pl.DataFrame | None = None,
    **params,
) -> pl.DataFrame:
    """Alternate recursive observe and predict_interval.

    Equivalent to calling ``observe(y, X_actual)`` then
    ``predict_interval()``.  Returns interval predictions.

    Overrides the parent implementation to pass ``observe_fn`` so that
    the wrapped ``point_forecaster_`` observation state is correctly
    advanced at each stride step.

    Parameters
    ----------
    y : pl.DataFrame
        Target time series with a ``"time"`` column (datetime) and one
        or more numeric value columns.
    X_actual : pl.DataFrame or None, default=None
        Actual feature observations with a ``"time"`` column aligned
        with ``y``. Sliced and observed incrementally at each step
        of the rolling loop.
    forecasting_horizon : int or None, default=None
        Number of time steps to forecast into the future.  If ``None``,
        uses the horizon specified at fit time.
    coverage_rates : list of float or None, default=None
        Coverage levels for prediction intervals (e.g., ``[0.9, 0.95]``
        for 90 % and 95 % intervals).  If ``None``, defaults to the rates
        used at fit time.
    strategy : {"mean", "median", "point"} or None, default=None
        Strategy for deriving point predictions from prediction intervals
        during recursive multi-step forecasting:

        - ``"mean"``: use the mean of the interval bounds
        - ``"median"``: use the median of the interval bounds
        - ``"point"``: use the point forecast directly (if available)

        If ``None``, defaults to ``"mean"``.
    groups : list of str or None, default=None
        Panel group prefixes to operate on.  If ``None``, all groups
        are used.
    stride : int or None, default=None
        Step size for rolling update-predict.  If ``None``, defaults to
        the forecasting horizon used at fit time
        (``fit_forecasting_horizon_``).
    X_future : pl.DataFrame or None, default=None
        Known future features with a ``"time"`` column.
    X_forecast : pl.DataFrame or None, default=None
        External forecasts with ``"vintage_time"`` and ``"time"``
        columns.
    **params : dict
        Metadata to route to nested estimators.

    Returns
    -------
    pl.DataFrame
        Interval predictions with ``"vintage_time"``, ``"time"``, and
        lower/upper bound columns for each target at each coverage rate.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If the forecaster has not been fitted yet.
    ValueError
        If ``y`` / ``X_actual`` have invalid structure, ``coverage_rates`` not in
        [0, 1], or ``groups`` contains names not seen during fit.

    """
    check_is_fitted(
        self,
        ["local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
    )

    y, X_actual, groups = validate_forecaster_data(
        self,
        y=y,
        X_actual=X_actual,
        reset=False,
        groups=groups,
        X_future=X_future,
        X_forecast=X_forecast,
    )

    forecasting_horizon, _ = self._validate_predict_params(forecasting_horizon, coverage_rates)

    if stride is None:
        stride = self.fit_forecasting_horizon_

    return self._observe_predict_loop(
        predict_fn=self.predict_interval,
        y=y,
        X_actual=X_actual,
        X_future=X_future,
        X_forecast=X_forecast,
        groups=groups,
        stride=stride,
        observe_fn=self.observe,
        forecasting_horizon=forecasting_horizon,
        coverage_rates=coverage_rates,
        strategy=strategy,
        **params,
    )

predict_interval(forecasting_horizon=None, coverage_rates=None, strategy=None, groups=None, X_future=None, X_forecast=None, **params)

Generate interval forecasts.

Uses calibrated conformity scores to construct prediction intervals around point forecasts.

Parameters
Name Type Description Default
forecasting_horizon int or None

Number of time steps to forecast into the future. If None, uses the horizon specified at fit time.

None
coverage_rates list of float or None

Coverage levels for prediction intervals (e.g., [0.9, 0.95] for 90 % and 95 % intervals). If None, defaults to the rates used at fit time.

None
strategy (mean, median, point)

Strategy for deriving point predictions from prediction intervals during recursive multi-step forecasting. If None, defaults to "mean".

"mean"
groups list of str or None

Panel group prefixes to operate on. If None, all groups are used. Ignored when the forecaster was not fitted on panel data.

None
X_future DataFrame or None

Known future features override. Re-derives step columns without mutating forecaster state.

None
X_forecast DataFrame or None

External forecast override with "vintage_time" and "time" columns. Re-derives step columns without mutating forecaster state.

None
**params dict

Metadata to route to nested estimators.

{}
Returns
Type Description
DataFrame

Interval predictions with "vintage_time", "time", and lower/upper bound columns for each target at each coverage rate.

Source Code
Source code in src/yohou/interval/split_conformal.py
def predict_interval(  # ty: ignore[invalid-method-override]
    self,
    forecasting_horizon: StrictInt | None = None,
    coverage_rates: list[float] | None = None,
    strategy: Literal["mean", "median", "point"] | None = None,
    groups: list[str] | None = None,
    X_future: pl.DataFrame | None = None,
    X_forecast: pl.DataFrame | None = None,
    **params,
) -> pl.DataFrame:
    """Generate interval forecasts.

    Uses calibrated conformity scores to construct prediction intervals
    around point forecasts.

    Parameters
    ----------
    forecasting_horizon : int or None, default=None
        Number of time steps to forecast into the future.  If ``None``,
        uses the horizon specified at fit time.
    coverage_rates : list of float or None, default=None
        Coverage levels for prediction intervals (e.g., ``[0.9, 0.95]``
        for 90 % and 95 % intervals).  If ``None``, defaults to the rates
        used at fit time.
    strategy : {"mean", "median", "point"} or None, default=None
        Strategy for deriving point predictions from prediction intervals
        during recursive multi-step forecasting.  If ``None``, defaults
        to ``"mean"``.
    groups : list of str or None, default=None
        Panel group prefixes to operate on.  If ``None``, all groups
        are used.  Ignored when the forecaster was not fitted on panel
        data.
    X_future : pl.DataFrame or None, default=None
        Known future features override. Re-derives step columns
        without mutating forecaster state.
    X_forecast : pl.DataFrame or None, default=None
        External forecast override with ``"vintage_time"`` and
        ``"time"`` columns. Re-derives step columns without mutating
        forecaster state.
    **params : dict
        Metadata to route to nested estimators.

    Returns
    -------
    pl.DataFrame
        Interval predictions with ``"vintage_time"``, ``"time"``, and
        lower/upper bound columns for each target at each coverage rate.

    """
    check_is_fitted(
        self,
        ["local_y_schema_", "local_X_actual_schema_", "shared_X_actual_schema_", "groups_"],
    )

    _, _, groups = validate_forecaster_data(
        self,
        y=None,
        X_actual=None,
        reset=False,
        groups=groups,
    )

    forecasting_horizon, coverage_rates = self._validate_predict_params(forecasting_horizon, coverage_rates)

    y_pred_full = self.point_forecaster_.predict(X_future=X_future, X_forecast=X_forecast)
    has_vintage_time = "vintage_time" in y_pred_full.columns
    if has_vintage_time:
        vintage_time_col = y_pred_full["vintage_time"]
        y_pred = y_pred_full.drop("vintage_time")
    else:
        y_pred = y_pred_full

    # Extract time for later reconstruction
    y_pred_time = y_pred.select("time")
    y_pred_values = y_pred.drop("time")

    y_pred_intervals_list: list[pl.DataFrame] = []
    for step in range(1, 1 + forecasting_horizon):
        # Get step predictions
        y_pred_step_values = y_pred_values.slice(step - 1, 1)
        y_pred_step_time = y_pred_time.slice(step - 1, 1)

        # Combine time and values for inverse_score (conformity scorers need time)
        y_pred_step = y_pred_step_time.hstack(y_pred_step_values)

        conformity_scorer_step = self.conformity_scorers_[f"step_{step}"]
        conformity_scores_step = self.conformity_scores_.filter(pl.col("step") == step).drop("step")

        rate_parts: list[pl.DataFrame] = []
        for coverage_rate in coverage_rates:
            if self.similarity is not None and hasattr(self, "similarities_"):
                similarity_step = self.similarities_[f"step_{step}"]
                weights_array = similarity_step.predict(y_pred=y_pred_step)
                step_weights = weights_array[0].astype(np.float64)

                y_pred_interval_rate_step = self._weighted_inverse_score(
                    y_pred_step=y_pred_step,
                    conformity_scores_step=conformity_scores_step,
                    coverage_rate=coverage_rate,
                    weights=step_weights,
                    conformity_scorer_step=conformity_scorer_step,
                )
            else:
                y_pred_interval_rate_step = conformity_scorer_step.inverse_score(
                    y_pred=y_pred_step,
                    conformity_scores=conformity_scores_step,
                    coverage_rate=coverage_rate,
                ).drop("time")

            rate_parts.append(y_pred_interval_rate_step)

        # Add time column once at the front
        y_pred_intervals_step = pl.concat([y_pred_step_time, *rate_parts], how="horizontal")

        y_pred_intervals_list.append(y_pred_intervals_step)

    y_pred_intervals = pl.concat(y_pred_intervals_list)

    if has_vintage_time:
        return y_pred_intervals.insert_column(0, vintage_time_col.head(len(y_pred_intervals)))
    return y_pred_intervals

Tutorials

The following example notebooks use this component:

  • Conformal Prediction Intervals


    Build distribution-free prediction intervals with SplitConformalForecaster using calibration holdouts and configurable conformity scoring functions.

    View · Open in marimo

  • Forecast Visualization


    Visualise point forecasts from single and multiple models, decomposition pipeline components, and time weight decay functions with interactive Plotly.

    View · Open in marimo

  • How to Choose a Forecasting Method


    Interactive decision guide progressing from SeasonalNaive baseline through linear reduction, stationarity transforms, feature enrichment, nonlinear models, decomposition, and prediction intervals.

    View · Open in marimo

  • How to Combine Interval Forecasters


    Build interval ensembles with VotingIntervalForecaster using envelope, mean, and median aggregation strategies.

    View · Open in marimo

  • How to Create a Custom Interval Forecaster


    Implement a NaiveIntervalForecaster from scratch, validate it with the check generator, and compare it against SplitConformalForecaster.

    View · Open in marimo

  • How to Forecast Panel Prediction Intervals


    Combine conformal and quantile regression intervals on panel data with per-group coverage analysis, calibration plots, and groupwise interval scoring.

    View · Open in marimo

See all 14 examples in the gallery