Skip to content

ForecastingAssistant

ForecastingAssistant is the whole public API of skforecast-ai. Each step of the pipeline is one of its methods, and each method returns a Pydantic object that renders itself in a notebook, serializes to JSON and can be passed to the next step or to ask().

All methods work without an LLM except ask(). refine_plan() and create_cv() use one only when you pass a prompt, so nothing is sent to an LLM unless you ask for it.

Method What it does Returns LLM
profile() Inspects the data and recommends a forecaster and an estimator ForecastingProfile no
plan() Builds the plan: lags, window and calendar features, interval, metric ForecastPlan no
refine_plan() Changes a plan with explicit overrides, or with a prompt ForecastPlan optional
create_cv() Builds the cross-validation strategy, or translates a prompt into one CVResult optional
forecast_code() Returns the forecasting script without running it CodeGenerationResult no
forecast() Runs that script: predictions, and metrics when evaluating ForecastResult no
backtest_code() Returns the backtesting script without running it CodeGenerationResult no
backtest() Runs that script over the folds of a cross-validation BacktestResult no
compare() Backtests several configurations and ranks them ComparisonResult no
ask() Explains a profile, plan, script or result, or answers a question AskResult required
check_llm() Reports how the LLM configuration resolves; with test_call=True, also calls the model LLMCheckResult optional

The same pipeline runs from the terminal: see the CLI reference.

skforecast_ai.assistant.ForecastingAssistant

ForecastingAssistant(
    llm=None,
    base_url=None,
    api_key=None,
    send_data_to_llm=False,
)

Time series forecasting assistant built on skforecast.

Analyses a time series dataset, selects a forecaster and estimator, produces a ready-to-run Python script, and optionally executes it, returning predictions, metrics, and the exact code that generated them.

All modeling decisions are deterministic and reproducible. An optional LLM explains them and answers questions. On request, it also proposes lags and window features (refine_plan()) or a cross-validation strategy (create_cv()), which are validated before they are used.

Parameters:

Name Type Description Default
llm str

LLM provider string in format 'provider:model_name'. If None, only deterministic methods are available.

None
base_url str

Custom base URL for the LLM provider (used for Ollama or OpenAI-compatible endpoints).

None
api_key str

Explicit API key for the LLM provider. When None, Pydantic AI resolves credentials from environment variables (e.g. OPENAI_API_KEY, GOOGLE_API_KEY). Use this for notebook workflows or multi-tenant scenarios.

None
send_data_to_llm bool

Acknowledges that the values a result owns (its predictions and metrics) are sent to the LLM when the result is passed to ask() as context. When False, ask() still sends them but emits DataSentToLLMWarning; when True the warning is silenced. The input data is never sent in either mode: profiles carry summary statistics only, and a result holds the model's output, not the data it was fitted on.

False

Attributes:

Name Type Description
llm (str, None)

LLM provider string or None for deterministic-only mode.

base_url (str, None)

Custom base URL for the LLM provider.

api_key (str, None)

Explicit API key or None (resolve from environment).

send_data_to_llm bool

Whether sending result values to the LLM has been acknowledged.

Notes

Four workflows are available:

Fast path: call a single method that handles everything internally:

  • forecast_code() profiles the data, builds a plan, and returns a ready-to-run Python script.
  • forecast() does the same and also executes the forecast, returning predictions and metrics.

Step-by-step path: for full control over each stage:

  • profile() inspects the dataset and selects the recommended forecaster and estimator (with alternative candidates).
  • plan() takes the profile and derives the detailed configuration (lags, metric, preprocessing, intervals, NaN handling).
  • refine_plan() adjusts an existing plan with user overrides, or with LLM guidance when a prompt is provided (if desired).
  • forecast_code() generates a Python script (accepts pre-computed profile and plan for full control).
  • forecast() executes the workflow from a pre-computed profile and plan, returning predictions and metrics.

Backtesting path: evaluate model performance with time series cross-validation:

  • create_cv() produces a TimeSeriesFold with smart defaults (optionally guided by an LLM prompt). Requires a profile and plan.
  • backtest_code() generates a backtesting script without executing it (accepts pre-computed profile and plan).
  • backtest() runs backtesting using the CV strategy and returns metrics, predictions, and reproducible code.

Comparison path: evaluate several configurations side by side:

  • compare() backtests a set of candidate configurations with the same cross-validation strategy and returns a metric-ranked leaderboard plus the winning configuration as a reusable BacktestResult.

ask() is an LLM-powered method (requires llm to be configured) available in any workflow. Pass it the object to explain as context (a profile, a generated script, a cross-validation strategy, or a result), or nothing to ask a general forecasting question.

check_llm() reports how the LLM configuration resolves (provider, credentials, endpoint, installed extras) before any workflow runs.

Methods:

Name Description
profile

Profile a dataset and select the recommended forecaster and estimator.

plan

Build a detailed ForecastPlan from a ForecastingProfile.

refine_plan

Re-derive a forecast plan applying user overrides or LLM guidance.

forecast_code

Profile, plan, and generate a complete forecasting script.

forecast

Execute a full forecasting workflow end-to-end.

create_cv

Generate a time series cross-validation strategy for backtesting.

backtest_code

Profile, plan, and generate a complete backtesting script.

backtest

Execute backtesting with a pre-configured time series cross-validation

compare

Compare several forecaster configurations on the same data.

ask

Ask a forecasting question, optionally about an object to explain.

check_llm

Check whether the configured LLM can be used, and report why not.

Source code in skforecast_ai/assistant.py
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
def __init__(
    self,
    llm: str | None = None,
    base_url: str | None = None,
    api_key: str | None = None,
    send_data_to_llm: bool = False,
) -> None:

    self.llm              = llm
    self.base_url         = base_url
    self.api_key          = api_key
    self.send_data_to_llm = send_data_to_llm
    self._model           = None
    self._agent           = None
    self._cv_agent        = None
    self._plan_refinement_agent = None
    # Data profiles that `_refresh_profile` computed, by the fingerprint
    # of the data and the columns of the saved profile: a workflow
    # passes the same data with the same profile to every method.
    self._fresh_profiles: OrderedDict[tuple, DataProfile] = OrderedDict()

Attributes

llm instance-attribute

llm = llm

base_url instance-attribute

base_url = base_url

api_key instance-attribute

api_key = api_key

send_data_to_llm instance-attribute

send_data_to_llm = send_data_to_llm

Methods:

profile

profile(
    data,
    target=None,
    date_column=None,
    series_id_column=None,
    *,
    exog_columns=None,
    sep=None,
    decimal=None
)

Profile a dataset and select the recommended forecaster and estimator.

Assembles the data profile and selects the recommended forecaster, estimator, and their compatible candidates. The returned ForecastingProfile carries the DataProfile plus the coarse modeling decisions.

Parameters:

Name Type Description Default
data pandas Series, pandas DataFrame, str, Path

Input dataset, a single series, or path to a CSV file. When a pandas Series is passed, the target is derived from its name.

required
target str, list of str

Name of the column to forecast. For wide-format multi-series, pass a list of column names where each column is a series. Optional only when data is a pandas Series, in which case the Series name is used (or 'y' when the Series has no name).

None
date_column str

Name of the column containing timestamps.

None
series_id_column str

Name of the column identifying individual series.

None
exog_columns list of str

Columns to use as exogenous variables. If None, every column that is not the target, the date or the series id. Otherwise a subset of them, kept in the order of the data (an empty list for none): the other columns are listed in DataProfile.unused_columns, with a note in DataProfile.warnings, and neither the plan nor the generated script uses them. A plan built from this profile records the selection (ForecastPlan.exog_columns), so it runs on the same columns when it is given without its profile.

None
sep str

Separator of the fields of a CSV file: ',', ';' or a tabulator ('\t'). If None, it is detected from the first lines of the file. Ignored for data passed in memory.

None
decimal str

Decimal mark of a CSV file: '.' or ',' (only when the separator is not ','). If None, it is detected from the first lines of the file. Ignored for data passed in memory.

None

Returns:

Name Type Description
profile ForecastingProfile

Dataset profile + recommended forecaster + estimator (with alternative candidates) + analysis context.

Notes

The separator and the decimal mark of a CSV file are recorded in the profile (DataProfile.csv_sep and DataProfile.csv_decimal): the generated script reads the file with them. A file that reads with more than one separator, or that holds numbers written with both decimal marks, raises InvalidInputError (code 'data_unreadable') instead of guessing: pass sep and decimal. The other methods detect how the file they receive is written, and read such a file as the profile passed with it says.

Source code in skforecast_ai/assistant.py
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
def profile(
    self,
    data: pd.Series | pd.DataFrame | str | Path,
    target: str | list[str] | None = None,
    date_column: str | None = None,
    series_id_column: str | None = None,
    *,
    exog_columns: list[str] | None = None,
    sep: str | None = None,
    decimal: str | None = None,
) -> ForecastingProfile:
    """
    Profile a dataset and select the recommended forecaster and estimator.

    Assembles the data profile and selects the recommended forecaster,
    estimator, and their compatible candidates. The returned
    `ForecastingProfile` carries the `DataProfile` plus the coarse
    modeling decisions.

    Parameters
    ----------
    data : pandas Series, pandas DataFrame, str, Path
        Input dataset, a single series, or path to a CSV file. When a
        pandas Series is passed, the target is derived from its name.
    target : str, list of str, default None
        Name of the column to forecast. For wide-format multi-series,
        pass a list of column names where each column is a series.
        Optional only when `data` is a pandas Series, in which case the
        Series name is used (or `'y'` when the Series has no name).
    date_column : str, default None
        Name of the column containing timestamps.
    series_id_column : str, default None
        Name of the column identifying individual series.
    exog_columns : list of str, default None
        Columns to use as exogenous variables. If None, every column that
        is not the target, the date or the series id. Otherwise a subset
        of them, kept in the order of the data (an empty list for none):
        the other columns are listed in `DataProfile.unused_columns`,
        with a note in `DataProfile.warnings`, and neither the plan nor
        the generated script uses them. A plan built from this profile
        records the selection (`ForecastPlan.exog_columns`), so it runs
        on the same columns when it is given without its profile.
    sep : str, default None
        Separator of the fields of a CSV file: `','`, `';'` or a
        tabulator (`'\\t'`). If None, it is detected from the first
        lines of the file. Ignored for data passed in memory.
    decimal : str, default None
        Decimal mark of a CSV file: `'.'` or `','` (only when the
        separator is not `','`). If None, it is detected from the
        first lines of the file. Ignored for data passed in memory.

    Returns
    -------
    profile : ForecastingProfile
        Dataset profile + recommended forecaster + estimator
        (with alternative candidates) + analysis context.

    Notes
    -----
    The separator and the decimal mark of a CSV file are recorded in
    the profile (`DataProfile.csv_sep` and `DataProfile.csv_decimal`):
    the generated script reads the file with them. A file that reads
    with more than one separator, or that holds numbers written with
    both decimal marks, raises `InvalidInputError` (code
    `'data_unreadable'`) instead of guessing: pass `sep` and `decimal`.
    The other methods detect how the file they receive is written, and
    read such a file as the profile passed with it says.
    """

    data_path = recorded_data_path(data)
    data, target = _resolve_data_and_target(
                       data        = data,
                       target      = target,
                       date_column = date_column,
                       sep         = sep,
                       decimal     = decimal,
                   )
    # How the file was read, which the script repeats; nothing for data
    # passed in memory.
    sep, decimal = _dialect_read(data_path)

    data_profile = create_data_profile(
        data             = data,
        target           = target,
        date_column      = date_column,
        series_id_column = series_id_column,
        data_path        = data_path,
        exog_columns     = exog_columns,
        sep              = sep,
        decimal          = decimal,
    )
    validate_target_numeric(data, data_profile.target)

    forecaster, forecaster_candidates = select_forecaster_and_candidates(data_profile)
    task_type = select_task_type_from_forecaster(forecaster)

    series_pacf = compute_series_pacf(data=data, profile=data_profile)
    window_features = select_window_features(
                          task_type      = task_type,
                          n_observations = data_profile.span_index_length,
                          frequency      = data_profile.frequency,
                      )

    calendar_features = select_calendar_features(
                            task_type      = task_type,
                            frequency      = data_profile.frequency,
                            n_observations = data_profile.span_index_length,
                        )

    estimator, estimator_candidates = select_estimator_and_candidates(
        task_type=task_type, n_observations=data_profile.n_total_observations
    )

    explanation = _build_profile_explanation(
        task_type             = task_type,
        forecaster            = forecaster,
        forecaster_candidates = forecaster_candidates,
        estimator             = estimator,
        estimator_candidates  = estimator_candidates,
        data_profile          = data_profile,
    )

    return ForecastingProfile(
        data_profile          = data_profile,
        task_type             = task_type,
        forecaster            = forecaster,
        forecaster_candidates = forecaster_candidates,
        estimator             = estimator,
        estimator_candidates  = estimator_candidates,
        series_pacf           = series_pacf,
        window_features       = window_features,
        calendar_features     = calendar_features,
        explanation           = explanation,
    )

plan

plan(
    profile,
    steps,
    interval=None,
    forecaster=None,
    estimator=None,
    estimator_kwargs=None,
    lags=None,
    window_features=None,
    *,
    metric=None,
    use_exog=None,
    differentiation=None,
    calendar_features=None,
    target_transformer=None,
    dropna_from_series=None
)

Build a detailed ForecastPlan from a ForecastingProfile.

Performs the fine-grained configuration (lags, prediction intervals, NaN handling, exogenous usage, preprocessing steps) without re-evaluating the coarse decisions already encoded in profile.

Parameters:

Name Type Description Default
profile ForecastingProfile

Output of profile().

required
steps int

Forecast horizon (number of steps ahead to predict): an integer greater than or equal to 1. An integral float (12.0) is accepted and stored as an int.

required
interval list of float

Prediction interval quantiles as a two-element list [lower, upper] (e.g. [0.1, 0.9] for 80 % interval). If None, no prediction intervals are computed.

None
forecaster str

Explicit forecaster class name to override the profile recommendation. When it is not in profile.forecaster_candidates but is a supported forecaster, it is used anyway and an UnrecommendedForecasterWarning is issued. 'ForecasterEquivalentDate' builds a seasonal naive baseline (single series only) without that warning: it is a reference to compare against, not a recommendation. Its offset is chosen from the frequency, and it takes no estimator, estimator_kwargs, lags or window_features.

None
estimator str

Explicit estimator class name to override the profile recommendation (e.g. 'HistGradientBoostingRegressor'). For ForecasterFoundation it is the Hugging Face model ID of a foundation model supported by skforecast (e.g. 'google/timesfm-3.0-pytorch'); the default is 'autogluon/chronos-2-small'.

None
estimator_kwargs dict

Keyword arguments for the estimator constructor (e.g. {'n_estimators': 200, 'learning_rate': 0.05}). Merged on top of built-in defaults (random_state, silencing flags). User values take precedence. For ForecasterFoundation they are passed to FoundationModel (e.g. {'context_length': 1024}), and the model ID goes in estimator, not here.

None
lags int, list of int

Explicit lag configuration. If provided, bypasses the deterministic PACF-based lag selection. Only the ML forecasters take lags: passing them for ForecasterStats, ForecasterFoundation or ForecasterEquivalentDate raises ValueError.

None
window_features list of dict

Explicit window (rolling) features configuration. Each dict must contain the keys 'stats' (a list of rolling statistics) and 'window_size' (a scalar int applied to every stat in that same dict), for example [{'stats': ['mean', 'std'], 'window_size': 3}, {'stats': ['mean'], 'window_size': 24}, {'stats': ['mean'], 'window_size': 168}]. To combine several window sizes, add one dict per size. Allowed stats are 'mean', 'std', 'min', 'max', 'sum', 'median', 'ratio_min_max', 'coef_variation', and 'ewm'. If provided, bypasses the deterministic window feature selection. Like lags, raises ValueError for a forecaster without window features.

None
metric str, list of str

Metric(s) the plan computes, among the regression metrics of skforecast ('mean_absolute_error', 'mean_squared_error', ...), with the semantics of compare(): the first one is the primary metric (plan.metric) and only the ones given are computed (plan.metrics_to_compute). If None, the metric is selected from the data (MAE for one series, MASE for several) and the default panel is computed.

None
use_exog bool

Whether the plan uses the exogenous columns of the profile. False leaves them out (forecast() then takes no exog); True raises ValueError when the data has no exogenous columns or the forecaster cannot use them (ForecasterEquivalentDate, a foundation model without covariate support, ForecasterStats when every exogenous column is categorical). Columns left out do not shape the plan (their missing values and categories need no handling). If None, they are used whenever the forecaster can use them.

None
differentiation int

Order of differencing applied to the target before training (an integer of at least 1); predictions are integrated back. Only the machine learning forecasters take it. The order counts in the window of the forecaster, so the lags selected leave room for it, the default window features without room for it are left out, and explicit lags and window features must fit with it. If None, the target is not differenced.

None
calendar_features list of str

Calendar features generated from the dates, among 'year', 'month', 'week', 'day_of_week', 'day_of_month', 'day_of_year', 'weekend', 'hour', 'minute', 'second' and 'quarter'; an empty list for none. Only the machine learning forecasters take them, and only with a datetime index. A feature whose column is already an exogenous column used by the plan raises ValueError (the rule leaves those out). A feature finer than the frequency whose column takes a single value on the data ('hour' on daily data, 'day_of_week' on weekly data) is kept, with a UserWarning whose text also goes to plan.warnings. The encoding follows the estimator. If None, they are selected from the frequency.

None
target_transformer str

Scaler of the target series of the machine learning forecasters: 'StandardScaler', or 'none' for no scaling. Written as transformer_y or transformer_series depending on the forecaster. If None, the target is scaled for the estimators that need it (not the tree-based ones).

None
dropna_from_series bool

Whether the machine learning forecasters drop the training rows with missing values. False with missing values in the data and an estimator that does not accept them raises ValueError. If None, rows are dropped only when the data has missing values and the estimator does not accept them.

None

Returns:

Name Type Description
plan ForecastPlan

Detailed forecasting plan. Its warnings hold the text of the warnings this call emitted, in the order they were emitted, and its overridden_fields the arguments passed with a value other than None (an empty estimator_kwargs is the default).

Raises:

Type Description
ValueError

If steps is not an integer greater than or equal to 1, if forecaster is not supported, if the input does not fit its task type, or if an argument does not apply to it: lags and window_features for ForecasterStats, ForecasterFoundation and ForecasterEquivalentDate, and also estimator and estimator_kwargs for ForecasterEquivalentDate. For ForecasterFoundation, also if estimator is not a model supported by skforecast, if estimator_kwargs contains 'model_id', or if the model cannot predict interval. Also if metric is empty, repeats a metric or names one outside the regression metrics of skforecast.

TypeError

If metric is not a str or a list of str.

Source code in skforecast_ai/assistant.py
 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
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
def plan(
    self,
    profile: ForecastingProfile,
    steps: int,
    interval: list[float] | None = None,
    forecaster: str | None = None,
    estimator: str | None = None,
    estimator_kwargs: dict | None = None,
    lags: int | list[int] | None = None,
    window_features: list[dict[str, list[str] | int]] | None = None,
    *,
    metric: str | list[str] | None = None,
    use_exog: bool | None = None,
    differentiation: int | None = None,
    calendar_features: list[str] | None = None,
    target_transformer: str | None = None,
    dropna_from_series: bool | None = None,
) -> ForecastPlan:
    """
    Build a detailed `ForecastPlan` from a `ForecastingProfile`.

    Performs the fine-grained configuration (lags, prediction
    intervals, NaN handling, exogenous usage, preprocessing steps)
    without re-evaluating the coarse decisions already encoded in
    `profile`.

    Parameters
    ----------
    profile : ForecastingProfile
        Output of `profile()`.
    steps : int
        Forecast horizon (number of steps ahead to predict): an integer
        greater than or equal to 1. An integral float (`12.0`) is
        accepted and stored as an int.
    interval : list of float, default None
        Prediction interval quantiles as a two-element list
        `[lower, upper]` (e.g. `[0.1, 0.9]` for 80 % interval). If
        None, no prediction intervals are computed.
    forecaster : str, default None
        Explicit forecaster class name to override the profile
        recommendation. When it is not in
        `profile.forecaster_candidates` but is a supported
        forecaster, it is used anyway and an
        `UnrecommendedForecasterWarning` is issued.
        `'ForecasterEquivalentDate'` builds a seasonal naive baseline
        (single series only) without that warning: it is a reference
        to compare against, not a recommendation. Its offset is
        chosen from the frequency, and it takes no `estimator`,
        `estimator_kwargs`, `lags` or `window_features`.
    estimator : str, default None
        Explicit estimator class name to override the profile
        recommendation (e.g. `'HistGradientBoostingRegressor'`). For
        `ForecasterFoundation` it is the Hugging Face model ID of a
        foundation model supported by skforecast (e.g.
        `'google/timesfm-3.0-pytorch'`); the default is
        `'autogluon/chronos-2-small'`.
    estimator_kwargs : dict, default None
        Keyword arguments for the estimator constructor (e.g.
        `{'n_estimators': 200, 'learning_rate': 0.05}`). Merged
        on top of built-in defaults (`random_state`, silencing
        flags). User values take precedence. For
        `ForecasterFoundation` they are passed to `FoundationModel`
        (e.g. `{'context_length': 1024}`), and the model ID goes in
        `estimator`, not here.
    lags : int, list of int, default None
        Explicit lag configuration. If provided, bypasses the
        deterministic PACF-based lag selection. Only the ML forecasters
        take lags: passing them for `ForecasterStats`,
        `ForecasterFoundation` or `ForecasterEquivalentDate` raises
        `ValueError`.
    window_features : list of dict, default None
        Explicit window (rolling) features configuration. Each dict
        must contain the keys `'stats'` (a list of rolling statistics)
        and `'window_size'` (a scalar int applied to every stat in
        that same dict), for example `[{'stats': ['mean', 'std'],
        'window_size': 3}, {'stats': ['mean'], 'window_size': 24},
        {'stats': ['mean'], 'window_size': 168}]`. To combine several
        window sizes, add one dict per size. Allowed stats are
        `'mean'`, `'std'`, `'min'`, `'max'`, `'sum'`, `'median'`,
        `'ratio_min_max'`, `'coef_variation'`, and `'ewm'`. If
        provided, bypasses the deterministic window feature selection.
        Like `lags`, raises `ValueError` for a forecaster without
        window features.
    metric : str, list of str, default None
        Metric(s) the plan computes, among the regression metrics of
        skforecast (`'mean_absolute_error'`, `'mean_squared_error'`,
        ...), with the semantics of `compare()`: the first one is the
        primary metric (`plan.metric`) and only the ones given are
        computed (`plan.metrics_to_compute`). If None, the metric is
        selected from the data (MAE for one series, MASE for several)
        and the default panel is computed.
    use_exog : bool, default None
        Whether the plan uses the exogenous columns of the profile.
        False leaves them out (`forecast()` then takes no `exog`); True
        raises `ValueError` when the data has no exogenous columns or
        the forecaster cannot use them (`ForecasterEquivalentDate`, a
        foundation model without covariate support, `ForecasterStats`
        when every exogenous column is categorical). Columns left out
        do not shape the plan (their missing values and categories need
        no handling). If None, they are used whenever the forecaster can
        use them.
    differentiation : int, default None
        Order of differencing applied to the target before training (an
        integer of at least 1); predictions are integrated back. Only
        the machine learning forecasters take it. The order counts in
        the window of the forecaster, so the lags selected leave room
        for it, the default window features without room for it are
        left out, and explicit lags and window features must fit with
        it.
        If None, the target is not differenced.
    calendar_features : list of str, default None
        Calendar features generated from the dates, among `'year'`,
        `'month'`, `'week'`, `'day_of_week'`, `'day_of_month'`,
        `'day_of_year'`, `'weekend'`, `'hour'`, `'minute'`, `'second'`
        and `'quarter'`; an empty list for none. Only the machine
        learning forecasters take them, and only with a datetime index.
        A feature whose column is already an exogenous column used by
        the plan raises `ValueError` (the rule leaves those out). A
        feature finer than the frequency whose column takes a single
        value on the data (`'hour'` on daily data, `'day_of_week'` on
        weekly data) is kept, with a `UserWarning` whose text also goes
        to `plan.warnings`. The encoding follows the estimator. If None,
        they are selected from the frequency.
    target_transformer : str, default None
        Scaler of the target series of the machine learning
        forecasters: `'StandardScaler'`, or `'none'` for no scaling.
        Written as `transformer_y` or `transformer_series` depending on
        the forecaster. If None, the target is scaled for the
        estimators that need it (not the tree-based ones).
    dropna_from_series : bool, default None
        Whether the machine learning forecasters drop the training rows
        with missing values. False with missing values in the data and
        an estimator that does not accept them raises `ValueError`. If
        None, rows are dropped only when the data has missing values and
        the estimator does not accept them.

    Returns
    -------
    plan : ForecastPlan
        Detailed forecasting plan. Its `warnings` hold the text of the
        warnings this call emitted, in the order they were emitted, and
        its `overridden_fields` the arguments passed with a value other
        than None (an empty `estimator_kwargs` is the default).

    Raises
    ------
    ValueError
        If `steps` is not an integer greater than or equal to 1, if
        `forecaster` is not supported, if the input does not fit its
        task type, or if an argument does not apply to it: `lags` and
        `window_features` for `ForecasterStats`, `ForecasterFoundation`
        and `ForecasterEquivalentDate`, and also `estimator` and
        `estimator_kwargs` for `ForecasterEquivalentDate`. For
        `ForecasterFoundation`, also if `estimator` is not a model
        supported by skforecast, if `estimator_kwargs` contains
        `'model_id'`, or if the model cannot predict `interval`. Also if
        `metric` is empty, repeats a metric or names one outside the
        regression metrics of skforecast.
    TypeError
        If `metric` is not a str or a list of str.
    """

    # Checked first, so an invalid horizon fails before anything is
    # derived from it (a bool or a string would otherwise be coerced).
    steps = validate_steps(steps)
    metric_override = resolve_metric_override(metric)
    if use_exog is not None and not isinstance(use_exog, bool):
        raise InvalidInputTypeError(
            f"`use_exog` must be True, False or None, got {use_exog!r}.",
            field = "use_exog",
        )
    use_exog_override = use_exog
    if differentiation is not None:
        error = (
            InvalidInputTypeError
            if isinstance(differentiation, bool)
            or not isinstance(differentiation, numbers.Integral)
            else InvalidInputError if differentiation < 1 else None
        )
        if error is not None:
            raise error(
                f"`differentiation` must be an integer greater than or "
                f"equal to 1, got {differentiation!r}.",
                field = "differentiation",
            )
    differentiation = None if differentiation is None else int(differentiation)
    calendar_override = validate_calendar_override(calendar_features)
    if target_transformer is not None and (
        target_transformer != "none"
        and target_transformer not in SUPPORTED_TRANSFORMERS
    ):
        raise InvalidInputError(
            f"`target_transformer` must be one of "
            f"{[*SUPPORTED_TRANSFORMERS, 'none']}, got {target_transformer!r}.",
            field = "target_transformer",
        )
    if dropna_from_series is not None and not isinstance(dropna_from_series, bool):
        raise InvalidInputTypeError(
            f"`dropna_from_series` must be True, False or None, got "
            f"{dropna_from_series!r}.",
            field = "dropna_from_series",
        )
    dropna_override = dropna_from_series

    data_profile = profile.data_profile

    fc = profile.forecaster
    unrecommended = False
    if forecaster is not None:
        if (
            forecaster not in profile.forecaster_candidates
            and forecaster not in BASELINE_FORECASTERS
        ):
            if forecaster not in FORECASTER_TASK_TYPES:
                raise InvalidInputError(
                    f"Forecaster '{forecaster}' is not compatible with this "
                    f"profile. Available candidates: "
                    f"{profile.forecaster_candidates}.",
                    field = "forecaster",
                )
            unrecommended = True
        fc = forecaster

    task_type = select_task_type_from_forecaster(fc)

    # Reject inputs incompatible with the resolved task type
    # (single-series tasks with multi-series input; multivariate with
    # series of different lengths or on long-format data).
    _validate_task_input(data_profile, task_type)

    # Arguments the forecaster has no use for are rejected rather than
    # silently ignored, so the plan never differs from what was asked.
    if task_type in ("statistical", "foundation", "baseline"):
        inapplicable = [("lags", lags), ("window_features", window_features)]
        if task_type == "baseline":
            inapplicable = [
                ("estimator", estimator),
                ("estimator_kwargs", estimator_kwargs),
                *inapplicable,
            ]
            reason = (
                "is a baseline that repeats past values: it has no "
                "estimator and no lag or window features"
            )
        else:
            reason = (
                "models the past values itself: it takes no lag or "
                "window features"
            )
        given = [name for name, value in inapplicable if value is not None]
        if given:
            raise InvalidInputError(
                f"'{fc}' {reason}, so {given} cannot be applied. Omit them.",
                field = given[0],
            )
        # Arguments of the machine learning forecasters only.
        given = [
            name for name, value in (
                ("differentiation", differentiation),
                ("calendar_features", calendar_override),
                ("target_transformer", target_transformer),
                ("dropna_from_series", dropna_override),
            )
            if value is not None
        ]
        if given:
            raise InvalidInputError(
                f"{given} only apply to the machine learning forecasters "
                f"({sorted(AUTOREG_FORECASTERS)}), not to '{fc}'. Omit "
                f"them.",
                field = given[0],
            )

    # The baseline needs a frequency too (its offset counts periods of
    # it); checked before its warning about missing values.
    if task_type == "baseline":
        _check_frequency_known(fc, data_profile)

    # Every warning this call emits is also kept in `plan.warnings`, with
    # the same text, so it travels with the plan where Python warnings
    # are not seen (a server, JSON output, a saved plan).
    plan_warnings: list[str] = []

    if task_type == "baseline":
        missing_note = baseline_missing_values_note(data_profile)
        if missing_note is not None:
            message = (
                f"'{fc}' cannot handle missing values: {missing_note}. "
                f"Impute the target before fitting, or the predictions "
                f"and metrics will contain missing values."
            )
            warnings.warn(message, UserWarning, stacklevel=2)
            plan_warnings.append(message)

    n_obs_total = data_profile.n_total_observations

    # Recompute the estimator only when the task type changed.
    if task_type != profile.task_type:
        est, _ = select_estimator_and_candidates(
            task_type      = task_type,
            n_observations = n_obs_total,
        )
    else:
        est = profile.estimator

    if estimator is not None:
        est = estimator

    # The estimator name and its keyword arguments are written into the
    # generated script, so they are checked before anything is derived.
    validate_estimator(
        estimator        = est,
        estimator_kwargs = estimator_kwargs,
        task_type        = task_type,
    )
    plan_warnings += validate_estimator_kwargs(est, estimator_kwargs)

    # The foundation model is validated before anything else is derived,
    # so an unsupported model ID or interval fails with its own message.
    foundation_model = None
    if task_type == "foundation":
        foundation_model = validate_foundation_plan(
            estimator        = est,
            estimator_kwargs = estimator_kwargs,
            interval         = interval,
        )
    else:
        validate_interval(
            interval   = interval,
            task_type  = task_type,
            forecaster = fc,
        )

    if fc in REQUIRES_DATETIME_FREQ:
        _check_frequency_known(fc, data_profile)

    # The baseline cannot take exogenous variables; the explanation says
    # they are left out. A foundation model uses the columns its backend
    # accepts, so a model without covariate support uses none.
    if task_type == "foundation":
        use_exog = bool(foundation_exog_columns(
            info             = foundation_model,
            exog_columns     = data_profile.exog_columns,
            categorical_exog = data_profile.categorical_exog,
        ))
    else:
        use_exog = (
            task_type != "baseline"
            and check_exog_usage(data_profile.exog_columns)
        )
    if use_exog_override is True and not use_exog:
        if not data_profile.exog_columns and data_profile.unused_columns:
            reason = (
                "the profile has no exogenous columns (`exog_columns` of "
                "profile() left them out)"
            )
        elif not data_profile.exog_columns:
            reason = "the data has no exogenous columns"
        elif task_type == "baseline":
            reason = f"'{fc}' only repeats past values of the target"
        else:
            reason = (
                f"'{est}' does not accept the exogenous columns of the "
                f"data as covariates"
            )
        raise InvalidInputError(
            f"`use_exog=True` cannot be applied: {reason}. Omit it, or "
            f"pass False.",
            field = "use_exog",
        )
    if (
        use_exog_override is True
        and task_type == "statistical"
        and set(data_profile.exog_columns) <= set(data_profile.categorical_exog)
    ):
        # The ARIMA script leaves categorical columns out, so it would
        # use none while `forecast()` asked for their future values.
        raise InvalidInputError(
            f"`use_exog=True` cannot be applied: '{fc}' only uses numeric "
            f"exogenous columns, and {data_profile.exog_columns} are "
            f"categorical. Omit it, or pass False.",
            field = "use_exog",
        )
    rule_use_exog = use_exog
    if use_exog_override is not None:
        use_exog = use_exog_override
    # The exogenous columns the user leaves out do not shape the plan:
    # their missing values and categories need no handling. (A plan the
    # rule leaves without them keeps its notes, as before.)
    planned_profile = data_profile.model_copy(
        update={"exog_columns": [], "categorical_exog": [], "missing_exog": {}}
    ) if use_exog_override is False else data_profile

    baseline_explanation = None
    skipped_calendar_features: list[str] = []
    dropped_windows: list[int] = []
    if task_type in ("statistical", "foundation", "baseline"):
        final_lags = None
        final_window_features = None
        transformer_series = None
        transformer_exog = None
        dropna_from_series = None
        calendar_features = None
    else:
        # Explicit lag/window overrides (manual or LLM-supplied) bypass the
        # deterministic PACF selection and its budget guard, so validate
        # them against the data budget before building the forecaster.
        if lags is not None:
            _validate_lags(lags)

        if window_features is not None:
            _validate_window_features(window_features)

        if lags is not None or window_features is not None:
            _validate_max_window_size(
                lags              = lags,
                window_features   = window_features,
                span_index_length = data_profile.span_index_length
            )

        if lags is not None:
            final_lags = lags
        else:
            # Direct forecasters lose `steps - 1` extra rows beyond the
            # `window_size` (the last-step regressor needs the target at
            # t + steps), so reserve them from the lag budget. Recursive
            # forecasters reserve nothing.
            n_reserved_rows = steps - 1 if fc in DIRECT_FORECASTERS else 0
            # The differentiation order adds to the window as well.
            n_reserved_rows += differentiation or 0
            final_lags = finalize_lags(
                series_pacf     = profile.series_pacf,
                task_type       = task_type,
                n_observations  = data_profile.span_index_length,
                frequency       = data_profile.frequency,
                n_reserved_rows = n_reserved_rows,
            )

        if window_features is not None:
            final_window_features = window_features
        else:
            final_window_features = profile.window_features
            if differentiation is not None and final_window_features:
                # The rule sized the windows for the whole budget; those
                # that leave no room for the order are dropped, as the
                # lags selected leave room for it.
                max_window = (
                    int(data_profile.span_index_length * MAX_FEATURE_FRACTION)
                    - differentiation
                )
                kept = [
                    entry for entry in final_window_features
                    if _max_window_size(None, [entry]) <= max_window
                ]
                dropped_windows = [
                    _max_window_size(None, [entry])
                    for entry in final_window_features
                    if entry not in kept
                ]
                final_window_features = kept or None

        if differentiation is not None:
            _validate_max_window_size(
                lags              = final_lags,
                window_features   = final_window_features,
                span_index_length = data_profile.span_index_length,
                differentiation   = differentiation,
            )

        if calendar_override is not None:
            calendar_features = _chosen_calendar_features(
                features     = calendar_override,
                encoding     = select_calendar_encoding(est, task_type),
                data_profile = data_profile,
                use_exog     = use_exog,
            )
        elif profile.calendar_features:
            calendar_encoding = select_calendar_encoding(est, task_type)
            calendar_names = list(profile.calendar_features)
            if use_exog:
                # A generated column named like an exogenous column makes
                # skforecast fail with duplicated feature names; the
                # user's column is kept and the calendar feature skipped.
                calendar_names, skipped_calendar_features = (
                    drop_colliding_calendar_features(
                        features     = calendar_names,
                        encoding     = calendar_encoding,
                        exog_columns = data_profile.exog_columns,
                    )
                )
            if calendar_names:
                calendar_features = {
                    "features": calendar_names,
                    "encoding": calendar_encoding,
                }
            else:
                calendar_features = None
        else:
            calendar_features = None

        if target_transformer is not None:
            transformer_series = (
                None if target_transformer == "none" else target_transformer
            )
        else:
            transformer_series = select_transformer_series(est, task_type)

        transformer_exog = select_transformer_exog(
            estimator        = est,
            task_type        = task_type,
            exog_columns     = data_profile.exog_columns,
            categorical_exog = data_profile.categorical_exog,
        ) if use_exog else None

        rule_dropna = select_dropna_from_series(
            estimator        = est,
            missing_target   = data_profile.missing_target,
            missing_exog     = planned_profile.missing_exog,
            task_type        = task_type,
            has_gaps         = data_profile.has_gaps,
        )
        if dropna_override is False and rule_dropna:
            # The rule drops the rows only when there are missing values
            # and the estimator does not accept them: it would fail.
            raise InvalidInputError(
                f"`dropna_from_series=False` cannot be applied: the data "
                f"has missing values and '{est}' does not accept them. "
                f"Omit it, impute the missing values, or choose an "
                f"estimator that accepts them (for example "
                f"'LGBMRegressor').",
                field = "dropna_from_series",
            )
        dropna_from_series = (
            rule_dropna if dropna_override is None else dropna_override
        )

    forecaster_kwargs = build_forecaster_kwargs(
        forecaster         = fc,
        task_type          = task_type,
        steps              = steps,
        lags               = final_lags,
        window_features    = final_window_features,
        calendar_features  = calendar_features,
        transformer_series = transformer_series,
        transformer_exog   = transformer_exog,
        dropna_from_series = dropna_from_series,
        differentiation    = differentiation,
    )

    if task_type == "baseline":
        forecaster_kwargs, baseline_explanation = select_baseline_config(
            data_profile
        )

    interval_method = resolve_interval_method(task_type, interval)

    preprocessing_steps = derive_preprocessing_steps(
        profile          = planned_profile,
        forecaster       = fc,
        foundation_model = foundation_model,
    )

    if metric_override is None:
        metric, metric_explanation, metrics_to_compute = select_metric(
            data_profile = data_profile,
        )
    else:
        metric = metric_override[0]
        metrics_to_compute = metric_override
        metric_explanation = build_metric_override_explanation(metric_override)

    # `dropna_from_series=False` also means that no value is missing
    # (missing timestamps become missing values after `asfreq()`), and
    # then there is no NaN handling to explain: saying the rows are kept
    # because the estimator tolerates NaN would be wrong for Ridge.
    has_missing = (
        bool(data_profile.missing_target)
        or bool(planned_profile.missing_exog)
        or data_profile.has_gaps
    )
    explanation = build_plan_explanation(
        forecaster                = fc,
        estimator                 = est,
        lags                      = final_lags,
        window_features           = final_window_features,
        interval_method           = interval_method,
        dropna_from_series        = dropna_from_series if has_missing else None,
        use_exog                  = use_exog,
        metric_explanation        = metric_explanation,
        calendar_features         = calendar_features,
        task_type                 = task_type,
        skipped_calendar_features = skipped_calendar_features,
    )
    if foundation_model is not None:
        foundation_explanation = build_foundation_explanation(
            foundation_model = foundation_model,
            exog_columns     = data_profile.exog_columns,
            context_length   = (estimator_kwargs or {}).get(
                "context_length", foundation_model.default_context_length
            ),
            n_observations   = max(
                info.length for info in data_profile.series_lengths.values()
            ),
            n_series         = data_profile.n_series,
        )
        explanation = f"{explanation} {foundation_explanation}"
    if differentiation is not None:
        explanation += (
            f" The target is differenced (order {differentiation}) before "
            f"training, as requested, and the predictions are integrated "
            f"back."
        )
        if dropped_windows:
            explanation += (
                f" Window features of size {dropped_windows} are left out: "
                f"with the differentiation they exceed the data budget."
            )
    if calendar_override is not None:
        explanation += (
            " Calendar features as requested." if calendar_override
            else " No calendar features, as requested."
        )
    if target_transformer is not None:
        explanation += (
            " Target not scaled, as requested." if target_transformer == "none"
            else f" Target scaled with {target_transformer}, as requested."
        )
    if dropna_override is not None:
        explanation += (
            " Training rows with missing values are dropped, as requested."
            if dropna_override
            else " Training rows with missing values are kept, as requested."
        )
    if use_exog_override is False and rule_use_exog:
        explanation += (
            f" Exogenous variables {data_profile.exog_columns} are not "
            f"used, as requested."
        )
    if fc == "ForecasterDirectMultiVariate":
        # Its script predicts one series, the first of the target, which
        # the plan did not say.
        explanation += (
            f" It predicts '{data_profile.target[0]}', the first series of "
            f"the target, from the lags of all the series."
            if isinstance(data_profile.target, list)
            else f" It predicts '{data_profile.target}'."
        )
    if baseline_explanation is not None:
        explanation = f"{explanation} {baseline_explanation}"
        if data_profile.exog_columns:
            explanation += (
                f" Exogenous variables {data_profile.exog_columns} are "
                f"not used: the baseline only repeats past target values."
            )

    unrecommended_message = None
    if unrecommended:
        unrecommended_message = (
            f"Forecaster '{forecaster}' is not among the recommended "
            f"candidates for this profile "
            f"({profile.forecaster_candidates}), but it is used as "
            f"requested. It may be slow or perform poorly on this data."
        )
        plan_warnings.append(unrecommended_message)

    # A chosen calendar feature finer than the frequency (`hour` on daily
    # data) gives a constant column: skforecast accepts it, so it is a
    # warning and not an error (decision 5 of 19.1).
    constant_calendar_message = None
    constant = constant_calendar_features(
        calendar_override or [], data_profile
    )
    if constant:
        constant_calendar_message = (
            f"Calendar features {constant} are finer than the frequency "
            f"of the data ('{data_profile.frequency}'): each one gives a "
            f"column with a single value, from which the model learns "
            f"nothing. Leave them out of `calendar_features`."
        )
        plan_warnings.append(constant_calendar_message)

    # The decisions the caller made instead of the rules: None asks for
    # the rule, and empty keyword arguments are the defaults.
    overridden_fields = [
        name
        for name, value in (
            ("forecaster", forecaster),
            ("estimator", estimator),
            ("estimator_kwargs", estimator_kwargs or None),
            ("lags", lags),
            ("window_features", window_features),
            ("metric", metric_override),
            ("use_exog", use_exog_override),
            ("differentiation", differentiation),
            ("calendar_features", calendar_override),
            ("target_transformer", target_transformer),
            ("dropna_from_series", dropna_override),
        )
        if value is not None
    ]

    plan = ForecastPlan(
        task_type           = task_type,
        forecaster          = fc,
        forecaster_kwargs   = forecaster_kwargs,
        estimator           = est,
        estimator_kwargs    = estimator_kwargs or {},
        steps               = steps,
        frequency           = data_profile.frequency,
        interval            = interval,
        interval_method     = interval_method,
        metric              = metric,
        metrics_to_compute  = metrics_to_compute,
        use_exog            = use_exog,
        preprocessing_steps = preprocessing_steps,
        warnings            = plan_warnings,
        overridden_fields   = overridden_fields,
        # Recorded only when the profile left columns out: without its
        # profile, the plan would run on every column of the data.
        exog_columns        = (
            list(data_profile.exog_columns)
            if data_profile.unused_columns else None
        ),
        explanation         = explanation,
    )

    _check_feature_name_collisions(plan, data_profile)

    # Warned once the plan exists, so a forecaster that a later check
    # rejects (the shape of the data, an argument it has no use for, a
    # foundation model or an interval it cannot serve) is never said to
    # be "used as requested", and with warnings raised as errors the
    # warning never hides that error.
    if unrecommended_message is not None:
        warnings.warn(unrecommended_message, UnrecommendedForecasterWarning)
    if constant_calendar_message is not None:
        warnings.warn(constant_calendar_message, UserWarning, stacklevel=2)

    return plan

refine_plan

refine_plan(profile, plan, prompt=None, **overrides)

Re-derive a forecast plan applying user overrides or LLM guidance.

Operates in two modes:

  • Deterministic mode (prompt=None): takes an existing plan and a set of overrides, then calls plan() with the merged parameters. Only the overridden fields change; everything else is re-derived deterministically from the original profile.
  • LLM mode (prompt provided): a specialized agent interprets the natural-language domain knowledge and suggests lags and window_features, which are merged on top of the deterministic plan. The agent's reasoning is appended to the returned plan's explanation.

Supported overrides: forecaster, estimator, estimator_kwargs, steps, interval, lags, window_features, metric, use_exog, differentiation, calendar_features, target_transformer, dropna_from_series (see RefinePlanOverrides). What matters is whether a key is passed: an omitted key keeps the value of plan, while a key passed as None asks for the deterministic default (interval=None removes the prediction intervals, lags=None re-runs the PACF-based selection, estimator_kwargs=None resets the hyperparameters).

Note that lags and window_features default to the values already stored in plan.forecaster_kwargs, so refining an unrelated field (e.g. steps) preserves the existing features rather than re-running the PACF-based selection. A value the new forecaster cannot use is not carried over: switching to a forecaster without lags (ForecasterStats, ForecasterFoundation, ForecasterEquivalentDate) drops lags and window_features, switching to another forecaster family drops estimator and estimator_kwargs, which are then re-derived, and changing the estimator without passing estimator_kwargs drops the kwargs of the previous estimator. The decisions added in 0.4.0 (metric, use_exog, differentiation, calendar_features, target_transformer, dropna_from_series) are carried over only when they are in plan.overridden_fields (and when the new forecaster can apply them); otherwise the rules decide them again. The llm_refined_fields marks of the original plan are kept for the fields whose value is carried over unchanged. The end_train split boundary is not kept: a refined plan starts in prediction mode, so pass test_size again to evaluate it.

In LLM mode, explicit lags/window_features overrides take precedence over the LLM suggestion (a UserWarning is emitted for each shadowed field, and a note recording the overridden field(s) is appended to the explanation). When both are supplied explicitly, the LLM has nothing left to decide and is not called. LLM mode does not apply when the refined plan's task_type is 'statistical', 'foundation' or 'baseline', which do not use lags or window features; the prompt is ignored with a UserWarning and the LLM is not called. When the agent omits a field, or the LLM call fails or its suggestion is invalid (non-positive, duplicated or empty lags, malformed window features) or cannot satisfy the data budget, that field keeps the plan's existing value (a UserWarning is emitted on failure).

Parameters:

Name Type Description Default
profile ForecastingProfile

Original profile that produced the plan.

required
plan ForecastPlan

Existing plan to refine.

required
prompt str

Natural-language domain knowledge used to guide LLM refinement of lags and window_features. When None, only the explicit overrides are applied. Requires an LLM to be configured.

None
**overrides Unpack[RefinePlanOverrides]

Keyword arguments to override. Accepted keys: forecaster, estimator, estimator_kwargs, steps, interval, lags, window_features, metric, use_exog, differentiation, calendar_features, target_transformer, dropna_from_series. Typed through RefinePlanOverrides, so editors autocomplete them and type checkers reject unknown names; unknown keys also raise ValueError at run time.

{}

Returns:

Name Type Description
plan ForecastPlan

Updated plan with overrides (and any LLM refinement) applied. In LLM mode, the agent's reasoning is appended to plan.explanation. The plan is rebuilt with plan(), so its warnings are those of that call (the ones of plan are not carried over), plus the text of a PlanEditsDiscardedWarning; the warnings about the prompt that this method emits are not added to them. Its overridden_fields names the overrides passed with a value and those of plan whose value the refined plan keeps.

Warns:

Type Description
PlanEditsDiscardedWarning

When plan holds values that plan() does not build from what is carried over (values edited by hand, such as a key of forecaster_kwargs, or a metric that is not passed as metric): the refined plan does not keep them. The split boundary, the explanation, the warnings, the marks and the exogenous columns (recorded from the profile) are not compared, nor the fields overridden in the call.

Source code in skforecast_ai/assistant.py
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
def refine_plan(
    self,
    profile: ForecastingProfile,
    plan: ForecastPlan,
    prompt: str | None = None,
    **overrides: Unpack[RefinePlanOverrides],
) -> ForecastPlan:
    """
    Re-derive a forecast plan applying user overrides or LLM guidance.

    Operates in two modes:

    - Deterministic mode (`prompt=None`): takes an existing plan and a
      set of overrides, then calls `plan()` with the merged parameters.
      Only the overridden fields change; everything else is re-derived
      deterministically from the original profile.
    - LLM mode (`prompt` provided): a specialized agent interprets the
      natural-language domain knowledge and suggests `lags` and
      `window_features`, which are merged on top of the deterministic
      plan. The agent's reasoning is appended to the returned plan's
      `explanation`.

    Supported overrides: `forecaster`, `estimator`, `estimator_kwargs`, `steps`,
    `interval`, `lags`, `window_features`, `metric`, `use_exog`, `differentiation`,
    `calendar_features`, `target_transformer`, `dropna_from_series` (see
    `RefinePlanOverrides`). What matters is whether a key is passed: an omitted
    key keeps the value of `plan`, while a key passed as None asks for the
    deterministic default (`interval=None` removes the prediction intervals,
    `lags=None` re-runs the PACF-based selection, `estimator_kwargs=None` resets the
    hyperparameters).

    Note that `lags` and `window_features` default to the values
    already stored in `plan.forecaster_kwargs`, so refining an
    unrelated field (e.g. `steps`) preserves the existing features
    rather than re-running the PACF-based selection. A value the new
    forecaster cannot use is not carried over: switching to a
    forecaster without lags (`ForecasterStats`, `ForecasterFoundation`,
    `ForecasterEquivalentDate`) drops `lags` and `window_features`,
    switching to another forecaster family drops `estimator` and
    `estimator_kwargs`, which are then re-derived, and changing the
    `estimator` without passing `estimator_kwargs` drops the kwargs of
    the previous estimator. The decisions added in 0.4.0 (`metric`,
    `use_exog`, `differentiation`, `calendar_features`,
    `target_transformer`, `dropna_from_series`) are carried over only
    when they are in `plan.overridden_fields` (and when the new forecaster can apply
    them); otherwise the rules decide them again. The
    `llm_refined_fields` marks of the original plan are kept for the
    fields whose value is carried over unchanged. The `end_train` split
    boundary is not kept: a refined plan starts in prediction mode, so
    pass `test_size` again to evaluate it.

    In LLM mode, explicit `lags`/`window_features` overrides take
    precedence over the LLM suggestion (a `UserWarning` is emitted for
    each shadowed field, and a note recording the overridden field(s) is
    appended to the explanation). When both are supplied explicitly, the
    LLM has nothing left to decide and is not called. LLM mode does not
    apply when the refined plan's `task_type` is `'statistical'`,
    `'foundation'` or `'baseline'`, which do not use lags or window
    features; the prompt is ignored with a `UserWarning` and the LLM is
    not called. When the agent omits a field, or the LLM call fails or
    its suggestion is invalid (non-positive, duplicated or empty lags,
    malformed window features) or cannot satisfy the data budget, that
    field keeps the plan's existing value (a `UserWarning` is emitted on
    failure).

    Parameters
    ----------
    profile : ForecastingProfile
        Original profile that produced the plan.
    plan : ForecastPlan
        Existing plan to refine.
    prompt : str, default None
        Natural-language domain knowledge used to guide LLM refinement of
        `lags` and `window_features`. When None, only the explicit
        overrides are applied. Requires an LLM to be configured.
    **overrides : Unpack[RefinePlanOverrides]
        Keyword arguments to override. Accepted keys:
        `forecaster`, `estimator`, `estimator_kwargs`, `steps`,
        `interval`, `lags`, `window_features`, `metric`, `use_exog`,
        `differentiation`, `calendar_features`, `target_transformer`,
        `dropna_from_series`. Typed through
        `RefinePlanOverrides`, so editors autocomplete them and type
        checkers reject unknown names; unknown keys also raise
        `ValueError` at run time.

    Returns
    -------
    plan : ForecastPlan
        Updated plan with overrides (and any LLM refinement) applied. In
        LLM mode, the agent's reasoning is appended to `plan.explanation`.
        The plan is rebuilt with `plan()`, so its `warnings` are those of
        that call (the ones of `plan` are not carried over), plus the
        text of a `PlanEditsDiscardedWarning`; the warnings about the
        `prompt` that this method emits are not added to them. Its
        `overridden_fields` names the overrides passed with a value and
        those of `plan` whose value the refined plan keeps.

    Warns
    -----
    PlanEditsDiscardedWarning
        When `plan` holds values that `plan()` does not build from what
        is carried over (values edited by hand, such as a key of
        `forecaster_kwargs`, or a metric that is not passed as `metric`):
        the refined plan does not keep them.
        The split boundary, the explanation, the warnings, the marks
        and the exogenous columns (recorded from the profile) are not
        compared, nor the fields overridden in the call.
    """

    allowed_keys = REFINE_PLAN_OVERRIDE_KEYS
    invalid_keys = set(overrides) - allowed_keys
    if invalid_keys:
        raise InvalidInputError(
            f"Invalid override keys: {sorted(invalid_keys)}. "
            f"Allowed keys: {sorted(allowed_keys)}.",
            field = sorted(invalid_keys)[0],
        )
    # Snapshot taken before the LLM branch injects its suggestions into
    # `overrides`, so that an inherited LLM mark is dropped only for a
    # field the caller overrode explicitly.
    explicit_keys = set(overrides)

    # The forecaster of the refined plan, not the one of `plan`, decides
    # whether the LLM has lags and window features to refine and which
    # values of `plan` still apply.
    target_forecaster = (
        overrides.get("forecaster", plan.forecaster) or profile.forecaster
    )
    target_task_type = FORECASTER_TASK_TYPES.get(
        target_forecaster, plan.task_type
    )

    reasoning = None
    shadowed_fields: list[str] = []
    llm_applied_fields: list[str] = []
    if prompt is not None:
        if self.llm is None:
            raise LLMRequiredError("refine_plan")

        if target_task_type in ("statistical", "foundation", "baseline"):
            warnings.warn(
                f"LLM plan refinement does not apply to task_type "
                f"'{target_task_type}' (no lags/window_features to refine). "
                f"Ignoring prompt.",
                UserWarning,
                stacklevel=2,
            )
        elif "lags" in overrides and "window_features" in overrides:
            warnings.warn(
                "Prompt ignored: both lags and window_features were set "
                "explicitly, leaving nothing for the LLM to decide.",
                UserWarning,
                stacklevel=2,
            )
        else:
            # Fail fast when an explicit lags/window_features override
            # already exceeds the data budget, so an LLM call is not spent
            # only for self.plan() to reject the explicit value afterwards.
            explicit_lags = overrides.get("lags")
            explicit_window_features = overrides.get("window_features")
            if explicit_lags is not None:
                _validate_lags(explicit_lags)
            if explicit_window_features is not None:
                _validate_window_features(explicit_window_features)
            if explicit_lags is not None or explicit_window_features is not None:
                _validate_max_window_size(
                    lags              = explicit_lags,
                    window_features   = explicit_window_features,
                    span_index_length = profile.data_profile.span_index_length,
                )

            # pydantic-ai builds the provider with the agent, so a
            # missing API key fails here, before any call: it degrades
            # like a failed call. A missing package stays an
            # ImportError, which names the extra to install.
            try:
                refinement_agent = self._resolve_plan_refinement_agent()
            except ImportError:
                raise
            except Exception as exc:
                warnings.warn(
                    f"LLM plan refinement failed ({exc}). Returning "
                    f"deterministic plan.",
                    UserWarning,
                    stacklevel=2,
                )
                llm_lags = llm_window_features = reasoning = None
            else:
                llm_lags, llm_window_features, reasoning = (
                    refine_features_with_llm(
                        agent   = refinement_agent,
                        profile = profile,
                        plan    = plan,
                        prompt  = prompt,
                    )
                )
            # Category-A precedence: an explicit lags/window_features
            # override wins over the LLM suggestion (warn and record each
            # shadowed field). Otherwise inject the LLM value only when the
            # agent actually suggested one; a field the agent omitted
            # (None), or a failed call (reasoning=None), preserves the
            # plan's existing value rather than re-running the
            # deterministic selection.
            if reasoning is not None:
                llm_features = {
                    "lags": llm_lags,
                    "window_features": llm_window_features,
                }
                for field, value in llm_features.items():
                    if value is None:
                        continue
                    if field in overrides:
                        shadowed_fields.append(field)
                        warnings.warn(
                            f"Explicit {field} override shadowed the LLM "
                            f"suggestion.",
                            UserWarning,
                            stacklevel=2,
                        )
                    else:
                        overrides[field] = value
                        llm_applied_fields.append(field)

    carried = _carried_plan_arguments(plan, target_forecaster)
    # A differentiation order passed here takes room from the lags and
    # the window features. Those the rules chose are chosen again with
    # that order, as `plan()` does, instead of being carried over as
    # explicit values that no longer fit.
    if overrides.get("differentiation") is not None:
        for field in ("lags", "window_features"):
            if (
                field not in overrides
                and field not in plan.overridden_fields
                and field not in plan.llm_refined_fields
            ):
                carried[field] = None
    inherited_estimator = carried["estimator"]
    inherited_lags = carried["lags"]
    inherited_window_features = carried["window_features"]

    estimator = overrides.get("estimator", inherited_estimator)
    # Keyword arguments belong to the estimator they were written for
    # (`alpha` of Ridge, `cross_learning` of Chronos-2), so a different
    # estimator starts from its own defaults unless new ones are passed.
    if estimator != inherited_estimator:
        carried["estimator_kwargs"] = None
    plan_arguments = {"profile": profile, **carried, **overrides}
    try:
        refined_plan = self.plan(**plan_arguments)
    except InvalidInputError as exc:
        if (
            exc.field in _CHOSEN_DECISIONS
            and exc.field not in explicit_keys
            and carried.get(exc.field) is not None
        ):
            # A decision of `plan` that no longer applies (dropna False
            # with another estimator, say) is not dropped silently: the
            # error says where it came from and how to clear it.
            raise type(exc)(
                f"{exc} `{exc.field}` was carried over from the plan, where "
                f"the user chose it (`plan.overridden_fields`); pass "
                f"`{exc.field}=None` to let the rule decide.",
                code  = exc.code,
                field = exc.field,
                hint  = exc.hint,
            ) from exc
        if not llm_applied_fields:
            raise
        # A suggestion of the LLM that the plan rejects (lags named like
        # an exogenous column, too long for the data) falls back to the
        # values the plan had, which are valid on their own.
        warnings.warn(
            f"The LLM suggestion for {llm_applied_fields} was rejected "
            f"({exc}); the refined plan keeps the previous values.",
            UserWarning,
            stacklevel=2,
        )
        plan_arguments["lags"] = inherited_lags
        plan_arguments["window_features"] = inherited_window_features
        llm_applied_fields = []
        reasoning = None
        refined_plan = self.plan(**plan_arguments)

    # `self.plan()` returns a fresh plan that knows nothing about the
    # original one, so its LLM marks would otherwise be lost. A mark is
    # inherited only when the marked value itself was carried over: the
    # field was neither overridden explicitly nor re-suggested by the
    # LLM, and the refined plan still holds the same value. Switching to
    # a forecaster family without lags drops the value and the mark.
    inherited_fields = [
        field
        for field in plan.llm_refined_fields
        if field not in explicit_keys
        and field not in llm_applied_fields
        and plan.forecaster_kwargs.get(field) is not None
        and refined_plan.forecaster_kwargs.get(field)
        == plan.forecaster_kwargs.get(field)
    ]
    refined_plan.llm_refined_fields = inherited_fields + llm_applied_fields

    # `self.plan()` records every value passed to it, carried over or
    # suggested by the LLM. A decision of the user is one passed in this
    # call, or one of `plan` whose value the refined plan still holds.
    refined_plan.overridden_fields = [
        name
        for name in OVERRIDE_NAMES
        if (
            name in explicit_keys
            and overrides[name] is not None
            and overrides[name] != {}
        )
        or (
            name not in explicit_keys
            and name in plan.overridden_fields
            and plan_override_value(refined_plan, name)
            == plan_override_value(plan, name)
        )
    ]

    # Values edited by hand in `plan`, which `self.plan()` does not
    # rebuild, are lost: said, not dropped silently.
    discarded = self._discarded_edits(profile, plan, overrides, explicit_keys)
    message = None
    if discarded is None:
        message = (
            "refine_plan() rebuilds the plan with plan(), which rejects "
            "the values of the plan for this profile, so values edited by "
            "hand in the plan may have been discarded without being "
            "compared."
        )
    elif discarded:
        message = (
            f"refine_plan() rebuilds the plan with plan(), so these values "
            f"of the plan, which differ from what plan() builds for it, "
            f"were discarded: {discarded}. Pass the ones that "
            f"`refine_plan()` accepts ({sorted(REFINE_PLAN_OVERRIDE_KEYS)}) "
            f"as overrides to keep them."
        )
    if message is not None:
        refined_plan.warnings.append(message)
        warnings.warn(message, PlanEditsDiscardedWarning, stacklevel=2)

    if reasoning is not None:
        refined_plan.explanation += (
            f"\n\nLLM Refinement Reasoning:\n{reasoning}"
        )
        if shadowed_fields:
            # The LLM's narrative may describe a field that an explicit
            # override replaced. Record which fields actually took
            # precedence so the persisted explanation is not misleading.
            refined_plan.explanation += (
                f"\n\nNote: explicit override(s) took precedence over the "
                f"LLM suggestion for: {', '.join(shadowed_fields)}."
            )
        if llm_applied_fields:
            # LLM-suggested features are hypotheses, not measured
            # improvements. Make the lack of validation explicit so the
            # user does not read the plan as a proven accuracy gain.
            refined_plan.explanation += (
                "\n\nNote: the LLM-suggested "
                f"{' and '.join(llm_applied_fields)} are hypotheses, not "
                "validated improvements. Confirm any expected accuracy "
                "gain before relying on them."
            )

    return refined_plan

forecast_code

forecast_code(
    data=None,
    steps=None,
    target=None,
    date_column=None,
    series_id_column=None,
    exog=None,
    interval=None,
    test_size=None,
    forecaster=None,
    estimator=None,
    estimator_kwargs=None,
    lags=None,
    window_features=None,
    profile=None,
    plan=None,
    *,
    metric=None,
    use_exog=None,
    differentiation=None,
    calendar_features=None,
    target_transformer=None,
    dropna_from_series=None
)

Profile, plan, and generate a complete forecasting script.

Convenience wrapper that chains profile(), plan(), and code generation in a single call. Pre-computed profile and/or plan can be passed to skip those stages (e.g. after modifying the plan with refine_plan()).

The method operates in one of two modes depending on test_size:

  • Evaluation mode (test_size is set): the data is split into train and test sets, the forecaster is trained on the training set, predictions are made for the test set and metrics are computed against the held-out observations.
  • Prediction mode (test_size is None, the default): the forecaster is trained on all available data and forecasts the future. No metrics are returned because there is no ground truth to compare against. When the data contains exogenous variables, future values must be supplied through exog.

Parameters:

Name Type Description Default
data pandas Series, pandas DataFrame, str, Path

Input dataset, a single series, or path to a CSV file. Required when profile is not provided. When a pandas Series is passed, the target is derived from its name. A CSV path or URL is the file the generated script loads, also with a profile built from another file; with a DataFrame the script loads the path recorded in the profile.

None
steps int

Forecast horizon (number of steps ahead to predict). Required when plan is not provided. When a plan is given it defaults to plan.steps and must match it if given.

None
target str, list of str

Name of the column to forecast. Required when profile is not provided, unless data is a pandas Series (the Series name is used instead). For wide-format multi-series, pass a list of column names where each column is a series. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
date_column str

Name of the column containing timestamps. When None, the index of data is assumed to be a DatetimeIndex. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
series_id_column str

Name of the column identifying individual series (long-format multi-series input). When None, the data is treated as single-series or wide-format multi-series. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
exog pandas DataFrame, pandas Series

Future exogenous variables covering the forecast horizon. Mirrors forecast() for signature consistency. Because this method only generates code (the rendered prediction-mode script loads the future values from 'exog_future.csv' at run time), exog is optional here and is used only to validate the inputs: it must not be combined with test_size, and it must not be supplied when the data has no exogenous columns. Unlike forecast(), its dates and values are not checked, nor are the last values of the target: the script reads them when it runs.

None
interval list of float

Prediction interval quantiles as a two-element list [lower, upper] (e.g. [0.1, 0.9] for 80 % interval). When None, no prediction intervals are computed. With a pre-built plan, a value replaces plan.interval and None keeps the intervals of the plan.

None
test_size int, float, str, pandas Timestamp

Size or start of the test set, selecting the evaluation or prediction mode described above.

  • int: the last test_size observations form the test set.
  • float in (0, 1): the last fraction test_size of the observations form the test set.
  • str or pandas Timestamp: the first timestamp of the test set (the split boundary).

When None (default), the method runs in prediction mode.

None
forecaster str

Explicit forecaster class name to use instead of the recommended one (e.g. 'ForecasterRecursive', 'ForecasterDirect', 'ForecasterRecursiveMultiSeries'). When None, the most suitable forecaster is selected automatically from the characteristics of the data.

None
estimator str

Explicit regressor class name to use instead of the recommended one (e.g. 'HistGradientBoostingRegressor', 'LGBMRegressor'). When None, a suitable estimator is selected automatically based on the dataset size.

None
estimator_kwargs dict

Keyword arguments for the estimator constructor (e.g. {'n_estimators': 200, 'learning_rate': 0.05}). Merged on top of built-in defaults (random_state and silencing flags), with user values taking precedence. When None, only the built-in defaults are used.

None
lags int, list of int

Explicit lag configuration. An integer uses lags 1 to lags; a list uses the specified lags. When None, lags are selected automatically from the partial autocorrelation of the series. Raises ValueError for a forecaster without lags (ForecasterStats, ForecasterFoundation, ForecasterEquivalentDate).

None
window_features list of dict

Explicit window (rolling) features configuration. Each dict must contain the keys 'stats' (a list of rolling statistics) and 'window_size' (a scalar int applied to every stat in that same dict), for example [{'stats': ['mean', 'std'], 'window_size': 3}, {'stats': ['mean'], 'window_size': 24}, {'stats': ['mean'], 'window_size': 168}]. To combine several window sizes, add one dict per size. Allowed stats are 'mean', 'std', 'min', 'max', 'sum', 'median', 'ratio_min_max', 'coef_variation', and 'ewm'. When None, window features are selected automatically from the characteristics of the data.

None
profile ForecastingProfile

Pre-computed profile. If None, profiling is performed from data. When given, it is usually the profile the plan was built from. data is profiled again with its target, date and series id columns: data of another structure (another frequency, an exogenous column missing) raise ValueError, and data with other values (new rows or a new series, for example) run with the new profile, which says so in DataProfile.warnings. Columns the profile does not name are not used.

None
plan ForecastPlan

Pre-computed plan to skip planning. If None, a plan is generated from the profile. Requires profile to also be provided. forecaster, estimator, estimator_kwargs, lags and window_features are fixed by the plan: a value equal to the plan's is accepted and a different one raises ValueError, pointing to refine_plan(). The plan is validated again before the script is rendered, so one edited with model_copy(update=...) or by assignment raises ValidationError unless it is still a valid ForecastPlan. A plan built for data of another frequency or shape, or that uses exogenous variables the data does not have, raises ValueError.

None
metric str, list of str

Metric(s) the plan computes, as in plan(): the first one is the primary metric and only the ones given are computed. When None, they are selected from the data. With plan, a value equal to what the plan computes is accepted and a different one raises ValueError, pointing to refine_plan().

None
use_exog bool

Whether the plan uses the exogenous columns, as in plan(). When None, the rule decides. With plan, it must match plan.use_exog.

None
differentiation int

Order of differencing of the target, as in plan(). With plan, it must match the order of the plan.

None
calendar_features list of str

Calendar features, as in plan(); an empty list for none.

None
target_transformer str

'StandardScaler' or 'none', as in plan().

None
dropna_from_series bool

Whether to drop the training rows with missing values, as in plan(). With plan, these three must match the plan.

None

Returns:

Name Type Description
result CodeGenerationResult

Generated forecasting script and the decisions behind it. Contains the following attributes:

  • profile: profile of the input dataset and high-level modeling decisions.
  • plan: detailed forecasting plan.
  • code: generated Python script.
Source code in skforecast_ai/assistant.py
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
def forecast_code(
    self,
    data: pd.Series | pd.DataFrame | str | Path | None = None,
    steps: int | None = None,
    target: str | list[str] | None = None,
    date_column: str | None = None,
    series_id_column: str | None = None,
    exog: pd.DataFrame | pd.Series | None = None,
    interval: list[float] | None = None,
    test_size: int | float | str | pd.Timestamp | None = None,
    forecaster: str | None = None,
    estimator: str | None = None,
    estimator_kwargs: dict | None = None,
    lags: int | list[int] | None = None,
    window_features: list[dict[str, list[str] | int]] | None = None,
    profile: ForecastingProfile | None = None,
    plan: ForecastPlan | None = None,
    *,
    metric: str | list[str] | None = None,
    use_exog: bool | None = None,
    differentiation: int | None = None,
    calendar_features: list[str] | None = None,
    target_transformer: str | None = None,
    dropna_from_series: bool | None = None,
) -> CodeGenerationResult:
    """
    Profile, plan, and generate a complete forecasting script.

    Convenience wrapper that chains `profile()`, `plan()`,
    and code generation in a single call. Pre-computed `profile`
    and/or `plan` can be passed to skip those stages (e.g. after
    modifying the plan with `refine_plan()`).

    The method operates in one of two modes depending on `test_size`:

    - Evaluation mode (`test_size` is set): the data is split into
    train and test sets, the forecaster is trained on the training
    set, predictions are made for the test set and metrics are
    computed against the held-out observations.
    - Prediction mode (`test_size` is None, the default): the
    forecaster is trained on all available data and forecasts the
    future. No metrics are returned because there is no ground
    truth to compare against. When the data contains exogenous
    variables, future values must be supplied through `exog`.

    Parameters
    ----------
    data : pandas Series, pandas DataFrame, str, Path, default None
        Input dataset, a single series, or path to a CSV file. Required
        when `profile` is not provided. When a pandas Series is passed,
        the target is derived from its name.
        A CSV path or URL is the file the generated script loads, also
        with a `profile` built from another file; with a DataFrame the
        script loads the path recorded in the profile.
    steps : int, default None
        Forecast horizon (number of steps ahead to predict). Required
        when `plan` is not provided. When a `plan` is given it defaults
        to `plan.steps` and must match it if given.
    target : str, list of str, default None
        Name of the column to forecast. Required when `profile`
        is not provided, unless `data` is a pandas Series (the Series
        name is used instead). For wide-format multi-series, pass a
        list of column names where each column is a series.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    date_column : str, default None
        Name of the column containing timestamps. When None, the
        index of `data` is assumed to be a DatetimeIndex.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    series_id_column : str, default None
        Name of the column identifying individual series (long-format
        multi-series input). When None, the data is treated as
        single-series or wide-format multi-series.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    exog : pandas DataFrame, pandas Series, default None
        Future exogenous variables covering the forecast horizon.
        Mirrors `forecast()` for signature consistency. Because this
        method only generates code (the rendered prediction-mode
        script loads the future values from `'exog_future.csv'` at run
        time), `exog` is optional here and is used only to validate
        the inputs: it must not be combined with `test_size`, and it
        must not be supplied when the data has no exogenous columns.
        Unlike `forecast()`, its dates and values are not checked, nor
        are the last values of the target: the script reads them when
        it runs.
    interval : list of float, default None
        Prediction interval quantiles as a two-element list
        `[lower, upper]` (e.g. `[0.1, 0.9]` for 80 % interval). When
        None, no prediction intervals are computed. With a pre-built
        `plan`, a value replaces `plan.interval` and None keeps the
        intervals of the plan.
    test_size : int, float, str, pandas Timestamp, default None
        Size or start of the test set, selecting the evaluation or
        prediction mode described above.

        - int: the last `test_size` observations form the test set.
        - float in `(0, 1)`: the last fraction `test_size` of the
        observations form the test set.
        - str or pandas Timestamp: the first timestamp of the test
        set (the split boundary).

        When None (default), the method runs in prediction mode.
    forecaster : str, default None
        Explicit forecaster class name to use instead of the
        recommended one (e.g. `'ForecasterRecursive'`,
        `'ForecasterDirect'`, `'ForecasterRecursiveMultiSeries'`).
        When None, the most suitable forecaster is selected
        automatically from the characteristics of the data.
    estimator : str, default None
        Explicit regressor class name to use instead of the
        recommended one (e.g. `'HistGradientBoostingRegressor'`,
        `'LGBMRegressor'`). When None, a suitable estimator is
        selected automatically based on the dataset size.
    estimator_kwargs : dict, default None
        Keyword arguments for the estimator constructor (e.g.
        `{'n_estimators': 200, 'learning_rate': 0.05}`). Merged on
        top of built-in defaults (`random_state` and silencing
        flags), with user values taking precedence. When None, only
        the built-in defaults are used.
    lags : int, list of int, default None
        Explicit lag configuration. An integer uses lags 1 to `lags`;
        a list uses the specified lags. When None, lags are selected
        automatically from the partial autocorrelation of the series.
        Raises `ValueError` for a forecaster without lags
        (`ForecasterStats`, `ForecasterFoundation`,
        `ForecasterEquivalentDate`).
    window_features : list of dict, default None
        Explicit window (rolling) features configuration. Each dict
        must contain the keys `'stats'` (a list of rolling statistics)
        and `'window_size'` (a scalar int applied to every stat in
        that same dict), for example `[{'stats': ['mean', 'std'],
        'window_size': 3}, {'stats': ['mean'], 'window_size': 24},
        {'stats': ['mean'], 'window_size': 168}]`. To combine several
        window sizes, add one dict per size. Allowed stats are
        `'mean'`, `'std'`, `'min'`, `'max'`, `'sum'`, `'median'`,
        `'ratio_min_max'`, `'coef_variation'`, and `'ewm'`. When None,
        window features are selected automatically from the
        characteristics of the data.
    profile : ForecastingProfile, default None
        Pre-computed profile. If None, profiling is performed from
        `data`. When given, it is usually the profile the plan was
        built from. `data` is profiled again with its target, date and
        series id columns: data of another structure (another
        frequency, an exogenous column missing) raise `ValueError`, and
        data with other values (new rows or a new series, for example)
        run with the new profile, which says so in
        `DataProfile.warnings`. Columns the profile does not name are
        not used.
    plan : ForecastPlan, default None
        Pre-computed plan to skip planning. If None, a plan is
        generated from the profile. Requires `profile` to also be
        provided. `forecaster`, `estimator`, `estimator_kwargs`,
        `lags` and `window_features` are fixed by the plan: a value
        equal to the plan's is accepted and a different one raises
        `ValueError`, pointing to `refine_plan()`. The plan is validated
        again before the script is rendered, so one edited with
        `model_copy(update=...)` or by assignment raises
        `ValidationError` unless it is still a valid `ForecastPlan`.
        A plan built for data of another frequency or shape, or that
        uses exogenous variables the data does not have, raises
        `ValueError`.
    metric : str, list of str, default None
        Metric(s) the plan computes, as in `plan()`: the first one is
        the primary metric and only the ones given are computed. When
        None, they are selected from the data. With `plan`, a value
        equal to what the plan computes is accepted and a different one
        raises `ValueError`, pointing to `refine_plan()`.
    use_exog : bool, default None
        Whether the plan uses the exogenous columns, as in `plan()`.
        When None, the rule decides. With `plan`, it must match
        `plan.use_exog`.
    differentiation : int, default None
        Order of differencing of the target, as in `plan()`. With
        `plan`, it must match the order of the plan.
    calendar_features : list of str, default None
        Calendar features, as in `plan()`; an empty list for none.
    target_transformer : str, default None
        `'StandardScaler'` or `'none'`, as in `plan()`.
    dropna_from_series : bool, default None
        Whether to drop the training rows with missing values, as in
        `plan()`. With `plan`, these three must match the plan.

    Returns
    -------
    result : CodeGenerationResult
        Generated forecasting script and the decisions behind it.
        Contains the following attributes:

        - profile: profile of the input dataset and high-level
        modeling decisions.
        - plan: detailed forecasting plan.
        - code: generated Python script.
    """

    # A supplied profile is what the script is rendered from. When data
    # is supplied as well, check it is the dataset the profile describes.
    data_df = data
    if profile is not None and data is not None:
        data_df, *_ = _resolve_inputs_with_profile(
            data, target, date_column, series_id_column, profile
        )

    profile, plan = self._prepare_forecast(
        data             = data_df,
        target           = target,
        date_column      = date_column,
        series_id_column = series_id_column,
        steps            = steps,
        exog             = exog,
        interval         = interval,
        test_size        = test_size,
        forecaster       = forecaster,
        estimator        = estimator,
        estimator_kwargs = estimator_kwargs,
        lags             = lags,
        window_features  = window_features,
        profile          = profile,
        plan             = plan,
        require_exog     = False,
        overrides        = {
            "metric": metric,
            "use_exog": use_exog,
            "differentiation": differentiation,
            "calendar_features": calendar_features,
            "target_transformer": target_transformer,
            "dropna_from_series": dropna_from_series,
        },
    )
    profile = _with_data_path(profile, data)

    code = render_forecast_script(
        profile=profile.data_profile, plan=plan
    ).full_script

    return CodeGenerationResult(
        profile = profile,
        plan    = plan,
        code    = code,
    )

forecast

forecast(
    data,
    steps=None,
    target=None,
    date_column=None,
    series_id_column=None,
    exog=None,
    interval=None,
    test_size=None,
    forecaster=None,
    estimator=None,
    estimator_kwargs=None,
    lags=None,
    window_features=None,
    profile=None,
    plan=None,
    *,
    metric=None,
    use_exog=None,
    differentiation=None,
    calendar_features=None,
    target_transformer=None,
    dropna_from_series=None
)

Execute a full forecasting workflow end-to-end.

Convenience wrapper that chains profile(), plan(), validation and programmatic execution. Pre-computed profile and/or plan can be passed to skip those stages (e.g. after modifying the plan with refine_plan()).

The method operates in one of two modes depending on test_size:

  • Evaluation mode (test_size is set): the data is split into train and test sets, the forecaster is trained on the training set, predictions are made for the test set and metrics are computed against the held-out observations. Before running, a missing value of the training set that the predictions read follows the rule of the prediction mode below (its last dates are not final rows: test_size sets them), and with ForecasterRecursiveMultiSeries every series needs a value on the last training date and on the test dates. A plan that carries the end_train of an earlier evaluation raises ValueError when test_size is not passed, instead of evaluating the same dates again: pass test_size to evaluate.
  • Prediction mode (test_size is None, the default): the forecaster is trained on all available data and forecasts the future. No metrics are returned because there is no ground truth to compare against. When the data contains exogenous variables, future values must be supplied through exog. Before running, final rows without a target value raise InvalidInputError (ForecasterRecursiveMultiSeries, which ignores them, gives a warning), and so does a missing value of the target that the lags read when the estimator does not tolerate missing values (a warning when it does), or that ForecasterEquivalentDate or the inverse of the differentiation reads. A missing value that no lag reads for the steps asked, or that only the rolling statistics read (they skip it), is left to the warning of skforecast: the predictions do not use it.

Parameters:

Name Type Description Default
data pandas Series, pandas DataFrame, str, Path

Input dataset, a single series, or path to a CSV file. When a pandas Series is passed, the target is derived from its name. A CSV path or URL is the file the generated script loads, also with a profile built from another file; with a DataFrame the script loads the path recorded in the profile.

required
steps int

Forecast horizon (number of steps ahead to predict). Required when plan is not provided. When a plan is given it defaults to plan.steps and must match it if given.

None
target str, list of str

Name of the column to forecast. Optional only when data is a pandas Series (the Series name is used instead). For wide-format multi-series, pass a list of column names where each column is a series. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
date_column str

Name of the column containing timestamps. When None, the index of data is assumed to be a DatetimeIndex. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
series_id_column str

Name of the column identifying individual series (long-format multi-series input). When None, the data is treated as single-series or wide-format multi-series. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
exog pandas DataFrame, pandas Series

Future exogenous variables covering the forecast horizon: a row for each of the steps dates that follow the last date of the data (in long format, for each series, with the series id column), indexed or keyed by date as the data. A named pandas Series is one variable. Used only in prediction mode (test_size=None) and required there when the plan uses exogenous variables. Must not be combined with test_size: in evaluation mode the test-set exogenous values are taken from the split. Its columns, dates and values are checked before running: a missing date raises InvalidInputError, and a new category or a missing value raises when the estimator does not tolerate missing values and gives a warning when it does.

None
interval list of float

Prediction interval quantiles as a two-element list [lower, upper] (e.g. [0.1, 0.9] for 80 % interval). When None, no prediction intervals are computed. With a pre-built plan, a value replaces plan.interval and None keeps the intervals of the plan.

None
test_size int, float, str, pandas Timestamp

Size or start of the test set, selecting the evaluation or prediction mode described above.

  • int: the last test_size observations form the test set.
  • float in (0, 1): the last fraction test_size of the observations form the test set.
  • str or pandas Timestamp: the first timestamp of the test set (the split boundary).

When None (default), the method runs in prediction mode.

None
forecaster str

Explicit forecaster class name to use instead of the recommended one (e.g. 'ForecasterRecursive', 'ForecasterDirect', 'ForecasterRecursiveMultiSeries'). When None, the most suitable forecaster is selected automatically from the characteristics of the data.

None
estimator str

Explicit regressor class name to use instead of the recommended one (e.g. 'HistGradientBoostingRegressor', 'LGBMRegressor'). When None, a suitable estimator is selected automatically based on the dataset size.

None
estimator_kwargs dict

Keyword arguments for the estimator constructor (e.g. {'n_estimators': 200, 'learning_rate': 0.05}). Merged on top of built-in defaults (random_state and silencing flags), with user values taking precedence. When None, only the built-in defaults are used.

None
lags int, list of int

Explicit lag configuration. An integer uses lags 1 to lags; a list uses the specified lags. When None, lags are selected automatically from the partial autocorrelation of the series. Raises ValueError for a forecaster without lags (ForecasterStats, ForecasterFoundation, ForecasterEquivalentDate).

None
window_features list of dict

Explicit window (rolling) features configuration. Each dict must contain the keys 'stats' (a list of rolling statistics) and 'window_size' (a scalar int applied to every stat in that same dict), for example [{'stats': ['mean', 'std'], 'window_size': 3}, {'stats': ['mean'], 'window_size': 24}, {'stats': ['mean'], 'window_size': 168}]. To combine several window sizes, add one dict per size. Allowed stats are 'mean', 'std', 'min', 'max', 'sum', 'median', 'ratio_min_max', 'coef_variation', and 'ewm'. When None, window features are selected automatically from the characteristics of the data.

None
profile ForecastingProfile

Pre-computed profile. If None, profiling is performed from data. When given, it is usually the profile the plan was built from. data is profiled again with its target, date and series id columns: data of another structure (another frequency, an exogenous column missing) raise ValueError, and data with other values (new rows or a new series, for example) run with the new profile, which says so in DataProfile.warnings. Columns the profile does not name are not used.

None
plan ForecastPlan

Pre-computed plan to skip planning. If None, a plan is generated from the profile. Requires profile to also be provided. forecaster, estimator, estimator_kwargs, lags and window_features are fixed by the plan: a value equal to the plan's is accepted and a different one raises ValueError, pointing to refine_plan(). The plan is validated again before the script is rendered, so one edited with model_copy(update=...) or by assignment raises ValidationError unless it is still a valid ForecastPlan. A plan built for data of another frequency or shape, or that uses exogenous variables the data does not have, raises ValueError.

None
metric str, list of str

Metric(s) the plan computes, as in plan(): the first one is the primary metric and only the ones given are computed. When None, they are selected from the data. With plan, a value equal to what the plan computes is accepted and a different one raises ValueError, pointing to refine_plan().

None
use_exog bool

Whether the plan uses the exogenous columns, as in plan(). When None, the rule decides. With plan, it must match plan.use_exog.

None
differentiation int

Order of differencing of the target, as in plan(). With plan, it must match the order of the plan.

None
calendar_features list of str

Calendar features, as in plan(); an empty list for none.

None
target_transformer str

'StandardScaler' or 'none', as in plan().

None
dropna_from_series bool

Whether to drop the training rows with missing values, as in plan(). With plan, these three must match the plan.

None

Returns:

Name Type Description
result ForecastResult

Executed forecast and the decisions behind it. Contains the following attributes:

  • profile: profile of the input dataset and high-level modeling decisions.
  • plan: detailed forecasting plan that was executed.
  • code: generated Python script equivalent to the execution.
  • predictions: forecasted values for the requested steps, including the interval columns when interval is set.
  • metrics: evaluation metrics, one row per series. None in prediction mode (test_size=None), where there is no ground truth to evaluate against.
Notes

This method executes the same code that forecast_code() produces, ensuring perfect fidelity between the inspectable script (ForecastResult.code) and the actual execution.

Source code in skforecast_ai/assistant.py
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
def forecast(
    self,
    data: pd.Series | pd.DataFrame | str | Path,
    steps: int | None = None,
    target: str | list[str] | None = None,
    date_column: str | None = None,
    series_id_column: str | None = None,
    exog: pd.DataFrame | pd.Series | None = None,
    interval: list[float] | None = None,
    test_size: int | float | str | pd.Timestamp | None = None,
    forecaster: str | None = None,
    estimator: str | None = None,
    estimator_kwargs: dict | None = None,
    lags: int | list[int] | None = None,
    window_features: list[dict[str, list[str] | int]] | None = None,
    profile: ForecastingProfile | None = None,
    plan: ForecastPlan | None = None,
    *,
    metric: str | list[str] | None = None,
    use_exog: bool | None = None,
    differentiation: int | None = None,
    calendar_features: list[str] | None = None,
    target_transformer: str | None = None,
    dropna_from_series: bool | None = None,
) -> ForecastResult:
    """
    Execute a full forecasting workflow end-to-end.

    Convenience wrapper that chains `profile()`, `plan()`,
    validation and programmatic execution. Pre-computed `profile`
    and/or `plan` can be passed to skip those stages (e.g. after
    modifying the plan with `refine_plan()`).

    The method operates in one of two modes depending on `test_size`:

    - Evaluation mode (`test_size` is set): the data is split into
    train and test sets, the forecaster is trained on the training
    set, predictions are made for the test set and metrics are
    computed against the held-out observations. Before running, a
    missing value of the training set that the predictions read
    follows the rule of the prediction mode below (its last dates are
    not final rows: `test_size` sets them), and with
    `ForecasterRecursiveMultiSeries` every series needs a value on the
    last training date and on the test dates.
    A plan that carries the `end_train` of an earlier evaluation
    raises `ValueError` when `test_size` is not passed, instead of
    evaluating the same dates again: pass `test_size` to evaluate.
    - Prediction mode (`test_size` is None, the default): the
    forecaster is trained on all available data and forecasts the
    future. No metrics are returned because there is no ground
    truth to compare against. When the data contains exogenous
    variables, future values must be supplied through `exog`.
    Before running, final rows without a target value raise
    `InvalidInputError` (`ForecasterRecursiveMultiSeries`, which
    ignores them, gives a warning), and so does a missing value of the
    target that the lags read when the estimator does not tolerate
    missing values (a warning when it does), or that
    `ForecasterEquivalentDate` or the inverse of the differentiation
    reads. A missing value that no lag reads for the `steps` asked, or
    that only the rolling statistics read (they skip it), is left to
    the warning of skforecast: the predictions do not use it.

    Parameters
    ----------
    data : pandas Series, pandas DataFrame, str, Path
        Input dataset, a single series, or path to a CSV file. When a
        pandas Series is passed, the target is derived from its name.
        A CSV path or URL is the file the generated script loads, also
        with a `profile` built from another file; with a DataFrame the
        script loads the path recorded in the profile.
    steps : int, default None
        Forecast horizon (number of steps ahead to predict). Required
        when `plan` is not provided. When a `plan` is given it defaults
        to `plan.steps` and must match it if given.
    target : str, list of str, default None
        Name of the column to forecast. Optional only when `data` is a
        pandas Series (the Series name is used instead). For
        wide-format multi-series, pass a list of column names where
        each column is a series.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    date_column : str, default None
        Name of the column containing timestamps. When None, the
        index of `data` is assumed to be a DatetimeIndex.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    series_id_column : str, default None
        Name of the column identifying individual series (long-format
        multi-series input). When None, the data is treated as
        single-series or wide-format multi-series.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    exog : pandas DataFrame, pandas Series, default None
        Future exogenous variables covering the forecast horizon: a
        row for each of the `steps` dates that follow the last date of
        the data (in long format, for each series, with the series id
        column), indexed or keyed by date as the data. A named pandas
        Series is one variable. Used only in prediction mode
        (`test_size=None`) and required there when the plan uses
        exogenous variables. Must not be combined with `test_size`:
        in evaluation mode the test-set exogenous values are taken
        from the split. Its columns, dates and values are checked
        before running: a missing date raises `InvalidInputError`, and
        a new category or a missing value raises when the estimator
        does not tolerate missing values and gives a warning when it
        does.
    interval : list of float, default None
        Prediction interval quantiles as a two-element list
        `[lower, upper]` (e.g. `[0.1, 0.9]` for 80 % interval). When
        None, no prediction intervals are computed. With a pre-built
        `plan`, a value replaces `plan.interval` and None keeps the
        intervals of the plan.
    test_size : int, float, str, pandas Timestamp, default None
        Size or start of the test set, selecting the evaluation or
        prediction mode described above.

        - int: the last `test_size` observations form the test set.
        - float in `(0, 1)`: the last fraction `test_size` of the
        observations form the test set.
        - str or pandas Timestamp: the first timestamp of the test
        set (the split boundary).

        When None (default), the method runs in prediction mode.
    forecaster : str, default None
        Explicit forecaster class name to use instead of the
        recommended one (e.g. `'ForecasterRecursive'`,
        `'ForecasterDirect'`, `'ForecasterRecursiveMultiSeries'`).
        When None, the most suitable forecaster is selected
        automatically from the characteristics of the data.
    estimator : str, default None
        Explicit regressor class name to use instead of the
        recommended one (e.g. `'HistGradientBoostingRegressor'`,
        `'LGBMRegressor'`). When None, a suitable estimator is
        selected automatically based on the dataset size.
    estimator_kwargs : dict, default None
        Keyword arguments for the estimator constructor (e.g.
        `{'n_estimators': 200, 'learning_rate': 0.05}`). Merged on
        top of built-in defaults (`random_state` and silencing
        flags), with user values taking precedence. When None, only
        the built-in defaults are used.
    lags : int, list of int, default None
        Explicit lag configuration. An integer uses lags 1 to `lags`;
        a list uses the specified lags. When None, lags are selected
        automatically from the partial autocorrelation of the series.
        Raises `ValueError` for a forecaster without lags
        (`ForecasterStats`, `ForecasterFoundation`,
        `ForecasterEquivalentDate`).
    window_features : list of dict, default None
        Explicit window (rolling) features configuration. Each dict
        must contain the keys `'stats'` (a list of rolling statistics)
        and `'window_size'` (a scalar int applied to every stat in
        that same dict), for example `[{'stats': ['mean', 'std'],
        'window_size': 3}, {'stats': ['mean'], 'window_size': 24},
        {'stats': ['mean'], 'window_size': 168}]`. To combine several
        window sizes, add one dict per size. Allowed stats are
        `'mean'`, `'std'`, `'min'`, `'max'`, `'sum'`, `'median'`,
        `'ratio_min_max'`, `'coef_variation'`, and `'ewm'`. When None,
        window features are selected automatically from the
        characteristics of the data.
    profile : ForecastingProfile, default None
        Pre-computed profile. If None, profiling is performed from
        `data`. When given, it is usually the profile the plan was
        built from. `data` is profiled again with its target, date and
        series id columns: data of another structure (another
        frequency, an exogenous column missing) raise `ValueError`, and
        data with other values (new rows or a new series, for example)
        run with the new profile, which says so in
        `DataProfile.warnings`. Columns the profile does not name are
        not used.
    plan : ForecastPlan, default None
        Pre-computed plan to skip planning. If None, a plan is
        generated from the profile. Requires `profile` to also be
        provided. `forecaster`, `estimator`, `estimator_kwargs`,
        `lags` and `window_features` are fixed by the plan: a value
        equal to the plan's is accepted and a different one raises
        `ValueError`, pointing to `refine_plan()`. The plan is validated
        again before the script is rendered, so one edited with
        `model_copy(update=...)` or by assignment raises
        `ValidationError` unless it is still a valid `ForecastPlan`.
        A plan built for data of another frequency or shape, or that
        uses exogenous variables the data does not have, raises
        `ValueError`.
    metric : str, list of str, default None
        Metric(s) the plan computes, as in `plan()`: the first one is
        the primary metric and only the ones given are computed. When
        None, they are selected from the data. With `plan`, a value
        equal to what the plan computes is accepted and a different one
        raises `ValueError`, pointing to `refine_plan()`.
    use_exog : bool, default None
        Whether the plan uses the exogenous columns, as in `plan()`.
        When None, the rule decides. With `plan`, it must match
        `plan.use_exog`.
    differentiation : int, default None
        Order of differencing of the target, as in `plan()`. With
        `plan`, it must match the order of the plan.
    calendar_features : list of str, default None
        Calendar features, as in `plan()`; an empty list for none.
    target_transformer : str, default None
        `'StandardScaler'` or `'none'`, as in `plan()`.
    dropna_from_series : bool, default None
        Whether to drop the training rows with missing values, as in
        `plan()`. With `plan`, these three must match the plan.

    Returns
    -------
    result : ForecastResult
        Executed forecast and the decisions behind it. Contains the
        following attributes:

        - profile: profile of the input dataset and high-level
        modeling decisions.
        - plan: detailed forecasting plan that was executed.
        - code: generated Python script equivalent to the execution.
        - predictions: forecasted values for the requested steps,
        including the interval columns when `interval` is set.
        - metrics: evaluation metrics, one row per series. None in
        prediction mode (`test_size=None`), where there is no ground
        truth to evaluate against.

    Notes
    -----
    This method executes the same code that `forecast_code()`
    produces, ensuring perfect fidelity between the inspectable
    script (`ForecastResult.code`) and the actual execution.
    """

    exog = as_exog_frame(exog)
    data_df, target, date_column, series_id_column = (
        _resolve_inputs_with_profile(
            data, target, date_column, series_id_column, profile
        )
    )

    profile, plan = self._prepare_forecast(
        data             = data_df,
        target           = target,
        date_column      = date_column,
        series_id_column = series_id_column,
        steps            = steps,
        exog             = exog,
        interval         = interval,
        test_size        = test_size,
        forecaster       = forecaster,
        estimator        = estimator,
        estimator_kwargs = estimator_kwargs,
        lags             = lags,
        window_features  = window_features,
        profile          = profile,
        plan             = plan,
        require_exog     = True,
        overrides        = {
            "metric": metric,
            "use_exog": use_exog,
            "differentiation": differentiation,
            "calendar_features": calendar_features,
            "target_transformer": target_transformer,
            "dropna_from_series": dropna_from_series,
        },
    )
    profile = _with_data_path(profile, data)

    if plan.end_train is not None:
        _check_evaluated_target(
            data         = data_df,
            data_profile = profile.data_profile,
            end_train    = plan.end_train,
            steps        = plan.steps,
            # ForecasterDirectMultiVariate is scored on its level, the
            # first target column, as the script writes it.
            level        = (
                profile.data_profile.target[0]
                if plan.forecaster == "ForecasterDirectMultiVariate"
                and isinstance(profile.data_profile.target, list)
                else None
            ),
        )
    elif exog is not None:
        validate_future_exog(
            exog    = exog,
            data    = data_df,
            profile = profile.data_profile,
            plan    = plan,
        )
    validate_infinite_target(
        data       = data_df,
        profile    = profile.data_profile,
        plan       = plan,
        prediction = plan.end_train is None,
    )
    validate_series_lengths(
        data      = data_df,
        profile   = profile.data_profile,
        plan      = plan,
        end_train = plan.end_train,
    )
    # Last of the checks: it can warn, and a warning is not given for a
    # forecast that a later check rejects.
    validate_evaluation_partition(
        data    = data_df,
        profile = profile.data_profile,
        plan    = plan,
    )

    check_estimator_installed(plan.estimator, plan.task_type)

    result = run_forecast(
        data    = data_df,
        profile = profile.data_profile,
        plan    = plan,
        exog    = exog,
    )

    return ForecastResult(
        profile     = profile,
        plan        = plan,
        code        = result["rendered_code"].full_script,
        metrics     = result["metrics"],
        predictions = result["predictions"],
    )

create_cv

create_cv(
    profile,
    plan,
    prompt=None,
    initial_train_size=None,
    fold_stride=None,
    refit=None,
    fixed_train_size=None,
    gap=None,
    skip_folds=None,
    allow_incomplete_fold=None,
)

Generate a time series cross-validation strategy for backtesting.

Produces a TimeSeriesFold [1]_ configured with smart defaults derived from the profile and plan.

Explicit keyword arguments override defaults. If None, they are automatically determined based on the profile and plan characteristics.

Parameters:

Name Type Description Default
profile ForecastingProfile

Output of profile().

required
plan ForecastPlan

Output of plan().

required
prompt str

Natural language description of the evaluation scenario. Requires an LLM to be configured. If None or no LLM is available, deterministic defaults are used.

None
initial_train_size int, str, pandas Timestamp

Number of observations used for initial training.

  • If None, initial training size is automatically determined based on the profile and plan.
  • If an integer, the number of observations used for initial training.
  • If a date string (ISO format, e.g. '2023-03-01') or pandas Timestamp, it is the last date included in the initial training set. Requires a datetime index with a known frequency; a ValueError is raised otherwise, or when the date cannot be parsed.

When the first training window is shorter than the forecaster of plan needs (more than its window: its largest lag or window feature plus the differentiation order, or the offsets of the baseline; plus steps for a direct forecaster), for example with a horizon that leaves no room for it, a UserWarning says that backtest() of this plan raises (backtest_code() raises too); the strategy can still serve forecasters with a smaller window in compare().

None
fold_stride int

Number of observations that the start of the test set advances between consecutive folds.

  • If None, it defaults to the same value as steps, meaning that folds are placed back-to-back without overlap.
  • If fold_stride < steps, test sets overlap and multiple forecasts will be generated for the same observations.
  • If fold_stride > steps, gaps are left between consecutive test sets. New in version 0.18.0
None
refit (bool, int)

Whether to refit the forecaster in each fold.

  • If None, the forecaster is trained once, in the first fold (False, the skforecast default). Refitting multiplies the training cost by the number of folds, which the explanation states.
  • If True, the forecaster is refitted in each fold.
  • If False, the forecaster is trained only in the first fold.
  • If an integer, the forecaster is trained in the first fold and then refitted every refit folds.
None
fixed_train_size bool

Whether the training size is fixed or increases in each fold. Only applies when the forecaster is refitted: with refit=False (explicit or by default) it has no effect and an IgnoredArgumentWarning says so, except for ForecasterStats, which is always refitted.

None
gap int

Number of observations between the end of the training set and the start of the test set. A direct forecaster cannot be backtested with a gap (each fold would ask it for steps + gap steps): for a direct plan, a UserWarning says that its backtest() and backtest_code() raise, while the strategy can still serve the candidates of compare() that are not direct.

None
skip_folds (int, list)

Number of folds to skip.

  • If an integer, every 'skip_folds'-th is returned.
  • If a list, the indexes of the folds to skip.

For example, if skip_folds=3 and there are 10 folds, the returned folds are 0, 3, 6, and 9. If skip_folds=[1, 2, 3], the returned folds are 0, 4, 5, 6, 7, 8, and 9. A list with an index beyond the folds of the strategy raises ValueError.

None
allow_incomplete_fold bool

Whether to allow the last fold to include fewer observations than steps. If False, the last fold is excluded if it is incomplete.

None

Returns:

Name Type Description
result CVResult

Cross-validation strategy and the decisions behind it. Pass it as cv to backtest(), backtest_code() or compare(), or as context to ask(). Contains the following attributes:

  • profile: profile the strategy was derived from.
  • plan: plan the strategy was derived from.
  • cv: configured TimeSeriesFold fold splitter.
  • cv_config: resolved TimeSeriesFold parameters plus the resulting n_folds. For a ForecasterStats plan, the strategy skforecast runs: refit=True (it refits ARIMA in every fold) and, when cv does not refit, fixed_train_size=True.
  • code: Python snippet that builds the TimeSeriesFold of cv_config, the one the backtesting script embeds.
  • explanation: human-readable explanation of the chosen configuration (LLM reasoning first when a prompt was used).
  • overridden_fields: names of the parameters passed with a value other than None, instead of the defaults.
  • fields_without_effect: names in overridden_fields that have no effect on the forecaster of the plan.
  • llm_configured: whether the LLM set the parameters that were not passed (False when its call failed).
  • defaults_explanation: why the parameters that were not passed have their value, and which ones were passed.
References

[1] Skforecast TimeSeriesFold API Reference: https://skforecast.org/latest/api/model_selection#skforecast.model_selection._split.TimeSeriesFold

Source code in skforecast_ai/assistant.py
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
def create_cv(
    self,
    profile: ForecastingProfile,
    plan: ForecastPlan,
    prompt: str | None = None,
    initial_train_size: int | str | pd.Timestamp | None = None,
    fold_stride: int | None = None,
    refit: bool | int | None = None,
    fixed_train_size: bool | None = None,
    gap: int | None = None,
    skip_folds: int | list[int] | None = None,
    allow_incomplete_fold: bool | None = None,
) -> CVResult:
    """
    Generate a time series cross-validation strategy for backtesting.

    Produces a `TimeSeriesFold` [1]_ configured with smart defaults
    derived from the profile and plan. 

    Explicit keyword arguments override defaults. If None, they are 
    automatically determined based on the profile and plan characteristics.

    Parameters
    ----------
    profile : ForecastingProfile
        Output of `profile()`.
    plan : ForecastPlan
        Output of `plan()`.
    prompt : str, default None
        Natural language description of the evaluation scenario.
        Requires an LLM to be configured. If None or no LLM is
        available, deterministic defaults are used.
    initial_train_size : int, str, pandas Timestamp, default None
        Number of observations used for initial training. 

        - If `None`, initial training size is automatically determined based 
        on the profile and plan.
        - If an integer, the number of observations used for initial training.
        - If a date string (ISO format, e.g. `'2023-03-01'`) or pandas 
        Timestamp, it is the last date included in the initial training set. 
        Requires a datetime index with a known frequency; a `ValueError` is 
        raised otherwise, or when the date cannot be parsed.

        When the first training window is shorter than the forecaster
        of `plan` needs (more than its window: its largest lag or window
        feature plus the differentiation order, or the offsets of the
        baseline; plus `steps` for a direct forecaster), for example
        with a horizon that leaves no room for it, a `UserWarning` says
        that `backtest()` of this plan raises (`backtest_code()` raises
        too); the strategy can still serve forecasters with a smaller
        window in `compare()`.
    fold_stride : int, default None
        Number of observations that the start of the test set advances between
        consecutive folds.

        - If `None`, it defaults to the same value as `steps`, meaning that folds
        are placed back-to-back without overlap.
        - If `fold_stride < steps`, test sets overlap and multiple forecasts will
        be generated for the same observations.
        - If `fold_stride > steps`, gaps are left between consecutive test sets.
        **New in version 0.18.0**
    refit : bool, int, default None
        Whether to refit the forecaster in each fold.

        - If `None`, the forecaster is trained once, in the first fold
        (`False`, the skforecast default). Refitting multiplies the
        training cost by the number of folds, which the explanation
        states.
        - If `True`, the forecaster is refitted in each fold.
        - If `False`, the forecaster is trained only in the first fold.
        - If an integer, the forecaster is trained in the first fold and then refitted
        every `refit` folds.
    fixed_train_size : bool, default None
        Whether the training size is fixed or increases in each fold.
        Only applies when the forecaster is refitted: with `refit=False`
        (explicit or by default) it has no effect and an
        `IgnoredArgumentWarning` says so, except for `ForecasterStats`,
        which is always refitted.
    gap : int, default None
        Number of observations between the end of the training set and the start of the
        test set. A direct forecaster cannot be backtested with a gap
        (each fold would ask it for `steps + gap` steps): for a direct
        plan, a `UserWarning` says that its `backtest()` and
        `backtest_code()` raise, while the strategy can still serve the
        candidates of `compare()` that are not direct.
    skip_folds : int, list, default None
        Number of folds to skip.

        - If an integer, every 'skip_folds'-th is returned.
        - If a list, the indexes of the folds to skip.

        For example, if `skip_folds=3` and there are 10 folds, the returned folds are
        0, 3, 6, and 9. If `skip_folds=[1, 2, 3]`, the returned folds are 0, 4, 5, 6, 7,
        8, and 9. A list with an index beyond the folds of the strategy
        raises `ValueError`.
    allow_incomplete_fold : bool, default None
        Whether to allow the last fold to include fewer observations than `steps`.
        If `False`, the last fold is excluded if it is incomplete.

    Returns
    -------
    result : CVResult
        Cross-validation strategy and the decisions behind it. Pass it
        as `cv` to `backtest()`, `backtest_code()` or `compare()`, or
        as `context` to `ask()`. Contains the following attributes:

        - profile: profile the strategy was derived from.
        - plan: plan the strategy was derived from.
        - cv: configured `TimeSeriesFold` fold splitter.
        - cv_config: resolved `TimeSeriesFold` parameters plus the
        resulting `n_folds`. For a `ForecasterStats` plan, the
        strategy skforecast runs: `refit=True` (it refits ARIMA in
        every fold) and, when `cv` does not refit,
        `fixed_train_size=True`.
        - code: Python snippet that builds the `TimeSeriesFold` of
        `cv_config`, the one the backtesting script embeds.
        - explanation: human-readable explanation of the chosen
        configuration (LLM reasoning first when a prompt was used).
        - overridden_fields: names of the parameters passed with a
        value other than None, instead of the defaults.
        - fields_without_effect: names in `overridden_fields` that have
        no effect on the forecaster of the plan.
        - llm_configured: whether the LLM set the parameters that were
        not passed (False when its call failed).
        - defaults_explanation: why the parameters that were not passed
        have their value, and which ones were passed.

    References
    ----------
    [1] Skforecast `TimeSeriesFold` API Reference:
        https://skforecast.org/latest/api/model_selection#skforecast.model_selection._split.TimeSeriesFold

    """

    # -----------------------------------------------------------------
    # LLM path: when prompt is provided, use LLM for CV configuration
    # -----------------------------------------------------------------
    if prompt is not None and self.llm is None:
        raise LLMRequiredError("create_cv")

    # All CV parameters the LLM would otherwise decide. When every one is
    # set explicitly, the LLM has nothing left to decide, so skip the call.
    llm_decidable = (
        initial_train_size,
        refit,
        fixed_train_size,
        gap,
        fold_stride,
        skip_folds,
        allow_incomplete_fold,
    )
    use_llm = prompt is not None
    if use_llm and all(param is not None for param in llm_decidable):
        warnings.warn(
            "Prompt ignored: all CV parameters were set explicitly, "
            "leaving nothing for the LLM to decide.",
            UserWarning,
            stacklevel=2,
        )
        use_llm = False

    cv_agent = None
    if use_llm:
        # pydantic-ai builds the provider with the agent, so a missing
        # API key fails here, before any call: it degrades like a
        # failed call. A missing package stays an ImportError, which
        # names the extra to install.
        try:
            cv_agent = self._resolve_cv_agent()
        except ImportError:
            raise
        except Exception as exc:
            warnings.warn(
                f"LLM CV configuration failed ({exc}). Falling back to "
                f"deterministic defaults.",
                UserWarning,
                stacklevel=2,
            )
            use_llm = False

    if use_llm:
        defaults = configure_cv_with_llm(
                       agent    = cv_agent,
                       profile  = profile,
                       plan     = plan,
                       prompt   = prompt,
                       explicit = frozenset(
                           name for name, value in {
                               "initial_train_size": initial_train_size,
                               "refit": refit,
                               "fixed_train_size": fixed_train_size,
                               "gap": gap,
                               "fold_stride": fold_stride,
                               "skip_folds": skip_folds,
                               "allow_incomplete_fold": allow_incomplete_fold,
                           }.items()
                           if value is not None
                       ),
                   )
    else:
        # Compute deterministic defaults
        defaults = derive_cv_defaults(profile=profile, plan=plan)

    # Apply explicit overrides
    overrides = {
        "initial_train_size": initial_train_size,
        "refit": refit,
        "fixed_train_size": fixed_train_size,
        "gap": gap,
        "fold_stride": fold_stride,
        "skip_folds": skip_folds,
        "allow_incomplete_fold": allow_incomplete_fold,
    }
    for key, value in overrides.items():
        if value is not None:
            defaults[key] = value

    # The parameters the caller chose instead of the defaults: None asks
    # for the default, any other value is applied above, an empty list
    # of folds to skip included. In the canonical order, the one the
    # result keeps, so the text built here names them as `backtest()`
    # and `compare()` do.
    overridden_fields = [
        name for name in CV_OVERRIDE_NAMES if overrides[name] is not None
    ]
    # Present when the LLM configured the strategy, absent when its call
    # failed and the deterministic defaults were used instead.
    llm_configured = "_reasoning" in defaults

    # The LLM narrative is not a TimeSeriesFold parameter: keep it out
    # of the splitter and prepend it to the explanation.
    reasoning = defaults.pop("_reasoning", None)

    # With the `refit` that runs, which the LLM may have chosen.
    _warn_window_without_refit(
        fixed_train_size = fixed_train_size,
        refit            = defaults.get("refit"),
        forecaster       = plan.forecaster,
    )

    # Same construction and validation path the LLM loop uses, so a
    # configuration that passed there cannot fail here. Resolves a
    # fractional or Timestamp initial_train_size, checks a date-based
    # one against the dataset index and requires at least 2 folds.
    cv = build_cv(cv_params=defaults, data_profile=profile.data_profile)
    _warn_direct_gap(plan, cv.gap)
    warn_first_window(plan, cv, profile.data_profile)
    warn_backtest_missing_values(plan, profile.data_profile)
    warn_interval_residuals(plan, cv, profile.data_profile)
    cv_config, cv_explanation = resolve_cv_config(
        cv,
        profile.data_profile,
        trains     = plan.task_type != "foundation",
        forecaster = plan.forecaster,
    )

    if reasoning:
        cv_explanation = f"{reasoning} {cv_explanation}"

    # skforecast refits ForecasterStats in every fold whatever `refit`
    # says. The script, `cv_config` and the explanation say what runs;
    # an argument the user passed and that does not run is also warned
    # about, so it is not replaced without notice.
    executed = cv_as_executed(cv, plan.forecaster)
    ignored = [
        f"`{name}={value!r}`"
        for name, value, ran in (
            ("refit", refit, executed.refit),
            ("fixed_train_size", fixed_train_size, executed.fixed_train_size),
        )
        if value is not None and value != ran
    ]
    if ignored:
        warnings.warn(
            f"{' and '.join(ignored)} do not apply to ForecasterStats: "
            f"skforecast refits it in every fold, so its backtest runs "
            f"with `refit=True` and "
            f"`fixed_train_size={executed.fixed_train_size}`. Pass those "
            f"values to avoid this warning.",
            IgnoredArgumentWarning,
            stacklevel = 2,
        )

    # The same snippet the backtesting script embeds, so the strategy
    # can be inspected and reproduced on its own. For ForecasterStats it
    # is the strategy skforecast runs, while `cv` keeps the parameters
    # as given, so reusing it with another forecaster does not change
    # how that one is trained.
    code_lines = ["from skforecast.model_selection import TimeSeriesFold", ""]
    _emit_cv_configuration(code_lines, executed)

    # Where each value comes from, kept apart from the explanation of
    # the strategy, which `backtest()` rebuilds from the splitter alone.
    fields_without_effect, defaults_explanation = resolve_cv_provenance(
        created_profile = profile,
        created_plan    = plan,
        cv              = cv,
        cv_config       = cv_config,
        plan            = plan,
        overridden      = overridden_fields,
        llm_configured  = llm_configured,
    )

    return CVResult(
        profile               = profile,
        plan                  = plan,
        cv                    = cv,
        cv_config             = cv_config,
        code                  = "\n".join(code_lines).rstrip("\n") + "\n",
        explanation           = cv_explanation,
        overridden_fields     = overridden_fields,
        fields_without_effect = fields_without_effect,
        llm_configured        = llm_configured,
        defaults_explanation  = defaults_explanation,
    )

backtest_code

backtest_code(
    data,
    cv,
    target=None,
    date_column=None,
    series_id_column=None,
    interval=None,
    forecaster=None,
    estimator=None,
    estimator_kwargs=None,
    profile=None,
    plan=None,
    *,
    lags=None,
    window_features=None,
    metric=None,
    use_exog=None,
    differentiation=None,
    calendar_features=None,
    target_transformer=None,
    dropna_from_series=None
)

Profile, plan, and generate a complete backtesting script.

Convenience wrapper that chains profile(), plan(), and backtesting code generation in a single call. Pre-computed profile and/or plan can be passed to skip those stages.

Parameters:

Name Type Description Default
data pandas Series, pandas DataFrame, str, Path, None

Input dataset, a single series, or path to a CSV file. When a pandas Series is passed, the target is derived from its name. A CSV path or URL is the file the generated script loads, also with a profile built from another file. None is accepted when profile and plan are given: the script is rendered from the profile and loads the data path recorded in it.

required
cv (TimeSeriesFold, CVResult)

Time series cross-validation fold splitter (output of create_cv() or user-constructed) [1]_. The CVResult returned by create_cv() is accepted as well; its cv splitter is used. Without plan and the arguments of the model (forecaster, estimator, estimator_kwargs, interval and the keyword-only overrides), its plan is the one run. Its profile must describe data of the same structure (format, target, series, frequency, exogenous columns), or ValueError is raised. A direct forecaster with a gap raises ValueError: each fold would ask it for steps + gap steps. So does a first training window with fewer observations than the forecaster needs: more than its window size, or at least its window size plus steps for a direct forecaster.

required
target str, list of str

Name of the column(s) to forecast. Optional only when data is a pandas Series (the Series name is used instead). For wide-format multi-series, pass a list of column names where each column is a series. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
date_column str

Name of the column containing timestamps. When None, the index of data is assumed to be a DatetimeIndex. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
series_id_column str

Name of the column identifying individual series (long-format multi-series input). When None, the data is treated as single-series or wide-format multi-series. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
interval list of float

Prediction interval quantiles as a two-element list [lower, upper] (e.g. [0.1, 0.9] for 80 % interval). When None, no prediction intervals are computed. With a pre-built plan, a value replaces plan.interval and None keeps the intervals of the plan.

None
forecaster str

Explicit forecaster class name to use instead of the recommended one (e.g. 'ForecasterRecursive', 'ForecasterDirect', 'ForecasterRecursiveMultiSeries'). When None, the most suitable forecaster is selected automatically from the characteristics of the data.

None
estimator str

Explicit regressor class name to use instead of the recommended one (e.g. 'HistGradientBoostingRegressor', 'LGBMRegressor'). When None, a suitable estimator is selected automatically based on the dataset size.

None
estimator_kwargs dict

Keyword arguments for the estimator constructor (e.g. {'n_estimators': 200, 'learning_rate': 0.05}). Merged on top of built-in defaults (random_state and silencing flags), with user values taking precedence. When None, only the built-in defaults are used.

None
profile ForecastingProfile

Pre-computed profile, usually the profile the plan was built from. data is profiled again with its target, date and series id columns: data of another structure (another frequency, an exogenous column missing) raise ValueError, and data with other values (new rows or a new series, for example) run with the new profile, which says so in DataProfile.warnings. Columns the profile does not name are not used.

None
plan ForecastPlan

Pre-computed plan to skip planning. forecaster, estimator and estimator_kwargs are fixed by the plan: a value equal to the plan's is accepted and a different one raises ValueError, pointing to refine_plan(). The plan is validated again before the script is rendered, so one edited with model_copy(update=...) or by assignment raises ValidationError unless it is still a valid ForecastPlan.

None
lags int, list of int

Explicit lag configuration, as in plan(). With plan, it must match the plan.

None
window_features list of dict

Explicit window features configuration, as in plan(). With plan, it must match the plan.

None
metric str, list of str

Metric(s) the plan computes, as in plan(): the first one is the primary metric and only the ones given are computed. When None, they are selected from the data. With plan, a value equal to what the plan computes is accepted and a different one raises ValueError, pointing to refine_plan().

None
use_exog bool

Whether the plan uses the exogenous columns, as in plan(). When None, the rule decides. With plan, it must match plan.use_exog.

None
differentiation int

Order of differencing of the target, as in plan(). With plan, it must match the order of the plan.

None
calendar_features list of str

Calendar features, as in plan(); an empty list for none.

None
target_transformer str

'StandardScaler' or 'none', as in plan().

None
dropna_from_series bool

Whether to drop the training rows with missing values, as in plan(). With plan, these three must match the plan.

None

Returns:

Name Type Description
result CodeGenerationResult

Generated backtesting script and the decisions behind it. Contains the following attributes:

  • profile: profile of the input dataset and high-level modeling decisions.
  • plan: detailed forecasting plan.
  • code: generated Python backtesting script.
Notes

lags and window_features can be passed here, but the strategy of create_cv() was sized for the window of the plan it was built from, and a first training window too short for the window of the forecaster raises (see cv).

To keep both consistent, build the plan with plan() (or refine_plan()), pass it via plan, and build a matching cv with create_cv().

References

[1] Skforecast TimeSeriesFold API Reference: https://skforecast.org/latest/api/model_selection#skforecast.model_selection._split.TimeSeriesFold

Source code in skforecast_ai/assistant.py
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
def backtest_code(
    self,
    data: pd.Series | pd.DataFrame | str | Path | None,
    cv: TimeSeriesFold | CVResult,
    target: str | list[str] | None = None,
    date_column: str | None = None,
    series_id_column: str | None = None,
    interval: list[float] | None = None,
    forecaster: str | None = None,
    estimator: str | None = None,
    estimator_kwargs: dict | None = None,
    profile: ForecastingProfile | None = None,
    plan: ForecastPlan | None = None,
    *,
    lags: int | list[int] | None = None,
    window_features: list[dict[str, list[str] | int]] | None = None,
    metric: str | list[str] | None = None,
    use_exog: bool | None = None,
    differentiation: int | None = None,
    calendar_features: list[str] | None = None,
    target_transformer: str | None = None,
    dropna_from_series: bool | None = None,
) -> CodeGenerationResult:
    """
    Profile, plan, and generate a complete backtesting script.

    Convenience wrapper that chains `profile()`, `plan()`, and
    backtesting code generation in a single call. Pre-computed
    `profile` and/or `plan` can be passed to skip those stages.

    Parameters
    ----------
    data : pandas Series, pandas DataFrame, str, Path, None
        Input dataset, a single series, or path to a CSV file. When a
        pandas Series is passed, the target is derived from its name.
        A CSV path or URL is the file the generated script loads, also
        with a `profile` built from another file. None is accepted when
        `profile` and `plan` are given: the script is rendered from the
        profile and loads the data path recorded in it.
    cv : TimeSeriesFold, CVResult
        Time series cross-validation fold splitter (output of
        `create_cv()` or user-constructed) [1]_.
        The `CVResult` returned by `create_cv()` is accepted as well;
        its `cv` splitter is used.
        Without `plan` and the arguments of the model (`forecaster`,
        `estimator`, `estimator_kwargs`, `interval` and the
        keyword-only overrides), its plan is the one run. Its profile must
        describe data of the same structure (format, target, series,
        frequency, exogenous columns), or `ValueError` is raised. A
        direct forecaster with a `gap` raises `ValueError`: each fold
        would ask it for `steps + gap` steps. So does a first training
        window with fewer observations than the forecaster needs: more
        than its window size, or at least its window size plus `steps`
        for a direct forecaster.
    target : str, list of str, default None
        Name of the column(s) to forecast. Optional only when `data`
        is a pandas Series (the Series name is used instead). For
        wide-format multi-series, pass a list of column names where
        each column is a series.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    date_column : str, default None
        Name of the column containing timestamps. When None, the
        index of `data` is assumed to be a DatetimeIndex.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    series_id_column : str, default None
        Name of the column identifying individual series (long-format
        multi-series input). When None, the data is treated as
        single-series or wide-format multi-series.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    interval : list of float, default None
        Prediction interval quantiles as a two-element list
        `[lower, upper]` (e.g. `[0.1, 0.9]` for 80 % interval). When
        None, no prediction intervals are computed. With a pre-built
        `plan`, a value replaces `plan.interval` and None keeps the
        intervals of the plan.
    forecaster : str, default None
        Explicit forecaster class name to use instead of the
        recommended one (e.g. `'ForecasterRecursive'`,
        `'ForecasterDirect'`, `'ForecasterRecursiveMultiSeries'`).
        When None, the most suitable forecaster is selected
        automatically from the characteristics of the data.
    estimator : str, default None
        Explicit regressor class name to use instead of the
        recommended one (e.g. `'HistGradientBoostingRegressor'`,
        `'LGBMRegressor'`). When None, a suitable estimator is
        selected automatically based on the dataset size.
    estimator_kwargs : dict, default None
        Keyword arguments for the estimator constructor (e.g.
        `{'n_estimators': 200, 'learning_rate': 0.05}`). Merged on
        top of built-in defaults (`random_state` and silencing
        flags), with user values taking precedence. When None, only
        the built-in defaults are used.
    profile : ForecastingProfile, default None
        Pre-computed profile, usually the profile the plan was built
        from. `data` is profiled again with its target, date and
        series id columns: data of another structure (another
        frequency, an exogenous column missing) raise `ValueError`, and
        data with other values (new rows or a new series, for example)
        run with the new profile, which says so in
        `DataProfile.warnings`. Columns the profile does not name are
        not used.
    plan : ForecastPlan, default None
        Pre-computed plan to skip planning. `forecaster`, `estimator`
        and `estimator_kwargs` are fixed by the plan: a value equal
        to the plan's is accepted and a different one raises
        `ValueError`, pointing to `refine_plan()`. The plan is validated
        again before the script is rendered, so one edited with
        `model_copy(update=...)` or by assignment raises
        `ValidationError` unless it is still a valid `ForecastPlan`.
    lags : int, list of int, default None
        Explicit lag configuration, as in `plan()`. With `plan`, it
        must match the plan.
    window_features : list of dict, default None
        Explicit window features configuration, as in `plan()`. With
        `plan`, it must match the plan.
    metric : str, list of str, default None
        Metric(s) the plan computes, as in `plan()`: the first one is
        the primary metric and only the ones given are computed. When
        None, they are selected from the data. With `plan`, a value
        equal to what the plan computes is accepted and a different one
        raises `ValueError`, pointing to `refine_plan()`.
    use_exog : bool, default None
        Whether the plan uses the exogenous columns, as in `plan()`.
        When None, the rule decides. With `plan`, it must match
        `plan.use_exog`.
    differentiation : int, default None
        Order of differencing of the target, as in `plan()`. With
        `plan`, it must match the order of the plan.
    calendar_features : list of str, default None
        Calendar features, as in `plan()`; an empty list for none.
    target_transformer : str, default None
        `'StandardScaler'` or `'none'`, as in `plan()`.
    dropna_from_series : bool, default None
        Whether to drop the training rows with missing values, as in
        `plan()`. With `plan`, these three must match the plan.

    Returns
    -------
    result : CodeGenerationResult
        Generated backtesting script and the decisions behind it.
        Contains the following attributes:

        - profile: profile of the input dataset and high-level
        modeling decisions.
        - plan: detailed forecasting plan.
        - code: generated Python backtesting script.

    Notes
    -----
    `lags` and `window_features` can be passed here, but the strategy of
    `create_cv()` was sized for the window of the plan it was built
    from, and a first training window too short for the window of the
    forecaster raises (see `cv`).

    To keep both consistent, build the plan with `plan()` (or
    `refine_plan()`), pass it via `plan`, and build a matching `cv` with
    `create_cv()`.

    References
    ----------
    [1] Skforecast `TimeSeriesFold` API Reference:
        https://skforecast.org/latest/api/model_selection#skforecast.model_selection._split.TimeSeriesFold

    """

    cv_result = cv if isinstance(cv, CVResult) else None
    cv = _unwrap_cv(cv)
    # Read once, as `backtest()` does: the dates give the time zone of
    # the strategy the script runs.
    data_df = data
    if data is not None:
        data_df, target, date_column, series_id_column = (
            _resolve_inputs_with_profile(
                data, target, date_column, series_id_column, profile
            )
        )

    profile, plan = self._prepare_backtest(
        data             = data_df,
        target           = target,
        cv               = cv,
        date_column      = date_column,
        series_id_column = series_id_column,
        forecaster       = forecaster,
        estimator        = estimator,
        estimator_kwargs = estimator_kwargs,
        interval         = interval,
        profile          = profile,
        plan             = plan,
        cv_result        = cv_result,
        lags             = lags,
        window_features  = window_features,
        overrides        = {
            "metric": metric,
            "use_exog": use_exog,
            "differentiation": differentiation,
            "calendar_features": calendar_features,
            "target_transformer": target_transformer,
            "dropna_from_series": dropna_from_series,
        },
    )
    profile = _with_data_path(profile, data)
    # The script would fail, as `backtest()` says before running it.
    _check_direct_gap(plan, cv)
    check_first_window(plan, cv, profile.data_profile, strict=False)

    code = render_backtesting_script(
        profile = profile.data_profile,
        plan    = plan,
        cv      = _cv_in_time_zone(cv, data_df, profile.data_profile),
    ).full_script

    return CodeGenerationResult(
        profile = profile,
        plan    = plan,
        code    = code,
    )

backtest

backtest(
    data,
    cv,
    target=None,
    date_column=None,
    series_id_column=None,
    interval=None,
    forecaster=None,
    estimator=None,
    estimator_kwargs=None,
    profile=None,
    plan=None,
    show_progress=True,
    *,
    lags=None,
    window_features=None,
    metric=None,
    use_exog=None,
    differentiation=None,
    calendar_features=None,
    target_transformer=None,
    dropna_from_series=None
)

Execute backtesting with a pre-configured time series cross-validation strategy (TimeSeriesFold [1]_).

Chains profile(), plan(), and backtesting execution using the provided TimeSeriesFold. The steps parameter is inferred from cv.steps.

Parameters:

Name Type Description Default
data pandas Series, pandas DataFrame, str, Path

Input dataset, a single series, or path to a CSV file. When a pandas Series is passed, the target is derived from its name. A CSV path or URL is the file the generated script loads, also with a profile built from another file; with a DataFrame the script loads the path recorded in the profile.

required
cv (TimeSeriesFold, CVResult)

Time series cross-validation fold splitter (output of create_cv() or user-constructed) [1]_. The CVResult returned by create_cv() is accepted as well; its cv splitter is used. Without plan and the arguments of the model (forecaster, estimator, estimator_kwargs, interval and the keyword-only overrides), its plan is the one run. Its profile must describe data of the same structure (format, target, series, frequency, exogenous columns), or ValueError is raised. A direct forecaster with a gap raises ValueError: each fold would ask it for steps + gap steps. So does a first training window with fewer observations than the forecaster needs: more than its window size, or at least its window size plus steps for a direct forecaster.

required
target str, list of str

Name of the column(s) to forecast. Optional only when data is a pandas Series (the Series name is used instead). For wide-format multi-series, pass a list of column names where each column is a series. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
date_column str

Name of the column containing timestamps. When None, the index of data is assumed to be a DatetimeIndex. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
series_id_column str

Name of the column identifying individual series (long-format multi-series input). When None, the data is treated as single-series or wide-format multi-series. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
interval list of float

Prediction interval quantiles as a two-element list [lower, upper] (e.g. [0.1, 0.9] for 80 % interval). When None, no prediction intervals are computed. With a pre-built plan, a value replaces plan.interval and None keeps the intervals of the plan.

None
forecaster str

Explicit forecaster class name to use instead of the recommended one (e.g. 'ForecasterRecursive', 'ForecasterDirect', 'ForecasterRecursiveMultiSeries'). When None, the most suitable forecaster is selected automatically from the characteristics of the data.

None
estimator str

Explicit regressor class name to use instead of the recommended one (e.g. 'HistGradientBoostingRegressor', 'LGBMRegressor'). When None, a suitable estimator is selected automatically based on the dataset size.

None
estimator_kwargs dict

Keyword arguments for the estimator constructor (e.g. {'n_estimators': 200, 'learning_rate': 0.05}). Merged on top of built-in defaults (random_state and silencing flags), with user values taking precedence. When None, only the built-in defaults are used.

None
profile ForecastingProfile

Pre-computed profile, usually the profile the plan was built from. data is profiled again with its target, date and series id columns: data of another structure (another frequency, an exogenous column missing) raise ValueError, and data with other values (new rows or a new series, for example) run with the new profile, which says so in DataProfile.warnings. Columns the profile does not name are not used.

None
plan ForecastPlan

Pre-computed plan to skip planning. forecaster, estimator and estimator_kwargs are fixed by the plan: a value equal to the plan's is accepted and a different one raises ValueError, pointing to refine_plan(). The plan is validated again before the script is rendered, so one edited with model_copy(update=...) or by assignment raises ValidationError unless it is still a valid ForecastPlan.

None
show_progress bool

Whether to display a progress bar during backtesting.

True
lags int, list of int

Explicit lag configuration, as in plan(). With plan, it must match the plan.

None
window_features list of dict

Explicit window features configuration, as in plan(). With plan, it must match the plan.

None
metric str, list of str

Metric(s) the plan computes, as in plan(): the first one is the primary metric and only the ones given are computed. When None, they are selected from the data. With plan, a value equal to what the plan computes is accepted and a different one raises ValueError, pointing to refine_plan().

None
use_exog bool

Whether the plan uses the exogenous columns, as in plan(). When None, the rule decides. With plan, it must match plan.use_exog.

None
differentiation int

Order of differencing of the target, as in plan(). With plan, it must match the order of the plan.

None
calendar_features list of str

Calendar features, as in plan(); an empty list for none.

None
target_transformer str

'StandardScaler' or 'none', as in plan().

None
dropna_from_series bool

Whether to drop the training rows with missing values, as in plan(). With plan, these three must match the plan.

None

Returns:

Name Type Description
result BacktestResult

Backtesting outcome and the decisions behind it. Contains the following attributes:

  • profile: profile of the input dataset and high-level modeling decisions.
  • plan: detailed forecasting plan that was executed.
  • cv_config: resolved TimeSeriesFold parameters plus the resulting n_folds.
  • metrics: backtesting metric values returned by skforecast.
  • predictions: full backtest predictions across all folds.
  • code: generated Python script reproducing the workflow.
  • explanation: human-readable summary of the configuration and results.
  • cv_overridden_fields: names of the strategy parameters passed to create_cv() instead of its defaults. None when cv is a TimeSeriesFold, whose origin is not known.
  • cv_fields_without_effect: names in cv_overridden_fields that have no effect on the forecaster that ran.
  • cv_llm_configured: whether the LLM of create_cv() set the parameters that were not passed.
  • cv_defaults_explanation: why the strategy parameters that were not passed have their value, and which ones were passed.
Notes

The data DataFrame must include exogenous columns if the plan uses them. Exogenous variables are extracted automatically from profile.data_profile.exog_columns.

lags and window_features can be passed here, but the strategy of create_cv() was sized for the window of the plan it was built from, and a first training window too short for the window of the forecaster raises (see cv).

To keep both consistent, build the plan with plan() (or refine_plan()), pass it via plan, and build a matching cv with create_cv().

References

[1] Skforecast TimeSeriesFold API Reference: https://skforecast.org/latest/api/model_selection#skforecast.model_selection._split.TimeSeriesFold

Source code in skforecast_ai/assistant.py
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
def backtest(
    self,
    data: pd.Series | pd.DataFrame | str | Path,
    cv: TimeSeriesFold | CVResult,
    target: str | list[str] | None = None,
    date_column: str | None = None,
    series_id_column: str | None = None,
    interval: list[float] | None = None,
    forecaster: str | None = None,
    estimator: str | None = None,
    estimator_kwargs: dict | None = None,
    profile: ForecastingProfile | None = None,
    plan: ForecastPlan | None = None,
    show_progress: bool = True,
    *,
    lags: int | list[int] | None = None,
    window_features: list[dict[str, list[str] | int]] | None = None,
    metric: str | list[str] | None = None,
    use_exog: bool | None = None,
    differentiation: int | None = None,
    calendar_features: list[str] | None = None,
    target_transformer: str | None = None,
    dropna_from_series: bool | None = None,
) -> BacktestResult:
    """
    Execute backtesting with a pre-configured time series cross-validation 
    strategy (`TimeSeriesFold` [1]_).

    Chains `profile()`, `plan()`, and backtesting execution
    using the provided `TimeSeriesFold`. The `steps` parameter is
    inferred from `cv.steps`.

    Parameters
    ----------
    data : pandas Series, pandas DataFrame, str, Path
        Input dataset, a single series, or path to a CSV file. When a
        pandas Series is passed, the target is derived from its name.
        A CSV path or URL is the file the generated script loads, also
        with a `profile` built from another file; with a DataFrame the
        script loads the path recorded in the profile.
    cv : TimeSeriesFold, CVResult
        Time series cross-validation fold splitter (output of `create_cv()`
        or user-constructed) [1]_.
        The `CVResult` returned by `create_cv()` is accepted as well;
        its `cv` splitter is used.
        Without `plan` and the arguments of the model (`forecaster`,
        `estimator`, `estimator_kwargs`, `interval` and the
        keyword-only overrides), its plan is the one run. Its profile must
        describe data of the same structure (format, target, series,
        frequency, exogenous columns), or `ValueError` is raised. A
        direct forecaster with a `gap` raises `ValueError`: each fold
        would ask it for `steps + gap` steps. So does a first training
        window with fewer observations than the forecaster needs: more
        than its window size, or at least its window size plus `steps`
        for a direct forecaster.
    target : str, list of str, default None
        Name of the column(s) to forecast. Optional only when `data`
        is a pandas Series (the Series name is used instead). For
        wide-format multi-series, pass a list of column names where
        each column is a series.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    date_column : str, default None
        Name of the column containing timestamps. When None, the
        index of `data` is assumed to be a DatetimeIndex.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    series_id_column : str, default None
        Name of the column identifying individual series (long-format
        multi-series input). When None, the data is treated as
        single-series or wide-format multi-series.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    interval : list of float, default None
        Prediction interval quantiles as a two-element list
        `[lower, upper]` (e.g. `[0.1, 0.9]` for 80 % interval). When
        None, no prediction intervals are computed. With a pre-built
        `plan`, a value replaces `plan.interval` and None keeps the
        intervals of the plan.
    forecaster : str, default None
        Explicit forecaster class name to use instead of the
        recommended one (e.g. `'ForecasterRecursive'`,
        `'ForecasterDirect'`, `'ForecasterRecursiveMultiSeries'`).
        When None, the most suitable forecaster is selected
        automatically from the characteristics of the data.
    estimator : str, default None
        Explicit regressor class name to use instead of the
        recommended one (e.g. `'HistGradientBoostingRegressor'`,
        `'LGBMRegressor'`). When None, a suitable estimator is
        selected automatically based on the dataset size.
    estimator_kwargs : dict, default None
        Keyword arguments for the estimator constructor (e.g.
        `{'n_estimators': 200, 'learning_rate': 0.05}`). Merged on
        top of built-in defaults (`random_state` and silencing
        flags), with user values taking precedence. When None, only
        the built-in defaults are used.
    profile : ForecastingProfile, default None
        Pre-computed profile, usually the profile the plan was built
        from. `data` is profiled again with its target, date and
        series id columns: data of another structure (another
        frequency, an exogenous column missing) raise `ValueError`, and
        data with other values (new rows or a new series, for example)
        run with the new profile, which says so in
        `DataProfile.warnings`. Columns the profile does not name are
        not used.
    plan : ForecastPlan, default None
        Pre-computed plan to skip planning. `forecaster`, `estimator`
        and `estimator_kwargs` are fixed by the plan: a value equal
        to the plan's is accepted and a different one raises
        `ValueError`, pointing to `refine_plan()`. The plan is validated
        again before the script is rendered, so one edited with
        `model_copy(update=...)` or by assignment raises
        `ValidationError` unless it is still a valid `ForecastPlan`.
    show_progress : bool, default True
        Whether to display a progress bar during backtesting.
    lags : int, list of int, default None
        Explicit lag configuration, as in `plan()`. With `plan`, it
        must match the plan.
    window_features : list of dict, default None
        Explicit window features configuration, as in `plan()`. With
        `plan`, it must match the plan.
    metric : str, list of str, default None
        Metric(s) the plan computes, as in `plan()`: the first one is
        the primary metric and only the ones given are computed. When
        None, they are selected from the data. With `plan`, a value
        equal to what the plan computes is accepted and a different one
        raises `ValueError`, pointing to `refine_plan()`.
    use_exog : bool, default None
        Whether the plan uses the exogenous columns, as in `plan()`.
        When None, the rule decides. With `plan`, it must match
        `plan.use_exog`.
    differentiation : int, default None
        Order of differencing of the target, as in `plan()`. With
        `plan`, it must match the order of the plan.
    calendar_features : list of str, default None
        Calendar features, as in `plan()`; an empty list for none.
    target_transformer : str, default None
        `'StandardScaler'` or `'none'`, as in `plan()`.
    dropna_from_series : bool, default None
        Whether to drop the training rows with missing values, as in
        `plan()`. With `plan`, these three must match the plan.

    Returns
    -------
    result : BacktestResult
        Backtesting outcome and the decisions behind it. Contains the
        following attributes:

        - profile: profile of the input dataset and high-level
        modeling decisions.
        - plan: detailed forecasting plan that was executed.
        - cv_config: resolved `TimeSeriesFold` parameters plus the
        resulting `n_folds`.
        - metrics: backtesting metric values returned by skforecast.
        - predictions: full backtest predictions across all folds.
        - code: generated Python script reproducing the workflow.
        - explanation: human-readable summary of the configuration
        and results.
        - cv_overridden_fields: names of the strategy parameters passed
        to `create_cv()` instead of its defaults. None when `cv` is a
        `TimeSeriesFold`, whose origin is not known.
        - cv_fields_without_effect: names in `cv_overridden_fields`
        that have no effect on the forecaster that ran.
        - cv_llm_configured: whether the LLM of `create_cv()` set the
        parameters that were not passed.
        - cv_defaults_explanation: why the strategy parameters that
        were not passed have their value, and which ones were passed.

    Notes
    -----
    The `data` DataFrame must include exogenous columns if the plan
    uses them. Exogenous variables are extracted automatically from
    `profile.data_profile.exog_columns`.

    `lags` and `window_features` can be passed here, but the strategy of
    `create_cv()` was sized for the window of the plan it was built
    from, and a first training window too short for the window of the
    forecaster raises (see `cv`).

    To keep both consistent, build the plan with `plan()` (or
    `refine_plan()`), pass it via `plan`, and build a matching `cv` with
    `create_cv()`.

    References
    ----------
    [1] Skforecast `TimeSeriesFold` API Reference:
        https://skforecast.org/latest/api/model_selection#skforecast.model_selection._split.TimeSeriesFold

    """

    cv_result = cv if isinstance(cv, CVResult) else None
    cv = _unwrap_cv(cv)

    data_df, target, date_column, series_id_column = (
        _resolve_inputs_with_profile(
            data, target, date_column, series_id_column, profile
        )
    )

    profile, plan = self._prepare_backtest(
        data             = data_df,
        target           = target,
        cv               = cv,
        date_column      = date_column,
        series_id_column = series_id_column,
        interval         = interval,
        forecaster       = forecaster,
        estimator        = estimator,
        estimator_kwargs = estimator_kwargs,
        profile          = profile,
        plan             = plan,
        cv_result        = cv_result,
        lags             = lags,
        window_features  = window_features,
        overrides        = {
            "metric": metric,
            "use_exog": use_exog,
            "differentiation": differentiation,
            "calendar_features": calendar_features,
            "target_transformer": target_transformer,
            "dropna_from_series": dropna_from_series,
        },
    )
    profile = _with_data_path(profile, data)

    _check_evaluated_target(
        data         = data_df,
        data_profile = profile.data_profile,
        cv           = cv,
    )
    validate_infinite_target(
        data       = data_df,
        profile    = profile.data_profile,
        plan       = plan,
        prediction = False,
    )
    validate_series_lengths(
        data       = data_df,
        profile    = profile.data_profile,
        plan       = plan,
        whole_data = True,
    )
    _check_direct_gap(plan, cv)
    check_first_window(plan, cv, profile.data_profile)
    validate_backtest_windows(
        data    = data_df,
        profile = profile.data_profile,
        plan    = plan,
        cv      = cv,
    )

    # Resolved CV parameters (with the fold and training counts) and their
    # explanation, which states the cost of the backtest.
    cv_config, cv_explanation = resolve_cv_config(
        cv,
        profile.data_profile,
        trains     = plan.task_type != "foundation",
        forecaster = plan.forecaster,
    )
    warn_long_training(
        estimator_fits = count_estimator_fits(
                             n_fits     = cv_config["n_fits"],
                             forecaster = plan.forecaster,
                             steps      = plan.steps,
                         ),
        n_fits         = cv_config["n_fits"],
        forecaster     = plan.forecaster,
        steps          = plan.steps,
    )
    # A foundation model fits nothing: its cost is the inference of
    # each series in each fold.
    warn_long_inference(
        inference_windows = count_inference_windows(
                                n_folds    = cv_config["n_folds"],
                                n_series   = profile.data_profile.n_series,
                                forecaster = plan.forecaster,
                            ),
        n_series          = profile.data_profile.n_series,
        n_folds           = cv_config["n_folds"],
    )

    check_estimator_installed(plan.estimator, plan.task_type)

    result = run_backtest(
        data           = data_df,
        profile        = profile.data_profile,
        plan           = plan,
        cv             = _cv_in_time_zone(
                             cv, data_df, profile.data_profile
                         ),
        cv_explanation = cv_explanation,
        show_progress  = show_progress,
    )

    return BacktestResult(
        profile     = profile,
        plan        = plan,
        cv_config   = cv_config,
        metrics     = result["metrics"],
        predictions = result["predictions"],
        code        = result["rendered_code"].full_script,
        explanation = result["explanation"],
        **_cv_provenance_fields(cv_result, cv, cv_config, plan, profile),
    )

compare

compare(
    data,
    cv,
    target=None,
    date_column=None,
    series_id_column=None,
    candidates=None,
    metric=None,
    interval=None,
    profile=None,
    show_progress=True,
    baseline=True,
    progress_callback=None,
)

Compare several forecaster configurations on the same data.

Backtests each candidate configuration with the same cross-validation strategy and returns a metric-ranked leaderboard plus the winning configuration as a reusable BacktestResult.

The dataset is profiled once and the profile is shared across all candidates; only the plan varies per candidate. Each candidate is evaluated through the same plan() + backtest() path, so every row carries a reproducible code. Ranking is a pure ascending sort of the metric column (all default metrics are error metrics where lower is better); the LLM never influences the outcome.

Parameters:

Name Type Description Default
data pandas Series, pandas DataFrame, str, Path

Input dataset, a single series, or path to a CSV file. When a pandas Series is passed, the target is derived from its name. A CSV path or URL is the file the generated script loads, also with a profile built from another file; with a DataFrame the script loads the path recorded in the profile.

required
cv (TimeSeriesFold, CVResult)

Cross-validation strategy applied identically to every candidate. The steps value is inferred from cv.steps. The CVResult returned by create_cv() is accepted as well; its cv splitter is used and, when candidates is None, its plan runs too (see candidates). Its profile must describe data of the same structure (format, target, series, frequency, exogenous columns), or ValueError is raised.

required
target str, list of str

Name of the column(s) to forecast. Optional only when data is a pandas Series (the Series name is used instead). For wide-format multi-series, pass a list of column names. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
date_column str

Name of the column containing timestamps. When None, the index of data is assumed to be a DatetimeIndex. When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
series_id_column str

Name of the column identifying individual series (long-format multi-series input). When profile is provided, defaults to the value recorded in the profile and must match it if given.

None
candidates list of tuple of (str, CandidateConfig)

Configurations to compare. Each entry is a (name, config) tuple, where name labels the row in the results table and config holds the forecaster/estimator settings. The config dict accepts the same override keys understood by plan(): 'forecaster', 'estimator', 'estimator_kwargs', 'lags', 'window_features', 'use_exog', 'differentiation', 'calendar_features', 'target_transformer' and 'dropna_from_series' (see CandidateConfig). Names must be unique, and every candidate must belong to the same forecaster family: a multivariate forecaster is scored on the single series it predicts, a multi-series forecaster on the average across all series, so the two are never ranked together. ForecasterFoundation belongs to the family of the data: it is ranked with the single-series forecasters on one series and with ForecasterRecursiveMultiSeries on several. When None, the set is built from the profile's forecaster candidates of the same family as the recommended forecaster (with several series, ForecasterRecursiveMultiSeries and ForecasterFoundation); when that leaves a single forecaster, its estimator candidates are compared instead. When cv is a CVResult created for a plan that makes other decisions than all of those candidates (one refined with another estimator, lags or window features, for example), that plan runs too, as the row 'Strategy plan', with its decisions and the metric and interval of the comparison, and the explanation says where it ranks. strategy_plan_name names the row that runs that plan, which is one of the candidates when it makes the same decisions. A plan of a forecaster family that cannot be ranked with the candidates is left out with a warning.

None
metric str, list of str

Metric(s) computed per candidate. When a list is passed, the first metric is used to rank the table. When None, the plan default metric (and its metric panel) is used.

None
interval list of float

Prediction interval quantiles as a two-element list [lower, upper] computed for every candidate. When None, no prediction intervals are computed.

None
profile ForecastingProfile

Pre-computed profile, shared by every candidate. data is profiled again with its target, date and series id columns: data of another structure (another frequency, an exogenous column missing) raise ValueError, and data with other values (new rows or a new series, for example) run with the new profile, which says so in DataProfile.warnings. Columns the profile does not name are not used.

None
show_progress bool

Whether to display a progress bar across candidates.

True
baseline bool

Whether to add a ForecasterEquivalentDate baseline (seasonal naive, or naive when no seasonal period applies) as one more row, ranked like the other candidates. The explanation states how many configurations beat it, which ones, and by how much the best one does. It is not added for multi-series data, which it cannot forecast, nor when the target has missing values or missing timestamps, which it would repeat as missing predictions (the explanation says why), nor with an asymmetric interval, which it cannot predict, nor when candidates already contains a ForecasterEquivalentDate (that candidate is then the baseline).

True
progress_callback Callable

Function called with a CompareProgress when each candidate starts and when it ends, for example to report progress outside a notebook. An exception it raises is not recorded as a candidate failure: it stops the comparison and propagates to the caller, so raising from it cancels the remaining candidates. When None, no events are sent.

None

Returns:

Name Type Description
result ComparisonResult

Ranked comparison of the candidate configurations. Contains the following attributes:

  • profile: shared profile used for every candidate.
  • cv_config: resolved TimeSeriesFold parameters plus the resulting n_folds, applied identically to every candidate.
  • results: ranked comparison table, one row per candidate sorted best to worst by ranking_metric.
  • candidates: mapping of candidate name to the full BacktestResult of every candidate that ran successfully, ordered best to worst.
  • failures: mapping of candidate name to a CandidateFailure describing why it failed. Empty when all candidates succeed.
  • ranking_metric: name of the metric used to sort results.
  • explanation: human-readable summary of the comparison.
  • baseline_name: name of the baseline candidate, or None.
  • cv_overridden_fields: names of the strategy parameters passed to create_cv() instead of its defaults. None when cv is a TimeSeriesFold, whose origin is not known.
  • cv_fields_without_effect: names in cv_overridden_fields that have no effect on the shared strategy.
  • cv_llm_configured: whether the LLM of create_cv() set the parameters that were not passed.
  • cv_defaults_explanation: why the strategy parameters that were not passed have their value, and which ones were passed.
  • strategy_plan_name: name of the row that runs the plan of the CVResult, or None.
  • best_name: name of the top-ranked candidate.
  • best_candidate: top-ranked candidate as a BacktestResult.

Raises:

Type Description
AllCandidatesFailedError

If every candidate fails to run. The individual failures are available on the failures attribute of the raised error.

TypeError

If progress_callback is not callable.

ValueError

If metric is an empty list or repeats a metric, or if candidates is empty, contains a malformed entry, repeats a name, mixes forecaster families whose metrics are not comparable (multi-series with multivariate), or uses the name reserved for the baseline, or if data does not have the structure of profile.

Warns:

Type Description
CandidateFailedWarning

Once per failed candidate.

PlanEditsDiscardedWarning

When candidates is None and the plan of the CVResult runs without values that plan() does not rebuild from its decisions (values edited by hand, for example).

UserWarning

When candidates is None and the plan of the CVResult is left out: a forecaster family whose metrics cannot be ranked with the candidates.

MissingBackendWarning

When candidates is None and the backend of the default foundation model is not installed, so ForecasterFoundation is left out of the comparison.

Notes

If a candidate fails to run, a CandidateFailedWarning is issued, its row records the error and is sorted last, and a CandidateFailure carrying the full traceback and the generated code is kept in failures. One bad configuration therefore never aborts the whole comparison. Ties keep input order, so the table is deterministic.

The winning configuration, best_candidate, is a full BacktestResult carrying both a profile and a plan, which can be fed directly into forecast(), backtest(), or forecast_code().

Source code in skforecast_ai/assistant.py
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
def compare(
    self,
    data: pd.Series | pd.DataFrame | str | Path,
    cv: TimeSeriesFold | CVResult,
    target: str | list[str] | None = None,
    date_column: str | None = None,
    series_id_column: str | None = None,
    candidates: list[tuple[str, CandidateConfig]] | None = None,
    metric: str | list[str] | None = None,
    interval: list[float] | None = None,
    profile: ForecastingProfile | None = None,
    show_progress: bool = True,
    baseline: bool = True,
    progress_callback: Callable[[CompareProgress], None] | None = None,
) -> ComparisonResult:
    """
    Compare several forecaster configurations on the same data.

    Backtests each candidate configuration with the **same**
    cross-validation strategy and returns a metric-ranked leaderboard
    plus the winning configuration as a reusable `BacktestResult`.

    The dataset is profiled once and the profile is shared across all
    candidates; only the plan varies per candidate. Each candidate is
    evaluated through the same `plan()` + `backtest()` path, so every
    row carries a reproducible `code`. Ranking is a pure ascending sort
    of the metric column (all default metrics are error metrics where
    lower is better); the LLM never influences the outcome.

    Parameters
    ----------
    data : pandas Series, pandas DataFrame, str, Path
        Input dataset, a single series, or path to a CSV file. When a
        pandas Series is passed, the target is derived from its name.
        A CSV path or URL is the file the generated script loads, also
        with a `profile` built from another file; with a DataFrame the
        script loads the path recorded in the profile.
    cv : TimeSeriesFold, CVResult
        Cross-validation strategy applied identically to every
        candidate. The `steps` value is inferred from `cv.steps`.
        The `CVResult` returned by `create_cv()` is accepted as well;
        its `cv` splitter is used and, when `candidates` is None, its
        plan runs too (see `candidates`).
        Its profile must describe data of the same structure (format,
        target, series, frequency, exogenous columns), or `ValueError`
        is raised.
    target : str, list of str, default None
        Name of the column(s) to forecast. Optional only when `data`
        is a pandas Series (the Series name is used instead). For
        wide-format multi-series, pass a list of column names.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    date_column : str, default None
        Name of the column containing timestamps. When None, the index
        of `data` is assumed to be a DatetimeIndex.
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    series_id_column : str, default None
        Name of the column identifying individual series (long-format
        multi-series input).
        When `profile` is provided, defaults to the value recorded in
        the profile and must match it if given.
    candidates : list of tuple of (str, CandidateConfig), default None
        Configurations to compare. Each entry is a `(name, config)`
        tuple, where `name` labels the row in the results table and
        `config` holds the forecaster/estimator settings. The `config`
        dict accepts the same override keys understood by `plan()`:
        `'forecaster'`, `'estimator'`, `'estimator_kwargs'`, `'lags'`,
        `'window_features'`, `'use_exog'`, `'differentiation'`,
        `'calendar_features'`, `'target_transformer'` and
        `'dropna_from_series'` (see `CandidateConfig`). Names must be
        unique, and every candidate must belong to the same forecaster
        family: a multivariate forecaster is scored on the single series
        it predicts, a multi-series forecaster on the average across all
        series, so the two are never ranked together.
        `ForecasterFoundation` belongs to the family of the data: it
        is ranked with the single-series forecasters on one series and
        with `ForecasterRecursiveMultiSeries` on several. When None,
        the set is built from the profile's forecaster candidates of
        the same family as the recommended forecaster (with several
        series, `ForecasterRecursiveMultiSeries` and
        `ForecasterFoundation`); when that leaves a single forecaster,
        its estimator candidates are compared instead. When `cv` is a
        `CVResult` created for a plan that makes other decisions than
        all of those candidates (one refined with another estimator,
        lags or window features, for example), that plan runs too, as
        the row `'Strategy plan'`, with its decisions and the `metric`
        and `interval` of the comparison, and the explanation says
        where it ranks. `strategy_plan_name` names the row that runs
        that plan, which is one of the candidates when it makes the
        same decisions. A plan of a forecaster family that cannot be
        ranked with the candidates is left out with a warning.
    metric : str, list of str, default None
        Metric(s) computed per candidate. When a list is passed, the
        first metric is used to rank the table. When None, the plan
        default metric (and its metric panel) is used.
    interval : list of float, default None
        Prediction interval quantiles as a two-element list
        `[lower, upper]` computed for every candidate. When None, no
        prediction intervals are computed.
    profile : ForecastingProfile, default None
        Pre-computed profile, shared by every candidate. `data` is
        profiled again with its target, date and series id columns:
        data of another structure (another frequency, an exogenous
        column missing) raise `ValueError`, and data with other
        values (new rows or a new series, for example) run with the
        new profile, which says so in `DataProfile.warnings`. Columns
        the profile does not name are not used.
    show_progress : bool, default True
        Whether to display a progress bar across candidates.
    baseline : bool, default True
        Whether to add a `ForecasterEquivalentDate` baseline (seasonal
        naive, or naive when no seasonal period applies) as one more
        row, ranked like the other candidates. The explanation states
        how many configurations beat it, which ones, and by how much
        the best one does. It is
        not added for multi-series data, which it cannot forecast, nor
        when the target has missing values or missing timestamps, which
        it would repeat as missing predictions (the explanation says
        why), nor with an asymmetric `interval`, which it cannot
        predict, nor when `candidates` already contains a
        `ForecasterEquivalentDate` (that candidate is then the
        baseline).
    progress_callback : Callable, default None
        Function called with a `CompareProgress` when each candidate
        starts and when it ends, for example to report progress
        outside a notebook. An exception it raises is not recorded as
        a candidate failure: it stops the comparison and propagates
        to the caller, so raising from it cancels the remaining
        candidates. When None, no events are sent.

    Returns
    -------
    result : ComparisonResult
        Ranked comparison of the candidate configurations. Contains
        the following attributes:

        - profile: shared profile used for every candidate.
        - cv_config: resolved `TimeSeriesFold` parameters plus the
        resulting `n_folds`, applied
        identically to every candidate.
        - results: ranked comparison table, one row per candidate
        sorted best to worst by `ranking_metric`.
        - candidates: mapping of candidate name to the full
        `BacktestResult` of every candidate that ran successfully,
        ordered best to worst.
        - failures: mapping of candidate name to a `CandidateFailure`
        describing why it failed. Empty when all candidates succeed.
        - ranking_metric: name of the metric used to sort `results`.
        - explanation: human-readable summary of the comparison.
        - baseline_name: name of the baseline candidate, or None.
        - cv_overridden_fields: names of the strategy parameters passed
        to `create_cv()` instead of its defaults. None when `cv` is a
        `TimeSeriesFold`, whose origin is not known.
        - cv_fields_without_effect: names in `cv_overridden_fields`
        that have no effect on the shared strategy.
        - cv_llm_configured: whether the LLM of `create_cv()` set the
        parameters that were not passed.
        - cv_defaults_explanation: why the strategy parameters that
        were not passed have their value, and which ones were passed.
        - strategy_plan_name: name of the row that runs the plan of
        the `CVResult`, or None.
        - best_name: name of the top-ranked candidate.
        - best_candidate: top-ranked candidate as a `BacktestResult`.

    Raises
    ------
    AllCandidatesFailedError
        If every candidate fails to run. The individual failures are
        available on the `failures` attribute of the raised error.
    TypeError
        If `progress_callback` is not callable.
    ValueError
        If `metric` is an empty list or repeats a metric, or if
        `candidates` is empty,
        contains a malformed entry, repeats a name, mixes forecaster
        families whose metrics are not comparable (multi-series with
        multivariate), or uses the name reserved for the baseline, or
        if `data` does not have the structure of `profile`.

    Warns
    -----
    CandidateFailedWarning
        Once per failed candidate.
    PlanEditsDiscardedWarning
        When `candidates` is None and the plan of the `CVResult` runs
        without values that `plan()` does not rebuild from its
        decisions (values edited by hand, for example).
    UserWarning
        When `candidates` is None and the plan of the `CVResult` is left
        out: a forecaster family whose metrics cannot be ranked with the
        candidates.
    MissingBackendWarning
        When `candidates` is None and the backend of the default
        foundation model is not installed, so `ForecasterFoundation`
        is left out of the comparison.

    Notes
    -----
    If a candidate fails to run, a `CandidateFailedWarning` is issued,
    its row records the error and is sorted last, and a
    `CandidateFailure` carrying the full traceback and the generated
    code is kept in `failures`. One bad configuration therefore never
    aborts the whole comparison. Ties keep input order, so the table
    is deterministic.

    The winning configuration, `best_candidate`, is a full
    `BacktestResult` carrying both a `profile` and a `plan`, which can
    be fed directly into `forecast()`, `backtest()`, or
    `forecast_code()`.
    """

    if progress_callback is not None and not callable(progress_callback):
        raise InvalidInputTypeError(
            f"`progress_callback` must be callable or None, got "
            f"{type(progress_callback).__name__}.",
            field = "progress_callback",
        )

    cv_result = cv if isinstance(cv, CVResult) else None
    cv = _unwrap_cv(cv)

    data_df, target, date_column, series_id_column = (
        _resolve_inputs_with_profile(
            data, target, date_column, series_id_column, profile
        )
    )

    if profile is None:
        profile = self.profile(
            data             = data_df,
            target           = target,
            date_column      = date_column,
            series_id_column = series_id_column,
        )
    else:
        profile = self._refresh_profile(data_df, profile)
    if cv_result is not None:
        _check_cv_matches_profile(cv_result, profile.data_profile)
    # Every candidate script loads the file the comparison read.
    profile = _with_data_path(profile, data)
    run_data_path = (
        profile.data_profile.data_path if isinstance(data, (str, Path)) else None
    )

    # Explicit candidates are the user's choice and run as given; they
    # are checked before anything else.
    if candidates is not None:
        candidate_configs = resolve_compare_candidates(candidates, profile)

    # Checked once here: an invalid interval would make every candidate
    # fail. Whether a method needs a symmetric interval is checked per
    # candidate by `plan()`.
    validate_interval(interval)

    # Checked once here: every candidate would fail on the same dates.
    _check_evaluated_target(
        data         = data_df,
        data_profile = profile.data_profile,
        cv           = cv,
    )

    # Resolve the ranking metric and the metric columns once, so the
    # table is consistent across candidates regardless of which ones
    # succeed. When `metric` is None the deterministic plan default is
    # used; otherwise the requested metrics override the plan panel.
    if metric is None:
        metric_override = None
        ranking_metric, _, metric_columns = select_metric(
            profile.data_profile
        )
    else:
        metric_override = resolve_metric_override(metric)
        ranking_metric = metric_override[0]
        metric_columns = metric_override

    steps = cv.steps

    # Shared CV parameters (with the fold and training counts) and their
    # explanation. The folds are counted before any candidate runs, on
    # the untouched `cv`.
    cv_config, cv_explanation = resolve_cv_config(cv, profile.data_profile)
    n_fits = cv_config["n_fits"]

    # Automatic candidates leave out a foundation model whose backend is
    # not installed, rather than fail on every call, and the
    # alternatives above a fit budget (with a strategy that refits in
    # every fold a direct forecaster can take hours). The plan the
    # strategy was created for is the one the user means to compare: it
    # runs as one more row unless it is one of the candidates of the
    # profile, and the summary names its row. Each note is warned.
    strategy_plan = cv_result.plan if cv_result is not None else None
    strategy_name = None
    backend_note = None
    strategy_note = None
    budget_note = None
    if candidates is None:

        def build_plan(config: CandidateConfig) -> ForecastPlan:
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")
                return self.plan(
                    profile  = profile,
                    steps    = steps,
                    interval = interval,
                    metric   = metric_override,
                    **config,
                )

        default = resolve_default_candidates(
                      profile       = profile,
                      strategy_plan = strategy_plan,
                      build_plan    = build_plan,
                      n_fits        = n_fits,
                      steps         = steps,
                      n_folds       = cv_config["n_folds"],
                  )
        candidate_configs = default.candidates
        strategy_name = default.strategy_name
        backend_note = default.backend_note
        strategy_note = default.strategy_note
        budget_note = default.budget_note
        if backend_note is not None:
            warnings.warn(backend_note, MissingBackendWarning, stacklevel=2)
        if strategy_note is not None:
            # Left out (no row), or run without values edited by hand.
            category = (
                UserWarning if strategy_name is None
                else PlanEditsDiscardedWarning
            )
            warnings.warn(strategy_note, category, stacklevel=2)
        if budget_note is not None:
            warnings.warn(budget_note, LongTrainingWarning, stacklevel=2)
    # The row added for that plan, which the summary describes; a
    # candidate of the profile that is that plan keeps its own name.
    strategy_row = strategy_name if strategy_name == STRATEGY_PLAN_NAME else None

    baseline_name = None
    baseline_note = None
    if baseline:
        candidate_configs, baseline_name, baseline_note = (
            add_baseline_candidate(candidate_configs, profile, interval)
        )

    # Warned once per costly candidate here; the warning of each
    # backtest() call is silenced in the loop so it is not repeated.
    for _, config in candidate_configs:
        forecaster = config.get("forecaster") or profile.forecaster
        warn_long_training(
            estimator_fits = count_estimator_fits(
                                 n_fits     = n_fits,
                                 forecaster = forecaster,
                                 steps      = steps,
                                 n_folds    = cv_config["n_folds"],
                             ),
            n_fits         = n_fits,
            forecaster     = forecaster,
            steps          = steps,
        )

    # The foundation candidates run one after another: their inference
    # windows add up, and one warning states the total.
    n_series = profile.data_profile.n_series
    foundation_windows = [
        count_inference_windows(
            n_folds    = cv_config["n_folds"],
            n_series   = n_series,
            forecaster = config.get("forecaster") or profile.forecaster,
        )
        for _, config in candidate_configs
    ]
    warn_long_inference(
        inference_windows = sum(foundation_windows),
        n_series          = n_series,
        n_folds           = cv_config["n_folds"],
        n_candidates      = sum(windows > 0 for windows in foundation_windows),
    )

    iterator: Any = candidate_configs
    if show_progress:
        from tqdm.auto import tqdm

        iterator = tqdm(candidate_configs, desc="Comparing forecasters")

    rows: list[tuple[dict, float]] = []
    ranked: list[tuple[str, BacktestResult, float]] = []
    own_differentiation: dict[str, int | None] = {}
    failures: dict[str, CandidateFailure] = {}
    n_candidates = len(candidate_configs)

    def notify(event: CompareProgress) -> None:
        # A callback that cancels leaves the loop early, so the progress
        # bar would stay open on a partial count.
        try:
            progress_callback(event)
        except BaseException:
            if show_progress:
                iterator.close()
            raise

    for position, (name, config) in enumerate(iterator):
        # Called outside the `try` below: an exception raised by the
        # callback cancels the comparison instead of failing a candidate.
        if progress_callback is not None:
            notify(
                CompareProgress(
                    candidate = name,
                    status    = "started",
                    completed = position,
                    total     = n_candidates,
                )
            )

        row: dict[str, Any] = {
            "name": name,
            "forecaster": config.get("forecaster") or profile.forecaster,
            "estimator": config.get("estimator"),
            "error": None,
        }
        for col in metric_columns:
            row[col] = float("nan")
        ranking_value = float("nan")

        try:
            # The metric of the comparison is a decision of each plan,
            # so it is validated and carried over like any override.
            cand_plan = self.plan(
                profile            = profile,
                steps              = steps,
                forecaster         = config.get("forecaster"),
                estimator          = config.get("estimator"),
                estimator_kwargs   = config.get("estimator_kwargs"),
                lags               = config.get("lags"),
                window_features    = config.get("window_features"),
                use_exog           = config.get("use_exog"),
                differentiation    = config.get("differentiation"),
                calendar_features  = config.get("calendar_features"),
                target_transformer = config.get("target_transformer"),
                dropna_from_series = config.get("dropna_from_series"),
                interval           = interval,
                metric             = metric_override,
            )
            # `plan()` records every argument it receives as a decision of
            # the user. The forecaster and the estimator of an automatic
            # candidate and of the baseline are chosen here, by the rules:
            # only the metric of the comparison is the user's.
            # The row of the plan of the strategy keeps the decisions the
            # user made in that plan, and the metric of the comparison.
            if name == strategy_row and name != baseline_name:
                cand_plan.overridden_fields = [
                    field for field in cand_plan.overridden_fields
                    if field == "metric" or field in strategy_plan.overridden_fields
                ]
                cand_plan.llm_refined_fields = [
                    field for field in strategy_plan.llm_refined_fields
                    if cand_plan.forecaster_kwargs.get(field)
                    == strategy_plan.forecaster_kwargs.get(field)
                ]
            elif candidates is None or name == baseline_name:
                cand_plan.overridden_fields = [
                    field for field in cand_plan.overridden_fields
                    if field == "metric"
                ]
            # Each candidate runs with its own differentiation order: the
            # shared strategy is copied with it when they differ (10.4).
            candidate_cv = cv
            cand_differentiation = cand_plan.forecaster_kwargs.get(
                "differentiation"
            )
            # The baseline has no order of its own: it runs without one.
            if (
                cand_plan.forecaster in _DIFFERENTIATION_FORECASTERS
                and cand_differentiation != cv.differentiation
            ):
                candidate_cv = copy.deepcopy(cv)
                candidate_cv.set_params(
                    {"differentiation": cand_differentiation}
                )
                if cand_plan.forecaster not in BASELINE_FORECASTERS:
                    own_differentiation[name] = cand_differentiation

            row["forecaster"] = cand_plan.forecaster
            row["estimator"] = cand_plan.estimator

            with warnings.catch_warnings():
                warnings.simplefilter("ignore", category=LongTrainingWarning)
                # The candidate runs on the DataFrame already read;
                # its script loads the file the comparison read.
                with _data_path_of_run(run_data_path):
                    bt = self.backtest(
                        data             = data_df,
                        cv               = candidate_cv,
                        target           = target,
                        date_column      = date_column,
                        series_id_column = series_id_column,
                        profile          = profile,
                        plan             = cand_plan,
                        show_progress    = False,
                    )
            agg = aggregate_metrics(bt.metrics)
            for col in metric_columns:
                row[col] = agg.get(col, float("nan"))
            ranking_value = agg.get(ranking_metric, float("nan"))
            ranked.append((name, bt, ranking_value))
        except Exception as exc:
            failure = CandidateFailure.from_exception(exc)
            failures[name] = failure
            row["error"] = failure.summary()
            warnings.warn(
                f"Candidate '{name}' failed and is ranked last: "
                f"{failure.summary()}. The full traceback is available in "
                f"`ComparisonResult.failures['{name}'].traceback`.",
                CandidateFailedWarning,
                stacklevel=2,
            )

        rows.append((row, ranking_value))

        if progress_callback is not None:
            notify(
                CompareProgress(
                    candidate = name,
                    status    = "failed" if name in failures else "succeeded",
                    completed = position + 1,
                    total     = n_candidates,
                    error     = row["error"],
                )
            )

    results = build_comparison_table(
        rows           = rows,
        metric_columns = metric_columns,
        any_error      = bool(failures),
        baseline_name  = baseline_name,
    )

    # Order the successful candidates using the same
    # ascending-with-NaN-last, stable ordering as the results table
    # (the baseline wins ties), so the mapping iterates best to worst
    # and its first entry is the winner reported by `best_name` /
    # `best_candidate`.
    ranked_sorted = sorted(
        ranked, key=lambda item: compare_sort_key(item, baseline_name)
    )
    if not ranked_sorted:
        raise AllCandidatesFailedError(failures)
    candidate_results = {name: bt for name, bt, _ in ranked_sorted}

    explanation = build_comparison_explanation(
        n_candidates   = len(candidate_configs),
        ranked         = ranked_sorted,
        ranking_metric = ranking_metric,
        any_error      = bool(failures),
        cv_explanation = cv_explanation,
        baseline_name  = baseline_name,
        baseline_note  = baseline_note,
        backend_note   = backend_note,
        budget_note    = budget_note,
        differentiation_note = (
            f"These candidates ran on a copy of the strategy with their "
            f"own differentiation order (the strategy has "
            f"{cv.differentiation}): {own_differentiation}."
            if own_differentiation else None
        ),
        strategy_row         = strategy_row,
        strategy_note        = strategy_note,
    )

    return ComparisonResult(
        profile            = profile,
        cv_config          = cv_config,
        results            = results,
        candidates         = candidate_results,
        failures           = failures,
        ranking_metric     = ranking_metric,
        explanation        = explanation,
        baseline_name      = baseline_name,
        strategy_plan_name = strategy_name,
        **_cv_provenance_fields(
            cv_result, cv, cv_config, None, profile, created_for_row=strategy_name
        ),
    )

ask

ask(
    prompt,
    context=None,
    *,
    plan=None,
    skills=None,
    include_reference=False
)

Ask a forecasting question, optionally about an object to explain.

Without context the LLM answers a general forecasting or skforecast question using its skills. With context, the object renders its own context block (dataset, plan, cross-validation strategy, metrics, predictions, or leaderboard, whatever it holds) and the LLM explains it. ask() never computes anything: profile, plan and results are produced by the other methods and handed over here.

Parameters:

Name Type Description Default
prompt str

Natural-language question or instruction.

required
context ExplainableResult

Object to explain. Accepts a ForecastingProfile (from profile()), a CodeGenerationResult (from forecast_code() or backtest_code()), a CVResult (from create_cv()), or a ForecastResult, BacktestResult or ComparisonResult. The returned profile, plan, and code are the context's own; for a ComparisonResult these are the shared profile and the winning candidate's plan and code. The values a result owns (its predictions and metrics) are always sent to the LLM, regardless of send_data_to_llm, since a question about a result cannot be answered from summary statistics alone. The input data is never sent: a result holds only the model's output, and a profile holds summary statistics only.

None
plan ForecastPlan

Plan to explain together with the profile passed as context. The two are rendered into the same script forecast_code() would produce, so the LLM sees the plan and the returned code is that script. Not accepted with any other kind of context, which already carries its own plan. The plan is validated again before the script is rendered, so one edited with model_copy(update=...) or by assignment raises ValidationError unless it is still a valid ForecastPlan.

None
skills list of str

List of skill names to include in the agent system prompt. If None, skills are selected automatically based on the task type and question content. See skforecast_ai.ALL_SKILLS for valid names.

None
include_reference bool

Whether to include the skforecast API reference in the prompt.

False

Returns:

Name Type Description
result AskResult

LLM response and the artifacts of the explained context. Contains the following attributes:

  • profile: profile of the explained context, when there is one.
  • plan: forecasting plan of the explained context, when there is one.
  • code: generated Python script of the explained context, when there is one.
  • explanation: LLM-generated explanation or response.
  • skills: names of the skill documents sent to the model, after trimming to fit the context budget.

Raises:

Type Description
LLMRequiredError

If no LLM was configured at init time.

LLMCallError

If the call to the LLM fails (network, credentials, provider error, or a local model that is not reachable). The original exception is available as original_error. Unlike refine_plan() and create_cv(), ask() has no deterministic answer to fall back on.

TypeError

If context is not explainable (a bare ForecastPlan is not: pass context=profile, plan=plan), or if plan accompanies a context other than a ForecastingProfile.

ValidationError

If plan does not pass the validators of ForecastPlan (a subclass of ValueError).

Warns:

Type Description
DataSentToLLMWarning

If context carries values of its own (predictions, metrics) while send_data_to_llm is False.

Notes

An LLM must be configured at init time. When llm is None, this method cannot operate and raises LLMRequiredError.

Source code in skforecast_ai/assistant.py
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
def ask(
    self,
    prompt: str,
    context: ExplainableResult | None = None,
    *,
    plan: ForecastPlan | None = None,
    skills: list[str] | None = None,
    include_reference: bool = False,
) -> AskResult:
    """
    Ask a forecasting question, optionally about an object to explain.

    Without `context` the LLM answers a general forecasting or
    skforecast question using its skills. With `context`, the object
    renders its own context block (dataset, plan, cross-validation
    strategy, metrics, predictions, or leaderboard, whatever it holds)
    and the LLM explains it. `ask()` never computes anything: profile,
    plan and results are produced by the other methods and handed
    over here.

    Parameters
    ----------
    prompt : str
        Natural-language question or instruction.
    context : ExplainableResult, default None
        Object to explain. Accepts a `ForecastingProfile` (from
        `profile()`), a `CodeGenerationResult` (from `forecast_code()`
        or `backtest_code()`), a `CVResult` (from `create_cv()`), or a
        `ForecastResult`, `BacktestResult` or `ComparisonResult`. The
        returned `profile`, `plan`, and `code` are the context's own;
        for a `ComparisonResult` these are the shared profile and the
        winning candidate's plan and code. The values a result owns
        (its predictions and metrics) are always sent to the LLM,
        regardless of `send_data_to_llm`, since a question about a
        result cannot be answered from summary statistics alone. The
        input data is never sent: a result holds only the model's
        output, and a profile holds summary statistics only.
    plan : ForecastPlan, default None
        Plan to explain together with the profile passed as
        `context`. The two are rendered into the same script
        `forecast_code()` would produce, so the LLM sees the plan and
        the returned `code` is that script. Not accepted with any
        other kind of context, which already carries its own plan.
        The plan is validated again before the script is rendered, so
        one edited with `model_copy(update=...)` or by assignment
        raises `ValidationError` unless it is still a valid
        `ForecastPlan`.
    skills : list of str, default None
        List of skill names to include in the agent system prompt.
        If None, skills are selected automatically based on the
        task type and question content. See `skforecast_ai.ALL_SKILLS`
        for valid names.
    include_reference : bool, default False
        Whether to include the skforecast API reference in the
        prompt.

    Returns
    -------
    result : AskResult
        LLM response and the artifacts of the explained context.
        Contains the following attributes:

        - profile: profile of the explained context, when there is
        one.
        - plan: forecasting plan of the explained context, when there
        is one.
        - code: generated Python script of the explained context, when
        there is one.
        - explanation: LLM-generated explanation or response.
        - skills: names of the skill documents sent to the model,
        after trimming to fit the context budget.

    Raises
    ------
    LLMRequiredError
        If no LLM was configured at init time.
    LLMCallError
        If the call to the LLM fails (network, credentials, provider
        error, or a local model that is not reachable). The original
        exception is available as `original_error`. Unlike
        `refine_plan()` and `create_cv()`, `ask()` has no deterministic
        answer to fall back on.
    TypeError
        If `context` is not explainable (a bare `ForecastPlan` is
        not: pass `context=profile, plan=plan`), or if `plan`
        accompanies a context other than a `ForecastingProfile`.
    pydantic.ValidationError
        If `plan` does not pass the validators of `ForecastPlan` (a
        subclass of `ValueError`).

    Warns
    -----
    DataSentToLLMWarning
        If `context` carries values of its own (predictions, metrics)
        while `send_data_to_llm` is False.

    Notes
    -----
    An LLM must be configured at init time. When `llm` is None,
    this method cannot operate and raises `LLMRequiredError`.
    """

    if self.llm is None:
        raise LLMRequiredError("ask")

    if isinstance(context, ForecastPlan):
        raise InvalidInputTypeError(
            "A `ForecastPlan` cannot be explained on its own: it does not "
            "carry the dataset it was derived from. Pass "
            "`context=profile, plan=plan`.",
            field = "context",
        )
    if context is not None and not isinstance(context, ExplainableResult):
        raise InvalidInputTypeError(
            f"`context` must be a `ForecastingProfile` or a workflow "
            f"result (for example `ForecastResult`, `BacktestResult`, "
            f"`ComparisonResult`, `CodeGenerationResult`, or `CVResult`), "
            f"got {type(context).__name__}.",
            field = "context",
        )
    if plan is not None and not isinstance(context, ForecastingProfile):
        raise InvalidInputTypeError(
            "`plan` only accompanies a `ForecastingProfile` passed as "
            "`context`; any other context already carries its own plan.",
            field = "plan",
        )

    # A profile with a plan is explained through the script the two
    # produce together, exactly as `forecast_code()` would render it,
    # after validating the plan again as `forecast_code()` does.
    if isinstance(context, ForecastingProfile) and plan is not None:
        plan = _revalidate_plan(plan)
        context = CodeGenerationResult(
            profile = context,
            plan    = plan,
            code    = render_forecast_script(
                          profile=context.data_profile, plan=plan
                      ).full_script,
        )

    # --- Deterministic stage: the context describes itself ---
    if context is None:
        profile        = None
        plan           = None
        generated_code = None
        text           = ""
    else:
        # The values a result owns are always sent, so the LLM can
        # discuss specific numbers. This overrides `send_data_to_llm`,
        # so say so: a user who set it to False for privacy reasons
        # would otherwise ship predicted values without being told.
        # The context itself reports whether it ships any such values.
        llm_context = context.to_llm_context(send_data=True)
        if not self.send_data_to_llm and llm_context.sends_result_values:
            warnings.warn(
                "`send_data_to_llm=False` does not apply to `context`: the "
                "predicted values it carries are sent to the LLM, because "
                "a question about a result cannot be answered from "
                "summary statistics alone. Your input data is not sent: a "
                "result holds only the model's output, never the data it "
                "was fitted on. To keep predictions local, ask without "
                "`context`.",
                DataSentToLLMWarning,
                stacklevel=2,
            )
        profile        = llm_context.profile
        plan           = llm_context.plan
        generated_code = llm_context.code
        text           = llm_context.text

    # --- Pre-flight check for Ollama ---
    if self.llm.startswith("ollama:"):
        try:
            ensure_ollama_reachable(self.base_url)
        except ConnectionError as exc:
            raise LLMCallError(self.llm, exc) from exc

    # --- Build user message with context ---
    # The question is delimited so it cannot be mistaken for part of
    # the deterministic context block that precedes it.
    user_message = (
        f"{text}\n\n<question>\n{prompt}\n</question>"
        if text
        else prompt
    )

    # --- Dynamic skill selection when not explicitly provided ---
    resolved_skills = skills
    if resolved_skills is None:
        # The profile gives the data's task type, the plan the one of
        # the forecaster actually used (a statistical model or the
        # baseline chosen over the recommendation). A comparison also
        # ranks its baseline, which the winner's plan does not show.
        task_types: list[str] = []
        if profile is not None:
            task_types.append(profile.task_type)
        if plan is not None and plan.task_type not in task_types:
            task_types.append(plan.task_type)
        if (
            isinstance(context, ComparisonResult)
            and context.baseline_name is not None
            and "baseline" not in task_types
        ):
            task_types.append("baseline")
        # Budget the skills against the space the context block has
        # already taken. Only local models expose a context window
        # known up front; hosted windows are provider specific and
        # generally far larger, so no budget is imposed for them and
        # the full selection is sent.
        token_budget = None
        if self.llm.startswith("ollama:"):
            token_budget = compute_skill_token_budget(
                max_context_tokens = OLLAMA_MAX_CONTEXT_TOKENS,
                context_tokens     = estimate_context_tokens(user_message),
                include_reference  = include_reference,
            )
        resolved_skills = select_skills(
            task_type    = task_types,
            question     = prompt,
            token_budget = token_budget,
        )

    # --- LLM call ---
    from .llm import AskDeps

    # pydantic-ai builds the provider with the agent, so a missing API
    # key fails here, before any call. A missing package stays an
    # ImportError, which names the extra to install.
    try:
        agent = self._resolve_agent()
    except ImportError:
        raise
    except Exception as exc:
        raise LLMCallError(self.llm, exc) from exc
    deps = AskDeps(
        profile           = profile,
        plan              = plan,
        skills            = resolved_skills,
        include_reference = include_reference,
    )

    estimated_tokens = estimate_prompt_tokens(
        resolved_skills, include_reference
    )
    model_settings = build_ollama_settings(
        self.llm, estimated_tokens, user_message
    )

    # Unlike `refine_plan()` and `create_cv()`, which fall back to a
    # valid deterministic output, `ask()` has no answer without the
    # LLM, so a failed call is an error rather than a degraded result.
    try:
        agent_result = run_agent_sync(
            agent,
            user_message,
            deps=deps,
            model_settings=model_settings,
        )
    except Exception as exc:
        raise LLMCallError(self.llm, exc) from exc
    explanation = agent_result.output

    # Strip code blocks when a validated script exists.
    if generated_code is not None:
        explanation = _strip_code_blocks(explanation)

    return AskResult(
        profile     = profile,
        plan        = plan,
        code        = generated_code,
        explanation = explanation,
        skills      = list(resolved_skills),
    )

check_llm

check_llm(test_call=False)

Check whether the configured LLM can be used, and report why not.

Runs the static checks of check_llm_config() on the assistant's llm, base_url and api_key: the provider string is parsed, the credential source is identified (explicit api_key, the provider environment variable read by pydantic-ai, the AWS credential chain, or none for Ollama) and whether the variable is set, base_url is interpreted as the provider does (endpoint, Ollama server, AWS region, or ignored), the required modules are probed and, for Ollama, the server is contacted. With test_call=True and every static check passed, a one-line prompt is sent to the model through the same agent ask() uses, without skills, reference or data. Nothing is raised on a failed check: the result reports it. Credential values never appear in the result.

Parameters:

Name Type Description Default
test_call bool

Whether to send a minimal prompt to the model once the static checks pass. Costs one small request.

False

Returns:

Name Type Description
result LLMCheckResult

Outcome of every check. Contains the following attributes:

  • llm: provider string as given.
  • provider: provider prefix, None when the string is invalid.
  • model_name: model name, None when the string is invalid.
  • credential_source: 'api_key', 'env_var', 'aws_credential_chain', 'none' or 'unknown'.
  • env_var: environment variable the provider reads, if any.
  • env_var_set: whether it is set, None when not consulted.
  • credential_note: description of the credential resolution.
  • base_url: effective endpoint after provider defaults.
  • base_url_note: what base_url means for the provider.
  • dependencies_ok: whether the required modules are installed.
  • missing_dependencies: modules not installed, with the extra that installs them.
  • reachable: for Ollama, whether the server answered.
  • call_ok: whether the test call succeeded, None if skipped.
  • error: first failure found, None when all checks passed.
  • ok: True when no check failed.

Raises:

Type Description
LLMRequiredError

If no LLM was configured at init time.

Source code in skforecast_ai/assistant.py
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
def check_llm(self, test_call: bool = False) -> LLMCheckResult:
    """
    Check whether the configured LLM can be used, and report why not.

    Runs the static checks of `check_llm_config()` on the assistant's
    `llm`, `base_url` and `api_key`: the provider string is parsed,
    the credential source is identified (explicit `api_key`, the
    provider environment variable read by pydantic-ai, the AWS
    credential chain, or none for Ollama) and whether the variable is
    set, `base_url` is interpreted as the provider does (endpoint,
    Ollama server, AWS region, or ignored), the required modules are
    probed and, for Ollama, the server is contacted. With
    `test_call=True` and every static check passed, a one-line
    prompt is sent to the model through the same agent `ask()` uses,
    without skills, reference or data. Nothing is raised on a failed
    check: the result reports it. Credential values never appear in
    the result.

    Parameters
    ----------
    test_call : bool, default False
        Whether to send a minimal prompt to the model once the static
        checks pass. Costs one small request.

    Returns
    -------
    result : LLMCheckResult
        Outcome of every check. Contains the following attributes:

        - llm: provider string as given.
        - provider: provider prefix, None when the string is invalid.
        - model_name: model name, None when the string is invalid.
        - credential_source: `'api_key'`, `'env_var'`,
        `'aws_credential_chain'`, `'none'` or `'unknown'`.
        - env_var: environment variable the provider reads, if any.
        - env_var_set: whether it is set, None when not consulted.
        - credential_note: description of the credential resolution.
        - base_url: effective endpoint after provider defaults.
        - base_url_note: what `base_url` means for the provider.
        - dependencies_ok: whether the required modules are installed.
        - missing_dependencies: modules not installed, with the extra
        that installs them.
        - reachable: for Ollama, whether the server answered.
        - call_ok: whether the test call succeeded, None if skipped.
        - error: first failure found, None when all checks passed.
        - ok: True when no check failed.

    Raises
    ------
    LLMRequiredError
        If no LLM was configured at init time.
    """

    if self.llm is None:
        raise LLMRequiredError("check_llm")

    result = check_llm_config(
        llm      = self.llm,
        base_url = self.base_url,
        api_key  = self.api_key,
    )

    if not test_call or not result.ok:
        return result

    message = "Reply with the single word OK."
    try:
        from .llm import AskDeps

        agent = self._resolve_agent()
        deps = AskDeps(
            profile           = None,
            plan              = None,
            skills            = [],
            include_reference = False,
        )
        model_settings = build_ollama_settings(
            self.llm, estimate_prompt_tokens([], False), message
        )
        run_agent_sync(agent, message, deps=deps, model_settings=model_settings)
    except Exception as exc:
        return result.model_copy(
            update={"call_ok": False, "error": str(LLMCallError(self.llm, exc))}
        )

    return result.model_copy(update={"call_ok": True})