Skip to content
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.1 - extend to multiple forecast steps instead of only a single forecast step #1073

Merged
merged 22 commits into from
Jan 13, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fc1936a
Replaced yhat1 with the step_number for conformal.
Kevin-Chen2 Dec 17, 2022
322b239
Moved conformal_predict() method logic into conformal_prediction.py a…
Kevin-Chen2 Dec 18, 2022
e084a41
Changed self.config_train.quantiles to quantiles in conformal_predict…
Kevin-Chen2 Dec 18, 2022
d2be041
Removed q_hats in _conformalize().
Kevin-Chen2 Dec 18, 2022
e366bb6
Merge branch 'main' into refactor/conformal-multistep
Kevin-Chen0 Dec 21, 2022
d6b229a
Merge branch 'main' into refactor/conformal-multistep
Kevin-Chen0 Dec 21, 2022
bd4b225
Added Conformal dataclass from PR #1073.
Kevin-Chen2 Dec 23, 2022
feef1d2
Added conformal.py to replace conformal_prediction.py.
Kevin-Chen2 Dec 23, 2022
fa5e861
Fixed self.method in ValueError for conformal.py.
Kevin-Chen2 Dec 23, 2022
54458bf
Modified Conformal class to fit multiple lines in forecaster.py.
Kevin-Chen2 Dec 23, 2022
ed989ff
Uncommented auto-regression section for test_plot_conformal_predictio…
Kevin-Chen2 Jan 3, 2023
d2863a7
Merge branch 'main' into refactor/conformal-multistep
Kevin-Chen0 Jan 3, 2023
889a8fa
Merge remote-tracking branch 'origin/main' into refactor/conformal-mu…
Kevin-Chen2 Jan 5, 2023
4fbe1b5
Merge branch 'main' into refactor/conformal-multistep
Kevin-Chen0 Jan 8, 2023
195fe60
Uncommented m.plot_latest_forecast in test_plot_conformal_prediction …
Kevin-Chen2 Jan 8, 2023
9c91965
Merge branch 'main' into refactor/conformal-multistep
Kevin-Chen0 Jan 9, 2023
d491bea
Added step_number in docstring in the _get_nonconformity_scores() met…
Kevin-Chen2 Jan 10, 2023
8ad5088
Uncommented the tests in test_model_performance.py.
Kevin-Chen2 Jan 10, 2023
1a9e165
Merge branch 'main' into refactor/conformal-multistep
Kevin-Chen0 Jan 12, 2023
2aebf76
Merge branch 'main' into refactor/conformal-multistep
Kevin-Chen0 Jan 13, 2023
2a1ddf4
Added plot_interval_width_per_timestep() to plot_forecast_plotly.py a…
Kevin-Chen2 Jan 13, 2023
f4629e2
Modified test_plot_conformal_prediction in test_plotting.py by adding…
Kevin-Chen2 Jan 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 58 additions & 30 deletions neuralprophet/conformal.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import numpy as np
import pandas as pd

from neuralprophet.plot_forecast_matplotlib import plot_nonconformity_scores
from neuralprophet.plot_forecast_matplotlib import plot_interval_width_per_timestep, plot_nonconformity_scores
from neuralprophet.plot_forecast_plotly import plot_nonconformity_scores as plot_nonconformity_scores_plotly


Expand All @@ -23,13 +23,16 @@ class Conformal:
Options
* ``naive``: Naive or Absolute Residual
* ``cqr``: Conformalized Quantile Regression
n_forecasts : int
optional, number of steps ahead of prediction time step to forecast
quantiles : list
optional, list of quantiles for quantile regression uncertainty estimate

"""

alpha: float
method: str
n_forecasts: int = 1
quantiles: Optional[List[float]] = None

def predict(self, df: pd.DataFrame, df_cal: pd.DataFrame) -> pd.DataFrame:
Expand All @@ -48,34 +51,50 @@ def predict(self, df: pd.DataFrame, df_cal: pd.DataFrame) -> pd.DataFrame:
test dataframe with uncertainty prediction intervals

"""
# conformalize
self.noncon_scores = self._get_nonconformity_scores(df_cal)
self.q_hat = self._get_q_hat(df_cal)
df["qhat1"] = self.q_hat
if self.method == "naive":
df["yhat1 - qhat1"] = df["yhat1"] - self.q_hat
df["yhat1 + qhat1"] = df["yhat1"] + self.q_hat
elif self.method == "cqr":
quantile_hi = str(max(self.quantiles) * 100)
quantile_lo = str(min(self.quantiles) * 100)
df[f"yhat1 {quantile_hi}% - qhat1"] = df[f"yhat1 {quantile_hi}%"] - self.q_hat
df[f"yhat1 {quantile_hi}% + qhat1"] = df[f"yhat1 {quantile_hi}%"] + self.q_hat
df[f"yhat1 {quantile_lo}% - qhat1"] = df[f"yhat1 {quantile_lo}%"] - self.q_hat
df[f"yhat1 {quantile_lo}% + qhat1"] = df[f"yhat1 {quantile_lo}%"] + self.q_hat
else:
raise ValueError(
f"Unknown conformal prediction method '{self.method}'. Please input either 'naive' or 'cqr'."
)
self.q_hats = []
for step_number in range(1, self.n_forecasts + 1):
# conformalize
noncon_scores = self._get_nonconformity_scores(df_cal, step_number)
q_hat = self._get_q_hat(df_cal, noncon_scores)
df[f"qhat{step_number}"] = q_hat
if self.method == "naive":
df[f"yhat{step_number} - qhat{step_number}"] = df[f"yhat{step_number}"] - q_hat
df[f"yhat{step_number} + qhat{step_number}"] = df[f"yhat{step_number}"] + q_hat
elif self.method == "cqr":
quantile_hi = str(max(self.quantiles) * 100)
quantile_lo = str(min(self.quantiles) * 100)
df[f"yhat{step_number} {quantile_hi}% - qhat{step_number}"] = (
df[f"yhat{step_number} {quantile_hi}%"] - q_hat
)
df[f"yhat{step_number} {quantile_hi}% + qhat{step_number}"] = (
df[f"yhat{step_number} {quantile_hi}%"] + q_hat
)
df[f"yhat{step_number} {quantile_lo}% - qhat{step_number}"] = (
df[f"yhat{step_number} {quantile_lo}%"] - q_hat
)
df[f"yhat{step_number} {quantile_lo}% + qhat{step_number}"] = (
df[f"yhat{step_number} {quantile_lo}%"] + q_hat
)
else:
raise ValueError(
f"Unknown conformal prediction method '{self.method}'. Please input either 'naive' or 'cqr'."
)
if step_number == 1:
# save nonconformity scores of the first timestep
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these saved for the first step (only)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonconformity scores are saved in order to be inputted into plot_nonconformity_scores() , where it plots:
Naive One-Sided Interval Width with q

The nonconformity score is the score (blue line).

This plot is only called when n_forecasts ==1. For models with n_forecasts >1, it will call the plot_interval_width_per_timestep() instead (more details under your 3rd question), which doesn't require nonconformity scores. Therefore, only the first step needs to be saved.

Copy link
Owner

@ourownstory ourownstory Jan 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the explanation!

self.noncon_scores = noncon_scores
self.q_hats.append(q_hat)

return df

def _get_nonconformity_scores(self, df_cal: pd.DataFrame) -> np.ndarray:
def _get_nonconformity_scores(self, df_cal: pd.DataFrame, step_number: int) -> np.ndarray:
"""Get the nonconformity scores using the given conformal prediction technique.

Parameters
----------
df_cal : pd.DataFrame
calibration dataframe
step_number : int
i-th step ahead forecast

Returns
-------
Expand All @@ -89,35 +108,40 @@ def _get_nonconformity_scores(self, df_cal: pd.DataFrame) -> np.ndarray:
quantile_lo = str(min(self.quantiles) * 100)
cqr_scoring_func = (
lambda row: [None, None]
if row[f"yhat1 {quantile_lo}%"] is None or row[f"yhat1 {quantile_hi}%"] is None
if row[f"yhat{step_number} {quantile_lo}%"] is None or row[f"yhat{step_number} {quantile_hi}%"] is None
else [
max(
row[f"yhat1 {quantile_lo}%"] - row["y"],
row["y"] - row[f"yhat1 {quantile_hi}%"],
row[f"yhat{step_number} {quantile_lo}%"] - row["y"],
row["y"] - row[f"yhat{step_number} {quantile_hi}%"],
),
0 if row[f"yhat1 {quantile_lo}%"] - row["y"] > row["y"] - row[f"yhat1 {quantile_hi}%"] else 1,
0
if row[f"yhat{step_number} {quantile_lo}%"] - row["y"]
> row["y"] - row[f"yhat{step_number} {quantile_hi}%"]
else 1,
]
)
scores_df = df_cal.apply(cqr_scoring_func, axis=1, result_type="expand")
scores_df.columns = ["scores", "arg"]
noncon_scores = scores_df["scores"].values
else: # self.method == "naive"
# Naive nonconformity scoring function
noncon_scores = abs(df_cal["y"] - df_cal["yhat1"]).values
noncon_scores = abs(df_cal["y"] - df_cal[f"yhat{step_number}"]).values
# Remove NaN values
noncon_scores = noncon_scores[~pd.isnull(noncon_scores)]
# Sort
noncon_scores.sort()

return noncon_scores

def _get_q_hat(self, df_cal: pd.DataFrame) -> float:
def _get_q_hat(self, df_cal: pd.DataFrame, noncon_scores: np.ndarray) -> float:
"""Get the q_hat that is derived from the nonconformity scores.

Parameters
----------
df_cal : pd.DataFrame
calibration dataframe
noncon_scores : np.ndarray
nonconformity scores

Returns
-------
Expand All @@ -126,8 +150,8 @@ def _get_q_hat(self, df_cal: pd.DataFrame) -> float:

"""
# Get the q-hat index and value
q_hat_idx = int(len(self.noncon_scores) * self.alpha)
q_hat = self.noncon_scores[-q_hat_idx]
q_hat_idx = int(len(noncon_scores) * self.alpha)
q_hat = noncon_scores[-q_hat_idx]

return q_hat

Expand All @@ -146,8 +170,12 @@ def plot(self, plotting_backend: str):
"""
method = self.method.upper() if "cqr" in self.method.lower() else self.method.title()
if plotting_backend == "plotly":
fig = plot_nonconformity_scores_plotly(self.noncon_scores, self.alpha, self.q_hat, method)
fig = plot_nonconformity_scores_plotly(self.noncon_scores, self.alpha, self.q_hats[0], method)
elif plotting_backend == "matplotlib":
fig = plot_nonconformity_scores(self.noncon_scores, self.alpha, self.q_hat, method)
if self.n_forecasts == 1:
# includes nonconformity scores of the first timestep
fig = plot_nonconformity_scores(self.noncon_scores, self.alpha, self.q_hats[0], method)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we would have the same behavior for self.n_forecasts == 1 and other values of n_forecasts.
Maybe we could instead have the special plot that includes the scores be a separate plotting utility call instead of automatically overwriting the standard plotting utility.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Further, there seems to be these two options only in the matplotlib case but not for plotly - However the plot should be as identical as possible independent of the plotting backend. How can we make them return the same kind of plot?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two different plots show whether n_forecasts == 1 or n_forecasts >1. See your 1st and 3rd questions for details.

As for the matplotlib and plotly, both have the plot_nonconformity_scores(), but only matplotlib has the plot_interval_width_per_timestep(). I'll try to code that for plotly now and before the merge of this PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just added plot_interval_width_per_timestep() for for plotly and modified the test_plot_conformal_prediction so the test coverage for that addition is there.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes more sense to me now, thank you!
From a UI-perspective it's not ideal to have the plot change quite so drastically depending on an external factor, but I think it is OK in this case as this is more of a research/diagnostic plot.

else:
fig = plot_interval_width_per_timestep(self.q_hats, method)
if plotting_backend in ["matplotlib", "plotly"] and matplotlib.is_interactive():
fig.show()
7 changes: 6 additions & 1 deletion neuralprophet/forecaster.py
Original file line number Diff line number Diff line change
Expand Up @@ -3145,7 +3145,12 @@ def conformal_predict(
# get predictions for test dataframe
df = self.predict(df, **kwargs)
# initiate Conformal instance
c = Conformal(alpha=alpha, method=method, quantiles=self.config_train.quantiles)
c = Conformal(
alpha=alpha,
method=method,
n_forecasts=self.n_forecasts,
quantiles=self.config_train.quantiles,
)
# call Conformal's predict to output test df with conformal prediction intervals
df = c.predict(df=df, df_cal=df_cal)
# plot one-sided prediction interval width with q
Expand Down
33 changes: 31 additions & 2 deletions neuralprophet/plot_forecast_matplotlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ def plot_multiforecast_component(


def plot_nonconformity_scores(scores, alpha, q, method):
"""Plot the NeuralProphet forecast components.
"""Plot the nonconformity scores as well as the one-sided interval width (q).

Parameters
----------
Expand All @@ -470,8 +470,37 @@ def plot_nonconformity_scores(scores, alpha, q, method):
ax.plot(confidence_levels, scores, label="score")
ax.axvline(x=1 - alpha, color="g", linestyle="-", label=f"(1-alpha) = {1-alpha}", linewidth=1)
ax.axhline(y=q, color="r", linestyle="-", label=f"q1 = {round(q, 2)}", linewidth=1)
ax.set_title(f"{method} One-Sided Interval Width with q")
ax.set_xlabel("Confidence Level")
ax.set_ylabel("One-Sided Interval Width")
ax.set_title(f"{method} One-Sided Interval Width with q")
ax.legend()
return fig


def plot_interval_width_per_timestep(q_hats, method):
"""Plot the nonconformity scores as well as the one-sided interval width (q).

Parameters
----------
q_hats : list
prediction interval widths (or q) for each timestep
method : str
name of conformal prediction technique used

Options
* (default) ``naive``: Naive or Absolute Residual
* ``cqr``: Conformalized Quantile Regression

Returns
-------
matplotlib.pyplot.figure
Figure showing the q-values for each timestep
"""
fig, ax = plt.subplots()
ax.plot(range(1, len(q_hats) + 1), q_hats)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am sorry, I am a bit confused about what is being plotted based on the docstring and code.
Aren't the qhats static?
Or what are we plotting here?

Copy link
Collaborator Author

@Kevin-Chen0 Kevin-Chen0 Jan 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the plot_interval_width_per_timestep() method for models with n_lags > 1 and thus self.n_forecasts >1. You can try with the m3 and m4 models in the uncertainty_conformal_prediction.ipynb. Therefore, instead of plotting the Naive One-Side Interval Width from q (with the noncon scores and q1 horizontal line from just forecast1), this method will instead plot the q values (or q_hats) for each of the timesteps:
Naive One-Sdied Interval Width with q
So the blue line here is not the noncon scores but the q values on each timestep number.

You can see how q linearly increases the further the timestep (until it plateaus around t+12)? This shows the greater uncertainty, the further out the forecast that it requires wider intervals for the same confidence level (e.g., 90% in this example). The reason for this plateau could be attributed to the half-day seasonality of this hospital energy load dataset (i.e., day and night usage).

Copy link
Owner

@ourownstory ourownstory Jan 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for explaining!
Sorry, I interpreted x-axis to be datetime, not forecast step numbers.
Now it makes sense. Cool how one can see it increase and then plateau!
I like this new plot. I think it is helpful!

ax.set_title(f"{method} One-Sided Interval Width with q per Timestep")
ax.set_xlabel("Timestep Number")
ax.set_ylabel("One-Sided Interval Width")
# ax.set_xlim(left=1)
ax.set_ylim(bottom=0)
return fig
67 changes: 35 additions & 32 deletions tests/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,8 +551,8 @@ def test_plot_conformal_prediction(plotting_backend):
batch_size=BATCH_SIZE,
learning_rate=LR,
)
train_df, test_df = m.split_df(df, freq="MS", valid_p=0.2)
train_df, cal_df = m.split_df(train_df, freq="MS", valid_p=0.15)
train_df, test_df = m.split_df(df, freq="D", valid_p=0.2)
train_df, cal_df = m.split_df(train_df, freq="D", valid_p=0.15)
metrics_df = m.fit(train_df, freq="D")
alpha = 0.1
for method in ["naive", "cqr"]: # Naive and CQR SCP methods
Expand All @@ -567,36 +567,39 @@ def test_plot_conformal_prediction(plotting_backend):
fig1.show()
fig2.show()
# With auto-regression enabled
# TO-DO: Fix Assertion error n_train >= 1
# m = NeuralProphet(
# n_forecasts=7,
# n_lags=14,
# quantiles=[0.05, 0.95],
# epochs=EPOCHS,
# batch_size=BATCH_SIZE,
# learning_rate=LR,
# )
# train_df, test_df = m.split_df(df, freq="MS", valid_p=0.2)
# train_df, cal_df = m.split_df(train_df, freq="MS", valid_p=0.15)
# metrics_df = m.fit(train_df, freq="D")
# alpha = 0.1
# for method in ["naive", "cqr"]: # Naive and CQR SCP methods
# future = m.make_future_dataframe(df, periods=m.n_forecasts, n_historic_predictions=10)
# forecast = m.conformal_predict(future, calibration_df=cal_df, alpha=alpha, method=method)
# m.highlight_nth_step_ahead_of_each_forecast(m.n_forecasts)
# fig0 = m.plot(forecast)
# fig1 = m.plot_latest_forecast(forecast, include_previous_forecasts=10, plotting_backend="matplotlib")
# fig2 = m.plot_latest_forecast(forecast, include_previous_forecasts=10, plot_history_data=True, plotting_backend="matplotlib")
# fig3 = m.plot_latest_forecast(forecast, include_previous_forecasts=10, plot_history_data=False, plotting_backend="matplotlib")
# fig4 = m.plot_components(forecast, plotting_backend="matplotlib")
# fig5 = m.plot_parameters(plotting_backend="matplotlib")
# if PLOT:
# fig0.show()
# fig1.show()
# fig2.show()
# fig3.show()
# fig4.show()
# fig5.show()
m = NeuralProphet(
n_forecasts=7,
n_lags=14,
quantiles=[0.05, 0.95],
epochs=EPOCHS,
batch_size=BATCH_SIZE,
learning_rate=LR,
)
train_df, test_df = m.split_df(df, freq="D", valid_p=0.2)
train_df, cal_df = m.split_df(train_df, freq="D", valid_p=0.15)
metrics_df = m.fit(train_df, freq="D")
alpha = 0.1
for method in ["naive", "cqr"]: # Naive and CQR SCP methods
future = m.make_future_dataframe(df, periods=m.n_forecasts, n_historic_predictions=10)
forecast = m.conformal_predict(future, calibration_df=cal_df, alpha=alpha, method=method)
m.highlight_nth_step_ahead_of_each_forecast(m.n_forecasts)
fig0 = m.plot(forecast)
fig1 = m.plot_latest_forecast(forecast, include_previous_forecasts=10, plotting_backend="matplotlib")
fig2 = m.plot_latest_forecast(
forecast, include_previous_forecasts=10, plot_history_data=True, plotting_backend="matplotlib"
)
fig3 = m.plot_latest_forecast(
forecast, include_previous_forecasts=10, plot_history_data=False, plotting_backend="matplotlib"
)
fig4 = m.plot_components(forecast, plotting_backend="matplotlib")
fig5 = m.plot_parameters(plotting_backend="matplotlib")
if PLOT:
fig0.show()
fig1.show()
fig2.show()
fig3.show()
fig4.show()
fig5.show()


@pytest.mark.parametrize(*decorator_input)
Expand Down
Loading