lessPython 0.1.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.
- lessPy/ANOVA.py +680 -0
- lessPy/Chart.py +1055 -0
- lessPy/Correlation.py +236 -0
- lessPy/Flows.py +116 -0
- lessPy/Logit.py +615 -0
- lessPy/Prop_test.py +267 -0
- lessPy/Regression.py +1491 -0
- lessPy/VariableLabels.py +119 -0
- lessPy/X.py +426 -0
- lessPy/XY.py +2007 -0
- lessPy/__init__.py +60 -0
- lessPy/anova_rmd.py +227 -0
- lessPy/bc_plotly.py +575 -0
- lessPy/bubble_plotly.py +470 -0
- lessPy/corCFA.py +316 -0
- lessPy/corEFA.py +220 -0
- lessPy/corPrint.py +45 -0
- lessPy/corProp.py +73 -0
- lessPy/corRead.py +48 -0
- lessPy/corReflect.py +72 -0
- lessPy/corReorder.py +161 -0
- lessPy/corScree.py +87 -0
- lessPy/data/Anova_1way.csv +25 -0
- lessPy/data/Anova_2way.csv +49 -0
- lessPy/data/Anova_rb.csv +8 -0
- lessPy/data/Anova_rbf.csv +49 -0
- lessPy/data/Anova_sp.csv +57 -0
- lessPy/data/BodyMeas.csv +341 -0
- lessPy/data/Cars93.csv +94 -0
- lessPy/data/Employee.csv +38 -0
- lessPy/data/Employee_lbl.csv +9 -0
- lessPy/data/FreqTable99.csv +5 -0
- lessPy/data/Jackets.csv +1026 -0
- lessPy/data/Learn.csv +35 -0
- lessPy/data/Mach4.csv +352 -0
- lessPy/data/Mach4_lbl.csv +21 -0
- lessPy/data/Reading.csv +101 -0
- lessPy/data/StockPrice.csv +1489 -0
- lessPy/data/WeightLoss.csv +11 -0
- lessPy/datasets.py +46 -0
- lessPy/date_infer.py +112 -0
- lessPy/details.py +314 -0
- lessPy/dn_plotly.py +495 -0
- lessPy/dot_plotly.py +385 -0
- lessPy/freq_poly_plotly.py +324 -0
- lessPy/getColors.py +399 -0
- lessPy/hier_plotly.py +352 -0
- lessPy/hs_plotly.py +395 -0
- lessPy/logit_rmd.py +410 -0
- lessPy/order_by.py +94 -0
- lessPy/pie_plotly.py +292 -0
- lessPy/pivot.py +158 -0
- lessPy/plotly_utils.py +787 -0
- lessPy/plt_add.py +129 -0
- lessPy/plt_contour.py +192 -0
- lessPy/plt_contour_facet.py +194 -0
- lessPy/plt_forecast.py +677 -0
- lessPy/plt_mat_plotly.py +201 -0
- lessPy/plt_plotly.py +216 -0
- lessPy/plt_smooth.py +170 -0
- lessPy/plt_time.py +143 -0
- lessPy/prob_norm.py +111 -0
- lessPy/prob_tcut.py +131 -0
- lessPy/prob_znorm.py +110 -0
- lessPy/radar_plotly.py +201 -0
- lessPy/reg_rmd.py +754 -0
- lessPy/rename.py +33 -0
- lessPy/reshape.py +95 -0
- lessPy/showColors.py +130 -0
- lessPy/simCImean.py +165 -0
- lessPy/simCLT.py +265 -0
- lessPy/simFlips.py +104 -0
- lessPy/simMeans.py +146 -0
- lessPy/stats_out.py +189 -0
- lessPy/ttest.py +641 -0
- lessPy/utils.py +235 -0
- lessPy/vbs_plotly.py +545 -0
- lesspython-0.1.0.dist-info/METADATA +93 -0
- lesspython-0.1.0.dist-info/RECORD +82 -0
- lesspython-0.1.0.dist-info/WHEEL +5 -0
- lesspython-0.1.0.dist-info/licenses/LICENSE +338 -0
- lesspython-0.1.0.dist-info/top_level.txt +1 -0
lessPy/plt_forecast.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
# plt_forecast.py — analog of plt.forecast.R.
|
|
2
|
+
#
|
|
3
|
+
# ts_source="fable" (the default, as in R) maps fable's models
|
|
4
|
+
# to statsmodels — the plan's settled decision #3 (ETS replaces
|
|
5
|
+
# fable). ts_method="es" fits ETSModel over the component grid
|
|
6
|
+
# fable::ETS searches (error A/M; trend N/A/Ad; seasons N/A/M;
|
|
7
|
+
# Md also accepted when specified), selecting by AICc as fable
|
|
8
|
+
# does, or fixing components the caller gives via
|
|
9
|
+
# ts_error/ts_trend/ts_seasons. ts_method="lm" is the TSLM
|
|
10
|
+
# analog: OLS on trend and calendar-season dummies, with the
|
|
11
|
+
# lessR fable-branch prediction intervals (qnorm quantiles on
|
|
12
|
+
# the forecast sigma). Results track R closely but not exactly:
|
|
13
|
+
# initial-state estimation differs between statsmodels and
|
|
14
|
+
# fable.
|
|
15
|
+
#
|
|
16
|
+
# ts_source="classic":
|
|
17
|
+
# ts_method="es" ports stats::HoltWinters directly — the same
|
|
18
|
+
# initialization (decompose of the first two periods, linear fit
|
|
19
|
+
# for level/trend start), the same one-step filter recursion, and
|
|
20
|
+
# the same L-BFGS-B minimization of SSE over only the free
|
|
21
|
+
# smoothing parameters (start 0.3/0.1/0.1, bounds [0, 1]) — so
|
|
22
|
+
# results track R closely. Prediction intervals port
|
|
23
|
+
# predict.HoltWinters: qnorm quantiles on var(residuals) scaled
|
|
24
|
+
# by the psi weights.
|
|
25
|
+
#
|
|
26
|
+
# ts_method="lm" ports the stl + regression path. The stl
|
|
27
|
+
# periodic decomposition comes from statsmodels STL (a port of
|
|
28
|
+
# the same Fortran algorithm as R's stl), with R's
|
|
29
|
+
# s.window="periodic" mapped to seasonal=10n+1, seasonal_deg=0.
|
|
30
|
+
# statsmodels is imported lazily, only on this path — the
|
|
31
|
+
# Python analog of an R Suggests dependency.
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
import pandas as pd
|
|
35
|
+
from scipy import optimize as spo
|
|
36
|
+
from scipy import stats as sps
|
|
37
|
+
|
|
38
|
+
from .utils import fmt
|
|
39
|
+
|
|
40
|
+
_UNITS = ("days", "days7", "weeks", "months", "quarters", "years")
|
|
41
|
+
_FREQ = {"days": 365, "days7": 7, "weeks": 52, "months": 12,
|
|
42
|
+
"quarters": 4, "years": 1}
|
|
43
|
+
_OFFSET = {"days": pd.DateOffset(days=1),
|
|
44
|
+
"days7": pd.DateOffset(days=1),
|
|
45
|
+
"weeks": pd.DateOffset(weeks=1),
|
|
46
|
+
"months": pd.DateOffset(months=1),
|
|
47
|
+
"quarters": pd.DateOffset(months=3),
|
|
48
|
+
"years": pd.DateOffset(years=1)}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ts_unit(dates, ts_unit):
|
|
52
|
+
"""Time unit and seasonal frequency from the date spacing.
|
|
53
|
+
R analog: .tsMake() interval logic; frequencies match
|
|
54
|
+
tsMake.R (days 365, days7 7, weeks 52, months 12,
|
|
55
|
+
quarters 4, years 1)."""
|
|
56
|
+
if ts_unit is not None and ts_unit not in _UNITS:
|
|
57
|
+
raise ValueError(f"ts_unit must be one of {_UNITS}")
|
|
58
|
+
dd = np.diff(dates).astype("timedelta64[D]").astype(int)
|
|
59
|
+
if len(dd) == 0 or (dd <= 0).any():
|
|
60
|
+
raise ValueError("Cannot forecast without a consistent "
|
|
61
|
+
"time unit: need increasing dates")
|
|
62
|
+
med = float(np.median(dd))
|
|
63
|
+
if med <= 1.5:
|
|
64
|
+
unit = "days"
|
|
65
|
+
elif 6 <= med <= 8:
|
|
66
|
+
unit = "weeks"
|
|
67
|
+
elif 27 <= med <= 32:
|
|
68
|
+
unit = "months"
|
|
69
|
+
elif 88 <= med <= 93:
|
|
70
|
+
unit = "quarters"
|
|
71
|
+
elif med >= 350:
|
|
72
|
+
unit = "years"
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError("Cannot forecast without a consistent "
|
|
75
|
+
f"time unit: median spacing {med} days")
|
|
76
|
+
if ts_unit is None:
|
|
77
|
+
ts_unit = unit
|
|
78
|
+
elif ts_unit == "days7" and unit == "days":
|
|
79
|
+
pass # weekly cycle in daily data
|
|
80
|
+
elif ts_unit != unit:
|
|
81
|
+
raise ValueError(f'dates are spaced as "{unit}" but '
|
|
82
|
+
f'ts_unit="{ts_unit}"')
|
|
83
|
+
return ts_unit, _FREQ[ts_unit]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _decompose(x, f, mult):
|
|
87
|
+
"""Classical decomposition for the Holt-Winters start values.
|
|
88
|
+
R analog: stats::decompose() — centered moving-average trend,
|
|
89
|
+
seasonal figure as centered cycle-position means."""
|
|
90
|
+
n = len(x)
|
|
91
|
+
if f % 2 == 0:
|
|
92
|
+
filt = np.r_[0.5, np.ones(f - 1), 0.5] / f
|
|
93
|
+
else:
|
|
94
|
+
filt = np.ones(f) / f
|
|
95
|
+
half = len(filt) // 2
|
|
96
|
+
trend = np.full(n, np.nan)
|
|
97
|
+
conv = np.convolve(x, filt[::-1], mode="valid")
|
|
98
|
+
trend[half:half + len(conv)] = conv
|
|
99
|
+
season = x / trend if mult else x - trend
|
|
100
|
+
figure = np.empty(f)
|
|
101
|
+
for i in range(f):
|
|
102
|
+
figure[i] = np.nanmean(season[i::f])
|
|
103
|
+
figure = figure / figure.mean() if mult \
|
|
104
|
+
else figure - figure.mean()
|
|
105
|
+
return trend, figure
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _hw_filter(x, f, alpha, beta, gamma, do_trend, do_seasons,
|
|
109
|
+
mult, l0, b0, s0, start):
|
|
110
|
+
"""One-step Holt-Winters filter from observation `start`
|
|
111
|
+
(0-based). R analog: C_HoltWinters (src/library/stats)."""
|
|
112
|
+
n = len(x)
|
|
113
|
+
a, b = l0, b0 if do_trend else 0.0
|
|
114
|
+
s = list(s0) if do_seasons else []
|
|
115
|
+
xhat = np.empty(n - start)
|
|
116
|
+
sse = 0.0
|
|
117
|
+
for j, i in enumerate(range(start, n)):
|
|
118
|
+
pred = a + b
|
|
119
|
+
if do_seasons:
|
|
120
|
+
pred = pred * s[j] if mult else pred + s[j]
|
|
121
|
+
xhat[j] = pred
|
|
122
|
+
res = x[i] - pred
|
|
123
|
+
sse += res * res
|
|
124
|
+
if do_seasons:
|
|
125
|
+
desea = x[i] / s[j] if mult else x[i] - s[j]
|
|
126
|
+
anew = alpha * desea + (1 - alpha) * (a + b)
|
|
127
|
+
else:
|
|
128
|
+
anew = alpha * x[i] + (1 - alpha) * (a + b)
|
|
129
|
+
if do_trend:
|
|
130
|
+
b = beta * (anew - a) + (1 - beta) * b
|
|
131
|
+
if do_seasons:
|
|
132
|
+
s.append(gamma * ((x[i] / anew) if mult
|
|
133
|
+
else (x[i] - anew))
|
|
134
|
+
+ (1 - gamma) * s[j])
|
|
135
|
+
a = anew
|
|
136
|
+
coefs = {"a": a}
|
|
137
|
+
if do_trend:
|
|
138
|
+
coefs["b"] = b
|
|
139
|
+
if do_seasons:
|
|
140
|
+
coefs["s"] = np.array(s[-f:])
|
|
141
|
+
return xhat, sse, coefs
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _holt_winters(x, f, do_trend, do_seasons, mult,
|
|
145
|
+
alpha, beta, gamma):
|
|
146
|
+
"""stats::HoltWinters port: R start values, then minimize
|
|
147
|
+
SSE over only the free smoothing parameters, as R optim
|
|
148
|
+
(L-BFGS-B, start 0.3/0.1/0.1, bounds [0, 1])."""
|
|
149
|
+
if do_seasons:
|
|
150
|
+
if len(x) < 2 * f:
|
|
151
|
+
raise ValueError("need at least 2 periods to compute "
|
|
152
|
+
"seasonal start values")
|
|
153
|
+
start = f
|
|
154
|
+
trend, figure = _decompose(x[:2 * f], f, mult)
|
|
155
|
+
dat = trend[np.isfinite(trend)]
|
|
156
|
+
# R .lm.fit on (1..m): l.start intercept, b.start slope
|
|
157
|
+
b0, l0 = np.polyfit(np.arange(1, len(dat) + 1), dat, 1)
|
|
158
|
+
s0 = figure
|
|
159
|
+
else:
|
|
160
|
+
if do_trend:
|
|
161
|
+
l0, b0, s0, start = x[1], x[1] - x[0], None, 2
|
|
162
|
+
else:
|
|
163
|
+
l0, b0, s0, start = x[0], None, None, 1
|
|
164
|
+
|
|
165
|
+
free = [] # optimized parameters
|
|
166
|
+
if alpha is None:
|
|
167
|
+
free.append(("alpha", 0.3))
|
|
168
|
+
if do_trend and beta is None:
|
|
169
|
+
free.append(("beta", 0.1))
|
|
170
|
+
if do_seasons and gamma is None:
|
|
171
|
+
free.append(("gamma", 0.1))
|
|
172
|
+
|
|
173
|
+
def run(vals):
|
|
174
|
+
p = {"alpha": alpha, "beta": beta, "gamma": gamma}
|
|
175
|
+
p.update(dict(zip([nm for nm, _ in free], vals)))
|
|
176
|
+
return _hw_filter(x, f, p["alpha"],
|
|
177
|
+
p["beta"] if do_trend else 0.0,
|
|
178
|
+
p["gamma"] if do_seasons else 0.0,
|
|
179
|
+
do_trend, do_seasons, mult,
|
|
180
|
+
l0, b0 if do_trend else 0.0, s0, start)
|
|
181
|
+
|
|
182
|
+
if free:
|
|
183
|
+
if len(free) == 1:
|
|
184
|
+
sol = spo.minimize_scalar(
|
|
185
|
+
lambda v: run([v])[1], bounds=(0, 1),
|
|
186
|
+
method="bounded")
|
|
187
|
+
best = [sol.x]
|
|
188
|
+
else:
|
|
189
|
+
sol = spo.minimize(
|
|
190
|
+
lambda v: run(v)[1],
|
|
191
|
+
x0=[st for _, st in free], method="L-BFGS-B",
|
|
192
|
+
bounds=[(0, 1)] * len(free))
|
|
193
|
+
best = list(sol.x)
|
|
194
|
+
else:
|
|
195
|
+
best = []
|
|
196
|
+
params = {"alpha": alpha, "beta": beta, "gamma": gamma}
|
|
197
|
+
params.update(dict(zip([nm for nm, _ in free], best)))
|
|
198
|
+
if not do_trend:
|
|
199
|
+
params["beta"] = False
|
|
200
|
+
if not do_seasons:
|
|
201
|
+
params["gamma"] = False
|
|
202
|
+
xhat, sse, coefs = run(best)
|
|
203
|
+
return params, coefs, xhat, sse, start
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _hw_predict(x, xhat, start, f, params, coefs, mult,
|
|
207
|
+
do_trend, do_seasons, h, level):
|
|
208
|
+
"""Forecasts and prediction intervals.
|
|
209
|
+
R analog: predict.HoltWinters — psi weights on
|
|
210
|
+
var(residuals), qnorm quantiles."""
|
|
211
|
+
steps = np.arange(1, h + 1)
|
|
212
|
+
y_hat = np.full(h, coefs["a"])
|
|
213
|
+
if do_trend:
|
|
214
|
+
y_hat = y_hat + steps * coefs["b"]
|
|
215
|
+
if do_seasons:
|
|
216
|
+
s_rep = np.resize(coefs["s"], h)
|
|
217
|
+
y_hat = y_hat * s_rep if mult else y_hat + s_rep
|
|
218
|
+
|
|
219
|
+
resid = x[start:] - xhat
|
|
220
|
+
s2 = float(np.var(resid, ddof=1))
|
|
221
|
+
alpha = params["alpha"]
|
|
222
|
+
beta_v = params["beta"] if do_trend else 0.0
|
|
223
|
+
gamma_v = params["gamma"] if do_seasons else 0.0
|
|
224
|
+
|
|
225
|
+
def psi(j):
|
|
226
|
+
return (alpha * (1 + j * beta_v)
|
|
227
|
+
+ (j % f == 0) * gamma_v * (1 - alpha))
|
|
228
|
+
|
|
229
|
+
var_h = np.empty(h)
|
|
230
|
+
if not mult:
|
|
231
|
+
for hh in steps:
|
|
232
|
+
js = np.arange(1, hh)
|
|
233
|
+
var_h[hh - 1] = s2 * (1 + np.sum(psi(js) ** 2))
|
|
234
|
+
else:
|
|
235
|
+
# R indexes (a, b, s1..sf); (rel-j) mod f == 0 falls on b,
|
|
236
|
+
# an R quirk preserved for parity
|
|
237
|
+
cvec = np.r_[coefs["a"],
|
|
238
|
+
coefs["b"] if do_trend else np.nan,
|
|
239
|
+
coefs["s"]]
|
|
240
|
+
for hh in steps:
|
|
241
|
+
rel = 1 + (hh - 1) % f
|
|
242
|
+
tot = 0.0
|
|
243
|
+
for j in range(hh):
|
|
244
|
+
tot += (psi(j) * cvec[1 + rel]
|
|
245
|
+
/ cvec[1 + (rel - j) % f]) ** 2
|
|
246
|
+
var_h[hh - 1] = s2 * tot
|
|
247
|
+
zq = sps.norm.ppf((1 + level) / 2)
|
|
248
|
+
half = zq * np.sqrt(var_h)
|
|
249
|
+
return y_hat, y_hat - half, y_hat + half
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _stl_periodic(y, f):
|
|
253
|
+
"""Seasonal component, R stl(y, s.window="periodic"):
|
|
254
|
+
statsmodels STL with the periodic mapping s.window = 10n+1,
|
|
255
|
+
s.degree = 0. Lazy import — the Python analog of Suggests."""
|
|
256
|
+
try:
|
|
257
|
+
from statsmodels.tsa.seasonal import STL
|
|
258
|
+
except ImportError:
|
|
259
|
+
raise ImportError(
|
|
260
|
+
'ts_method="lm" with seasonality needs statsmodels: '
|
|
261
|
+
"pip install statsmodels") from None
|
|
262
|
+
res = STL(y, period=f, seasonal=10 * len(y) + 1,
|
|
263
|
+
seasonal_deg=0, robust=False).fit()
|
|
264
|
+
return np.asarray(res.seasonal), np.asarray(res.trend)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
_ETS_ERR = {"A": "add", "M": "mul"}
|
|
268
|
+
_ETS_TREND = {"N": (None, False), "A": ("add", False),
|
|
269
|
+
"M": ("mul", False), "Ad": ("add", True),
|
|
270
|
+
"Md": ("mul", True)}
|
|
271
|
+
_ETS_SEAS = {"N": None, "A": "add", "M": "mul"}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _ets_forecast(yv, f, ts_error, ts_trend, ts_seasons,
|
|
275
|
+
alpha, beta, gamma, h, level, digits_d):
|
|
276
|
+
"""fable::ETS analog on statsmodels ETSModel: fixed
|
|
277
|
+
components when specified, otherwise AICc selection over the
|
|
278
|
+
grid fable searches. R analog: plt.forecast.R fable es."""
|
|
279
|
+
try:
|
|
280
|
+
from statsmodels.tsa.exponential_smoothing.ets import (
|
|
281
|
+
ETSModel)
|
|
282
|
+
except ImportError:
|
|
283
|
+
raise ImportError(
|
|
284
|
+
'ts_source="fable" needs statsmodels: '
|
|
285
|
+
"pip install statsmodels") from None
|
|
286
|
+
|
|
287
|
+
errs = [ts_error] if ts_error else ["A", "M"]
|
|
288
|
+
trds = [ts_trend] if ts_trend else ["N", "A", "Ad"]
|
|
289
|
+
seas_ok = f > 1 and len(yv) >= 2 * f
|
|
290
|
+
seas = ([ts_seasons] if ts_seasons
|
|
291
|
+
else (["N", "A", "M"] if seas_ok else ["N"]))
|
|
292
|
+
pos = bool((yv > 0).all())
|
|
293
|
+
|
|
294
|
+
y_ser = pd.Series(np.asarray(yv, float))
|
|
295
|
+
best = None
|
|
296
|
+
for e in errs:
|
|
297
|
+
for t in trds:
|
|
298
|
+
for s in seas:
|
|
299
|
+
if (e == "M" or s == "M") and not pos:
|
|
300
|
+
continue
|
|
301
|
+
trend, damped = _ETS_TREND[t]
|
|
302
|
+
try:
|
|
303
|
+
m = ETSModel(
|
|
304
|
+
y_ser, error=_ETS_ERR[e], trend=trend,
|
|
305
|
+
damped_trend=damped,
|
|
306
|
+
seasonal=_ETS_SEAS[s],
|
|
307
|
+
seasonal_periods=(f if _ETS_SEAS[s]
|
|
308
|
+
else None))
|
|
309
|
+
fixes = {}
|
|
310
|
+
if alpha is not None:
|
|
311
|
+
fixes["smoothing_level"] = alpha
|
|
312
|
+
if beta is not None and trend:
|
|
313
|
+
fixes["smoothing_trend"] = beta
|
|
314
|
+
if gamma is not None and _ETS_SEAS[s]:
|
|
315
|
+
fixes["smoothing_seasonal"] = gamma
|
|
316
|
+
if fixes:
|
|
317
|
+
with m.fix_params(fixes):
|
|
318
|
+
res = m.fit(disp=False)
|
|
319
|
+
else:
|
|
320
|
+
res = m.fit(disp=False)
|
|
321
|
+
except Exception:
|
|
322
|
+
continue
|
|
323
|
+
if np.isfinite(res.aicc) and (
|
|
324
|
+
best is None or res.aicc < best[0]):
|
|
325
|
+
best = (res.aicc, e, t, s, res)
|
|
326
|
+
if best is None:
|
|
327
|
+
raise ValueError(
|
|
328
|
+
"Model fitting failed. Simplify the model such as "
|
|
329
|
+
'ts_trend="N" and ts_seasons="N", or use more data.')
|
|
330
|
+
_, e, t, s, res = best
|
|
331
|
+
|
|
332
|
+
n = len(yv)
|
|
333
|
+
y_fit = res.fittedvalues.to_numpy()
|
|
334
|
+
pred = res.get_prediction(start=n, end=n + h - 1)
|
|
335
|
+
y_hat = np.asarray(pred.predicted_mean)
|
|
336
|
+
# PI as lessR builds it from the fable distribution:
|
|
337
|
+
# mean +/- qnorm((1+PI)/2) * sigma (plt.forecast.R ~330)
|
|
338
|
+
sigma = np.sqrt(np.asarray(pred.forecast_variance))
|
|
339
|
+
zq = sps.norm.ppf((1 + level) / 2)
|
|
340
|
+
y_lwr = y_hat - zq * sigma
|
|
341
|
+
y_upr = y_hat + zq * sigma
|
|
342
|
+
|
|
343
|
+
mse = float(np.mean((yv - y_fit) ** 2))
|
|
344
|
+
nd = max(digits_d - 1, 3)
|
|
345
|
+
report = ["[ETS from statsmodels; standard reference: "
|
|
346
|
+
"https://otexts.com/fpp3/]", "",
|
|
347
|
+
"Estimated model\n---------------",
|
|
348
|
+
f"ETS({e},{t},{s})", "",
|
|
349
|
+
"Smoothing Parameters"]
|
|
350
|
+
tx = " alpha: " + fmt(res.smoothing_level, nd)
|
|
351
|
+
if _ETS_TREND[t][0]:
|
|
352
|
+
tx += " beta: " + fmt(res.smoothing_trend, nd)
|
|
353
|
+
if _ETS_TREND[t][1]:
|
|
354
|
+
tx += " phi: " + fmt(res.damping_trend, nd)
|
|
355
|
+
if _ETS_SEAS[s]:
|
|
356
|
+
tx += " gamma: " + fmt(res.smoothing_seasonal, nd)
|
|
357
|
+
report += [tx, "",
|
|
358
|
+
"AIC: " + fmt(res.aic, 2)
|
|
359
|
+
+ " AICc: " + fmt(res.aicc, 2)
|
|
360
|
+
+ " BIC: " + fmt(res.bic, 2), "",
|
|
361
|
+
"Mean squared error of fit to data: "
|
|
362
|
+
+ fmt(mse, 3),
|
|
363
|
+
"Root mean squared error (RMSE) fit: "
|
|
364
|
+
+ fmt(np.sqrt(mse), 3), ""]
|
|
365
|
+
return y_fit, y_hat, y_lwr, y_upr, report
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _season_pos(dates, f, ts_unit):
|
|
369
|
+
"""Calendar season of each date for the TSLM dummies:
|
|
370
|
+
month or quarter number, else position in the cycle."""
|
|
371
|
+
dd = pd.DatetimeIndex(dates)
|
|
372
|
+
if ts_unit == "months":
|
|
373
|
+
return dd.month.to_numpy()
|
|
374
|
+
if ts_unit == "quarters":
|
|
375
|
+
return dd.quarter.to_numpy()
|
|
376
|
+
return (np.arange(len(dd)) % f) + 1
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _tslm_forecast(x_dates, yv, f, ts_unit, ts_trend,
|
|
380
|
+
ts_seasons, x_hat, level, digits_d):
|
|
381
|
+
"""fable::TSLM analog: OLS on trend and calendar-season
|
|
382
|
+
dummies; prediction intervals as in the lessR fable branch
|
|
383
|
+
(qnorm quantiles on the forecast sigma).
|
|
384
|
+
R analog: plt.forecast.R fable lm."""
|
|
385
|
+
n = len(yv)
|
|
386
|
+
h = len(x_hat)
|
|
387
|
+
do_trend = ts_trend == "A"
|
|
388
|
+
do_seasons = ts_seasons == "A"
|
|
389
|
+
|
|
390
|
+
cols, names = [np.ones(n)], ["(Intercept)"]
|
|
391
|
+
fcols = [np.ones(h)]
|
|
392
|
+
if do_trend:
|
|
393
|
+
cols.append(np.arange(1, n + 1, dtype=float))
|
|
394
|
+
fcols.append(np.arange(n + 1, n + h + 1, dtype=float))
|
|
395
|
+
names.append("trend")
|
|
396
|
+
if do_seasons:
|
|
397
|
+
p_now = _season_pos(x_dates, f, ts_unit)
|
|
398
|
+
if ts_unit in ("months", "quarters"):
|
|
399
|
+
p_new = _season_pos(x_hat, f, ts_unit)
|
|
400
|
+
else:
|
|
401
|
+
p_new = ((n + np.arange(h)) % f) + 1
|
|
402
|
+
unit_lbl = ts_unit[:-1] if ts_unit.endswith("s") \
|
|
403
|
+
else ts_unit
|
|
404
|
+
for k in range(2, f + 1):
|
|
405
|
+
cols.append((p_now == k).astype(float))
|
|
406
|
+
fcols.append((p_new == k).astype(float))
|
|
407
|
+
names.append(f"{unit_lbl}{k}")
|
|
408
|
+
X = np.column_stack(cols)
|
|
409
|
+
Xf = np.column_stack(fcols)
|
|
410
|
+
|
|
411
|
+
coef, *_ = np.linalg.lstsq(X, yv, rcond=None)
|
|
412
|
+
y_fit = X @ coef
|
|
413
|
+
resid = yv - y_fit
|
|
414
|
+
p = X.shape[1]
|
|
415
|
+
sse = float(resid @ resid)
|
|
416
|
+
sigma2 = sse / (n - p)
|
|
417
|
+
xtx_inv = np.linalg.pinv(X.T @ X)
|
|
418
|
+
|
|
419
|
+
y_hat = Xf @ coef
|
|
420
|
+
se_pred = np.sqrt(sigma2
|
|
421
|
+
* (1 + np.einsum("ij,jk,ik->i",
|
|
422
|
+
Xf, xtx_inv, Xf)))
|
|
423
|
+
zq = sps.norm.ppf((1 + level) / 2)
|
|
424
|
+
y_lwr = y_hat - zq * se_pred
|
|
425
|
+
y_upr = y_hat + zq * se_pred
|
|
426
|
+
|
|
427
|
+
# coefficient table, as the lessR fable lm report
|
|
428
|
+
se_b = np.sqrt(np.diag(xtx_inv) * sigma2)
|
|
429
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
430
|
+
tvals = coef / se_b
|
|
431
|
+
pvals = 2 * sps.t.sf(np.abs(tvals), n - p)
|
|
432
|
+
report = ["Estimated model\n---------------",
|
|
433
|
+
"Model: TSLM",
|
|
434
|
+
"", "Coefficients:"]
|
|
435
|
+
w = max(len(nm) for nm in names)
|
|
436
|
+
report.append(f"{'':{w}} {'Estimate':>12} {'Std.Err':>12}"
|
|
437
|
+
f" {'t-value':>9} {'p-value':>8}")
|
|
438
|
+
for i, nm in enumerate(names):
|
|
439
|
+
report.append(
|
|
440
|
+
f"{nm:{w}} {fmt(coef[i], digits_d):>12}"
|
|
441
|
+
f" {fmt(se_b[i], digits_d):>12}"
|
|
442
|
+
f" {fmt(tvals[i], 3):>9}"
|
|
443
|
+
f" {fmt(pvals[i], 4):>8}")
|
|
444
|
+
tss = float(((yv - yv.mean()) ** 2).sum())
|
|
445
|
+
if p > 1 and tss > 0:
|
|
446
|
+
r2 = 1 - sse / tss
|
|
447
|
+
adj = 1 - (1 - r2) * (n - 1) / (n - p)
|
|
448
|
+
# a (numerically) perfect fit gives r2 == 1 exactly, so
|
|
449
|
+
# 1 - r2 underflows to 0: F is infinite, as R reports
|
|
450
|
+
if r2 >= 1:
|
|
451
|
+
fstat, fp = np.inf, 0.0
|
|
452
|
+
else:
|
|
453
|
+
fstat = (r2 / (p - 1)) / ((1 - r2) / (n - p))
|
|
454
|
+
fp = sps.f.sf(fstat, p - 1, n - p)
|
|
455
|
+
report += ["",
|
|
456
|
+
f"Residual standard error: "
|
|
457
|
+
f"{fmt(np.sqrt(sigma2), 3)} on {n - p} "
|
|
458
|
+
"degrees of freedom",
|
|
459
|
+
f"Multiple R-squared: {fmt(r2, 4)},"
|
|
460
|
+
f"\tAdjusted R-squared: {fmt(adj, 4)}",
|
|
461
|
+
f"F-statistic: {fmt(fstat, 2)} on {p - 1} "
|
|
462
|
+
f"and {n - p} DF, p-value: {fmt(fp, 5)}"]
|
|
463
|
+
else:
|
|
464
|
+
report += ["", "[consider adding terms: ts_trend and "
|
|
465
|
+
"ts_seasons]"]
|
|
466
|
+
mse = sse / n
|
|
467
|
+
report += ["",
|
|
468
|
+
"Mean squared error of fit to data: "
|
|
469
|
+
+ fmt(mse, 3),
|
|
470
|
+
"Root mean squared error (RMSE) fit: "
|
|
471
|
+
+ fmt(np.sqrt(mse), 3), ""]
|
|
472
|
+
return y_fit, y_hat, y_lwr, y_upr, report
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def plt_forecast(x_dates, yv, x_name, y_name,
|
|
476
|
+
ts_unit=None, ts_ahead=0, ts_method="es",
|
|
477
|
+
ts_source="fable", ts_error=None,
|
|
478
|
+
ts_trend=None, ts_seasons=None, ts_alpha=None,
|
|
479
|
+
ts_beta=None, ts_gamma=None, ts_PI=0.95,
|
|
480
|
+
digits_d=None):
|
|
481
|
+
"""Classic-path time series forecast: fitted values, forecasts
|
|
482
|
+
with prediction intervals, and the console report lines.
|
|
483
|
+
R analog: .plt.forecast(), ts_source="classic" branch."""
|
|
484
|
+
if digits_d is None:
|
|
485
|
+
digits_d = 2
|
|
486
|
+
ts_unit, f = _ts_unit(x_dates, ts_unit)
|
|
487
|
+
|
|
488
|
+
# seasonality eligibility (plt.forecast.R lines 36-44)
|
|
489
|
+
if ts_seasons is not None and ts_seasons != "N":
|
|
490
|
+
if ts_unit == "years":
|
|
491
|
+
print("Seasonal effects are not possible with "
|
|
492
|
+
"annual data.")
|
|
493
|
+
ts_seasons = "N"
|
|
494
|
+
elif len(yv) < 2 * f:
|
|
495
|
+
print("\nUsually need two years worth of data to "
|
|
496
|
+
"estimate seasonality.\n")
|
|
497
|
+
ts_seasons = "N"
|
|
498
|
+
|
|
499
|
+
# forecast dates, needed by the TSLM dummies
|
|
500
|
+
last = pd.Timestamp(x_dates[-1])
|
|
501
|
+
off = _OFFSET[ts_unit]
|
|
502
|
+
x_hat = pd.DatetimeIndex([last + k * off
|
|
503
|
+
for k in range(1, ts_ahead + 1)])
|
|
504
|
+
|
|
505
|
+
report = []
|
|
506
|
+
coef_nm = None # classic report tail flag
|
|
507
|
+
|
|
508
|
+
# fable-equivalent: statsmodels ETS / TSLM analog
|
|
509
|
+
if ts_source == "fable":
|
|
510
|
+
if ts_method == "es":
|
|
511
|
+
if ts_error is not None and ts_error not in _ETS_ERR:
|
|
512
|
+
raise ValueError('ts_error: "A" or "M"')
|
|
513
|
+
if ts_trend is not None and \
|
|
514
|
+
ts_trend not in _ETS_TREND:
|
|
515
|
+
raise ValueError(
|
|
516
|
+
'ts_trend: "N", "A", "M", "Ad", or "Md"')
|
|
517
|
+
if ts_seasons is not None and \
|
|
518
|
+
ts_seasons not in _ETS_SEAS:
|
|
519
|
+
raise ValueError('ts_seasons: "N", "A", or "M"')
|
|
520
|
+
y_fit, y_hat, y_lwr, y_upr, rep = _ets_forecast(
|
|
521
|
+
yv, f, ts_error, ts_trend, ts_seasons,
|
|
522
|
+
ts_alpha, ts_beta, ts_gamma, ts_ahead, ts_PI,
|
|
523
|
+
digits_d)
|
|
524
|
+
elif ts_method == "lm":
|
|
525
|
+
if ts_trend is not None and \
|
|
526
|
+
ts_trend not in ("A", "N"):
|
|
527
|
+
raise ValueError('Enter either "A" for additive '
|
|
528
|
+
'trend or "N" for no trend.')
|
|
529
|
+
if ts_seasons is not None and \
|
|
530
|
+
ts_seasons not in ("A", "N"):
|
|
531
|
+
raise ValueError(
|
|
532
|
+
'Enter either "A" for additive seasons or '
|
|
533
|
+
'"N" for no seasons.')
|
|
534
|
+
y_fit, y_hat, y_lwr, y_upr, rep = _tslm_forecast(
|
|
535
|
+
x_dates, yv, f, ts_unit, ts_trend, ts_seasons,
|
|
536
|
+
x_hat, ts_PI, digits_d)
|
|
537
|
+
else:
|
|
538
|
+
raise ValueError('ts_method must be "es" or "lm"')
|
|
539
|
+
report += rep
|
|
540
|
+
x_fit = x_dates
|
|
541
|
+
|
|
542
|
+
elif ts_source != "classic":
|
|
543
|
+
raise ValueError('ts_source: "fable" or "classic"')
|
|
544
|
+
|
|
545
|
+
# es: Holt-Winters (plt.forecast.R classic es branch)
|
|
546
|
+
elif ts_method == "es":
|
|
547
|
+
if ts_trend is not None and ts_trend not in ("A", "N"):
|
|
548
|
+
raise ValueError(
|
|
549
|
+
'HoltWinters() only supports "A" for additive '
|
|
550
|
+
'trend or "N" for no trend.')
|
|
551
|
+
if ts_seasons is not None and \
|
|
552
|
+
ts_seasons not in ("A", "M", "N"):
|
|
553
|
+
raise ValueError(
|
|
554
|
+
'HoltWinters() only supports "A" for additive, '
|
|
555
|
+
'"M" for multiplicative, or "N" for no '
|
|
556
|
+
"seasonality.")
|
|
557
|
+
do_trend = ts_trend == "A"
|
|
558
|
+
do_seasons = ts_seasons in ("A", "M")
|
|
559
|
+
mult = ts_seasons == "M"
|
|
560
|
+
|
|
561
|
+
params, coefs, xhat, sse, start = _holt_winters(
|
|
562
|
+
yv, f, do_trend, do_seasons, mult,
|
|
563
|
+
ts_alpha, ts_beta if do_trend else None,
|
|
564
|
+
ts_gamma if do_seasons else None)
|
|
565
|
+
x_fit = x_dates[start:]
|
|
566
|
+
y_fit = xhat
|
|
567
|
+
y_hat, y_lwr, y_upr = _hw_predict(
|
|
568
|
+
yv, xhat, start, f, params, coefs, mult,
|
|
569
|
+
do_trend, do_seasons, ts_ahead, ts_PI)
|
|
570
|
+
|
|
571
|
+
n_param = 1 + do_trend + do_seasons
|
|
572
|
+
mse = sse / (len(x_fit) - n_param)
|
|
573
|
+
|
|
574
|
+
nd = max(digits_d - 1, 3)
|
|
575
|
+
tx = " alpha: " + fmt(params["alpha"], nd)
|
|
576
|
+
if do_trend:
|
|
577
|
+
tx += " beta: " + fmt(params["beta"], nd)
|
|
578
|
+
if do_seasons:
|
|
579
|
+
tx += " gamma: " + fmt(params["gamma"], nd)
|
|
580
|
+
report += ["Smoothing Parameters", tx, ""]
|
|
581
|
+
coef_v = np.r_[coefs["a"],
|
|
582
|
+
coefs["b"] if do_trend else [],
|
|
583
|
+
coefs["s"] if do_seasons else []]
|
|
584
|
+
coef_nm = (["b0"] + (["b1"] if do_trend else [])
|
|
585
|
+
+ ([f"s{i+1}" for i in range(f)]
|
|
586
|
+
if do_seasons else []))
|
|
587
|
+
|
|
588
|
+
# lm: regression on (usually) deseasonalized data
|
|
589
|
+
elif ts_method == "lm":
|
|
590
|
+
if ts_trend is not None and ts_trend not in ("A", "N"):
|
|
591
|
+
raise ValueError('Enter either "A" for additive '
|
|
592
|
+
'trend or "N" for no trend.')
|
|
593
|
+
if ts_seasons is not None and ts_seasons not in ("A", "N"):
|
|
594
|
+
raise ValueError('Enter either "A" for additive '
|
|
595
|
+
'seasons or "N" for no seasons.')
|
|
596
|
+
do_trend = ts_trend == "A"
|
|
597
|
+
do_seasons = ts_seasons == "A"
|
|
598
|
+
n = len(yv)
|
|
599
|
+
|
|
600
|
+
if do_seasons:
|
|
601
|
+
seas, stl_trend = _stl_periodic(yv, f)
|
|
602
|
+
# R: trend+remainder, or remainder + mean(y)
|
|
603
|
+
y_trend = yv - seas if do_trend \
|
|
604
|
+
else (yv - seas - stl_trend) + yv.mean()
|
|
605
|
+
else:
|
|
606
|
+
seas = np.zeros(n)
|
|
607
|
+
y_trend = yv if do_trend \
|
|
608
|
+
else np.full(n, yv.mean())
|
|
609
|
+
|
|
610
|
+
x_seq = np.arange(1, n + 1)
|
|
611
|
+
b1, b0 = np.polyfit(x_seq, y_trend, 1)
|
|
612
|
+
y_fit = (b0 + b1 * x_seq) + seas
|
|
613
|
+
x_fit = x_dates
|
|
614
|
+
|
|
615
|
+
sse = float(np.sum((yv - y_fit) ** 2))
|
|
616
|
+
n_param = 2 + (f if do_seasons else 0)
|
|
617
|
+
mse = sse / (n - n_param)
|
|
618
|
+
|
|
619
|
+
# seasonal indices of the forecast (plt.forecast.R
|
|
620
|
+
# lines 512-529): continue the cycle past the data
|
|
621
|
+
new_seq = np.arange(n + 1, n + ts_ahead + 1)
|
|
622
|
+
if do_seasons:
|
|
623
|
+
start_ind = (n % f) # 0-based next position
|
|
624
|
+
new_ind = (start_ind + np.arange(ts_ahead)) % f
|
|
625
|
+
new_seas = seas[:f][new_ind]
|
|
626
|
+
else:
|
|
627
|
+
new_seas = np.zeros(ts_ahead)
|
|
628
|
+
y_hat = (b0 + b1 * new_seq) + new_seas
|
|
629
|
+
|
|
630
|
+
x_mean = x_seq.mean()
|
|
631
|
+
sxx = np.sum((x_seq - x_mean) ** 2)
|
|
632
|
+
se_fore = np.sqrt(mse * (1 + 1 / n
|
|
633
|
+
+ (new_seq - x_mean) ** 2 / sxx))
|
|
634
|
+
tq = sps.t.ppf((1 + ts_PI) / 2, n - n_param)
|
|
635
|
+
y_lwr = y_hat - tq * se_fore
|
|
636
|
+
y_upr = y_hat + tq * se_fore
|
|
637
|
+
|
|
638
|
+
coef_v = np.r_[b0, b1, seas[:f] if do_seasons else []]
|
|
639
|
+
coef_nm = ["b0", "b1"] + ([f"s{i+1}" for i in range(f)]
|
|
640
|
+
if do_seasons else [])
|
|
641
|
+
else:
|
|
642
|
+
raise ValueError('ts_method must be "es" or "lm"')
|
|
643
|
+
|
|
644
|
+
if coef_nm is not None: # classic report tail
|
|
645
|
+
report += ["Mean squared error of fit to data: "
|
|
646
|
+
+ fmt(mse, digits_d + 1), ""]
|
|
647
|
+
ttl = "Coefficients for Linear Trend"
|
|
648
|
+
if ts_seasons not in (None, "N"):
|
|
649
|
+
ttl += " and Seasonality"
|
|
650
|
+
report.append(ttl)
|
|
651
|
+
line = ""
|
|
652
|
+
for nm, v in zip(coef_nm, coef_v):
|
|
653
|
+
if nm == "s1":
|
|
654
|
+
report.append(" " + line)
|
|
655
|
+
line = ""
|
|
656
|
+
line += f"{nm}: {fmt(v, digits_d + 1)} "
|
|
657
|
+
report.append(" " + line)
|
|
658
|
+
|
|
659
|
+
# forecast output data frame
|
|
660
|
+
dfmt = {"months": "%b %Y", "years": "%Y"}.get(ts_unit)
|
|
661
|
+
if ts_unit == "quarters":
|
|
662
|
+
dates_out = [f"{d.year} Q{d.quarter}" for d in x_hat]
|
|
663
|
+
elif dfmt:
|
|
664
|
+
dates_out = x_hat.strftime(dfmt)
|
|
665
|
+
else:
|
|
666
|
+
dates_out = x_hat.strftime("%Y-%m-%d")
|
|
667
|
+
forecast = pd.DataFrame({
|
|
668
|
+
x_name: dates_out,
|
|
669
|
+
"predicted": y_hat,
|
|
670
|
+
"lower": y_lwr,
|
|
671
|
+
"upper": y_upr,
|
|
672
|
+
"width": y_upr - y_lwr})
|
|
673
|
+
|
|
674
|
+
return {"x_fit": x_fit, "y_fit": y_fit,
|
|
675
|
+
"x_hat": x_hat, "y_hat": y_hat,
|
|
676
|
+
"y_lwr": y_lwr, "y_upr": y_upr,
|
|
677
|
+
"forecast": forecast, "report": report}
|