I'll review this the same way as Holt: check the algebra in Python against a direct transcription of your loop, then propose a revised file.
The additive VAR form and the concentrated-initializer trick both reproduce your loop exactly. I also found something about the forecast standard errors that I want to verify before mentioning it:
The check confirms a separate issue in
holt_add_fcse() (details below). Next I'll write the revised Winters file and compute reference numbers for it.
Now the revised hansl:
I've attached a revised
Winters.inp and a test script,
Winters_test.inp. As with Holt, I couldn't run gretl here. Instead I checked every algebraic step in Python against a direct transcription of your loop.
1. Additive case: same varsimul() trick as Holt, but a bigger payoff.-
The VAR form. The additive recursion is linear in the state (level, trend, s_t, …, s_{t−m+1}), so it is a VAR(1) of dimension m+2. It matches your loop to 1e-14.
-
Concentrating out the initializer. As in the revised Holt, the initializer is solved for exactly by OLS, so BFGS searches over (α, β, γ) only. With your original setup, BFGS searched over 3 + (m+2) parameters.
- The regressor matrix comes from a single extra
varsimul() call with transition A′ and starting value c, since row t of that output is c′A^(t−1). For monthly data this is much cheaper than the Kronecker-block approach I used for Holt.
- On aus_holidays it reaches the same SSE (13.596395) with about 60 function evaluations instead of 350.
2. The initializer isn't identified, in either case.-
Additive. Adding k to the initial level and subtracting k from every seasonal leaves all forecasts unchanged.
-
Multiplicative. Scaling level and trend by k and the seasonals by 1/k does the same.
- I confirmed both numerically: the forecast differences are about 1e-14.
-
Consequence. Your current optimizer has an exactly flat direction, and the OLS step would be singular. So the revised code makes the seasonals sum to 0 (additive) or average 1 (multiplicative), which drops one parameter with no loss.
3. Multiplicative case. This one is nonlinear, so it still needs a loop. But the loop now:
- runs on matrices rather than series;
- has no
if additive branch inside it;
- is a single filter function shared by the criterion and
Winters(), so the recursion is no longer written out twice.
4. Smaller fixes.-
Column names of W. These were only renamed to level/trend/seasonal/forecast when
verbose > 0, so
Winters_plot() failed with
verbose = 0. They're now always renamed.
-
Forecasts. The out-of-sample forecasts are computed as one vector expression.
-
New bundle members. I added
MAE_train and
limits, to match Holt.
-
Unused code. I dropped the unused variables (
optim,
h,
k,
n, the duplicate
t1).
add_virtual_series() is never called; I left it in, in case you have plans for it.
5. New: forecast standard errors for the additive case. These use the ETS(A,A,A) formula from fpp3 (Table 8.8) and add a
stderr column to
W, just as Holt does. This adds a sixth column to
W in the additive case. The multiplicative model has no closed form, so it gets no standard errors.
6. A bug in your Holt code. fpp3's variance formulas use the ETS parametrization, in which β_ets = αβ for your smoothing β.
holt_add_fcse() plugs in β directly. A Monte Carlo check confirms the correct version and shows that the current one overstates the variance badly; at h = 8 it gives 49.9 against a true 12.7. On airmiles you won't notice, because α ≈ 1 there. The fix is one line in
holt_add_fcse():
β = bh.theta[1] * bh.theta[2]
Expected results for Winters_test.inp:| case | α | β | γ | train RMSE | first forecasts |
|---|---|---|---|---|---|
| multiplicative, full sample | 0.2467 | 0.1229 | 0 | 0.410718 | 13.3079, 11.1824, 10.8305, 11.1392 |
| additive, full sample | 0.2336 | 0.1273 | 0 | 0.412256 | 12.9200, 11.1933, 10.9212, 11.1902 |
| additive, ntest = 8 | 0.2526 | 0.0954 | 0 | 0.416198 | test RMSE 0.433371, MAE 0.315077 |
For the full-sample additive case, the standard errors should start at 0.4123, 0.4263, 0.4431. γ goes to its lower bound in every case, so in the last decimal place gretl may differ slightly from the Python optimizer. If anything errors out, the newer hansl constructs are the first suspects:
mreverse(...)' on a transposed slice,
b |= ..., and assigning the slice
init[1:m+1] = q through a pointer.