divinity-forecast 0.2.0__tar.gz → 0.3.0__tar.gz
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_forecast-0.3.0/PKG-INFO +471 -0
- divinity_forecast-0.3.0/README.md +448 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/__init__.py +3 -2
- divinity_forecast-0.3.0/divinity/datasets.py +160 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/forecaster.py +49 -15
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/plotting.py +18 -2
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/residuals.py +32 -11
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/selection.py +13 -1
- divinity_forecast-0.3.0/divinity_forecast.egg-info/PKG-INFO +471 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity_forecast.egg-info/SOURCES.txt +3 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/pyproject.toml +1 -1
- divinity_forecast-0.3.0/tests/test_datasets.py +49 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/tests/test_forecaster.py +81 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/tests/test_legacy.py +10 -0
- divinity_forecast-0.3.0/tests/test_readme_examples.py +23 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/tests/test_residuals.py +30 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/tests/test_scenarios.py +1 -1
- divinity_forecast-0.2.0/PKG-INFO +0 -251
- divinity_forecast-0.2.0/README.md +0 -228
- divinity_forecast-0.2.0/divinity_forecast.egg-info/PKG-INFO +0 -251
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/LICENSE +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/features.py +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/legacy.py +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/periods.py +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity/synthetic.py +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity_forecast.egg-info/dependency_links.txt +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity_forecast.egg-info/requires.txt +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/divinity_forecast.egg-info/top_level.txt +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/setup.cfg +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/tests/test_features.py +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/tests/test_periods.py +0 -0
- {divinity_forecast-0.2.0 → divinity_forecast-0.3.0}/tests/test_selection.py +0 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: divinity-forecast
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Automatic timeseries forecasting with honest prediction intervals
|
|
5
|
+
Author-email: dstarkey23 <drds.gh1@gmail.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/drds1/divinity
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=1.24
|
|
12
|
+
Requires-Dist: pandas>=2.0
|
|
13
|
+
Requires-Dist: scipy>=1.10
|
|
14
|
+
Requires-Dist: statsmodels>=0.14
|
|
15
|
+
Requires-Dist: matplotlib>=3.7
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
18
|
+
Requires-Dist: pre-commit>=3; extra == "dev"
|
|
19
|
+
Requires-Dist: nbclient>=0.10; extra == "dev"
|
|
20
|
+
Requires-Dist: nbformat>=5.10; extra == "dev"
|
|
21
|
+
Requires-Dist: ipykernel>=6; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Divinity
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/divinity-forecast/)
|
|
27
|
+
[](https://github.com/drds1/divinity/actions/workflows/tests.yml)
|
|
28
|
+
[](https://github.com/psf/black)
|
|
29
|
+
|
|
30
|
+
Automatic timeseries forecasting with prediction intervals you can trust.
|
|
31
|
+
|
|
32
|
+
Give it a series and a horizon. Divinity decides whether there is a trend, which seasonal cycles are real and how much autocorrelation is left over. It returns a forecast with an interval that widens as much as the data say it should, and no more.
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install divinity-forecast
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The package installs as `divinity-forecast` but is imported as `divinity`. Version 0.1 was published as `divinity` and is no longer maintained there.
|
|
43
|
+
|
|
44
|
+
Or from a clone, with the development tools
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e ".[dev]"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Python 3.10 or later.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import divinity as dv
|
|
56
|
+
|
|
57
|
+
model = dv.Forecaster().fit(y) # y is a pandas Series, numpy array or list
|
|
58
|
+
result = model.predict(steps=30) # DataFrame with forecast, lower, upper
|
|
59
|
+
print(model.summary())
|
|
60
|
+
model.plot(result)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
Divinity forecaster
|
|
65
|
+
observations 1095
|
|
66
|
+
frequency D
|
|
67
|
+
log scale no
|
|
68
|
+
trend linear
|
|
69
|
+
seasonality 365.25 (1 harmonic), 7 (3 harmonics)
|
|
70
|
+
residuals ARIMA(0, 0, 0)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or in one line
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
result = dv.forecast(y, steps=30)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`result` is indexed by future time. A Series with a `DatetimeIndex` gives future dates, anything else gives integer steps following on from the data.
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
forecast lower upper
|
|
83
|
+
2024-12-31 61.48 59.55 63.41
|
|
84
|
+
2025-01-01 61.55 59.62 63.49
|
|
85
|
+
...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The [demo notebook](https://github.com/drds1/divinity/blob/master/notebooks/demo.ipynb) walks through the API and the tricky cases, and the [applications notebook](https://github.com/drds1/divinity/blob/master/notebooks/applications.ipynb) works through real uses, from stock planning to anomaly detection.
|
|
89
|
+
|
|
90
|
+
## How it works
|
|
91
|
+
|
|
92
|
+
The series is modelled as a constant, plus an optional linear trend, plus Fourier terms for each seasonal period, plus ARIMA errors (regression with ARIMA errors).
|
|
93
|
+
|
|
94
|
+
1. **Candidate periods.** These come from the date frequency (7 and 365.25 for daily data, 12 for monthly, 24 and 168 for hourly and so on) and from peaks in the periodogram, so a cycle nobody mentioned can still be found. A period is only considered if the data cover at least two full cycles.
|
|
95
|
+
2. **Feature selection.** Trend and seasonal terms are added one at a time while they improve BIC. Extra harmonics of a period (for sharper, non-sinusoidal shapes) are only tried once the lower harmonic is in. There is an additional penalty for searching over many candidates. Without it, testing dozens of periods on noise will find one by chance.
|
|
96
|
+
3. **Random walk or not?** Before choosing any features, an augmented Dickey-Fuller test is run on what is left after removing the trend and the known periods. Detected periods are left out because they might be spurious. Testing first matters. On a random walk, spurious features soak up the wander and make the leftovers look stationary, so a test afterwards confirms its own mistake.
|
|
97
|
+
4. **Allowing for autocorrelation.** Least squares assumes independent errors, and on a random walk it declares almost any trend "significant". If the series has a unit root, features are selected on differenced data. Otherwise the autocorrelation of the residuals is estimated and features are reselected on quasi-differenced data (feasible GLS), repeating until the choice settles. The ARIMA order for what remains is chosen by BIC over a small grid. An order whose fit collapses onto the edge of the parameter space (an AR root on the unit circle cancelled by an MA root) is skipped in favour of the next best. For a differenced series a drift is kept at the AIC price rather than BIC, as R's `auto.arima` does, because a missed drift costs more the further ahead you forecast.
|
|
98
|
+
5. **Joint fit and intervals.** Features and ARIMA errors are fitted together by maximum likelihood. Forecast intervals combine the ARIMA forecast variance with the uncertainty in the trend and seasonal coefficients, which matters most when extrapolating a trend a long way. Student t quantiles allow for the noise level being estimated, which matters for short series.
|
|
99
|
+
|
|
100
|
+
The fitted model does not depend on the forecast horizon, so `predict(10)` is exactly the first ten rows of `predict(100)`.
|
|
101
|
+
|
|
102
|
+
## Can it be fooled?
|
|
103
|
+
|
|
104
|
+
The cases below are built to catch out a naive forecaster. Each was generated by [`divinity.synthetic`](https://github.com/drds1/divinity/blob/master/divinity/synthetic.py) with seed 0, the same seed the smoke tests use, so none of them were picked because they looked good. In every chart the orange line is the true future, which the model never saw.
|
|
105
|
+
|
|
106
|
+
### The standard case
|
|
107
|
+
|
|
108
|
+
A linear trend, a 20 step cycle and noise. Neither component is specified.
|
|
109
|
+
|
|
110
|
+

|
|
111
|
+
|
|
112
|
+
### Multiplicative seasonality
|
|
113
|
+
|
|
114
|
+
The seasonal swing grows with the level. Fitting with `log=True` handles this.
|
|
115
|
+
|
|
116
|
+

|
|
117
|
+
|
|
118
|
+
### Pure noise
|
|
119
|
+
|
|
120
|
+
There is nothing to find. A model that looks hard enough for structure will find some anyway. Divinity returns a flat forecast at the mean.
|
|
121
|
+
|
|
122
|
+

|
|
123
|
+
|
|
124
|
+
### A random walk
|
|
125
|
+
|
|
126
|
+
Random walks look trendy. Ordinary least squares picks a "significant" trend in the large majority of them, which is then extrapolated with false confidence. Divinity finds no trend or seasonality, switches to a differenced model and lets the interval fan out.
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
130
|
+
### Autocorrelated but stationary
|
|
131
|
+
|
|
132
|
+
The opposite trap. The series wanders but always returns to its mean. The forecast decays back towards the mean rather than treating it as a random walk.
|
|
133
|
+
|
|
134
|
+

|
|
135
|
+
|
|
136
|
+
### A period nobody mentioned
|
|
137
|
+
|
|
138
|
+
A cycle of 17.3 steps on integer data with no frequency. It is found from the periodogram, including the fractional part.
|
|
139
|
+
|
|
140
|
+

|
|
141
|
+
|
|
142
|
+
### Missing data
|
|
143
|
+
|
|
144
|
+
15% of values missing at random plus a 25 step gap. Gaps are handled natively, with no interpolation needed.
|
|
145
|
+
|
|
146
|
+

|
|
147
|
+
|
|
148
|
+
### Outliers
|
|
149
|
+
|
|
150
|
+
Three large one-off spikes.
|
|
151
|
+
|
|
152
|
+

|
|
153
|
+
|
|
154
|
+
### Very short history
|
|
155
|
+
|
|
156
|
+
Five weeks of daily data. The yearly default period cannot be estimated from that and is dropped automatically, while the weekly cycle is kept.
|
|
157
|
+
|
|
158
|
+

|
|
159
|
+
|
|
160
|
+
### Across 100 random seeds per case
|
|
161
|
+
|
|
162
|
+
One chart per case could still be luck, so [`benchmarks/run_benchmark.py`](https://github.com/drds1/divinity/blob/master/benchmarks/run_benchmark.py) refits every scenario on 100 different seeds. *Interval coverage* is how often the true future falls inside the 95% interval, so it should be close to 95%. *Structure found* is how often the model chose the components the series was built with (for example no seasonality on noise, differencing on a random walk, period 17.3 when it is hidden). *RMSE vs naive* compares the error with simply repeating the last observed value, where below 1 is better.
|
|
163
|
+
|
|
164
|
+
| scenario | runs | 95% interval coverage | structure found | RMSE vs naive |
|
|
165
|
+
|---|---:|---:|---:|---:|
|
|
166
|
+
| `trend_seasonal` | 100 | 94.3% | 100% | 0.24 |
|
|
167
|
+
| `daily_multi_seasonal` | 100 | 95.1% | 100% | 0.32 |
|
|
168
|
+
| `monthly_multiplicative` | 100 | 94.0% | 100% | 0.19 |
|
|
169
|
+
| `white_noise` | 100 | 94.8% | 98% | 0.75 |
|
|
170
|
+
| `random_walk` | 100 | 93.8% | 94% | 1.06 |
|
|
171
|
+
| `ar1` | 100 | 91.9% | 88% | 0.83 |
|
|
172
|
+
| `missing_data` | 100 | 93.9% | 100% | 0.23 |
|
|
173
|
+
| `unknown_period` | 100 | 95.2% | 100% | 0.27 |
|
|
174
|
+
| `outliers` | 100 | 100.0% | 100% | 0.43 |
|
|
175
|
+
| `short_daily` | 100 | 92.4% | 100% | 0.38 |
|
|
176
|
+
|
|
177
|
+
How to read the weaker rows
|
|
178
|
+
|
|
179
|
+
- **`ar1` (91.9%).** Autoregressive coefficients estimated from 200 points are biased slightly towards zero, so the intervals come out a little narrow. In 12% of runs the model reads the series as a random walk or adds a small trend. Both are hard to tell apart from a strongly autocorrelated series of this length.
|
|
180
|
+
- **`random_walk` (RMSE 1.06).** For a random walk the naive forecast is the best possible point forecast, so about 1.0 is the floor. The small excess is the price of keeping a drift when one is supported, which is what makes trending series like CO₂ forecast well. What matters here is the interval, and 94% coverage means the fan is honest.
|
|
181
|
+
- **`short_daily` (92.4%).** 35 points carrying seven seasonal coefficients leaves little to estimate the noise from, even with t quantiles.
|
|
182
|
+
- **`outliers` (100%).** The spikes inflate the estimated noise, so the intervals are wider than they need to be. This errs in the safe direction.
|
|
183
|
+
|
|
184
|
+
Regenerate it with
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
python benchmarks/run_benchmark.py --seeds 100
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Practical applications
|
|
191
|
+
|
|
192
|
+
Anything measured regularly over time, where the next value matters and so does how sure you can be of it. Each example below is a few lines of code, and every chart in this section is produced by running exactly that code. The [applications notebook](https://github.com/drds1/divinity/blob/master/notebooks/applications.ipynb) goes further with each one.
|
|
193
|
+
|
|
194
|
+
Real data comes bundled with statsmodels, so nothing needs downloading. Business data is simulated (and labelled as such), because real company figures are not public.
|
|
195
|
+
|
|
196
|
+
| Application | What the forecast gives you | Data |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| [Retail demand planning](#retail-demand-planning) | how much stock to order so you rarely run out | simulated daily sales |
|
|
199
|
+
| [Capacity planning](#capacity-planning) | how many servers or staff to have on at peak | simulated hourly web traffic |
|
|
200
|
+
| [Revenue budgeting](#revenue-budgeting) | next year's monthly budget with a realistic range | simulated monthly revenue |
|
|
201
|
+
| [Environmental monitoring](#environmental-monitoring) | where a measurement is heading, despite gaps | real Mauna Loa CO₂, weekly |
|
|
202
|
+
| [Seasonal climate outlook](#seasonal-climate-outlook) | the expected seasonal pattern and its spread | real Pacific sea temperature, monthly |
|
|
203
|
+
| [Discovering cycles](#discovering-cycles) | which cycles are real, found from the data | real sunspot counts, yearly |
|
|
204
|
+
| [Anomaly detection](#anomaly-detection) | readings that the past says should not happen | simulated hourly sensor with a fault |
|
|
205
|
+
| [Financial risk ranges](#financial-risk-ranges) | a plausible range when the value itself is unpredictable | real US Treasury bill rate, quarterly |
|
|
206
|
+
| [Knowing the limits](#knowing-the-limits) | what no forecast from history alone can see | real US GDP, quarterly |
|
|
207
|
+
|
|
208
|
+
Other uses with the same shape include call centre staffing, energy demand, hospital admissions, footfall and app sign-ups.
|
|
209
|
+
|
|
210
|
+
### Retail demand planning
|
|
211
|
+
|
|
212
|
+
Order enough stock to meet demand on most days without over-ordering. The upper end of a 90% interval is the 95th percentile, so ordering that much covers demand on about 19 days in 20.
|
|
213
|
+
|
|
214
|
+
<!-- example: retail -->
|
|
215
|
+
```python
|
|
216
|
+
import divinity as dv
|
|
217
|
+
from divinity import datasets
|
|
218
|
+
|
|
219
|
+
sales = datasets.retail_sales() # simulated daily units sold
|
|
220
|
+
history, actual = sales[:-90], sales[-90:]
|
|
221
|
+
|
|
222
|
+
model = dv.Forecaster().fit(history)
|
|
223
|
+
plan = model.predict(90, interval=0.9)
|
|
224
|
+
plan["order_quantity"] = plan["upper"].round()
|
|
225
|
+
|
|
226
|
+
model.plot(plan, actual=actual, history=180, title="Retail demand, next 90 days")
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+

|
|
230
|
+
|
|
231
|
+
The model picks up the busy weekends, the summer lull and the run up to Christmas without being told about any of them.
|
|
232
|
+
|
|
233
|
+
### Capacity planning
|
|
234
|
+
|
|
235
|
+
Provision servers (or staff) for the busiest hour you can realistically expect. With `interval=0.98` the upper bound is the 99th percentile for each hour.
|
|
236
|
+
|
|
237
|
+
<!-- example: capacity -->
|
|
238
|
+
```python
|
|
239
|
+
import divinity as dv
|
|
240
|
+
from divinity import datasets
|
|
241
|
+
|
|
242
|
+
visits = datasets.web_traffic() # simulated hourly visits
|
|
243
|
+
history, actual = visits[: -7 * 24], visits[-7 * 24 :]
|
|
244
|
+
|
|
245
|
+
model = dv.Forecaster().fit(history)
|
|
246
|
+
week = model.predict(7 * 24, interval=0.98)
|
|
247
|
+
peak = week["upper"].max() # provision for this many visits an hour
|
|
248
|
+
|
|
249
|
+
model.plot(week, actual=actual, history=14 * 24, title=f"Next week, provision for {peak:,.0f} visits an hour")
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+

|
|
253
|
+
|
|
254
|
+
Daily and weekly cycles come from the hourly frequency automatically.
|
|
255
|
+
|
|
256
|
+
### Revenue budgeting
|
|
257
|
+
|
|
258
|
+
A growing business has seasonal swings that grow with it, so fit on the log scale.
|
|
259
|
+
|
|
260
|
+
<!-- example: budget -->
|
|
261
|
+
```python
|
|
262
|
+
import divinity as dv
|
|
263
|
+
from divinity import datasets
|
|
264
|
+
|
|
265
|
+
revenue = datasets.monthly_revenue() # simulated, thousands of pounds
|
|
266
|
+
history, actual = revenue[:-24], revenue[-24:]
|
|
267
|
+
|
|
268
|
+
model = dv.Forecaster(log=True).fit(history)
|
|
269
|
+
budget = model.predict(24)
|
|
270
|
+
|
|
271
|
+
model.plot(budget, actual=actual, history=72, title="Monthly revenue (£k), two year budget")
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+

|
|
275
|
+
|
|
276
|
+
The interval is asymmetric because it is symmetric on the log scale. Upside is larger than downside, as it is for real growth.
|
|
277
|
+
|
|
278
|
+
### Environmental monitoring
|
|
279
|
+
|
|
280
|
+
Weekly CO₂ readings from Mauna Loa, with weeks missing where the instruments were down. The gaps need no special handling.
|
|
281
|
+
|
|
282
|
+
<!-- example: co2 -->
|
|
283
|
+
```python
|
|
284
|
+
import divinity as dv
|
|
285
|
+
from divinity import datasets
|
|
286
|
+
|
|
287
|
+
co2 = datasets.co2() # real weekly data, with missing weeks
|
|
288
|
+
model = dv.Forecaster().fit(co2["1988":"1999"])
|
|
289
|
+
outlook = model.predict(104) # two years ahead
|
|
290
|
+
|
|
291
|
+
model.plot(outlook, actual=co2["2000":], history=260, title="Atmospheric CO₂ (ppm) at Mauna Loa")
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+

|
|
295
|
+
|
|
296
|
+
The yearly cycle is found from the weekly frequency, and the upward drift carries on through the forecast.
|
|
297
|
+
|
|
298
|
+
### Seasonal climate outlook
|
|
299
|
+
|
|
300
|
+
Monthly sea surface temperature in the El Niño region of the Pacific. The seasonal shape is predictable. El Niño and La Niña years are not, and the interval reflects that.
|
|
301
|
+
|
|
302
|
+
<!-- example: climate -->
|
|
303
|
+
```python
|
|
304
|
+
import divinity as dv
|
|
305
|
+
from divinity import datasets
|
|
306
|
+
|
|
307
|
+
sst = datasets.elnino() # real monthly sea surface temperature
|
|
308
|
+
model = dv.Forecaster().fit(sst[:"2005"])
|
|
309
|
+
outlook = model.predict(60)
|
|
310
|
+
|
|
311
|
+
model.plot(outlook, actual=sst["2006":], history=180, title="Pacific sea surface temperature (°C), five year outlook")
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+

|
|
315
|
+
|
|
316
|
+
### Discovering cycles
|
|
317
|
+
|
|
318
|
+
Yearly sunspot counts since 1700. Nobody tells the model about the solar cycle.
|
|
319
|
+
|
|
320
|
+
<!-- example: sunspots -->
|
|
321
|
+
```python
|
|
322
|
+
import divinity as dv
|
|
323
|
+
from divinity import datasets
|
|
324
|
+
|
|
325
|
+
sun = datasets.sunspots() # real yearly counts
|
|
326
|
+
model = dv.Forecaster().fit(sun[:"1979"])
|
|
327
|
+
print(model.summary())
|
|
328
|
+
|
|
329
|
+
model.plot(model.predict(29), actual=sun["1980":], history=100, title="Sunspots, cycle found from the data")
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+

|
|
333
|
+
|
|
334
|
+
The summary reports cycles of about 11 and 10 years, the well known solar cycle and its variation in length. The cycle is not perfectly regular, so the forecast drifts out of phase over several cycles, and the interval widens to allow for it. The lower bound dips below zero because the model does not know counts cannot be negative. Clip it at zero, or use `log=True` on a strictly positive series.
|
|
335
|
+
|
|
336
|
+
### Anomaly detection
|
|
337
|
+
|
|
338
|
+
Forecast what a sensor should read, then flag readings outside a 99% interval. Here a simulated temperature sensor develops a fault on its last day.
|
|
339
|
+
|
|
340
|
+
<!-- example: anomaly -->
|
|
341
|
+
```python
|
|
342
|
+
import divinity as dv
|
|
343
|
+
from divinity import datasets
|
|
344
|
+
|
|
345
|
+
readings = datasets.sensor_readings() # simulated hourly readings, fault on the last day
|
|
346
|
+
history, recent = readings[:-48], readings[-48:]
|
|
347
|
+
|
|
348
|
+
model = dv.Forecaster(interval=0.99).fit(history)
|
|
349
|
+
expected = model.predict(48)
|
|
350
|
+
alarms = recent[(recent < expected["lower"]) | (recent > expected["upper"])]
|
|
351
|
+
|
|
352
|
+
model.plot(expected, actual=recent, highlight=alarms, history=96, title=f"{len(alarms)} readings outside the expected range")
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+

|
|
356
|
+
|
|
357
|
+
All six faulty readings are flagged and nothing else is. A fixed threshold would either miss the fault (it starts small, in the warm part of the day) or fire every afternoon.
|
|
358
|
+
|
|
359
|
+
### Financial risk ranges
|
|
360
|
+
|
|
361
|
+
Interest rates and prices behave much like random walks, so no method can say where they will go. What a forecast can give is an honest range.
|
|
362
|
+
|
|
363
|
+
<!-- example: rates -->
|
|
364
|
+
```python
|
|
365
|
+
import divinity as dv
|
|
366
|
+
from divinity import datasets
|
|
367
|
+
|
|
368
|
+
rate = datasets.us_macro("tbilrate") # real 3 month Treasury bill rate (%)
|
|
369
|
+
model = dv.Forecaster().fit(rate[:"2004"])
|
|
370
|
+
|
|
371
|
+
model.plot(model.predict(19), actual=rate["2005":], history=80, title="US Treasury bill rate (%), range not prediction")
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+

|
|
375
|
+
|
|
376
|
+
The model finds no structure, says so with a flat forecast, and lets the range fan out. The 2008 cut to near zero falls inside it. The lower end goes well below zero, which a model that knows nothing about interest rates cannot rule out, so treat it as "towards zero".
|
|
377
|
+
|
|
378
|
+
### Knowing the limits
|
|
379
|
+
|
|
380
|
+
Any forecast built from a series' own history assumes the future behaves like the past. US GDP fitted up to 2006 shows what happens when it does not.
|
|
381
|
+
|
|
382
|
+
<!-- example: limits -->
|
|
383
|
+
```python
|
|
384
|
+
import divinity as dv
|
|
385
|
+
from divinity import datasets
|
|
386
|
+
|
|
387
|
+
gdp = datasets.us_macro("realgdp") # real GDP, billions of 2005 dollars
|
|
388
|
+
model = dv.Forecaster(log=True).fit(gdp[:"2006"])
|
|
389
|
+
|
|
390
|
+
model.plot(model.predict(11), actual=gdp["2007":], history=60, title="US real GDP, the 2008 crash was not in the history")
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+

|
|
394
|
+
|
|
395
|
+
The forecast is a sensible continuation of the trend and the 2008 recession falls outside it. Use forecasts for planning under normal conditions and keep the limitations below in mind.
|
|
396
|
+
|
|
397
|
+
## Options
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
dv.Forecaster(
|
|
401
|
+
seasonal_periods="auto", # "auto" (from the frequency), a list of periods, or None
|
|
402
|
+
detect_periods=True, # also look for periods in the data
|
|
403
|
+
max_detected_periods=3,
|
|
404
|
+
max_harmonics=3, # Fourier harmonics per period, more allow sharper shapes
|
|
405
|
+
trend="auto", # "auto", "linear" or None
|
|
406
|
+
arima="auto", # "auto", a (p, d, q) tuple, or None for white noise
|
|
407
|
+
log=False, # fit on the log scale, for growth with growing seasonality
|
|
408
|
+
interval=0.95, # default interval coverage
|
|
409
|
+
search_penalty=1.0, # higher is more reluctant to add seasonality, 0 is plain BIC
|
|
410
|
+
)
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Useful things on a fitted model
|
|
414
|
+
|
|
415
|
+
| | |
|
|
416
|
+
|---|---|
|
|
417
|
+
| `predict(steps, interval=None)` | forecast DataFrame (`forecast`, `lower`, `upper`) |
|
|
418
|
+
| `plot(forecast, actual=None, history=None)` | chart, returns the matplotlib Axes |
|
|
419
|
+
| `summary()` | readable description of what was chosen |
|
|
420
|
+
| `components_` | the same as a dict |
|
|
421
|
+
| `fitted_` | in-sample one step ahead predictions |
|
|
422
|
+
|
|
423
|
+
If dates are missing entirely from a `DatetimeIndex` the frequency cannot be inferred. Pass it, for example `fit(y, freq="D")`, and the gaps become missing values.
|
|
424
|
+
|
|
425
|
+
## Limitations
|
|
426
|
+
|
|
427
|
+
- Trends are linear. A curve or saturation will be extrapolated as a straight line.
|
|
428
|
+
- Level shifts and regime changes are not modelled.
|
|
429
|
+
- Outliers are not removed. They widen the interval a little instead.
|
|
430
|
+
- Seasonal shape is fixed (or proportional to the level with `log=True`).
|
|
431
|
+
- One series at a time, with no external regressors.
|
|
432
|
+
|
|
433
|
+
## Development
|
|
434
|
+
|
|
435
|
+
```bash
|
|
436
|
+
pip install -e ".[dev]"
|
|
437
|
+
pre-commit install
|
|
438
|
+
pytest -m "not slow" # fast unit and smoke tests
|
|
439
|
+
pytest # also the coverage checks over many seeds (a few minutes)
|
|
440
|
+
python benchmarks/run_benchmark.py --seeds 100 # the coverage table above
|
|
441
|
+
python docs/make_figures.py # the charts above
|
|
442
|
+
jupyter execute --inplace notebooks/*.ipynb # rerun both notebooks
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
To try the scenarios yourself, `divinity.synthetic.make(name, seed)` returns a training history, the true future and any options the scenario needs. The names are in `divinity.synthetic.SCENARIOS`.
|
|
446
|
+
|
|
447
|
+
The code is in [`divinity/`](https://github.com/drds1/divinity/tree/master/divinity/), with one module per stage: `periods.py` (candidate periods), `features.py` (trend and Fourier terms), `selection.py` (feature selection), `residuals.py` (ARIMA order) and `forecaster.py` (the `Forecaster` class tying them together). `synthetic.py` holds the test scenarios and `datasets.py` the example data used under Practical applications.
|
|
448
|
+
|
|
449
|
+
### Upgrading from 0.1
|
|
450
|
+
|
|
451
|
+
Version 0.1 was installed as `divinity`, which uses the same import name. Remove it before installing the new package, or the two will overwrite each other's files.
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
pip uninstall divinity
|
|
455
|
+
pip install divinity-forecast
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
The old `divinity.divinity(forecast_length=...)` class still works but is deprecated and now runs the new model underneath. Replace
|
|
459
|
+
|
|
460
|
+
```python
|
|
461
|
+
model = dv.divinity(forecast_length=20)
|
|
462
|
+
model.fit(y)
|
|
463
|
+
y_pred = model.predict()
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
with
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
result = dv.Forecaster().fit(y).predict(20)
|
|
470
|
+
y_pred = result["forecast"]
|
|
471
|
+
```
|