-
Notifications
You must be signed in to change notification settings - Fork 488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uncertainty: Conformal Prediction V1 #802
Conversation
… conformal_prediction_energy_hospital_load.ipynb example notebook.
…on_energy_hospital_load.ipynb example notebook.
…_energy_hospital_load.ipynb example notebook.
…ction_energy_hospital_load.ipynb.
…prediction_energy_hospital_load.ipynb.
…l input params for m1 model except for quantiles.
…hospital_load.ipynb hence 4 models m1-4.
…hospital_load.ipynb hence 4 models m1-4.
…_energy_hospital_load.ipynb.
…al_prediction_energy_hospital_load.ipynb.
…and plot_forecast.py files.
…lude val_cov_pct and make fold_overlap_pct a dependent variable.
…l_load_enbpi.ipynb and cross_validation_energy_hospital_load.ipynb.
…load.ipynb and feature-use/conformal_prediction_energy_hospital_load_enbpi.ipynb.
…_enbpi_agg.ipynb.
…diction.ipynb and uncertainty_estimation.ipynb to uncertainty_quantile_regression.ipynb.
…rmalize() method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work Kevin!
As this exposes a lot of new UI, I focused on critically reviewing those areas.
Thus far, I have only reviewed the forecaster.py
file.
Will review rest later, adding a review already so you can move forward with the changes.
neuralprophet/forecaster.py
Outdated
# Conformal prediction interval with q | ||
if self.q_hats: | ||
if self.conformal_method == "naive": | ||
df["yhat1 - qhat1"] = df["yhat1"] - self.q_hats[0] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be more understandable to the general user, if we keep uncertainty naming consistent and also just show one uncertainty value in the df.
As we currently use f"yhat1 {quantile_lo}%"
and f"yhat1 {quantile_hi}%"
I suggest we stick with those.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CP method is naive, so it does not take into account quantile low and quantile high. That is for CQR. Naive is only for yhat
+- q, so saying f"yhat1 {quantile_lo}%"
in incorrect here.
neuralprophet/forecaster.py
Outdated
else: # self.conformal_method == "cqr" | ||
quantile_hi = str(max(self.config_train.quantiles) * 100) | ||
quantile_lo = str(min(self.config_train.quantiles) * 100) | ||
df[f"yhat1 {quantile_hi}% - qhat1"] = df[f"yhat1 {quantile_hi}%"] - self.q_hats[0] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in the case of CQR, I think my comment above also applies - let's simply overwrite f"yhat1 {quantile_lo}%"
and f"yhat1 {quantile_hi}%"
.
IMO, When a user explicitly executes thes conformalize steps, it should be evident enough that the outcome is the conformalized quantile.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't agree on this front. The only way it is evident is that is stating f"yhat1 {quantile_hi}% - qhat1"
.
df["yhat1 - qhat1"] = df["yhat1"] - self.q_hats[0] | ||
df["yhat1 + qhat1"] = df["yhat1"] + self.q_hats[0] | ||
else: # self.conformal_method == "cqr" | ||
quantile_hi = str(max(self.config_train.quantiles) * 100) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will skip all but the top and bottom quantiles - if a user specifies more than 2, they are not conformalized.
We should either raise an error in this case in the conformalize step (and here), or adopt the conformalize method for more than 2 quantiles. Or am I misreading something here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the CP is "naive", then NP doesn't use the quantiles at all. If CP is "CQR", then it will take the largest and smallest value in the quantile, regardless of the length of quantiles list.
I can make an assert check that the quantiles list need to have at least 2 values, for high and low, but that can be done as a separate PR.
@@ -3012,3 +3025,30 @@ def _reshape_raw_predictions_to_forecst_df(self, df, predicted, components): | |||
yhat_df = pd.Series(yhat, name=comp).set_axis(df_forecast.index) | |||
df_forecast = pd.concat([df_forecast, yhat_df], axis=1, ignore_index=False) | |||
return df_forecast | |||
|
|||
def conformalize(self, df_cal, alpha, method="naive", plotting_backend="default"): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a user configures QR, then uses CP, I would expect for them to receive CQR by default.
It appears to me that the default method="naive"
will however ignore the QR estimates.
Maybe we could even remove the method
arg, as QR + CP = CQR and no-QR + CP = naive CP.
Does that make sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, naive is a legitimate conformal prediction method. User should be able to use it regardless of whether there is any QR or not. So my design philosophy is that the user can use whatever CP method regardless of the trained model.
As for what the default method should be, for QR or not QR, that can be debatable. I am leaning towards creating an "auto"
, to encompass what you said: QR + CP = CQR and no-QR + CP = naive CP. But that can be added in a new PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would definitely love to see a more unified approach to our different uncertainty modelling approaches - it's getting rather confusing to me and also how different their APIs are or rather my uncertainty on how to use them / combine them.
df_cal : pd.DataFrame | ||
calibration dataframe | ||
alpha : float | ||
user-specified significance level of the prediction interval |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if quantiles in QR were already set? Shouldn't it then be using the same?
We could even make it optional, automatically retrieving the QR quantiles and fail if they are not set, non-symmetric or more than two?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not necessarily, you can have alpha
that is different from QR.
It is possible, by default, to retrieve the QR quantiles, check for symmetry, and automatically alpha, but that can be done as a separate PR.
* ``cqr``: Conformalized Quantile Regression | ||
""" | ||
df_cal = self.predict(df_cal) | ||
if isinstance(plotting_backend, str) and plotting_backend == "default": |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need to check for isinstance(plotting_backend, str)
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To ensure that plotting_backend
is not None
, as it is one of the default values.
q_hats = [] | ||
noncon_scores_list = _get_nonconformity_scores(df_cal, method, quantiles) | ||
|
||
for noncon_scores in noncon_scores_list: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can move this part to a separate method/function in a subsequent PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good work Kevin!
We can merge this PR, and address the comments in a subsequent PR.
scores_list = [scores_df["scores"].values] | ||
else: # method == "naive" | ||
# Naive nonconformity scoring function | ||
scores_list = [abs(df["y"] - df["yhat1"]).values] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seems like we are currently only doing this for one-step ahead?
Maybe we can extend this to multiple forecast steps in a subsequent PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it is hardcoded so it only does one-step ahead. I agree we can create a subsequent PR to enable conformal prediction for multiple steps.
nonconformity scores from the calibration datapoints | ||
|
||
""" | ||
quantile_hi = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can extend this to an arbitrary amount of quantiles in a subsequent PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't follow. CQR only looks at the lowest and highest quantiles regardless of the number of quantiles in-between (if any). Maybe can extend for more advanced versions of conformal prediction, although I don't know of any that need beyond high and low quantiles.
@@ -812,6 +816,18 @@ def predict(self, df, decompose=True, raw=False): | |||
forecast = pd.concat((forecast, fcst), ignore_index=True) | |||
df = df_utils.return_df_in_original_format(forecast, received_ID_col, received_single_time_series) | |||
self.predict_steps = self.n_forecasts | |||
# Conformal prediction interval with q |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this code block can be isolated and moved to the conformalize method if I understand this correctly?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I have it when the .predict()
outputs the forecast_df, that df also contains the forecasted value plus/min the conformal prediction interval. For example:
yhat1 - qhat1 | yhat1 + qhat1 |
---|
Just like what's been with QR, given forecast columns like:
yhat1 5.0% | yhat1 95.0% |
---|
@@ -3000,3 +3015,46 @@ def _reshape_raw_predictions_to_forecst_df(self, df, predicted, components): | |||
yhat_df = pd.Series(yhat, name=comp).set_axis(df_forecast.index) | |||
df_forecast = pd.concat([df_forecast, yhat_df], axis=1, ignore_index=False) | |||
return df_forecast | |||
|
|||
def conformalize(self, df_cal, alpha, method="naive", plotting_backend="default"): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
currently, predict
implicitly assume that this method has been called beforehand.
We could:
a) To improve encapsulation we can move the code block from predict here, call this method conformal_predict
or similar, and have it accept a prediction df
which is passed on to predict
internally.
b) alternatively, this could be split into a three step procedure: compute_conformalization
, predict
, conformalize_prediction
.
Both would have the advantage of separating regular prediction and conformal prediction.
There may be an even better approach though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please see PR #1044. This implements what you have for a). There is a conformal_predict()
method that combines the conformalize()
and predict()
, and will need to input most the calibration and test sets for split CP. With this implemented, conformalize()
will then be removed. Also, the config_conformal
will no longer be necessary and will be removed. @noxan
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, for future bootstrapped (e.g. Jackknife+ and CV+ based) CP methods, calibration set will not be needed, which is why calibration_df
is set to None
as default in conformal_predict()
.
Options | ||
* (default) ``naive``: Naive or Absolute Residual | ||
* ``cqr``: Conformalized Quantile Regression | ||
plotting_backend : str |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can separate the plotting functionality into a separate method in a subsequent PR.
df_cal, alpha, method, self.config_train.quantiles, plotting_backend | ||
) | ||
|
||
# def conformalize_predict(self, df, df_cal, alpha, method="naive"): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I see, you already created this method.
Maybe we can make conformalize a util instead of a class method and use this method as the main interface?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possible, but I intend to give the user the to run either the .conformalize()
and .predict()
methods separately or run as one with .conformalize_prediction()
, just like for scikit's .fit_transform()
. However, the reason why I haven't yet is because the .conformalize_prediction()
will need to input two datasets, on the calibration set and another test set. Maybe that is fine because b/c the .train()
gives the optionality of adding a validation set alongside the train set.
@@ -131,6 +138,17 @@ def plot( | |||
alpha=0.2, | |||
) | |||
|
|||
# Plot any conformal prediction intervals |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for the default plotting method I suggest we only plot one uncertainty type at once, as it may be confusing to most users to see multiple uncertainty types at once.
fig1.show() | ||
fig2.show() | ||
# With auto-regression enabled | ||
# TO-DO: Fix Assertion error n_train >= 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How can we best resolve this issue?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will need to dig into it.
q_hats: list | ||
|
||
|
||
ConfigConformalPrediction = Conformal |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no point in having Conformal
and ConfigConformalPrediction
right? For other dataclasses we had this separation because they might contain multiple items, yet this is not the case here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not necessarily for ConfigConformalPrediction
, but @ourownstory wants me to put q_hats
and method
into a conformal/uncertainty config dataclass instead of being a primary class variable for NeuralProphet
. So I did.
""" | ||
df_cal = self.predict(df_cal) | ||
if isinstance(plotting_backend, str) and plotting_backend == "default": | ||
plotting_backend = "matplotlib" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is here another selection of our plotting backend happening? We should definitely have this centralized to make the migration to plotly more easy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's needed to order to print out the One-Sided Interval Width with q plot when user is running .conformalize()
. This plot is very helpful in visualizing the q
for given alpha
, where q
is the basis of the prediction interval. See the uncertainty_conformal_prediction.ipynb.
* ``cqr``: Conformalized Quantile Regression | ||
|
||
quantiles : list | ||
list of quantiles for quantile regression uncertainty estimate |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Those conformal prediction rely on the quantile regression as it reuses those parameters or are those separate ones? Just asking as I'm trying to better understand how those to methods are connected (or not)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now there are two available conformal prediction methods: naive and CQR. Naive does conformal prediction independently from the QR while CQR applies conformal prediction on the QR itself rather than the point prediction.
df[f"yhat1 {quantile_hi}% - qhat1"] = df[f"yhat1 {quantile_hi}%"] - self.config_conformal.q_hats[0] | ||
df[f"yhat1 {quantile_hi}% + qhat1"] = df[f"yhat1 {quantile_hi}%"] + self.config_conformal.q_hats[0] | ||
df[f"yhat1 {quantile_lo}% - qhat1"] = df[f"yhat1 {quantile_lo}%"] - self.config_conformal.q_hats[0] | ||
df[f"yhat1 {quantile_lo}% + qhat1"] = df[f"yhat1 {quantile_lo}%"] + self.config_conformal.q_hats[0] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit confused with all the extra output values. What benefit do they bring me as a user and how would I interpret them? Maybe conformal prediction is an advanced feature overall, yet it would be great to have some guide or instructions on how to make use of this method or at least to better understand what I'm missing out on 😅
Incorporate the first 2 split conformal prediction (SCP) methods to create prediction intervals for NeuralProphet:
Example code:
Use the conformal_prediction_energy_hospital_load.ipynb example notebook as test.