Skip to content

Ets initialisation#405

Merged
config-i1 merged 20 commits into
masterfrom
ETS-initialisation
Jul 20, 2026
Merged

Ets initialisation#405
config-i1 merged 20 commits into
masterfrom
ETS-initialisation

Conversation

@config-i1

Copy link
Copy Markdown
Collaborator

What issue does this PR fix?

Fixes in the df calculation for backcasting.

Language

  • R
  • Python

AI assistance

  • AI was used in the creation of this PR

If yes — how was AI used?

Claude. Always Claude!

config-i1 and others added 20 commits July 19, 2026 22:08
Replace the length(B)+nStatesBackcasting patchwork and the fractional
dfForBack heuristic with a structural, estimate-gated df that is identical
across initialisation methods and correct for non-coprime multi-seasonal
models. Optimal df is preserved exactly for single/coprime models
(verified across ETS/ARIMA/CES/GUM/SSARIMA/om); only the intended changes
land.

- helper.R: dfInitialsETSLevelSeasonal() -- identifiable ETS level+seasonal
  initial df via inclusion-exclusion over gcd of seasonal periods (the one
  new piece of maths; single seasonal -> m, coprime -> naive, non-coprime ->
  the shared-frequency drop). Plus dfGCD (Euclid).
- adam: initials consume the same df however obtained. Under optimal they
  sit in B at the naive seasonal count, so only the ETS cross-block
  redundancy is subtracted; otherwise the identifiable count is added
  (ETS via the formula, ARIMA via initialArimaNumber, xreg initials only
  under "complete"). gradient/backcasting/complete unified.
- CES/GUM/SSARIMA/SPARMA: backcasting initial df = the structural count
  (CES own formula; GUM/SSARIMA/SPARMA sum of component lags), no
  non-coprime correction (verified rank == count). Scale always counted.
- om (Bernoulli): ETS initial df added under backcasting/complete/gradient,
  scale stays 0. omg inherits via omEstimator.
- Scale is now an estimated parameter for every loss (concentrated
  likelihood), not only likelihood -- df_scale = 1 for continuous engines,
  0 for occurrence.
- Remove calculateBackcastingDF/dfDiscounter/dfDiscounterFit and the
  dfForBack plumbing (globals/utils-adam/adamGeneral/sma). Zero refs remain.

Verified: nparam(backcasting) == nparam(optimal) == nparam(gradient) across
all engines; non-coprime multi-seasonal (e.g. lags 4,8) corrected for all
init methods incl. optimal (17->14); optimal single/coprime byte-identical.
R suite 475 pass; the 8 auto.adam baseline pins now reflect the (intended)
shifted model selection -- to be re-pinned. Python mirror + NEWS + man
pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
SMA has fixed equal 1/order weights and no estimated smoothing, so
D = F - g w' = 0: only the first fitted value depends on the initials and
the order initial states collapse to a single identifiable level (rank 1).
Correct df is 1 (level) + 1 (scale) = 2, independent of the order. The
generic adam "use" path SMA is built on hardcoded df = 1 (missing the
scale); set the SMA nParam table and logLik df explicitly in sma(), and
use df = 2 in the order-selection IC (constant, so selection is unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Earlier commit wrongly set SMA df to a constant 2. SMA is AR(order) with
fixed coefficients 1/order that sum to 1 -- a unit root -- so the model is
non-stationary and all `order` initial states persist and are identifiable:
rank(X) = order (verified by the probe for order 1/2/4/12). df = order
(backcast initials) + 1 (scale) = order + 1. This grows with the order, so
order selection now carries a proper complexity penalty (the old df = 1 had
none). Cap the searched maxOrder to obs-3 so the AICc small-sample
correction stays valid now that df can approach the sample size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Init-method invariance (nparam backcasting==optimal==gradient), non-coprime
multi-seasonal gcd correction, the dfInitialsETSLevelSeasonal helper against
known ranks, estimate-gating (provided alpha not counted), regressors='adapt'
delta parameters, scale for non-likelihood losses, om initials + no scale,
CES/GUM/SSARIMA backcasting==optimal, and sma df=order+1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Model selection shifts once backcasting initials count towards df: simpler
models win (ETS persistence 10->3; ARIMA(0,2,2) instead of (2,1,2)) and
two distribution flips (dgamma->dnorm, dnorm->dlnorm) as the IC now
penalises complexity correctly. AICc values re-pinned to the new selections;
the ARIMA test's seasonal arma[[2]] assertion dropped (non-seasonal model
now selected). All intended consequences of the structural df fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Mirror the R structural df fix. n_param.py: df_initials_ets_level_seasonal
(inclusion-exclusion over gcd). Estimator: initials count the same however
obtained (add for backcasting/complete/gradient, subtract the ETS seasonal
redundancy for optimal), ARIMA via initial_arima_number, xreg under complete;
scale counted for every loss. OM: _om_initials_df (Bernoulli, no scale);
OMG: both sides' initials so OM-vs-OMG selection is fair; CES: structural
initial count + scale.

ADAM AICc matches R bit-for-bit (optimal and backcasting). Tests updated
for the intended selection/df shifts (ETSX AIC pins, msarima AICc,
gradient df-invariance test, AutoOM fixture given real occurrence dynamics).
Python suite 738 pass, ruff/mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Additive SSOE models have residuals affine in the full initial profile, so
the one-shot least-squares gradient solve applies to ARIMA too, not only
ETS. Generalise adam_gradientProbeBasis: for additive models it adds a
slot-per-state basis over the ARIMA initial cells (each state's lag slots
are free; the rank-revealing pivoted QR drops the redundant directions),
alongside the existing ETS grouping. Thread componentsNumberARIMA and
lagsModelAll through the dispatcher.

Verified: at fixed dynamics, gradient reaches the optimal-initials SSE
exactly (ARIMA(2,1,2): 604.5721 vs optimal 604.5723) and beats backcasting
(618.12), for pure ARIMA, seasonal SARIMA and mixed ETS+ARIMA; df matches
optimal. Multiplicative ARIMA (whose Gauss-Newton Jacobian is ETS-only)
and xreg fall back to backcasting. No C++ change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
These are additive SSOE models, so the affine least-squares gradient solve
applies (residuals affine in the initial profile). Route their fit sites
(cost function + final fit) through adam_fitOrGradient with the additive
slot-per-state probe basis (etsModel=FALSE, arimaModel=TRUE, the engine's
Etype), instead of calling adamCpp$fit directly. No C++ change.

Verified: at fixed dynamics gradient matches the optimal-initials SSE
exactly (SPARMA 368.557, CES-none 367.303) and differs from backcasting
(genuinely solving). For GUM and seasonal CES where the joint optimisation
of the transition/measurement elements plus initials diverges, gradient is
markedly more robust (GUM SSE 9732 -> 368; CES full 543 -> 475).
Multiplicative CES/GUM and xreg fall back to backcasting.

R suite 516 pass; 40 gradient tests incl. a CES/GUM/SSARIMA/SPARMA case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Generalise adam_gradient_probe_basis (error_type / components_number_arima
/ lags_model_all args + ARIMA per-slot probes with the additive check) and
the adam_fit_or_gradient dispatcher so the affine least-squares solve covers
every additive single-source-of-error engine, mirroring the R side. Route
both CES fit sites (ces/cost_function.py, ces_model.py) through the
dispatcher. At fixed dynamics the gradient fit matches the optimal-initials
SSE and differs from backcasting for MSARIMA and CES; multiplicative-error
ARIMA and xreg/custom-loss still fall back to backcasting.

Update test_gradient.py: MSARIMA solves additive ARIMA initials, CES solves
additive SSOE initials (replacing the stale fall-back assertion). NEWS on
both sides documents the additive-SSOE extension and reconciles the launch
bullets. Python suite 738 passed; ruff/mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Occurrence models now solve their initials by gradient beyond pure ETS:

- om() with ARIMA and/or xreg components. The analytic Jacobian companions
  (adamGradient.h) are ETS-only, so gradientSolve now drops the occurrence
  Gauss-Newton to its finite-difference Jacobian when nArima/nXreg/constant
  are present — the FD path drives the same loop through the fully general
  adamW/F/Gvalue residual pass. The probe basis gains xreg state cells and
  (on the occurrence path) allows ARIMA/xreg; the demand path is unchanged
  (xreg and multiplicative ARIMA still fall back there).

- omg(), the coupled two-model occurrence. New adamCore::gradientSolveGeneral
  solves both sub-models' initial profiles jointly over the shared probability
  residual r = o - aFit/(aFit+bFit) by Gauss-Newton with an FD Jacobian
  (line search + Levenberg-Marquardt), covering ETS/ARIMA/xreg on either side.
  Bound for R (Rcpp) and Python (pybind). omgCF_local runs the coupled solve
  in the objective; omgFinalFit routes each reported side through the
  single-model dispatcher so its standalone initials are gradient-solved too.

Shared C++, so R and Python stay in parity. Verified at fixed dynamics the
gradient fit reaches optimal-quality likelihood and differs from backcasting
(om ARIMA/xreg; omg ETS/ARIMA/xreg). Non-gradient initials are byte-unchanged.
R gradient tests 40 -> 50; test_oes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Mirror the R-side occurrence gradient extension (shared C++, so parity is
automatic):

- Probe basis (utils/gradient.py) gains xreg state cells and o_type-aware
  gating: the occurrence path allows ARIMA/xreg (finite-difference Jacobian);
  the demand path is unchanged. adam_fit_or_gradient threads xreg_number.
- om.py / om_cost.py pass xreg_number through, so OM with ARIMA/xreg profiles
  its initials by gradient instead of falling back.
- omg_cost.py: _omg_gradient_profiles runs the coupled adamCore.gradientSolveGeneral
  over both sub-models' shared probability residual, then one forward pass from
  the solved profiles; omg_cf routes gradient through it (backcasting fallback
  out of scope / on the direct-link seed saturation).

Verified: OM ARIMA logLik -75.85 (beats backcasting), OMG ETS -69.90 (near
optimal), OMG ARIMA -63.63 (vs backcasting -71.92) — mirrors R. Three new
tests; Python suite 741 passed; ruff/mypy clean. NEWS on both sides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
omg reported fitted from the standalone per-side refits but logLik from the
coupled cost — so the reported fitted and logLik disagreed (Bernoulli of the
fitted != logLik), in both languages identically.

Fix, applied identically to both so parity is preserved: the top-level fitted
probability now comes from the same coupled recursion the optimiser minimised
(re-run the joint cost at the final B via a new returnFitted / return_fitted
flag), and logLik is the Bernoulli of that coupled probability. So
sum(o*log p + (1-o)*log(1-p)) == logLik exactly, for every loss. The
standalone modelA/modelB sub-objects are unchanged (kept for diagnostics).

- R: omgCF_local gains returnFitted; the final object's fitted/logLik derive
  from it. returnFitted is carried in nloptrArgs (so nloptr's .checkfunargs
  passes) and overridden via modifyList for the post-hoc coupled fitted.
- Python: omg_cf gains return_fitted; OMG.fit computes the coupled fitted and
  its Bernoulli logLik. The now-dead _joint_fitted fallback is removed.

For loss="likelihood" logLik/IC are unchanged; fitted/residuals change
numerically for every omg fit; fit-only losses now report a proper Bernoulli
logLik (as om() does). Verified R and Python bit-identical on shared data
(backcasting + gradient, likelihood + MSE); 32 live R-parity omg tests pass;
R gradient tests +4 consistency assertions (54); Python 741 + test_omg green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
The occurrence Gauss-Newton solve used a finite-difference Jacobian for
ARIMA/xreg models (nFree forward passes per iteration) because the analytic
companions are ETS-only. But for additive-error occurrence (E="A", the
odds-ratio/inverse-odds default) the whole state->yhat map is linear over the
full component vector, so the exact Jacobian needs one pass, not nFree.

- gradientPassJacobianOccurrenceLinear: single-model additive-error occurrence,
  raw-matrix propagation (d(yhat)=w'S, update F*S + g*dError) covering
  ARIMA/xreg/constant. Bit-identical to the companion pass for pure additive
  ETS (there jacGv=jacGy=0, jacGe=g).
- combinedJacobian in gradientSolveGeneral: coupled omg analytic Jacobian when
  both sides are additive-error -- propagates the two sides' sensitivities
  jointly through the shared probability p=aFit/(aFit+bFit), with closed-form
  dp/dfitA, dp/dfitB and the occurrenceError('g') split derivatives.

Shared C++, so R and Python stay bit-identical. Multiplicative-error occurrence
with extra components keeps FD; pure-ETS occurrence unchanged.

Controlled before/after (same data): om-ARIMA 550->353ms (1.6x), omg-ARIMA
4966->2761ms (1.8x), omg two-sided additive-ETS 1076->129ms (8.3x). The cleaner
Jacobian reaches an equal-or-slightly-better optimum, so these fits' logLik
shift marginally (e.g. omg-ARIMA -44.48 -> -43.93).

Tests: R gradient 54, oes 34; Python gradient/omg 93, full suite 741; live
R-parity omg 32 and om 114 all pass. The sharper convergence widens the flat
ARIMA-ridge coefficient spread between the R(-O0 dev) and Python(-O2) builds
from ~1e-2 to ~5e-2 while every observable still matches tightly, so test_coef
uses a coefficient-only identifiability-floor tolerance for ARIMA/xreg
scenarios (observable checks stay tight). NEWS on both sides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
Dated copy of the exponential-smoothing benchmark (M1 + M3 + Tourism,
5315 series, automatic ETS selection) re-run against the current package
versions, with aeon AutoETS added alongside statsforecast / sktime /
skforecast and smooth's own ADAM / ES.

- New aeon branch: aeon.forecasting.stats.AutoETS with iterative_forecast.
- Forced a fork multiprocessing context so the ProcessPool workers inherit
  the notebook namespace (Python 3.14 no longer defaults to fork).
- Records the full environment (python 3.14.4, numpy 2.3.5, pandas 2.3.3,
  smooth 1.0.7, statsforecast 2.1.1, sktime 1.0.1, skforecast 0.23.0,
  aeon 1.5.0) so the run is reproducible; numpy/pandas were pulled back by
  the statsforecast update and now appear in the version cell.

Results (mean RMSSE / median time, 0 failures across 14 methods, 17.3 min):
statsforecast AutoETS best accuracy (1.924); smooth ES/ADAM optimal ~1.947
close behind; aeon AutoETS the speed leader (0.023s) at 2.035. sktime
AutoETS blows up on some series (mean RMSSE ~1e5) -- a known upstream
statsmodels ETSModel instability, not a config issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
The "fitted equals pA/(pA+pB)" test asserted the old standalone per-side
combination, which the fitted/logLik consistency fix (commit 5bf9f40)
intentionally replaced: omg's fitted now comes from the coupled recursion the
optimiser minimised. The Python mirror of this test was already updated; this
is the missing R side. Reworked to the new contract -- fitted in (0,1) and its
Bernoulli equals the reported logLik exactly. test_omg.R: 110 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRdQhv3c5fgxm7GaequJtv
@config-i1
config-i1 merged commit 7aa451a into master Jul 20, 2026
10 checks passed
config-i1 added a commit that referenced this pull request Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant