mirror of
https://github.com/wassname/pytorch-ts.git
synced 2026-07-10 00:35:02 +08:00
539 lines
16 KiB
Python
539 lines
16 KiB
Python
from abc import ABC, abstractmethod
|
|
from enum import Enum
|
|
from typing import Dict, List, Optional, Set, Union, Callable
|
|
|
|
from pydantic import BaseModel, Field
|
|
import numpy as np
|
|
import pandas as pd
|
|
import torch
|
|
from torch.distributions import Distribution
|
|
|
|
from .quantile import Quantile
|
|
|
|
|
|
class OutputType(str, Enum):
|
|
mean = "mean"
|
|
samples = "samples"
|
|
quantiles = "quantiles"
|
|
|
|
|
|
class Config(BaseModel):
|
|
num_samples: int = Field(100, alias="num_eval_samples")
|
|
output_types: Set[OutputType] = {"quantiles", "mean"}
|
|
# FIXME: validate list elements
|
|
quantiles: List[str] = ["0.1", "0.5", "0.9"]
|
|
|
|
class Config:
|
|
allow_population_by_field_name = True
|
|
# store additional fields
|
|
extra = "allow"
|
|
|
|
|
|
class Forecast(ABC):
|
|
start_date: pd.Timestamp
|
|
freq: str
|
|
item_id: Optional[str]
|
|
info: Optional[Dict]
|
|
prediction_length: int
|
|
mean: np.ndarray
|
|
_index = None
|
|
|
|
@abstractmethod
|
|
def quantile(self, q: Union[float, str]) -> np.ndarray:
|
|
"""
|
|
Computes a quantile from the predicted distribution.
|
|
|
|
Parameters
|
|
----------
|
|
q
|
|
Quantile to compute.
|
|
|
|
Returns
|
|
-------
|
|
numpy.ndarray
|
|
Value of the quantile across the prediction range.
|
|
"""
|
|
pass
|
|
|
|
def quantile_ts(self, q):
|
|
return pd.Series(index=self.index, data=self.quantile(q))
|
|
|
|
@property
|
|
def median(self) -> np.ndarray:
|
|
return self.quantile(0.5)
|
|
|
|
def plot(
|
|
self,
|
|
prediction_intervals=(50.0, 90.0),
|
|
show_mean=False,
|
|
color="b",
|
|
label=None,
|
|
output_file=None,
|
|
*args,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
Plots the median of the forecast as well as confidence bounds.
|
|
(requires matplotlib and pandas).
|
|
|
|
Parameters
|
|
----------
|
|
prediction_intervals : float or list of floats in [0, 100]
|
|
Confidence interval size(s). If a list, it will stack the error
|
|
plots for each confidence interval. Only relevant for error styles
|
|
with "ci" in the name.
|
|
show_mean : boolean
|
|
Whether to also show the mean of the forecast.
|
|
color : matplotlib color name or dictionary
|
|
The color used for plotting the forecast.
|
|
label : string
|
|
A label (prefix) that is used for the forecast
|
|
output_file : str or None, default None
|
|
Output path for the plot file. If None, plot is not saved to file.
|
|
args :
|
|
Other arguments are passed to main plot() call
|
|
kwargs :
|
|
Other keyword arguments are passed to main plot() call
|
|
"""
|
|
|
|
# matplotlib==2.0.* gives errors in Brazil builds and has to be
|
|
# imported locally
|
|
import matplotlib.pyplot as plt
|
|
|
|
label_prefix = "" if label is None else label + "-"
|
|
|
|
for c in prediction_intervals:
|
|
assert 0.0 <= c <= 100.0
|
|
|
|
ps = [50.0] + [
|
|
50.0 + f * c / 2.0 for c in prediction_intervals for f in [-1.0, +1.0]
|
|
]
|
|
percentiles_sorted = sorted(set(ps))
|
|
|
|
def alpha_for_percentile(p):
|
|
return (p / 100.0) ** 0.3
|
|
|
|
ps_data = [self.quantile(p / 100.0) for p in percentiles_sorted]
|
|
i_p50 = len(percentiles_sorted) // 2
|
|
|
|
p50_data = ps_data[i_p50]
|
|
p50_series = pd.Series(data=p50_data, index=self.index)
|
|
p50_series.plot(color=color, ls="-", label=f"{label_prefix}median")
|
|
|
|
if show_mean:
|
|
mean_data = np.mean(self._sorted_samples, axis=0)
|
|
pd.Series(data=mean_data, index=self.index).plot(
|
|
color=color, ls=":", label=f"{label_prefix}mean", *args, **kwargs,
|
|
)
|
|
|
|
for i in range(len(percentiles_sorted) // 2):
|
|
ptile = percentiles_sorted[i]
|
|
alpha = alpha_for_percentile(ptile)
|
|
plt.fill_between(
|
|
self.index,
|
|
ps_data[i],
|
|
ps_data[-i - 1],
|
|
facecolor=color,
|
|
alpha=alpha,
|
|
interpolate=True,
|
|
*args,
|
|
**kwargs,
|
|
)
|
|
# Hack to create labels for the error intervals.
|
|
# Doesn't actually plot anything, because we only pass a single data point
|
|
pd.Series(data=p50_data[:1], index=self.index[:1]).plot(
|
|
color=color,
|
|
alpha=alpha,
|
|
linewidth=10,
|
|
label=f"{label_prefix}{100 - ptile * 2}%",
|
|
*args,
|
|
**kwargs,
|
|
)
|
|
if output_file:
|
|
plt.savefig(output_file)
|
|
|
|
@property
|
|
def index(self) -> pd.DatetimeIndex:
|
|
if self._index is None:
|
|
self._index = pd.date_range(
|
|
self.start_date, periods=self.prediction_length, freq=self.freq
|
|
)
|
|
return self._index
|
|
|
|
def as_json_dict(self, config: "Config") -> dict:
|
|
result = {}
|
|
|
|
if OutputType.mean in config.output_types:
|
|
result["mean"] = self.mean.tolist()
|
|
|
|
if OutputType.quantiles in config.output_types:
|
|
quantiles = map(Quantile.parse, config.quantiles)
|
|
|
|
result["quantiles"] = {
|
|
quantile.name: self.quantile(quantile.value).tolist()
|
|
for quantile in quantiles
|
|
}
|
|
|
|
if OutputType.samples in config.output_types:
|
|
result["samples"] = []
|
|
|
|
return result
|
|
|
|
|
|
class SampleForecast(Forecast):
|
|
"""
|
|
A `Forecast` object, where the predicted distribution is represented
|
|
internally as samples.
|
|
|
|
Parameters
|
|
----------
|
|
samples
|
|
Array of size (num_samples, prediction_length)
|
|
start_date
|
|
start of the forecast
|
|
freq
|
|
forecast frequency
|
|
info
|
|
additional information that the forecaster may provide e.g. estimated
|
|
parameters, number of iterations ran etc.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
samples: Union[torch.Tensor, np.ndarray],
|
|
start_date,
|
|
freq,
|
|
item_id: Optional[str] = None,
|
|
info: Optional[Dict] = None,
|
|
):
|
|
assert isinstance(
|
|
samples, (np.ndarray, torch.Tensor)
|
|
), "samples should be either a numpy array or an torch tensor"
|
|
assert (
|
|
len(np.shape(samples)) == 2 or len(np.shape(samples)) == 3
|
|
), "samples should be a 2-dimensional or 3-dimensional array. Dimensions found: {}".format(
|
|
len(np.shape(samples))
|
|
)
|
|
self.samples = (
|
|
samples if (isinstance(samples, np.ndarray)) else samples.cpu().numpy()
|
|
)
|
|
self._sorted_samples_value = None
|
|
self._mean = None
|
|
self._dim = None
|
|
self.item_id = item_id
|
|
self.info = info
|
|
|
|
assert isinstance(
|
|
start_date, pd.Timestamp
|
|
), "start_date should be a pandas Timestamp object"
|
|
self.start_date = start_date
|
|
|
|
assert isinstance(freq, str), "freq should be a string"
|
|
self.freq = freq
|
|
|
|
@property
|
|
def _sorted_samples(self):
|
|
if self._sorted_samples_value is None:
|
|
self._sorted_samples_value = np.sort(self.samples, axis=0)
|
|
return self._sorted_samples_value
|
|
|
|
@property
|
|
def num_samples(self):
|
|
"""
|
|
The number of samples representing the forecast.
|
|
"""
|
|
return self.samples.shape[0]
|
|
|
|
@property
|
|
def prediction_length(self):
|
|
"""
|
|
Time length of the forecast.
|
|
"""
|
|
return self.samples.shape[-1]
|
|
|
|
@property
|
|
def mean(self):
|
|
"""
|
|
Forecast mean.
|
|
"""
|
|
if self._mean is not None:
|
|
return self._mean
|
|
else:
|
|
return np.mean(self.samples, axis=0)
|
|
|
|
@property
|
|
def mean_ts(self):
|
|
"""
|
|
Forecast mean, as a pandas.Series object.
|
|
"""
|
|
return pd.Series(index=self.index, data=self.mean)
|
|
|
|
def quantile(self, q):
|
|
q = Quantile.parse(q).value
|
|
sample_idx = int(np.round((self.num_samples - 1) * q))
|
|
return self._sorted_samples[sample_idx, :]
|
|
|
|
def copy_dim(self, dim: int):
|
|
"""
|
|
Returns a new Forecast object with only the selected sub-dimension.
|
|
|
|
Parameters
|
|
----------
|
|
dim
|
|
The returned forecast object will only represent this dimension.
|
|
"""
|
|
if len(self.samples.shape) == 2:
|
|
samples = self.samples
|
|
else:
|
|
target_dim = self.samples.shape[2]
|
|
assert dim < target_dim, (
|
|
f"must set 0 <= dim < target_dim, but got dim={dim},"
|
|
f" target_dim={target_dim}"
|
|
)
|
|
samples = self.samples[:, :, dim]
|
|
|
|
return SampleForecast(
|
|
samples=samples,
|
|
start_date=self.start_date,
|
|
freq=self.freq,
|
|
item_id=self.item_id,
|
|
info=self.info,
|
|
)
|
|
|
|
def copy_aggregate(self, agg_fun: Callable):
|
|
"""
|
|
Returns a new Forecast object with a time series aggregated over the
|
|
dimension axis.
|
|
|
|
Parameters
|
|
----------
|
|
agg_fun
|
|
Aggregation function that defines the aggregation operation
|
|
(typically mean or sum).
|
|
"""
|
|
if len(self.samples.shape) == 2:
|
|
samples = self.samples
|
|
else:
|
|
# Aggregate over target dimension axis
|
|
samples = agg_fun(self.samples, axis=2)
|
|
return SampleForecast(
|
|
samples=samples,
|
|
start_date=self.start_date,
|
|
freq=self.freq,
|
|
item_id=self.item_id,
|
|
info=self.info,
|
|
)
|
|
|
|
def dim(self) -> int:
|
|
"""
|
|
Returns the dimensionality of the forecast object.
|
|
"""
|
|
if self._dim is not None:
|
|
return self._dim
|
|
else:
|
|
if len(self.samples.shape) == 2:
|
|
# univariate target
|
|
# shape: (num_samples, prediction_length)
|
|
return 1
|
|
else:
|
|
# multivariate target
|
|
# shape: (num_samples, prediction_length, target_dim)
|
|
return self.samples.shape[2]
|
|
|
|
def as_json_dict(self, config: "Config") -> dict:
|
|
result = super().as_json_dict(config)
|
|
|
|
if OutputType.samples in config.output_types:
|
|
result["samples"] = self.samples.tolist()
|
|
|
|
return result
|
|
|
|
def __repr__(self):
|
|
return ", ".join(
|
|
[
|
|
f"SampleForecast({self.samples!r})",
|
|
f"{self.start_date!r}",
|
|
f"{self.freq!r}",
|
|
f"item_id={self.item_id!r}",
|
|
f"info={self.info!r})",
|
|
]
|
|
)
|
|
|
|
|
|
class QuantileForecast(Forecast):
|
|
"""
|
|
A Forecast that contains arrays (i.e. time series) for quantiles and mean
|
|
|
|
Parameters
|
|
----------
|
|
forecast_arrays
|
|
An array of forecasts
|
|
start_date
|
|
start of the forecast
|
|
freq
|
|
forecast frequency
|
|
forecast_keys
|
|
A list of quantiles of the form '0.1', '0.9', etc.,
|
|
and potentially 'mean'. Each entry corresponds to one array in
|
|
forecast_arrays.
|
|
info
|
|
additional information that the forecaster may provide e.g. estimated
|
|
parameters, number of iterations ran etc.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
forecast_arrays: np.ndarray,
|
|
start_date: pd.Timestamp,
|
|
freq: str,
|
|
forecast_keys: List[str],
|
|
item_id: Optional[str] = None,
|
|
info: Optional[Dict] = None,
|
|
):
|
|
self.forecast_array = forecast_arrays
|
|
self.start_date = pd.Timestamp(start_date, freq=freq)
|
|
self.freq = freq
|
|
|
|
# normalize keys
|
|
self.forecast_keys = [
|
|
Quantile.from_str(key).name if key != "mean" else key
|
|
for key in forecast_keys
|
|
]
|
|
self.item_id = item_id
|
|
self.info = info
|
|
self._dim = None
|
|
|
|
shape = self.forecast_array.shape
|
|
assert shape[0] == len(self.forecast_keys), (
|
|
f"The forecast_array (shape={shape} should have the same "
|
|
f"length as the forecast_keys (len={len(self.forecast_keys)})."
|
|
)
|
|
self.prediction_length = shape[-1]
|
|
self._forecast_dict = {
|
|
k: self.forecast_array[i] for i, k in enumerate(self.forecast_keys)
|
|
}
|
|
|
|
self._nan_out = np.array([np.nan] * self.prediction_length)
|
|
|
|
def quantile(self, q: Union[float, str]) -> np.ndarray:
|
|
q_str = Quantile.parse(q).name
|
|
# We return nan here such that evaluation runs through
|
|
return self._forecast_dict.get(q_str, self._nan_out)
|
|
|
|
@property
|
|
def mean(self):
|
|
"""
|
|
Forecast mean.
|
|
"""
|
|
return self._forecast_dict.get("mean", self._nan_out)
|
|
|
|
def dim(self) -> int:
|
|
"""
|
|
Returns the dimensionality of the forecast object.
|
|
"""
|
|
if self._dim is not None:
|
|
return self._dim
|
|
else:
|
|
if (
|
|
len(self.forecast_array.shape) == 2
|
|
): # 1D target. shape: (num_samples, prediction_length)
|
|
return 1
|
|
else:
|
|
return self.forecast_array.shape[
|
|
1
|
|
] # 2D target. shape: (num_samples, target_dim, prediction_length)
|
|
|
|
def __repr__(self):
|
|
return ", ".join(
|
|
[
|
|
f"QuantileForecast({self.forecast_array!r})",
|
|
f"start_date={self.start_date!r}",
|
|
f"freq={self.freq!r}",
|
|
f"forecast_keys={self.forecast_keys!r}",
|
|
f"item_id={self.item_id!r}",
|
|
f"info={self.info!r})",
|
|
]
|
|
)
|
|
|
|
|
|
class DistributionForecast(Forecast):
|
|
"""
|
|
A `Forecast` object that uses a distribution directly.
|
|
This can for instance be used to represent marginal probability
|
|
distributions for each time point -- although joint distributions are
|
|
also possible, e.g. when using MultiVariateGaussian).
|
|
|
|
Parameters
|
|
----------
|
|
distribution
|
|
Distribution object. This should represent the entire prediction
|
|
length, i.e., if we draw `num_samples` samples from the distribution,
|
|
the sample shape should be
|
|
|
|
samples = trans_dist.sample(num_samples)
|
|
samples.shape -> (num_samples, prediction_length)
|
|
|
|
start_date
|
|
start of the forecast
|
|
freq
|
|
forecast frequency
|
|
info
|
|
additional information that the forecaster may provide e.g. estimated
|
|
parameters, number of iterations ran etc.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
distribution: Distribution,
|
|
start_date,
|
|
freq,
|
|
item_id: Optional[str] = None,
|
|
info: Optional[Dict] = None,
|
|
):
|
|
self.distribution = distribution
|
|
self.shape = self.distribution.batch_shape + self.distribution.event_shape
|
|
self.prediction_length = self.shape[0]
|
|
self.item_id = item_id
|
|
self.info = info
|
|
|
|
assert isinstance(
|
|
start_date, pd.Timestamp
|
|
), "start_date should be a pandas Timestamp object"
|
|
self.start_date = start_date
|
|
|
|
assert isinstance(freq, str), "freq should be a string"
|
|
self.freq = freq
|
|
self._mean = None
|
|
|
|
@property
|
|
def mean(self):
|
|
"""
|
|
Forecast mean.
|
|
"""
|
|
if self._mean is not None:
|
|
return self._mean
|
|
else:
|
|
self._mean = self.distribution.mean.cpu().numpy()
|
|
return self._mean
|
|
|
|
@property
|
|
def mean_ts(self):
|
|
"""
|
|
Forecast mean, as a pandas.Series object.
|
|
"""
|
|
return pd.Series(index=self.index, data=self.mean)
|
|
|
|
def quantile(self, level):
|
|
level = Quantile.parse(level).value
|
|
q = self.distribution.icdf(torch.tensor([level])).cpu().numpy()
|
|
return q
|
|
|
|
def to_sample_forecast(self, num_samples: int = 200) -> SampleForecast:
|
|
return SampleForecast(
|
|
samples=self.distribution.sample((num_samples,)),
|
|
start_date=self.start_date,
|
|
freq=self.freq,
|
|
item_id=self.item_id,
|
|
info=self.info,
|
|
)
|