feat(models): ContinuousESN - #456
Conversation
|
Thanks @MartinuzziFrancesco — both the §3.2.6 pointer and the "skip the struct, run it raw" suggestion turned out to land in interesting places. On §3.2.6 (Lukoševičius 2012, "Leaking Rate")The relevant ODE is eq (5): — no This reframes PR3 quite a bit. The Anantharaman CTESN paper uses Smoke test — eq (5) raw on Lorenz, no CTESN structRan the tutorial pattern scaled to First pass, train NRMSE = 0.0004 (readout fits beautifully) but autoregressive rollout NRMSE was ~1.5 at all horizons. Tracked it down to the autoregressive current_state = res.prob.u0 # → zeros(N) every timeThe trained reservoir's terminal state never makes it into the predict-time
So eq (5) forecasts Lorenz at N=300 to ~3 Lyapunov times under NRMSE ≈ 0.11 before chaotic divergence dominates. Not quite our PR3 target of NRMSE < 0.1, but within striking distance of it before any hyperparameter tuning (spectral radius vs spectral norm, leak/time-constant, washout length, regularisation). I think the < 0.1 target is reachable. What I'd like your read on:
Will hold off on writing real code until you've had a chance to push back on any of this — easier to redo the design now than after I've committed 600 lines. |
|
Thanks for running the experiments, they are quite interesting and I think necessary even for merging #450. To address you point number 1) I think having the two code snippets side to side would help in understanding if this is something that could be a gotcha for the user, and therefore if we should find a way to deal with it in a elegant manner. As far as 2), I think we can go with |
|
Thanks @MartinuzziFrancesco. Re-ran both paths from scratch for the side-by-side, and lining up the other two answers below. Cold vs warmed predict — side-by-sideBoth scripts are identical except for the warmup step. Reproducible numbers on Julia 1.12.5 with
|
| Horizon | Cold (u0 = 0) |
Warmed (u0 = trained terminal) |
|---|---|---|
| 1 t_λ (55 steps) | 1.4893 | 0.1568 |
| 2 t_λ (110 steps) | 1.4960 | 0.1183 |
| 3 t_λ (166 steps) | 1.7431 | 0.1118 |
| 4 t_λ (221 steps) | 1.6959 | 0.2099 |
| 6 t_λ (331 steps) | 1.6444 | 0.7294 |
| 8 t_λ (442 steps) | 1.4708 | 0.9808 |
Train NRMSE (teacher-forced one-step on training data) was 0.0004 in both cases — the readout fits cleanly; the cold path's failure is purely the predict-time u0.
Two small caveats on what I ran:
- Eq (5) bundles bias as the leading column of
W_in[1;u]; my script splits it into a separate+bterm — algebraically identical, just different parameter layout. warm_u0 = trained_states[:, end]is the post-modifier state if anystate_modifiersare wired up. None here, so it equals the raw reservoir terminal. If a real warmup API were to expose this, "warm from the raw reservoir terminal" and "warm from the modified terminal" would need to be a deliberate choice.
If you read this as a real footgun, the lightest-touch fix I can see is a warmup_data::Union{Nothing, AbstractMatrix} = nothing kwarg on the SciMLProblemReservoir paths of predict, where a non-nothing value triggers collectstates(rc, warmup_data, ps, st) internally and seeds u0 from the last column. No behaviour change for users who don't pass it. Happy to follow your lead on whether this lands in #450 or in PR3's wrapper.
On the naming
ContinuousESN works for me. I'll rename the stub on add-ctesn → new branch + new file + re-titled PR. The Anantharaman parametric-surrogate flavour can be its own type in its own PR if anyone needs it.
On validation via Grassberger-Procaccia
Will pull in FractalDimensions.jl (v1.9.6 as of writing). Plan is grassberger_proccacia_dim(StateSpaceSet(...)) on both the true Lorenz attractor and the warmed-rollout attractor, then assert the predicted dimension matches reference (Lorenz '63 correlation dimension is reported at ≈ 2.05 in the literature) to some agreed tolerance, alongside the attractor-reconstruction eye-test from the README. I won't quote actual fractal-dim numbers from my run until I've actually computed them — flagging the plan rather than the result.
Francesco's call on SciML#456: drop the CTESN name (which referred to the Anantharaman 2021 parametric-surrogate paper) and ship instead a thin wrapper around the Lukoševičius 2012 §3.2.6 eq (5) leaky-integrator continuous ESN. Stub file moves from `src/extensions/` to `src/models/` to sit alongside ESN as a semantic sibling; docstring rewritten to reflect the new scope (leaky-integrator default, parametric-surrogate CTESN explicitly out of scope).
Skeleton-only placeholder so the draft PR exists as a discussion vehicle. No include, no export, no tests — the body errors with a clear message directing the user to the (not-yet-implemented) extension constructor. Open design questions are tracked in the PR description. Stacked on PR SciML#450; will be rebased onto master once that merges.
Francesco's call on SciML#456: drop the CTESN name (which referred to the Anantharaman 2021 parametric-surrogate paper) and ship instead a thin wrapper around the Lukoševičius 2012 §3.2.6 eq (5) leaky-integrator continuous ESN. Stub file moves from `src/extensions/` to `src/models/` to sit alongside ESN as a semantic sibling; docstring rewritten to reflect the new scope (leaky-integrator default, parametric-surrogate CTESN explicitly out of scope).
Prior commit captured only the file rename — the docstring content update was on disk but not staged. This commit folds in the actual docstring rewrite: references Lukoševičius 2012 §3.2.6 eq (5) as the canonical ODE, explains how the discrete leak rate α maps to the continuous Euler step, and explicitly distinguishes from the Anantharaman 2021 parametric-surrogate CTESN.
`ContinuousESN <: AbstractSciMLProblemReservoir`. Thin wrapper that
pre-bakes the Lukoševičius 2012 §3.2.6 eq (5) ODE
ẋ(t) = α · (-x(t) + tanh(W_in·u(t) + W_r·x(t) + b))
with `leak_coefficient = α`. Forward-Euler discretisation at Δt = 1
collapses to the discrete leaky ESN exactly. Struct lives in
src/models/, real constructor + in-place RHS (mul!, fused tanh
broadcast, compile-time bias branch) in `RCODEReservoirExt`.
Validates `tspan`, dim positivity, leak positivity, finite endpoints,
length-2 tspan, and Inf-on-cast at setup. Test suite covers shape,
construction errors, bias toggle, bias under Tsit5, forward
determinism, Euler equivalence vs discrete leaky ESN at α ∈ {1.0, 0.3},
both predict modes, modifier composition, and T-kwarg propagation —
43 assertions across 10 testsets.
Tutorial added under docs/src/tutorials/continuous_esn.md (Lorenz
forecasting walkthrough). Adds Lukoševičius 2012 and Anantharaman 2021
to refs.bib. Drops the stale PR3/PR5 stub comment in PR2's
sciml_reservoir.jl now that the first concrete subtype has landed.
| ```@docs | ||
| AbstractSciMLProblemReservoir | ||
| SciMLProblemReservoir | ||
| ContinuousESN |
There was a problem hiding this comment.
this can go on its own page since it's a model
| ```julia | ||
| using ReservoirComputing | ||
| using SciMLBase | ||
| using DataInterpolations | ||
| using OrdinaryDiffEqTsit5 | ||
| ``` |
There was a problem hiding this comment.
not needed if you ahve the example below
- Move `ContinuousESN` from layers.md to models.md — it is a model (sibling of `ESN`/`EuSN`), not a continuous-time reservoir building block; the `SciMLProblemReservoir` family stays under layers. - Drop the standalone "Loading the extension" block in the tutorial; the `@example continuous-esn-lorenz` block immediately below already carries the `using` lines, so the prose block was redundant.
|
Both done in |
| @concrete struct ContinuousESN <: AbstractSciMLProblemReservoir | ||
| prob | ||
| sampler | ||
| tspan | ||
| args | ||
| kwargs | ||
| in_dims | ||
| res_dims | ||
| leak_coefficient | ||
| use_bias | ||
| init_reservoir | ||
| init_input | ||
| init_bias | ||
| matrix_type | ||
| end |
There was a problem hiding this comment.
too much here, the ContinuousESN should just hold the reservoir, state modifires, and readout. the construction is similar to the ESN, we just provide a convenient wrapper for the user bu we use the internal components. so the the esn equations are fed into the SciMLProblemReservoir, the dims into the ESNCell etcetc
There was a problem hiding this comment.
Happy to restructure to the 3-field shape — want to make sure I land the composition right before I rewrite. Three things I'd like to confirm:
- Where does the cell sit? I see two readings: (a)
reservoir::SciMLProblemReservoirwithESNCellused only at construction time to mintprob.p, or (b) introduce a smallContinuousReservoir <: AbstractSciMLProblemReservoirthat holds theESNCellas a field and delegatesinitialparametersto it (so weights stay inps.reservoirand the existing_build_solve_paramsflow keeps working). I lean (b) since (a) bakes the weights intoprob.pat construction and they'd no longer live inps. Which were you picturing? leak_coefficient— agreed eq (5) doesn't carry α; α is the Euler step. Want me to drop theleak_coefficientkwarg entirely (user controls α implicitly viatspan/n_samples), or keep it as a thin convenience that adjuststspanunder the hood?ESNCellinit signatures — ESNCell's initializers take(rng, dims...)(no eltype), but the currentContinuousESNexposesTand passes(rng, T, dims...). To reuseESNCellas-is I'd lose theTkwarg and inherit ESNCell's Float32 default. Acceptable, or should I keepT?
There was a problem hiding this comment.
I see this raises a good point on adding the ContinuousESNCell as an additional layer. This way we can save those elements in the new cell, which should then also contain the esn equations. something like this maybe
@concrete struct ContinuousESNESNCell <: AbstractEchoStateNetworkCell #maybe change here
activation
in_dims <: IntegerType
out_dims <: IntegerType
init_bias
init_reservoir
init_input
#init_feedback::F
init_state
use_bias <: StaticBool
equations
endalso make sure to use IntegerType and StaticBool for Int and Bools
There was a problem hiding this comment.
Pushed d9b5fda9 with the restructure. ContinuousESN is now 3-field (reservoir, states_modifiers, readout) exactly like ESN; ContinuousESNCell <: AbstractSciMLProblemReservoir so the existing continuous dispatch in RCODEReservoirExt fires on rc.reservoir. initialparameters returns the canonical (input_matrix, reservoir_matrix, [bias]) trio matching ESNCell. Default equations is eq (5) without α — α emerges purely from the Euler step Δt, per your correction.
One field-list deviation worth flagging — the cell carries three more fields than you sketched (tspan, args, kwargs). The reason: since the cell is the dispatch target for _collectstates / _predict, the ext needs cell.tspan to set up the saveat grid and cell.args / cell.kwargs to call solve. If those lived on ContinuousESN instead, the cell would still need a way to reach them at dispatch time, which would mean either threading them through every _collectstates call or duplicating dispatch on the outer model. Adding them to the cell felt like the lowest-friction option, but happy to move them onto ContinuousESN and reach for them via rc.tspan etc. if you'd rather keep the cell strictly ESNCell-shaped — just say the word.
Also renamed u_t → input_t per your other comment; equivalence test now uses Float-exact α ({1.0, 0.5}) so the saveat grid aligns bit-for-bit with the Euler step boundaries (non-FP-exact α introduces a sub-step offset that the Euler interpolant smooths over).
| # path. | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| function _continuous_esn_rhs!(dx, x, p, t) |
There was a problem hiding this comment.
try not to use single letter variables
| # Concrete subtype of `AbstractSciMLProblemReservoir` that pre-bakes the | ||
| # leaky-integrator continuous ESN ODE of Lukoševičius 2012 §3.2.6 eq (5): | ||
| # | ||
| # ẋ(t) = α · (-x(t) + tanh(W_in·u(t) + W_r·x(t) + b)) |
There was a problem hiding this comment.
Lukoševičius 2012 §3.2.6 eq (5) does not have alpha in it, it comes out as a consequence of discretization of the ode
…3-field ESN shape Address Francesco's review on SciML#456: `ContinuousESN` now shares its struct shape with `ESN` — three fields `(reservoir, states_modifiers, readout)` — and the substance moves into a new `ContinuousESNCell` that mirrors `ESNCell`'s field layout plus an `equations` field carrying the ODE right-hand side. * `src/layers/continuous_esn_cell.jl` (NEW): `ContinuousESNCell <: AbstractSciMLProblemReservoir` so the existing continuous dispatch in `RCODEReservoirExt` fires on `rc.reservoir`. `initialparameters` returns the canonical `(input_matrix, reservoir_matrix, [bias])` trio. Default `_continuous_esn_rhs!` implements Lukoševičius 2012 §3.2.6 eq (5) verbatim — no α in the RHS; α emerges only when the ODE is forward-Euler discretised at step `Δt = α`. Only the `@concrete` inner constructor is exposed; the convenience builder lives in the extension's `ContinuousESN(...)` constructor (avoids a Pair-vs-activation ambiguity with the auto-generated inner ctor that Aqua flagged). * `src/models/continuous_esn.jl`: 13-field flat struct → 3-field container. Stub constructor errors without the extension; real one in `RCODEReservoirExt`. * `ext/RCODEReservoirExt.jl`: drop the bulk constructor + the old α-bearing RHS + the `_typed_zeros_init` helper. Add a slim `ContinuousESN(in_dims, res_dims, out_dims, [activation,] tspan, args...; use_bias, init_*, equations, state_modifiers, ...)` that validates inputs, builds the cell, and wraps it with `state_modifiers` + `LinearReadout`. Add `_collectstates(::ContinuousESNCell, ...)` (builds the `ODEProblem` on the fly from `cell.equations`, sized by `cell.out_dims`, with `ps.reservoir` merged into the solve `p`) and a parallel `_predict(::ContinuousESNCell, ..., steps; initialdata)` for the autoregressive path. Teacher-forced `_predict` inherits the generic `AbstractSciMLProblemReservoir` method unchanged. Rename `u_t` to `input_t` per the same review thread. * Field-list note: Francesco's sketch listed only the ESNCell-like fields. The cell additionally carries `tspan`, `args`, `kwargs` because it IS the dispatch target for the continuous path — without those fields `_collectstates` would have nothing to feed `solve`. Documented as an open question in the PR reply. * `test/test_continuous_esn.jl`: rewritten for the new field shape (`ps.reservoir.input_matrix` / `reservoir_matrix` / `bias`). Euler equivalence test moved from "α in the RHS, dt=1" to "no α in the RHS, dt=α with tspan=(0, T_steps·α)"; α values restricted to `{1.0, 0.5}` so the requested saveat grid aligns bit-for-bit with the Euler step boundaries (non-FP-exact α introduces a sub-step offset that the Euler interpolant smooths over). 9 testsets, 45 assertions, all green at `atol=1e-10` for the equivalence test. * Docs: add `ContinuousESNCell` to the layers API page and rewrite the Lorenz tutorial without the dropped `leak_coefficient` / `T` kwargs.
|
|
||
| ```@docs | ||
| AdditiveEIESNCell | ||
| ContinuousESNCell |
There was a problem hiding this comment.
I would also put this in a different subsection, similar to the models. Put it in a Continuous layers section
| # Continuous ESN: forecasting Lorenz | ||
|
|
||
| [`ContinuousESN`](@ref) is a thin convenience wrapper around a | ||
| [`ContinuousESNCell`](@ref) that pre-bakes the leaky-integrator |
There was a problem hiding this comment.
leaky-integrator
| @article{Anantharaman2021, | ||
| title = {Accelerating Simulation of Stiff Nonlinear Systems using Continuous-Time Echo State Networks}, | ||
| url = {https://arxiv.org/abs/2010.04004}, | ||
| author = {Anantharaman, Ranjan and Ma, Yingbo and Gowda, Shashi and Laughman, Christopher and Shah, Viral and Edelman, Alan and Rackauckas, Christopher}, | ||
| year = {2021}, | ||
| journal = {arXiv:2010.04004} | ||
| } |
There was a problem hiding this comment.
is this necessary now? I think the implementation we have is not the one provided by this paper
| # `solve` and `remake` come from `SciMLBase`. The user picks the concrete | ||
| # solver type (e.g. `Tsit5()`) and loads its package separately | ||
| # (`OrdinaryDiffEqTsit5`, `OrdinaryDiffEq`, …); dispatch at solve time | ||
| # selects the right method via the type they passed in `res.args[1]`. We | ||
| # deliberately don't list a solver package as a weakdep trigger so users | ||
| # aren't forced to pull the full `OrdinaryDiffEq` meta-package in. |
There was a problem hiding this comment.
comments not really useful here
| # --------------------------------------------------------------------------- | ||
| # ContinuousESN — convenience constructor | ||
| # | ||
| # Builds the 3-field `(reservoir, states_modifiers, readout)` model whose | ||
| # `reservoir` is a `ContinuousESNCell`. The cell carries Lukoševičius 2012 | ||
| # §3.2.6 eq (5) as its `equations` field. Each of `collectstates`, | ||
| # `predict(rc, data, ...)`, and `predict(rc, steps, ...; initialdata)` | ||
| # dispatches on `rc.reservoir::ContinuousESNCell` below, building the | ||
| # `ODEProblem` lazily so the reservoir weights stay in `ps.reservoir` and | ||
| # can be re-initialised by `setup(rng, rc)` like every other ESN-family | ||
| # model. | ||
| # --------------------------------------------------------------------------- |
| This type is normally constructed by the [`ContinuousESN`](@ref) | ||
| convenience constructor in the `RCODEReservoirExt` extension rather than | ||
| directly. Driving the inner constructor by hand requires placing every | ||
| field in the order above and pre-static-ing `use_bias`. |
There was a problem hiding this comment.
too low level information for a docstring
| - `reservoir_matrix :: (out_dims × out_dims)` — `W_r` | ||
| - `bias :: (out_dims,)` — present only if `use_bias = True()` | ||
| """ | ||
| @concrete struct ContinuousESNCell <: AbstractSciMLProblemReservoir |
There was a problem hiding this comment.
we still need a constructor here like for the normal ESNCell
| kwargs...) | ||
|
|
||
| Continuous-time leaky-integrator Echo State Network ([Lukosevicius2012](@cite) | ||
| §3.2.6, eq 5). Thin convenience wrapper that composes: |
| Structurally identical to [`ESN`](@ref) — three fields | ||
| `(reservoir, states_modifiers, readout)` — so it slots into the same | ||
| training and prediction pipeline. The reservoir field is the | ||
| `ContinuousESNCell` rather than a `StatefulLayer(ESNCell)`, which routes | ||
| `collectstates` / `predict` through the continuous-time | ||
| `RCODEReservoirExt` extension. |
There was a problem hiding this comment.
Structurally identical to ESN, but the reservoir field is the
ContinuousESNCell rather than a StatefulLayer(ESNCell)
There was a problem hiding this comment.
there are too many implementation level details that the user may not be familiar with or not care about
| No leaking-rate term `α` appears in the ODE. `α` emerges only when the | ||
| ODE is forward-Euler discretised with step `Δt = α`, recovering the | ||
| discrete leaky update of [`ESN`](@ref). To change the effective leak, | ||
| adjust `tspan` so the per-input-window width matches the desired `Δt`. |
|
I think we are almost there! There are a few points to clarify:
Also, we do need a convenience constructor for the Overall it's ok to use llms for coding, but make sure the code is up to standard. Forcing the SciML guide on a prompt usually helps in catching a lot of these issues, as well as having a local .md similar to this one |
|
I will have to look into this seriously, I was trying and experimenting documentations and code styling changes through autonomous agents (mostly claude & hermes) will have to fine tune the agents (will force the SciML guide on a project level). For now I will fallback to the prev workflow (without LLM agents) for the PR |
…s, and cell constructor Clean up implementation details from docstrings and comments across the ContinuousESN model, cell, tutorial, and extension. Add a Pair-signature convenience constructor for ContinuousESNCell matching the ESNCell pattern. Move ContinuousESNCell to a new Continuous-time Layers subsection in the API docs and drop the unused Anantharaman2021 citation.
|
I kept |
|
Coming back to the warmup discussion. The cold vs warm autoregressive predict gap is still there, and the user currently has to manually Do you want me to wire in automatic warmup as part of this PR, or keep it as-is and defer to a separate PR? Two lightweight approaches on the table:
|
|
I think we can add the warmup as part of a separate PR, since I would like to investigate that a bit further. As far as this pr goes I think we can merge, so if you want up the version as usual when tests pass this should be ready to go |
- Drop — Euler is re-exported by OrdinaryDiffEq and the sub-package is not a declared dependency. - Remove block from internal — it was never listed in any @docs block, causing Documenter missing_docs error. - Replace with descriptive text in ContinuousESNCell and ContinuousESN docstrings.
Add benchmarks/continuous_warmup to measure cold vs warm reservoir state at continuous autoregressive predict time (deferred from SciML#456). No package src/ changes — experimental seeded AR loop only.
Summary
Third deliverable of the SciML Summer Fellowship 2026 continuous-time track (#397), stacked on #446 / #450.
Adds a continuous-time Echo State Network model on the SciML continuous-reservoir path:
ContinuousESN— 3-field model (reservoir,states_modifiers,readout), sibling ofESNContinuousESNCell— reservoir cell implementing the continuous leaky-integrator ODERCODEReservoirExt— real constructor +_collectstates/_predictdispatchCanonical ODE is Lukoševičius 2012 §3.2.6 eq. (5):
Forward-Euler discretisation at step size
αrecovers the discrete leaky ESN. The discrete leak coefficient is not a free parameter of the continuous RHS; it appears only via the integration step.What feature report was addressed?
Part of #397 (continuous-time ESN model + docs; Mackey-Glass deferred / Lorenz tutorial as validation).
Design decisions (settled in review)
ContinuousESN/ContinuousESNCell(notCTESN)ESN: 3-field model; ODE substance lives in the cellαin the continuous RHSsrc/models/continuous_esn.jl+src/layers/continuous_esn_cell.jlu0on autoregressive predictUsage sketch
Requires the continuous-time extension (load
OrdinaryDiffEq/ related SciML stack soRCODEReservoirExtactivates).Checklist
contributor guidelines, in particular the SciML Style Guide and
COLPRAC.
Test plan
tspanvalidationTsit5)ESNatα ∈ {1.0, 0.5}predictdocs/src/tutorials/continuous_esn.md)models.md/layers.md)Files
src/layers/continuous_esn_cell.jlContinuousESNCellsrc/models/continuous_esn.jlContinuousESNmodel + stub without extensionext/RCODEReservoirExt.jltest/test_continuous_esn.jldocs/src/tutorials/continuous_esn.mddocs/src/api/{models,layers}.md@docsentriesdocs/src/refs.bibFollow-ups (not in this PR)
predict(discussed in thread)Additional context
PR3 of the continuous-time stack after #446 (core types) and #450 (
RCODEReservoirExt). Design discussion lived in this PR thread; implementation was refactored mid-review to the final 3-field +ContinuousESNCellshape.