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 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)