divinity-forecast 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- divinity/__init__.py +32 -0
- divinity/features.py +93 -0
- divinity/forecaster.py +466 -0
- divinity/legacy.py +138 -0
- divinity/periods.py +106 -0
- divinity/plotting.py +77 -0
- divinity/residuals.py +146 -0
- divinity/selection.py +157 -0
- divinity/synthetic.py +217 -0
- divinity_forecast-0.2.0.dist-info/METADATA +251 -0
- divinity_forecast-0.2.0.dist-info/RECORD +14 -0
- divinity_forecast-0.2.0.dist-info/WHEEL +5 -0
- divinity_forecast-0.2.0.dist-info/licenses/LICENSE +201 -0
- divinity_forecast-0.2.0.dist-info/top_level.txt +1 -0
divinity/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Divinity, automatic timeseries forecasting.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
import divinity as dv
|
|
7
|
+
|
|
8
|
+
model = dv.Forecaster().fit(y)
|
|
9
|
+
result = model.predict(30)
|
|
10
|
+
print(model.summary())
|
|
11
|
+
model.plot(result)
|
|
12
|
+
|
|
13
|
+
or in one line::
|
|
14
|
+
|
|
15
|
+
result = dv.forecast(y, steps=30)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from . import synthetic
|
|
19
|
+
from .forecaster import Forecaster, forecast
|
|
20
|
+
from .legacy import divinity, gen_fake, gen_season, gen_trend
|
|
21
|
+
|
|
22
|
+
__version__ = "0.2.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Forecaster",
|
|
26
|
+
"forecast",
|
|
27
|
+
"synthetic",
|
|
28
|
+
"divinity",
|
|
29
|
+
"gen_fake",
|
|
30
|
+
"gen_season",
|
|
31
|
+
"gen_trend",
|
|
32
|
+
]
|
divinity/features.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deterministic features (trend and Fourier seasonality) as functions of the
|
|
3
|
+
integer time step. Features depend only on the time step and the training
|
|
4
|
+
length, so the fitted model does not change with the forecast horizon.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class FeatureGroup:
|
|
15
|
+
"""
|
|
16
|
+
A set of columns that is added to or left out of the model as one unit.
|
|
17
|
+
|
|
18
|
+
:param kind: "trend" or "season".
|
|
19
|
+
:param period: seasonal period in time steps (seasonal groups only).
|
|
20
|
+
:param harmonic: Fourier harmonic number, 1 is the fundamental.
|
|
21
|
+
:param detected: True if the period was found in the data rather than
|
|
22
|
+
supplied by the user or the frequency defaults.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
kind: str
|
|
26
|
+
period: float = None
|
|
27
|
+
harmonic: int = None
|
|
28
|
+
detected: bool = False
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self):
|
|
32
|
+
if self.kind == "trend":
|
|
33
|
+
return "trend"
|
|
34
|
+
return "P={:g},k={}".format(self.period, self.harmonic)
|
|
35
|
+
|
|
36
|
+
def columns(self, t, n_train):
|
|
37
|
+
"""
|
|
38
|
+
Return a dict of column name to values for time steps ``t``.
|
|
39
|
+
"""
|
|
40
|
+
t = np.asarray(t, dtype=float)
|
|
41
|
+
if self.kind == "trend":
|
|
42
|
+
# scaled by training length so coefficients are well conditioned
|
|
43
|
+
return {"trend": t / n_train}
|
|
44
|
+
w = 2 * np.pi * self.harmonic / self.period
|
|
45
|
+
return {
|
|
46
|
+
"sin(" + self.name + ")": np.sin(w * t),
|
|
47
|
+
"cos(" + self.name + ")": np.cos(w * t),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def seasonal_groups(periods, max_harmonics, detected=False):
|
|
52
|
+
"""
|
|
53
|
+
Build the candidate Fourier groups for each period. A period P can carry
|
|
54
|
+
at most floor(P / 2) harmonics before they alias onto each other.
|
|
55
|
+
"""
|
|
56
|
+
groups = []
|
|
57
|
+
for p in periods:
|
|
58
|
+
n_harmonics = int(min(max_harmonics, max(1, np.floor(p / 2))))
|
|
59
|
+
for k in range(1, n_harmonics + 1):
|
|
60
|
+
groups.append(FeatureGroup("season", float(p), k, detected))
|
|
61
|
+
return groups
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def design_matrix(t, groups, n_train, drop_columns=()):
|
|
65
|
+
"""
|
|
66
|
+
Assemble the feature matrix for the given groups, without a constant.
|
|
67
|
+
|
|
68
|
+
:param t: integer time steps (training or future).
|
|
69
|
+
:param groups: iterable of FeatureGroup.
|
|
70
|
+
:param n_train: length of the training series, used for scaling.
|
|
71
|
+
:param drop_columns: column names to leave out (e.g. columns that are
|
|
72
|
+
identically zero over the training data).
|
|
73
|
+
"""
|
|
74
|
+
cols = {}
|
|
75
|
+
for g in groups:
|
|
76
|
+
for name, values in g.columns(t, n_train).items():
|
|
77
|
+
if name not in drop_columns:
|
|
78
|
+
cols[name] = values
|
|
79
|
+
return pd.DataFrame(cols, index=np.asarray(t))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def degenerate_columns(groups, n_train, tol=1e-8):
|
|
83
|
+
"""
|
|
84
|
+
Names of columns that are (numerically) zero over the training steps,
|
|
85
|
+
e.g. sin(pi t) for a period of 2. These carry no information.
|
|
86
|
+
"""
|
|
87
|
+
t = np.arange(n_train)
|
|
88
|
+
out = set()
|
|
89
|
+
for g in groups:
|
|
90
|
+
for name, values in g.columns(t, n_train).items():
|
|
91
|
+
if np.max(np.abs(values)) < tol:
|
|
92
|
+
out.add(name)
|
|
93
|
+
return out
|
divinity/forecaster.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The main forecasting model.
|
|
3
|
+
|
|
4
|
+
The series is modelled as
|
|
5
|
+
|
|
6
|
+
y_t = constant + trend + seasonality + u_t
|
|
7
|
+
|
|
8
|
+
where trend and seasonality are deterministic (a linear term and Fourier
|
|
9
|
+
pairs for each seasonal period) and u_t follows an ARIMA process. This is
|
|
10
|
+
"regression with ARIMA errors", fitted jointly by maximum likelihood once
|
|
11
|
+
the features and the ARIMA order have been chosen.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import warnings
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from scipy import stats
|
|
19
|
+
from statsmodels.tsa.arima.model import ARIMA
|
|
20
|
+
|
|
21
|
+
from .features import FeatureGroup, degenerate_columns, design_matrix, seasonal_groups
|
|
22
|
+
from .periods import default_periods, detect_periods, merge_periods
|
|
23
|
+
from .residuals import fit_quietly, has_unit_root, select_order
|
|
24
|
+
from .selection import forward_select, lag1_autocorrelation
|
|
25
|
+
|
|
26
|
+
MIN_OBSERVATIONS = 10
|
|
27
|
+
MAX_GLS_ITERATIONS = 4
|
|
28
|
+
MAX_STATIONARY_RHO = 0.95
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Forecaster:
|
|
32
|
+
"""
|
|
33
|
+
Automatic timeseries forecaster.
|
|
34
|
+
|
|
35
|
+
Example::
|
|
36
|
+
|
|
37
|
+
model = Forecaster().fit(y)
|
|
38
|
+
result = model.predict(30) # DataFrame with forecast, lower, upper
|
|
39
|
+
model.plot(result)
|
|
40
|
+
|
|
41
|
+
:param seasonal_periods: "auto" to use defaults for the series frequency
|
|
42
|
+
(e.g. 7 and 365.25 for daily data), a list of periods in time steps,
|
|
43
|
+
or None for no predefined periods.
|
|
44
|
+
:param detect_periods: also search the data for periodicities that are
|
|
45
|
+
not in ``seasonal_periods``.
|
|
46
|
+
:param max_detected_periods: how many detected periods to consider.
|
|
47
|
+
:param max_harmonics: maximum Fourier harmonics per period. More
|
|
48
|
+
harmonics allow sharper, less sinusoidal seasonal shapes.
|
|
49
|
+
:param trend: "auto" to include a linear trend only if the data support
|
|
50
|
+
it, "linear" to always include one, or None for no trend.
|
|
51
|
+
:param arima: "auto" to choose the residual ARIMA order, a (p, d, q)
|
|
52
|
+
tuple to fix it, or None to treat residuals as white noise.
|
|
53
|
+
:param log: fit on the log of the series. Use for strictly positive
|
|
54
|
+
data whose seasonal swings grow with its level.
|
|
55
|
+
:param interval: default coverage of the prediction interval.
|
|
56
|
+
:param search_penalty: strength of the penalty for searching over many
|
|
57
|
+
candidate features. Higher values make the model more reluctant to
|
|
58
|
+
add seasonality, 0 gives plain BIC.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
seasonal_periods="auto",
|
|
64
|
+
detect_periods=True,
|
|
65
|
+
max_detected_periods=3,
|
|
66
|
+
max_harmonics=3,
|
|
67
|
+
trend="auto",
|
|
68
|
+
arima="auto",
|
|
69
|
+
log=False,
|
|
70
|
+
interval=0.95,
|
|
71
|
+
search_penalty=1.0,
|
|
72
|
+
):
|
|
73
|
+
if trend not in ("auto", "linear", None):
|
|
74
|
+
raise ValueError("trend must be 'auto', 'linear' or None")
|
|
75
|
+
if not 0 < interval < 1:
|
|
76
|
+
raise ValueError("interval must be between 0 and 1, e.g. 0.95")
|
|
77
|
+
self.seasonal_periods = seasonal_periods
|
|
78
|
+
self.detect_periods = detect_periods
|
|
79
|
+
self.max_detected_periods = max_detected_periods
|
|
80
|
+
self.max_harmonics = max_harmonics
|
|
81
|
+
self.trend = trend
|
|
82
|
+
self.arima = arima
|
|
83
|
+
self.log = log
|
|
84
|
+
self.interval = interval
|
|
85
|
+
self.search_penalty = search_penalty
|
|
86
|
+
self._result = None
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------ fit
|
|
89
|
+
|
|
90
|
+
def fit(self, y, freq=None):
|
|
91
|
+
"""
|
|
92
|
+
Fit the model to a series.
|
|
93
|
+
|
|
94
|
+
:param y: pandas Series, numpy array or list. A Series with a
|
|
95
|
+
DatetimeIndex gives dated forecasts and frequency based seasonal
|
|
96
|
+
defaults. Missing values (NaN) are allowed.
|
|
97
|
+
:param freq: pandas frequency string (e.g. "D", "h", "MS"). Only
|
|
98
|
+
needed if it cannot be inferred from the index, for example
|
|
99
|
+
when some dates are missing entirely.
|
|
100
|
+
:return: self, so calls can be chained.
|
|
101
|
+
"""
|
|
102
|
+
series, self.freq_ = _as_series(y, freq)
|
|
103
|
+
values = series.to_numpy(dtype=float)
|
|
104
|
+
if self.log:
|
|
105
|
+
if np.nanmin(values) <= 0:
|
|
106
|
+
raise ValueError("log=True needs a strictly positive series")
|
|
107
|
+
values = np.log(values)
|
|
108
|
+
|
|
109
|
+
n = len(values)
|
|
110
|
+
observed = ~np.isnan(values)
|
|
111
|
+
if observed.sum() < MIN_OBSERVATIONS:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"need at least {} observed values, got {}".format(
|
|
114
|
+
MIN_OBSERVATIONS, int(observed.sum())
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
self.history_ = series
|
|
118
|
+
self.n_ = n
|
|
119
|
+
self.n_missing_ = int(n - observed.sum())
|
|
120
|
+
|
|
121
|
+
groups, forced, pool = self._candidates(values)
|
|
122
|
+
self._drop = degenerate_columns(groups + forced, n)
|
|
123
|
+
|
|
124
|
+
# Decide on a unit root before choosing features, using only the
|
|
125
|
+
# trusted terms (trend and known periods). Features fitted to a
|
|
126
|
+
# random walk soak up its wander and make the residuals look
|
|
127
|
+
# stationary, so testing afterwards would confirm its own mistake.
|
|
128
|
+
self.differenced_ = self._unit_root(values, groups, forced)
|
|
129
|
+
|
|
130
|
+
# With a unit root, select on differenced data. Otherwise select by
|
|
131
|
+
# OLS, then reselect allowing for AR(1) errors until the choice of
|
|
132
|
+
# features stops changing.
|
|
133
|
+
rho, seen = (1.0 if self.differenced_ else 0.0), []
|
|
134
|
+
for _ in range(MAX_GLS_ITERATIONS):
|
|
135
|
+
selection = forward_select(
|
|
136
|
+
values,
|
|
137
|
+
groups,
|
|
138
|
+
pool,
|
|
139
|
+
drop_columns=self._drop,
|
|
140
|
+
gamma=self.search_penalty,
|
|
141
|
+
forced=forced,
|
|
142
|
+
rho=rho,
|
|
143
|
+
)
|
|
144
|
+
self.groups_ = selection.groups
|
|
145
|
+
X = self._features(np.arange(n))
|
|
146
|
+
resid = _ols_residuals(X, values)
|
|
147
|
+
if self.differenced_ or self.arima is None:
|
|
148
|
+
break
|
|
149
|
+
rho = min(lag1_autocorrelation(resid), MAX_STATIONARY_RHO)
|
|
150
|
+
if set(self.groups_) in seen:
|
|
151
|
+
break
|
|
152
|
+
seen.append(set(self.groups_))
|
|
153
|
+
self.selection_history_ = selection.history
|
|
154
|
+
self.rho_ = rho
|
|
155
|
+
|
|
156
|
+
if np.nanstd(resid) < 1e-10 * max(1.0, np.nanstd(values)):
|
|
157
|
+
# deterministic series, nothing left for ARIMA to model
|
|
158
|
+
self.order_ = None
|
|
159
|
+
self._beta = _ols_beta(X, values)
|
|
160
|
+
self._result = None
|
|
161
|
+
self._n_obs, self._n_params = int(observed.sum()), 0
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
self.order_ = self._choose_order(resid)
|
|
165
|
+
self._result = self._fit_joint(values, X, self.order_)
|
|
166
|
+
self._n_obs = int(observed.sum())
|
|
167
|
+
self._n_params = len(self._result.params)
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def _candidates(self, values):
|
|
171
|
+
"""
|
|
172
|
+
Build candidate feature groups and their search pool sizes.
|
|
173
|
+
"""
|
|
174
|
+
n = len(values)
|
|
175
|
+
n_obs = int(np.sum(~np.isnan(values)))
|
|
176
|
+
if self.seasonal_periods == "auto":
|
|
177
|
+
known = default_periods(self.freq_)
|
|
178
|
+
else:
|
|
179
|
+
known = list(self.seasonal_periods or [])
|
|
180
|
+
detected = (
|
|
181
|
+
detect_periods(values, self.max_detected_periods)
|
|
182
|
+
if self.detect_periods
|
|
183
|
+
else []
|
|
184
|
+
)
|
|
185
|
+
known, detected = merge_periods(known, detected, n)
|
|
186
|
+
self.candidate_periods_ = {"known": known, "detected": detected}
|
|
187
|
+
|
|
188
|
+
groups, forced, pool = [], [], {}
|
|
189
|
+
trend = FeatureGroup("trend")
|
|
190
|
+
if self.trend == "linear":
|
|
191
|
+
forced.append(trend)
|
|
192
|
+
elif self.trend == "auto":
|
|
193
|
+
groups.append(trend)
|
|
194
|
+
pool[trend] = 1
|
|
195
|
+
for g in seasonal_groups(known, self.max_harmonics):
|
|
196
|
+
pool[g] = len(known) if g.harmonic == 1 else self.max_harmonics
|
|
197
|
+
groups.append(g)
|
|
198
|
+
for g in seasonal_groups(detected, self.max_harmonics, detected=True):
|
|
199
|
+
# a detected period was picked from roughly n / 2 frequencies
|
|
200
|
+
pool[g] = n_obs // 2 if g.harmonic == 1 else self.max_harmonics
|
|
201
|
+
groups.append(g)
|
|
202
|
+
return groups, forced, pool
|
|
203
|
+
|
|
204
|
+
def _features(self, t):
|
|
205
|
+
return design_matrix(t, self.groups_, self.n_, self._drop)
|
|
206
|
+
|
|
207
|
+
def _fixed_order(self):
|
|
208
|
+
order = tuple(int(v) for v in self.arima)
|
|
209
|
+
if len(order) != 3:
|
|
210
|
+
raise ValueError("arima must be 'auto', None or a (p, d, q) tuple")
|
|
211
|
+
return order
|
|
212
|
+
|
|
213
|
+
def _unit_root(self, values, groups, forced):
|
|
214
|
+
"""
|
|
215
|
+
Whether the series needs differencing, judged after removing the
|
|
216
|
+
trend and known seasonal periods. Detected periods are excluded
|
|
217
|
+
because they may be spurious.
|
|
218
|
+
"""
|
|
219
|
+
if self.arima is None:
|
|
220
|
+
return False
|
|
221
|
+
if self.arima != "auto":
|
|
222
|
+
return self._fixed_order()[1] > 0
|
|
223
|
+
trusted = forced + [g for g in groups if not g.detected]
|
|
224
|
+
X = design_matrix(np.arange(len(values)), trusted, len(values), self._drop)
|
|
225
|
+
return has_unit_root(_ols_residuals(X, values), trend=self.trend is not None)
|
|
226
|
+
|
|
227
|
+
def _choose_order(self, resid):
|
|
228
|
+
if self.arima is None:
|
|
229
|
+
return (0, 0, 0)
|
|
230
|
+
if self.arima != "auto":
|
|
231
|
+
return self._fixed_order()
|
|
232
|
+
order, _ = select_order(resid, 1 if self.differenced_ else 0)
|
|
233
|
+
return order
|
|
234
|
+
|
|
235
|
+
def _fit_joint(self, values, X, order):
|
|
236
|
+
exog = X.to_numpy() if X.shape[1] else None
|
|
237
|
+
trend = "c" if order[1] == 0 else "n"
|
|
238
|
+
try:
|
|
239
|
+
return fit_quietly(ARIMA(values, exog=exog, order=order, trend=trend))
|
|
240
|
+
except (np.linalg.LinAlgError, ValueError) as err:
|
|
241
|
+
warnings.warn(
|
|
242
|
+
"ARIMA{} failed ({}), falling back to white noise residuals".format(
|
|
243
|
+
order, err
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
self.order_ = (0, 0, 0)
|
|
247
|
+
return fit_quietly(ARIMA(values, exog=exog, order=(0, 0, 0), trend="c"))
|
|
248
|
+
|
|
249
|
+
# -------------------------------------------------------------- predict
|
|
250
|
+
|
|
251
|
+
def predict(self, steps, interval=None):
|
|
252
|
+
"""
|
|
253
|
+
Forecast ``steps`` time steps beyond the end of the training data.
|
|
254
|
+
|
|
255
|
+
:param steps: forecast horizon.
|
|
256
|
+
:param interval: coverage of the prediction interval, defaults to
|
|
257
|
+
the value given to the constructor.
|
|
258
|
+
:return: DataFrame indexed by future time with columns forecast,
|
|
259
|
+
lower and upper.
|
|
260
|
+
"""
|
|
261
|
+
self._check_fitted()
|
|
262
|
+
if int(steps) < 1:
|
|
263
|
+
raise ValueError("steps must be at least 1")
|
|
264
|
+
steps = int(steps)
|
|
265
|
+
interval = self.interval if interval is None else interval
|
|
266
|
+
t = np.arange(self.n_, self.n_ + steps)
|
|
267
|
+
X = self._features(t)
|
|
268
|
+
|
|
269
|
+
if self._result is None:
|
|
270
|
+
mean = _with_constant(X) @ self._beta
|
|
271
|
+
se = np.zeros(steps)
|
|
272
|
+
else:
|
|
273
|
+
res = self._result
|
|
274
|
+
fc = res.get_forecast(steps, exog=X.to_numpy() if X.shape[1] else None)
|
|
275
|
+
mean = np.asarray(fc.predicted_mean)
|
|
276
|
+
se = np.sqrt(np.asarray(fc.var_pred_mean) + self._parameter_variance(X))
|
|
277
|
+
|
|
278
|
+
# Student t quantiles allow for estimating the noise level, which
|
|
279
|
+
# matters for short series
|
|
280
|
+
dof = max(self._n_obs - self._n_params, 1)
|
|
281
|
+
z = stats.t.ppf(0.5 + interval / 2, dof)
|
|
282
|
+
out = pd.DataFrame(
|
|
283
|
+
{"forecast": mean, "lower": mean - z * se, "upper": mean + z * se},
|
|
284
|
+
index=self._future_index(steps),
|
|
285
|
+
)
|
|
286
|
+
if self.log:
|
|
287
|
+
out = np.exp(out)
|
|
288
|
+
out.attrs["interval"] = interval
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
def _parameter_variance(self, X_future):
|
|
292
|
+
"""
|
|
293
|
+
Extra forecast variance from uncertainty in the trend and seasonal
|
|
294
|
+
coefficients, which the ARIMA state space forecast leaves out. This
|
|
295
|
+
matters most for trend extrapolation over long horizons.
|
|
296
|
+
"""
|
|
297
|
+
res = self._result
|
|
298
|
+
names = list(res.model.exog_names or [])
|
|
299
|
+
if not names:
|
|
300
|
+
return 0.0
|
|
301
|
+
pos = [list(res.model.param_names).index(name) for name in names]
|
|
302
|
+
cov = np.asarray(res.cov_params())[np.ix_(pos, pos)]
|
|
303
|
+
if not np.all(np.isfinite(cov)):
|
|
304
|
+
return 0.0
|
|
305
|
+
has_const = "const" in names
|
|
306
|
+
x = X_future.to_numpy()
|
|
307
|
+
if self.order_[1] > 0:
|
|
308
|
+
# with differencing the level is anchored to the last observation,
|
|
309
|
+
# so only the change in the features since then is uncertain
|
|
310
|
+
x = x - self._features(np.array([self.n_ - 1])).to_numpy()
|
|
311
|
+
if has_const:
|
|
312
|
+
x = np.hstack([np.ones((len(x), 1)), x])
|
|
313
|
+
return np.einsum("ij,jk,ik->i", x, cov, x)
|
|
314
|
+
|
|
315
|
+
def _future_index(self, steps):
|
|
316
|
+
idx = self.history_.index
|
|
317
|
+
if isinstance(idx, pd.DatetimeIndex):
|
|
318
|
+
return pd.date_range(idx[-1], periods=steps + 1, freq=self.freq_)[1:]
|
|
319
|
+
return pd.RangeIndex(idx[-1] + 1, idx[-1] + 1 + steps)
|
|
320
|
+
|
|
321
|
+
# ------------------------------------------------------------ reporting
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def components_(self):
|
|
325
|
+
"""
|
|
326
|
+
What the model chose, as a dict.
|
|
327
|
+
"""
|
|
328
|
+
self._check_fitted()
|
|
329
|
+
seasonal = {}
|
|
330
|
+
for g in self.groups_:
|
|
331
|
+
if g.kind == "season":
|
|
332
|
+
seasonal[g.period] = max(seasonal.get(g.period, 0), g.harmonic)
|
|
333
|
+
return {
|
|
334
|
+
"trend": any(g.kind == "trend" for g in self.groups_),
|
|
335
|
+
"seasonal_periods": seasonal,
|
|
336
|
+
"detected_periods": sorted({g.period for g in self.groups_ if g.detected}),
|
|
337
|
+
"arima_order": self.order_,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def fitted_(self):
|
|
342
|
+
"""
|
|
343
|
+
In-sample one step ahead predictions.
|
|
344
|
+
"""
|
|
345
|
+
self._check_fitted()
|
|
346
|
+
if self._result is None:
|
|
347
|
+
values = _with_constant(self._features(np.arange(self.n_))) @ self._beta
|
|
348
|
+
else:
|
|
349
|
+
values = np.asarray(self._result.fittedvalues)
|
|
350
|
+
if self.log:
|
|
351
|
+
values = np.exp(values)
|
|
352
|
+
return pd.Series(values, index=self.history_.index, name="fitted")
|
|
353
|
+
|
|
354
|
+
def summary(self):
|
|
355
|
+
"""
|
|
356
|
+
A short human readable description of the fitted model.
|
|
357
|
+
"""
|
|
358
|
+
c = self.components_
|
|
359
|
+
lines = ["Divinity forecaster"]
|
|
360
|
+
missing = " ({} missing)".format(self.n_missing_) if self.n_missing_ else ""
|
|
361
|
+
lines.append(" observations {}{}".format(self.n_, missing))
|
|
362
|
+
if self.freq_:
|
|
363
|
+
lines.append(" frequency {}".format(self.freq_))
|
|
364
|
+
lines.append(" log scale {}".format("yes" if self.log else "no"))
|
|
365
|
+
lines.append(" trend {}".format("linear" if c["trend"] else "none"))
|
|
366
|
+
if c["seasonal_periods"]:
|
|
367
|
+
parts = []
|
|
368
|
+
for p, k in c["seasonal_periods"].items():
|
|
369
|
+
tag = ", detected" if p in c["detected_periods"] else ""
|
|
370
|
+
parts.append("{:g} ({} harmonic{}{})".format(p, k, "s" * (k > 1), tag))
|
|
371
|
+
lines.append(" seasonality " + ", ".join(parts))
|
|
372
|
+
else:
|
|
373
|
+
lines.append(" seasonality none")
|
|
374
|
+
if c["arima_order"] is None:
|
|
375
|
+
lines.append(" residuals none (series is deterministic)")
|
|
376
|
+
else:
|
|
377
|
+
lines.append(" residuals ARIMA{}".format(c["arima_order"]))
|
|
378
|
+
return "\n".join(lines)
|
|
379
|
+
|
|
380
|
+
def __repr__(self):
|
|
381
|
+
state = "fitted" if hasattr(self, "groups_") else "not fitted"
|
|
382
|
+
return "Forecaster({})".format(state)
|
|
383
|
+
|
|
384
|
+
def plot(self, forecast, actual=None, ax=None, history=None, title=None):
|
|
385
|
+
"""
|
|
386
|
+
Plot the training data, the forecast and its prediction interval.
|
|
387
|
+
|
|
388
|
+
:param forecast: DataFrame returned by :meth:`predict`.
|
|
389
|
+
:param actual: optional Series or array of the true future values,
|
|
390
|
+
drawn for comparison.
|
|
391
|
+
:param ax: matplotlib Axes to draw on. A new figure is made if None.
|
|
392
|
+
:param history: number of training points to show (default all).
|
|
393
|
+
:param title: optional title.
|
|
394
|
+
:return: the matplotlib Axes.
|
|
395
|
+
"""
|
|
396
|
+
from .plotting import plot_forecast
|
|
397
|
+
|
|
398
|
+
hist = self.history_ if history is None else self.history_.iloc[-history:]
|
|
399
|
+
return plot_forecast(hist, forecast, actual=actual, ax=ax, title=title)
|
|
400
|
+
|
|
401
|
+
def _check_fitted(self):
|
|
402
|
+
if not hasattr(self, "groups_"):
|
|
403
|
+
raise RuntimeError("call fit() before using the model")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def forecast(y, steps, freq=None, **kwargs):
|
|
407
|
+
"""
|
|
408
|
+
One line forecast. Fits a :class:`Forecaster` and returns its prediction.
|
|
409
|
+
|
|
410
|
+
:param y: series to forecast (Series, array or list).
|
|
411
|
+
:param steps: forecast horizon.
|
|
412
|
+
:param freq: pandas frequency, only needed if it cannot be inferred.
|
|
413
|
+
:param kwargs: passed to :class:`Forecaster`.
|
|
414
|
+
:return: DataFrame with columns forecast, lower and upper.
|
|
415
|
+
"""
|
|
416
|
+
return Forecaster(**kwargs).fit(y, freq=freq).predict(steps)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _as_series(y, freq):
|
|
420
|
+
"""
|
|
421
|
+
Normalise input to a float Series and work out its frequency.
|
|
422
|
+
"""
|
|
423
|
+
if isinstance(y, pd.DataFrame):
|
|
424
|
+
if y.shape[1] != 1:
|
|
425
|
+
raise ValueError("pass a single column, not a DataFrame")
|
|
426
|
+
y = y.iloc[:, 0]
|
|
427
|
+
if isinstance(y, pd.Series):
|
|
428
|
+
series = y.astype(float)
|
|
429
|
+
else:
|
|
430
|
+
arr = np.asarray(y, dtype=float)
|
|
431
|
+
if arr.ndim != 1:
|
|
432
|
+
raise ValueError("y must be one dimensional")
|
|
433
|
+
series = pd.Series(arr)
|
|
434
|
+
|
|
435
|
+
idx = series.index
|
|
436
|
+
if isinstance(idx, pd.DatetimeIndex):
|
|
437
|
+
if not idx.is_monotonic_increasing:
|
|
438
|
+
series = series.sort_index()
|
|
439
|
+
idx = series.index
|
|
440
|
+
if freq is None:
|
|
441
|
+
freq = idx.freqstr or pd.DatetimeIndex(idx, freq="infer").freqstr
|
|
442
|
+
if freq is None:
|
|
443
|
+
raise ValueError(
|
|
444
|
+
"could not infer the frequency of the DatetimeIndex, probably "
|
|
445
|
+
"because some dates are missing. Pass freq, e.g. fit(y, freq='D'), "
|
|
446
|
+
"and the gaps will be treated as missing values."
|
|
447
|
+
)
|
|
448
|
+
series = series.asfreq(freq)
|
|
449
|
+
return series, series.index.freqstr
|
|
450
|
+
if not pd.api.types.is_integer_dtype(idx) or not (np.diff(idx) == 1).all():
|
|
451
|
+
series = series.reset_index(drop=True)
|
|
452
|
+
return series, freq
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _with_constant(X):
|
|
456
|
+
return np.hstack([np.ones((len(X), 1)), X.to_numpy()])
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _ols_beta(X, y):
|
|
460
|
+
observed = ~np.isnan(y)
|
|
461
|
+
beta, *_ = np.linalg.lstsq(_with_constant(X)[observed], y[observed], rcond=None)
|
|
462
|
+
return beta
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _ols_residuals(X, y):
|
|
466
|
+
return y - _with_constant(X) @ _ols_beta(X, y)
|