MLflow for Darts implementation#3022
Conversation
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
|
Hey @daidahao, adding this draft PR in the meantime so you and @dennisbader can have a look at what I have currently regarding the integration. There are still some decisions I am not too thrilled about and decisions to be made about the overall direction, but I'm happy to talk more about it during the meeting. Thanks for being so active for the library, really nice to be working together :) |
|
Thanks everyone for all the work and the recent pushes to this PR 🚀 |
|
Off the top of my head, the status on the MLFlow PR:
series = AirPassengersDataset().load().astype(np.float32)
series_multiple = [series, series / 3.]
series_multivariate = series.stack(series / 3.)
series_multiple_multivariate = [series.stack(series / 3.), series.stack(series / 10.)]
model.backtest(
series=series,
historical_forecasts=hfc,
last_points_only=False,
metric=[darts_metrics.mape, darts_metrics.rmse, darts_metrics.ape, darts_metrics.mase, darts_metrics.mase],
metric_kwargs=[{}, {}, {}, {"m": 1}, {"m": 2}],
reduction=None,
)
Also, I believe the TODO regarding metrics/kwargs was implemented in the most recent commit by @jakubchlapek - very cool! :) |
jakubchlapek
left a comment
There was a problem hiding this comment.
Hey, looks nice @mizeller, just a few comments on the historical forecasts. The hfcs solution is nice.
| if metric is None: | ||
| try: | ||
| sig = inspect.signature(original) | ||
| bound = sig.bind(self, *args, **kwargs) | ||
| bound.apply_defaults() | ||
| metric = bound.arguments.get("metric") | ||
| except Exception: | ||
| pass |
There was a problem hiding this comment.
I'd say we can remove this, I don't believe anyone will pass in metrics positionally and it adds unnecessary complexity to the code (default mape will then still be covered by else branch)
| # 2-D and higher: skip to keep MVP simple | ||
|
|
There was a problem hiding this comment.
i'd prefer to include this in the PR if possible :)
| if isinstance(metric, (list | tuple)) and isinstance(result, list): | ||
| # multiple metrics → result is list[scalar_or_array], one per metric | ||
| for name, r in zip(names, result): | ||
| _log(f"backtest_{name}", r) | ||
| elif ( | ||
| isinstance(metric, list | tuple) | ||
| and result_arr is not None | ||
| and result_arr.ndim == 1 | ||
| and len(result_arr) == len(names) | ||
| ): | ||
| # multiple metrics with scalar reduction returned as a 1-D ndarray | ||
| # (e.g. np.mean/median/percentile) — log each as a separate scalar | ||
| for name, r in zip(names, result_arr): | ||
| autologging_client.log_metrics( | ||
| run_id=run_id, metrics={f"backtest_{name}": float(r)} | ||
| ) | ||
| elif result_arr is not None and result_arr.ndim == 2: | ||
| # (N_windows, N_metrics) ndarray — multi-metric + reduction=None | ||
| for col_i, name in enumerate(names[: result_arr.shape[1]]): | ||
| for step, val in enumerate(result_arr[:, col_i]): | ||
| autologging_client.log_metrics( | ||
| run_id=run_id, | ||
| metrics={f"backtest_{name}": float(val)}, | ||
| step=step, | ||
| ) | ||
| elif isinstance(result, list): | ||
| # single metric, multiple series → result is list[scalar_or_array] | ||
| for s_i, r in enumerate(result): | ||
| _log(f"backtest_{names[0]}_{s_i}", r) | ||
| else: | ||
| _log(f"backtest_{names[0]}", result) |
There was a problem hiding this comment.
ideally we would like to also support multivariate series where we can log per component if no reduction (e.g. maybe [backtest_MAE_x, backtest_MAE_y]). I worry that this approach can then get a bit complex with all the branches. Maybe we can think about normalizing the result to a dataframe first which could simplify logging? Let me know what you think here
There was a problem hiding this comment.
Yes, we should definitely support this. Can we somehow be smarter here for inferring what the output dimension represent? Right now we only look at the output which I think can be dangerous because the dimensions might not be what we think (depending on the reductions, ...). In theory we should be able to look at the metric kwargs and the metric signature defaults to know what the output dimension should be (I say should because in the end the metrics will try to unpack the final results if possible).
There are many kwargs and input type that affect the output shape:
metric kwargs:
time/component/series_reduction/label_reduction: aggregates over an axisqandq_interval(for computation on quantiles and quantile interval metrics -> goes into component dimensionlabel(for classification): goes into the component dimension (I believe)
metric input series that also affect the output shape:
- series: either a single series or multiple
- series: either univariate or multivariate
If we could bring the metrics into an expected shape that would make it more safe for downstream logging.
Something like:
shape: (n series, n times, n components * q/q_interval/label)
The reduced dimensions would be of length 1.
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
I could not find the logged model tags (model_type, dataset) in the model UI (not sure if I looked at the wrong places). Does it work as intended?
For autolog runs I find the tags
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
a (bullet) list of what is supported would be nice. Also mention backtesting, and anything that we forgot here
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
Line #3. with mlflow.start_run(run_name="linear-regression-autolog") as run:
When I inspect linear-regression-autolog run I find 2 logged models. Could this be a bug?
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
Line #9. auto_mape = darts.metrics.mape(val, auto_predictions)
In my opinion we should ignore the name of that actual_series (e.g. "val"). The variable name should not matter. Imagine we're looping through a set of series, then we still have the same problem of identical names (we discussed and said it's okay if metrics are overwritten). So it's not adding a benefit and at the same time users need to be aware of how they name their variables.
for pred in preds:
mape(pred, ...)
Also, this is ambiguous with the train/val loss logged by the torch models under the hood.
Instead, simply ignore it. You could show here already the metric "name" parameter, but i would choose a different name than "val_*"
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
Line #6. with mlflow.start_run(run_name="nbeats-epoch-metrics"):
Some notes here for Torch Autologging:
- I also see parameters logged which are not part of the TorchForecastingModel wrapper, for example:
optimizer_name Adam lr 0.001 betas (0.9, 0.999) eps 1e-08 weight_decay 0
Shouldn't we just log the top level Model parameters (e.g. NBEATSModel-level) to allow re-creating the model with the same parameters?
- There is an additional
checkpointsfolder underrun > Artifacts > checkpoints. This folder contains the latest checkpoint, which basically means we store the model twice. Is there away to remove this one and only rely on our Artifacts store undermodel > Artifacts? - Under
run > Metrics, I find some metrics (val_loss, ...) are assigned to modelNBEATSModelbut others are not (val_mape, ...). It should be identical for all
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
Line #11. multi_preds = multi_model.predict(n=len(val), series=series_list)
single and multi preds on different levels seems unintuitive.
multi_preds = multi_model.predict(n=len(val), series=series_list) single_pred = multi_preds[0]
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
Line #19. dm.ae(val, single_pred)
What happens when we compute non-aggregate metrics on a list of predictions with different horizons?
e.g.
dm.ae(val_list, [multi_preds[0], multi_preds[1][1:]])
I assume the multi-series aggregation will not work properly.
I don't really think that we need to support this case, but it should maybe raise an exception in the auto-logging that this isn't supported
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
Line #23. per_series_mae = dm.mae(val_list, multi_preds)
When we call two metrics on a list of series, then it writes two CSVs. Could / should we just write one CSV and appending subsequent metric calls to the first one?
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
Line #39. with open(csv_path) as f:
would be much simpler to use pandas here
print("\nPer-series breakdown (val_list_mae_per_series.csv):")
pd.read_csv(csv_path)
Reply via ReviewNB
| @@ -0,0 +1,6067 @@ | |||
| { | |||
There was a problem hiding this comment.
There was a problem hiding this comment.
Really nice work on this one @jakubchlapek, @mizeller and @daidahao 🚀
I've played a bit around with it and it's looking great! Really cool what has all been included in the logging. This will help users a lot during experimentation and modelling :)
I do have a couple of suggestions that revolve mainly about:
- currently some parts of the code can lead to ambiguous / incorrect logging (e.g. aggregation backtests for multi-series that have different time indices)
- we do a lot of skipping in case the metrics don't have the expected shape. This can lead to missing logs which might not be intuitive for the user, or can even silently ignore actual bugs. I would prefer raising exceptions, especially since the feature is new, we need to know what is not working.
- agreeing on the naming of what is logged
- alternatives to the metric and model method patchings
- and some other minor things
After this we should be good to go 💯
| "Programming Language :: Python :: Implementation :: PyPy", | ||
| ] | ||
| dependencies = [ | ||
| "coolname>=4.2.0", |
There was a problem hiding this comment.
are coolname and loguru really required? Would prefer to not include them in the core dependencies. I tested the notebook without these, and had no issues
| "statsforecast>=1.4", | ||
| "xgboost>=2.1.4", | ||
| ] | ||
| mlflow = ["mlflow>=3.0"] |
There was a problem hiding this comment.
either leave mlflow only in the optional dependency group below, or drop it from optional and leave it here.
| .venv | ||
| .env | ||
| uv.lock | ||
| repl/ |
| mlruns/* | ||
| examples/mlruns/* |
There was a problem hiding this comment.
| mlruns/* | |
| examples/mlruns/* | |
| *mlruns/ |
| MLflow Integration for Darts | ||
| ----------------------------- |
There was a problem hiding this comment.
| MLflow Integration for Darts | |
| ----------------------------- | |
| MLflow Integration | |
| ------------------ |
| series = args[0] | ||
| else: | ||
| series = kwargs.get("actual_series", None) | ||
| if series is None: |
There was a problem hiding this comment.
is this even possible?
| name_prefix = metric_names[0] if len(metric_names) == 1 else "metrics" | ||
| flat = np.asarray(r, dtype=float).flatten() | ||
| for i, val in enumerate(flat): | ||
| key = _sanitize_mlflow_key(f"backtest_{name_prefix}_{i}") |
There was a problem hiding this comment.
I don't fully understand why we can't produce the correct naming here. We do know what the axes and quantile / label names are per metric, no?
| # this is an issue, then I'd suggest falling back to flat integer-indexed keys | ||
| # and enforcing explicit labels. | ||
| if labels_unknown: | ||
| inferred_labels = np.unique(s.values()) |
There was a problem hiding this comment.
as mentioned somewhere else, we can drop support for this
| rest, extra = divmod(arr.size, c_size * n_metrics) | ||
| if extra: | ||
| logger.warning( | ||
| "Backtest metric logging skipped: result size (%d) is not " |
There was a problem hiding this comment.
raise instead of skipping (and the other occurrences)
| # both time and window axes present: backtest returns (W*T*C*M,) in C order so we can | ||
| # recover W and T only if forecast_horizon is known (T = forecast_horizon) | ||
| if has_time_axis and has_windows: | ||
| if not forecast_horizon or rest % forecast_horizon: |
There was a problem hiding this comment.
Can we infer the horizon from the backtest input args?
historical_forecasts=None: it is guaranteed to be the userforecast_horizonhistorical_forecasts!=None:last_points_only=False: it is the length of the first historical forecast windowlast_points_only=True: the forecast horizon shouldn't really matter, since the metrics are only computed on a single TimeSeries forecast that consists of the last predicted steps from each window
Checklist before merging this PR:
Addresses #2092 .
Summary
Provides a custom MLflow flavor for Darts on Darts' side. Supports autologging, logging, saving and loading of the models.
This PR focuses on the base MLflow integration, leaving serving of the models to be discussed in the future.
Included an example quickstart for the integration, however consider all of this a draft :)
Find example code in the .ipynb, however also providing a code snippet here as a quick reproducible example: