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/legacy.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The version 0.1 interface, kept so existing code keeps working.
|
|
3
|
+
New code should use :class:`divinity.Forecaster`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .forecaster import Forecaster
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class divinity:
|
|
15
|
+
"""
|
|
16
|
+
Deprecated version 0.1 interface, now a thin wrapper around
|
|
17
|
+
:class:`Forecaster`.
|
|
18
|
+
|
|
19
|
+
:param forecast_length: number of steps returned by :meth:`predict`.
|
|
20
|
+
:param confidence_interval: interval coverage in percent.
|
|
21
|
+
:param seasonal_periods: candidate seasonal periods. None uses the
|
|
22
|
+
Forecaster defaults.
|
|
23
|
+
:param trend_order: [0] for no trend, anything including 1 for "auto".
|
|
24
|
+
:param kwargs: other version 0.1 arguments are accepted and ignored.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
forecast_length,
|
|
30
|
+
confidence_interval=95.0,
|
|
31
|
+
seasonal_periods=None,
|
|
32
|
+
trend_order=(0, 1),
|
|
33
|
+
**kwargs,
|
|
34
|
+
):
|
|
35
|
+
warnings.warn(
|
|
36
|
+
"divinity.divinity is deprecated, use divinity.Forecaster",
|
|
37
|
+
DeprecationWarning,
|
|
38
|
+
stacklevel=2,
|
|
39
|
+
)
|
|
40
|
+
self.forecast_length = forecast_length
|
|
41
|
+
self.confidence_interval = confidence_interval
|
|
42
|
+
self.model = Forecaster(
|
|
43
|
+
seasonal_periods="auto" if seasonal_periods is None else seasonal_periods,
|
|
44
|
+
trend="auto" if max(trend_order) >= 1 else None,
|
|
45
|
+
interval=confidence_interval / 100.0,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def features(self):
|
|
50
|
+
return self.model._features(np.arange(self.model.n_))
|
|
51
|
+
|
|
52
|
+
def fit(self, y):
|
|
53
|
+
self.model.fit(y)
|
|
54
|
+
|
|
55
|
+
def predict(self):
|
|
56
|
+
"""
|
|
57
|
+
:return: numpy array of the forecast. The half width of the
|
|
58
|
+
prediction interval is stored in ``ystd``.
|
|
59
|
+
"""
|
|
60
|
+
result = self.model.predict(self.forecast_length)
|
|
61
|
+
self.ypred = result["forecast"].to_numpy()
|
|
62
|
+
self.ystd = ((result["upper"] - result["lower"]) / 2).to_numpy()
|
|
63
|
+
return self.ypred
|
|
64
|
+
|
|
65
|
+
def forecast(self, steps):
|
|
66
|
+
self.forecast_length = steps
|
|
67
|
+
return self.predict()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def gen_season(N, periods=(10, 20), sine_amplitudes=(1, 1), cosine_amplitudes=(0, 0)):
|
|
71
|
+
"""
|
|
72
|
+
Sinusoidal features and their weighted sum.
|
|
73
|
+
|
|
74
|
+
:return: dict with "features" (DataFrame) and "target" (array).
|
|
75
|
+
"""
|
|
76
|
+
t = np.arange(N)
|
|
77
|
+
features = {}
|
|
78
|
+
X = np.zeros(N)
|
|
79
|
+
for idx, p in enumerate(periods):
|
|
80
|
+
if p == 0 or p > N / 2:
|
|
81
|
+
warnings.warn("period {} ignored, must be in (0, N/2]".format(p))
|
|
82
|
+
continue
|
|
83
|
+
s = np.sin(2 * np.pi / p * t)
|
|
84
|
+
c = np.cos(2 * np.pi / p * t)
|
|
85
|
+
if sine_amplitudes is not None:
|
|
86
|
+
X += sine_amplitudes[idx] * s
|
|
87
|
+
if cosine_amplitudes is not None:
|
|
88
|
+
X += cosine_amplitudes[idx] * c
|
|
89
|
+
features["sin P=" + str(p)] = s
|
|
90
|
+
features["cos P=" + str(p)] = c
|
|
91
|
+
return {"features": pd.DataFrame(features), "target": X}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def gen_trend(N, coef=(0, 1, 2), amplitudes=(1, 0.5, 0.2)):
|
|
95
|
+
"""
|
|
96
|
+
Polynomial trend features and their weighted sum.
|
|
97
|
+
|
|
98
|
+
:return: dict with "features" (DataFrame) and "target" (array).
|
|
99
|
+
"""
|
|
100
|
+
t = np.arange(N)
|
|
101
|
+
features = {}
|
|
102
|
+
X = np.zeros(N)
|
|
103
|
+
for idx, order in enumerate(coef):
|
|
104
|
+
f = np.array(t**order, dtype=float)
|
|
105
|
+
if amplitudes is not None:
|
|
106
|
+
X += amplitudes[idx] * f
|
|
107
|
+
features["trend order " + str(order)] = f
|
|
108
|
+
return {"features": pd.DataFrame(features), "target": X}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def gen_fake(
|
|
112
|
+
N,
|
|
113
|
+
trend_order,
|
|
114
|
+
trend_amplitudes,
|
|
115
|
+
seasonal_periods,
|
|
116
|
+
seasonal_amplitudes_sine,
|
|
117
|
+
seasonal_amplitudes_cosine,
|
|
118
|
+
normalize="0to1",
|
|
119
|
+
):
|
|
120
|
+
"""
|
|
121
|
+
Synthetic trend plus seasonal series and its feature matrix. Prefer
|
|
122
|
+
:mod:`divinity.synthetic` for new code.
|
|
123
|
+
|
|
124
|
+
:return: dict with "features" (DataFrame) and "target" (array).
|
|
125
|
+
"""
|
|
126
|
+
trend = gen_trend(N, coef=list(range(trend_order + 1)), amplitudes=trend_amplitudes)
|
|
127
|
+
season = gen_season(
|
|
128
|
+
N,
|
|
129
|
+
periods=seasonal_periods,
|
|
130
|
+
sine_amplitudes=seasonal_amplitudes_sine,
|
|
131
|
+
cosine_amplitudes=seasonal_amplitudes_cosine,
|
|
132
|
+
)
|
|
133
|
+
features = pd.concat([trend["features"], season["features"]], axis=1)
|
|
134
|
+
if normalize == "0to1":
|
|
135
|
+
spread = features.max() - features.min()
|
|
136
|
+
cols = spread[spread > 0].index
|
|
137
|
+
features[cols] = (features[cols] - features[cols].min()) / spread[cols]
|
|
138
|
+
return {"features": features, "target": trend["target"] + season["target"]}
|
divinity/periods.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Candidate seasonal periods, from the series frequency and from the data.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from scipy import signal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def default_periods(freq):
|
|
11
|
+
"""
|
|
12
|
+
Sensible seasonal periods (in time steps) for a pandas frequency.
|
|
13
|
+
Returns an empty list if the frequency is unknown.
|
|
14
|
+
"""
|
|
15
|
+
if freq is None:
|
|
16
|
+
return []
|
|
17
|
+
offset = pd.tseries.frequencies.to_offset(freq)
|
|
18
|
+
if offset.n != 1:
|
|
19
|
+
return []
|
|
20
|
+
o = pd.offsets
|
|
21
|
+
if isinstance(offset, o.BusinessDay):
|
|
22
|
+
return [5.0, 261.0]
|
|
23
|
+
if isinstance(offset, o.Day):
|
|
24
|
+
return [7.0, 365.25]
|
|
25
|
+
if isinstance(offset, o.Hour):
|
|
26
|
+
return [24.0, 168.0]
|
|
27
|
+
if isinstance(offset, o.Minute):
|
|
28
|
+
return [60.0, 1440.0]
|
|
29
|
+
if isinstance(offset, o.Week):
|
|
30
|
+
return [365.25 / 7]
|
|
31
|
+
if isinstance(offset, (o.MonthBegin, o.MonthEnd)):
|
|
32
|
+
return [12.0]
|
|
33
|
+
if isinstance(offset, (o.QuarterBegin, o.QuarterEnd)):
|
|
34
|
+
return [4.0]
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_periods(y, max_periods=3, min_period=2.0, max_period=None, oversample=8):
|
|
39
|
+
"""
|
|
40
|
+
Find the strongest periodicities in a series from its periodogram.
|
|
41
|
+
|
|
42
|
+
The series is linearly detrended first (gaps are interpolated for this
|
|
43
|
+
step only). The periodogram is zero padded so that periods falling
|
|
44
|
+
between Fourier frequencies are located accurately.
|
|
45
|
+
|
|
46
|
+
:param y: 1D array, may contain NaN.
|
|
47
|
+
:param max_periods: maximum number of periods to return.
|
|
48
|
+
:param min_period: shortest period considered, in time steps.
|
|
49
|
+
:param max_period: longest period considered. Defaults to half the
|
|
50
|
+
series length, so at least two full cycles are observed.
|
|
51
|
+
:return: list of periods, strongest first.
|
|
52
|
+
"""
|
|
53
|
+
y = pd.Series(np.asarray(y, dtype=float)).interpolate(limit_direction="both")
|
|
54
|
+
y = y.to_numpy()
|
|
55
|
+
n = len(y)
|
|
56
|
+
if n < 8 or np.allclose(y, y[0]):
|
|
57
|
+
return []
|
|
58
|
+
if max_period is None:
|
|
59
|
+
max_period = n / 2
|
|
60
|
+
y = signal.detrend(y)
|
|
61
|
+
freqs, power = signal.periodogram(y, nfft=oversample * n, detrend=False)
|
|
62
|
+
with np.errstate(divide="ignore"):
|
|
63
|
+
periods = 1.0 / freqs
|
|
64
|
+
valid = (periods >= min_period) & (periods <= max_period)
|
|
65
|
+
if not np.any(valid):
|
|
66
|
+
return []
|
|
67
|
+
peaks, _ = signal.find_peaks(np.where(valid, power, 0.0))
|
|
68
|
+
peaks = peaks[valid[peaks]]
|
|
69
|
+
order = peaks[np.argsort(power[peaks])[::-1]]
|
|
70
|
+
|
|
71
|
+
found = []
|
|
72
|
+
for idx in order:
|
|
73
|
+
p = periods[idx]
|
|
74
|
+
# skip harmonics and near duplicates of periods already found
|
|
75
|
+
if any(_is_related(p, q) for q in found):
|
|
76
|
+
continue
|
|
77
|
+
found.append(float(p))
|
|
78
|
+
if len(found) == max_periods:
|
|
79
|
+
break
|
|
80
|
+
return found
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_related(p, q, tol=0.05):
|
|
84
|
+
"""
|
|
85
|
+
True if period p is within ``tol`` of q or of one of its harmonics q/k.
|
|
86
|
+
"""
|
|
87
|
+
for k in range(1, 6):
|
|
88
|
+
if abs(p - q / k) <= tol * q / k:
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def merge_periods(known, detected, n, tol=0.05):
|
|
94
|
+
"""
|
|
95
|
+
Combine known and detected periods, dropping detected periods that match
|
|
96
|
+
a known one and any period that cannot be seen twice in ``n`` steps.
|
|
97
|
+
|
|
98
|
+
:return: (known, detected) with invalid entries removed.
|
|
99
|
+
"""
|
|
100
|
+
known = [float(p) for p in known if 2 <= p <= n / 2]
|
|
101
|
+
detected = [
|
|
102
|
+
float(p)
|
|
103
|
+
for p in detected
|
|
104
|
+
if 2 <= p <= n / 2 and not any(_is_related(p, q, tol) for q in known)
|
|
105
|
+
]
|
|
106
|
+
return known, detected
|
divinity/plotting.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Forecast plots. Colours follow a colour blind safe categorical palette
|
|
3
|
+
(history in neutral grey, forecast in blue, true future values in orange).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
HISTORY = "#52514e"
|
|
10
|
+
FORECAST = "#2a78d6"
|
|
11
|
+
ACTUAL = "#eb6834"
|
|
12
|
+
GRID = "#e4e3df"
|
|
13
|
+
SURFACE = "#fcfcfb"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def plot_forecast(history, forecast, actual=None, ax=None, title=None):
|
|
17
|
+
"""
|
|
18
|
+
Plot history, forecast with its prediction interval, and optionally the
|
|
19
|
+
true future values.
|
|
20
|
+
|
|
21
|
+
:param history: Series of observed values.
|
|
22
|
+
:param forecast: DataFrame with columns forecast, lower and upper.
|
|
23
|
+
:param actual: optional Series or array of true future values.
|
|
24
|
+
:param ax: matplotlib Axes, a new figure is made if None.
|
|
25
|
+
:param title: optional title.
|
|
26
|
+
:return: the matplotlib Axes.
|
|
27
|
+
"""
|
|
28
|
+
import matplotlib.pyplot as plt
|
|
29
|
+
|
|
30
|
+
if ax is None:
|
|
31
|
+
_, ax = plt.subplots(figsize=(9, 3.9))
|
|
32
|
+
ax.set_facecolor(SURFACE)
|
|
33
|
+
ax.figure.set_facecolor(SURFACE)
|
|
34
|
+
|
|
35
|
+
pct = int(round(100 * forecast.attrs.get("interval", 0.95)))
|
|
36
|
+
ax.plot(history.index, history.values, color=HISTORY, lw=1.5, label="history")
|
|
37
|
+
ax.fill_between(
|
|
38
|
+
forecast.index,
|
|
39
|
+
forecast["lower"],
|
|
40
|
+
forecast["upper"],
|
|
41
|
+
color=FORECAST,
|
|
42
|
+
alpha=0.15,
|
|
43
|
+
lw=0,
|
|
44
|
+
label="{}% interval".format(pct),
|
|
45
|
+
)
|
|
46
|
+
ax.plot(
|
|
47
|
+
forecast.index, forecast["forecast"], color=FORECAST, lw=2, label="forecast"
|
|
48
|
+
)
|
|
49
|
+
if actual is not None:
|
|
50
|
+
if not isinstance(actual, pd.Series):
|
|
51
|
+
actual = pd.Series(np.asarray(actual), index=forecast.index[: len(actual)])
|
|
52
|
+
ax.plot(actual.index, actual.values, color=ACTUAL, lw=1.5, label="actual")
|
|
53
|
+
|
|
54
|
+
ax.grid(True, color=GRID, lw=0.8)
|
|
55
|
+
ax.set_axisbelow(True)
|
|
56
|
+
for side in ("top", "right"):
|
|
57
|
+
ax.spines[side].set_visible(False)
|
|
58
|
+
for side in ("left", "bottom"):
|
|
59
|
+
ax.spines[side].set_color(GRID)
|
|
60
|
+
ax.tick_params(colors=HISTORY, labelsize=9)
|
|
61
|
+
if isinstance(forecast.index, pd.DatetimeIndex):
|
|
62
|
+
import matplotlib.dates as mdates
|
|
63
|
+
|
|
64
|
+
locator = mdates.AutoDateLocator()
|
|
65
|
+
ax.xaxis.set_major_locator(locator)
|
|
66
|
+
ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
|
|
67
|
+
# below the axes so it never covers the data
|
|
68
|
+
ax.legend(
|
|
69
|
+
frameon=False,
|
|
70
|
+
fontsize=9,
|
|
71
|
+
loc="upper left",
|
|
72
|
+
bbox_to_anchor=(0, -0.12),
|
|
73
|
+
ncol=4,
|
|
74
|
+
)
|
|
75
|
+
if title:
|
|
76
|
+
ax.set_title(title, loc="left", fontsize=11, color="#0b0b0b")
|
|
77
|
+
return ax
|
divinity/residuals.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Choosing the ARIMA model for whatever the trend and seasonality leave behind.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from statsmodels.tools.sm_exceptions import ConvergenceWarning
|
|
10
|
+
from statsmodels.tsa.arima.model import ARIMA
|
|
11
|
+
from statsmodels.tsa.adfvalues import mackinnonp
|
|
12
|
+
from statsmodels.tsa.stattools import adfuller
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def has_unit_root(resid, alpha=0.05, trend=True):
|
|
16
|
+
"""
|
|
17
|
+
Augmented Dickey-Fuller test for a unit root (random walk behaviour).
|
|
18
|
+
|
|
19
|
+
Set ``trend`` to True if a trend was removed from the series (or could
|
|
20
|
+
be present). Testing detrended data against the no-trend critical
|
|
21
|
+
values rejects the unit root far too often, so random walks would be
|
|
22
|
+
mistaken for mean reverting series.
|
|
23
|
+
|
|
24
|
+
The null hypothesis is a unit root, so the series is treated as a random
|
|
25
|
+
walk unless the test rejects at ``alpha``. When in doubt this errs
|
|
26
|
+
towards differencing, which gives wider but honest intervals, rather
|
|
27
|
+
than towards mean reversion, which gives confident wrong ones.
|
|
28
|
+
"""
|
|
29
|
+
r = np.asarray(resid, dtype=float)
|
|
30
|
+
observed = r[~np.isnan(r)]
|
|
31
|
+
if len(observed) < 20 or np.allclose(observed, observed[0]):
|
|
32
|
+
return False
|
|
33
|
+
regression = "ct" if trend else "c"
|
|
34
|
+
if np.isnan(r).any():
|
|
35
|
+
r = fill_short_gaps(r)
|
|
36
|
+
if np.isnan(r).any():
|
|
37
|
+
p_value = _adf_with_gaps(r, regression)
|
|
38
|
+
if p_value is not None:
|
|
39
|
+
return p_value > alpha
|
|
40
|
+
# too few complete rows, fall back to interpolating the gaps
|
|
41
|
+
r = pd.Series(r).interpolate(limit_direction="both").to_numpy()
|
|
42
|
+
with warnings.catch_warnings():
|
|
43
|
+
warnings.simplefilter("ignore")
|
|
44
|
+
p_value = adfuller(r, regression=regression, autolag="AIC")[1]
|
|
45
|
+
return p_value > alpha
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fill_short_gaps(r, max_gap=2):
|
|
49
|
+
"""
|
|
50
|
+
Linearly interpolate runs of at most ``max_gap`` missing values that
|
|
51
|
+
have data on both sides. Longer gaps are left as NaN.
|
|
52
|
+
"""
|
|
53
|
+
r = np.asarray(r, dtype=float).copy()
|
|
54
|
+
missing = np.isnan(r)
|
|
55
|
+
filled = pd.Series(r).interpolate(limit_area="inside").to_numpy()
|
|
56
|
+
i = 0
|
|
57
|
+
while i < len(r):
|
|
58
|
+
if missing[i]:
|
|
59
|
+
j = i
|
|
60
|
+
while j < len(r) and missing[j]:
|
|
61
|
+
j += 1
|
|
62
|
+
if j - i <= max_gap:
|
|
63
|
+
r[i:j] = filled[i:j]
|
|
64
|
+
i = j
|
|
65
|
+
else:
|
|
66
|
+
i += 1
|
|
67
|
+
return r
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _adf_with_gaps(r, regression, max_lag=4, min_rows=30):
|
|
71
|
+
"""
|
|
72
|
+
ADF test on a series with missing values, using only the rows where the
|
|
73
|
+
value and all its lags are observed. Interpolating instead would turn
|
|
74
|
+
long gaps into smooth straight lines, which look like a random walk.
|
|
75
|
+
|
|
76
|
+
The lag length is chosen by AIC on a common sample, and the p-value
|
|
77
|
+
comes from MacKinnon's tables, as in statsmodels' adfuller.
|
|
78
|
+
|
|
79
|
+
:return: p-value, or None if there are too few complete rows.
|
|
80
|
+
"""
|
|
81
|
+
n = len(r)
|
|
82
|
+
dy = np.full(n, np.nan)
|
|
83
|
+
dy[1:] = np.diff(r)
|
|
84
|
+
t = np.arange(n, dtype=float)
|
|
85
|
+
rows = np.arange(max_lag + 1, n)
|
|
86
|
+
lags = np.column_stack([dy[rows - k] for k in range(1, max_lag + 1)])
|
|
87
|
+
target = dy[rows]
|
|
88
|
+
level = r[rows - 1]
|
|
89
|
+
base = [level, np.ones(len(rows))]
|
|
90
|
+
if regression == "ct":
|
|
91
|
+
base.append(t[rows] / n)
|
|
92
|
+
base = np.column_stack(base)
|
|
93
|
+
ok = ~(np.isnan(target) | np.isnan(level) | np.isnan(lags).any(axis=1))
|
|
94
|
+
if ok.sum() < min_rows:
|
|
95
|
+
return None
|
|
96
|
+
y, base, lags = target[ok], base[ok], lags[ok]
|
|
97
|
+
m = len(y)
|
|
98
|
+
|
|
99
|
+
best = None
|
|
100
|
+
for k in range(max_lag + 1):
|
|
101
|
+
X = np.hstack([base, lags[:, :k]])
|
|
102
|
+
beta, *_ = np.linalg.lstsq(X, y, rcond=None)
|
|
103
|
+
rss = float(np.sum((y - X @ beta) ** 2))
|
|
104
|
+
aic = m * np.log(max(rss, 1e-300) / m) + 2 * X.shape[1]
|
|
105
|
+
if best is None or aic < best[0]:
|
|
106
|
+
best = (aic, X, beta, rss)
|
|
107
|
+
_, X, beta, rss = best
|
|
108
|
+
sigma2 = rss / (m - X.shape[1])
|
|
109
|
+
cov = sigma2 * np.linalg.pinv(X.T @ X)
|
|
110
|
+
t_stat = beta[0] / np.sqrt(cov[0, 0])
|
|
111
|
+
return float(mackinnonp(t_stat, regression=regression, N=1))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fit_quietly(model):
|
|
115
|
+
"""
|
|
116
|
+
Fit a statsmodels model, silencing the routine convergence chatter.
|
|
117
|
+
"""
|
|
118
|
+
with warnings.catch_warnings():
|
|
119
|
+
warnings.simplefilter("ignore", ConvergenceWarning)
|
|
120
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
121
|
+
return model.fit()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def select_order(resid, d, max_p=2, max_q=2):
|
|
125
|
+
"""
|
|
126
|
+
Pick (p, d, q) for the residuals by BIC over a small grid.
|
|
127
|
+
|
|
128
|
+
:param resid: residual series (NaN allowed).
|
|
129
|
+
:param d: differencing order decided beforehand (0 or 1).
|
|
130
|
+
:return: (order, fitted results) for the best model.
|
|
131
|
+
"""
|
|
132
|
+
best = None
|
|
133
|
+
trend = "c" if d == 0 else "n"
|
|
134
|
+
for p in range(max_p + 1):
|
|
135
|
+
for q in range(max_q + 1):
|
|
136
|
+
try:
|
|
137
|
+
res = fit_quietly(ARIMA(resid, order=(p, d, q), trend=trend))
|
|
138
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
139
|
+
continue
|
|
140
|
+
if not np.isfinite(res.bic):
|
|
141
|
+
continue
|
|
142
|
+
if best is None or res.bic < best[1].bic:
|
|
143
|
+
best = ((p, d, q), res)
|
|
144
|
+
if best is None:
|
|
145
|
+
return (0, d, 0), None
|
|
146
|
+
return best
|
divinity/selection.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Forward selection of trend and seasonal feature groups.
|
|
3
|
+
|
|
4
|
+
Groups are scored with BIC plus an extra penalty for searching over many
|
|
5
|
+
candidates (in the spirit of the extended BIC of Chen and Chen, 2008).
|
|
6
|
+
Without that extra term, trying dozens of candidate periods on pure noise
|
|
7
|
+
will eventually find one that looks significant by chance.
|
|
8
|
+
|
|
9
|
+
Selection can also be run on quasi-differenced data (y_t - rho * y_{t-1}),
|
|
10
|
+
which is feasible GLS for AR(1) errors. Ordinary least squares assumes
|
|
11
|
+
independent errors, so on a random walk or a strongly autocorrelated series
|
|
12
|
+
it finds "significant" trends and cycles that are not there.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .features import design_matrix
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SelectionResult:
|
|
24
|
+
"""
|
|
25
|
+
Outcome of forward selection.
|
|
26
|
+
|
|
27
|
+
:param groups: selected FeatureGroups, in the order they were added.
|
|
28
|
+
:param history: list of (group name, score) for each accepted step,
|
|
29
|
+
starting with ("constant", score).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
groups: list
|
|
33
|
+
history: list = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ols_rss(X, y):
|
|
37
|
+
"""
|
|
38
|
+
Residual sum of squares of an ordinary least squares fit.
|
|
39
|
+
"""
|
|
40
|
+
beta, *_ = np.linalg.lstsq(X, y, rcond=None)
|
|
41
|
+
resid = y - X @ beta
|
|
42
|
+
return float(resid @ resid)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def score(rss, n, n_params, search_penalty):
|
|
46
|
+
"""
|
|
47
|
+
BIC for a Gaussian linear model plus the multiple search penalty.
|
|
48
|
+
"""
|
|
49
|
+
rss = max(rss, 1e-12 * n)
|
|
50
|
+
return n * np.log(rss / n) + n_params * np.log(n) + search_penalty
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def lag1_autocorrelation(resid):
|
|
54
|
+
"""
|
|
55
|
+
AR(1) coefficient of a residual series, using only adjacent pairs where
|
|
56
|
+
both values are observed. Clipped to [-0.95, 1].
|
|
57
|
+
"""
|
|
58
|
+
r = np.asarray(resid, dtype=float)
|
|
59
|
+
a, b = r[1:], r[:-1]
|
|
60
|
+
ok = ~(np.isnan(a) | np.isnan(b))
|
|
61
|
+
if ok.sum() < 3 or np.sum(b[ok] ** 2) == 0:
|
|
62
|
+
return 0.0
|
|
63
|
+
rho = np.sum(a[ok] * b[ok]) / np.sum(b[ok] ** 2)
|
|
64
|
+
return float(np.clip(rho, -0.95, 1.0))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def quasi_difference(values, rho):
|
|
68
|
+
"""
|
|
69
|
+
Apply x_t - rho * x_{t-1} to a 1D or 2D array (first row is dropped).
|
|
70
|
+
"""
|
|
71
|
+
values = np.asarray(values, dtype=float)
|
|
72
|
+
if rho == 0:
|
|
73
|
+
return values
|
|
74
|
+
return values[1:] - rho * values[:-1]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def forward_select(
|
|
78
|
+
y, candidates, pool_sizes, drop_columns=(), gamma=1.0, forced=(), rho=0.0
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Greedily add the candidate group that lowers the score most, until no
|
|
82
|
+
group lowers it.
|
|
83
|
+
|
|
84
|
+
A harmonic k > 1 of a period is only eligible once harmonic k - 1 of the
|
|
85
|
+
same period has been selected.
|
|
86
|
+
|
|
87
|
+
:param y: training series (NaN allowed, those rows are ignored).
|
|
88
|
+
:param candidates: list of FeatureGroup.
|
|
89
|
+
:param pool_sizes: dict of FeatureGroup to the number of alternatives it
|
|
90
|
+
was chosen from. Each selected group adds 2 * gamma * log(pool size)
|
|
91
|
+
to the score.
|
|
92
|
+
:param drop_columns: degenerate column names to leave out.
|
|
93
|
+
:param gamma: strength of the search penalty. 0 gives plain BIC.
|
|
94
|
+
:param forced: groups that are always included and carry no penalty.
|
|
95
|
+
:param rho: AR(1) coefficient of the errors. Non zero values select on
|
|
96
|
+
quasi-differenced data.
|
|
97
|
+
:return: SelectionResult
|
|
98
|
+
"""
|
|
99
|
+
y = np.asarray(y, dtype=float)
|
|
100
|
+
n_train = len(y)
|
|
101
|
+
t = np.arange(n_train)
|
|
102
|
+
|
|
103
|
+
forced = list(forced)
|
|
104
|
+
candidates = [g for g in candidates if g not in forced]
|
|
105
|
+
full = design_matrix(t, forced + candidates, n_train, drop_columns)
|
|
106
|
+
columns_of = {
|
|
107
|
+
g: [c for c in design_matrix(t[:1], [g], n_train).columns if c in full.columns]
|
|
108
|
+
for g in forced + candidates
|
|
109
|
+
}
|
|
110
|
+
candidates = [g for g in candidates if columns_of[g]]
|
|
111
|
+
col_pos = {c: i for i, c in enumerate(full.columns)}
|
|
112
|
+
|
|
113
|
+
y_t = quasi_difference(y, rho)
|
|
114
|
+
X_t = quasi_difference(np.hstack([np.ones((n_train, 1)), full.to_numpy()]), rho)
|
|
115
|
+
observed = ~np.isnan(y_t)
|
|
116
|
+
y_obs = y_t[observed]
|
|
117
|
+
X_obs = X_t[observed]
|
|
118
|
+
n = len(y_obs)
|
|
119
|
+
|
|
120
|
+
def evaluate(groups):
|
|
121
|
+
idx = [0] + [1 + col_pos[c] for g in forced + groups for c in columns_of[g]]
|
|
122
|
+
X = X_obs[:, idx]
|
|
123
|
+
penalty = sum(2 * gamma * np.log(max(pool_sizes[g], 1)) for g in groups)
|
|
124
|
+
return score(ols_rss(X, y_obs), n, X.shape[1], penalty)
|
|
125
|
+
|
|
126
|
+
selected = []
|
|
127
|
+
current = evaluate(selected)
|
|
128
|
+
history = [("constant", current)]
|
|
129
|
+
while True:
|
|
130
|
+
eligible = [
|
|
131
|
+
g
|
|
132
|
+
for g in candidates
|
|
133
|
+
if g not in selected
|
|
134
|
+
and (
|
|
135
|
+
g.kind == "trend"
|
|
136
|
+
or g.harmonic == 1
|
|
137
|
+
or any(
|
|
138
|
+
s.kind == "season"
|
|
139
|
+
and s.period == g.period
|
|
140
|
+
and s.harmonic == g.harmonic - 1
|
|
141
|
+
for s in selected
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
]
|
|
145
|
+
# never use more parameters than about a third of the data
|
|
146
|
+
n_cols = sum(len(columns_of[g]) for g in forced + selected)
|
|
147
|
+
eligible = [g for g in eligible if n_cols + len(columns_of[g]) + 1 < n / 3]
|
|
148
|
+
if not eligible:
|
|
149
|
+
break
|
|
150
|
+
scores = [evaluate(selected + [g]) for g in eligible]
|
|
151
|
+
best = int(np.argmin(scores))
|
|
152
|
+
if scores[best] >= current:
|
|
153
|
+
break
|
|
154
|
+
selected.append(eligible[best])
|
|
155
|
+
current = scores[best]
|
|
156
|
+
history.append((eligible[best].name, current))
|
|
157
|
+
return SelectionResult(forced + selected, history)
|