Skip to content

Commit e790723

Browse files
ooplesclaudefranklinic
authored
chore: timeSeries deep forecasters: correct autodiff training (GradientTape + IEngine) + GPU-residency seam (#1841)
* fix: fix NHiTS backward pass: train via GradientTape autodiff so the model actually learns NHiTSModel.TrainCore looped over ForwardWithGradients/ApplyGradients, but ForwardWithGradients built an EMPTY gradient dictionary ("// Backward removed") so ApplyGradients updated nothing — training was a no-op that returned an untrained model. Fix (mirrors the working NBEATSModel path): - Re-express the stack MLP forward under GradientTape<T> using IEngine tensor ops (NHiTSStackTensor.ForwardTape: TensorPermute / TensorMatMul / TensorBroadcastAdd / ReLU). Autodiff now produces gradients for every weight/bias and AdamOptimizer.Step applies them. The forward is fully GPU-dispatchable (the point of the residency campaign). - Register every weight/bias via RegisterTrainableParameter so TapeTrainingStep.CollectParameters picks them up. - TrainCore: z-normalize the series, build [B,L]->[B,H] windowed batches, run tape forward over all stacks (multi-rate avg-pooled inputs), MSE loss, tape.ComputeGradients + optimizer.Step. Denormalize at inference. - Fix a train/inference mismatch: inference ForwardInternal used a hand-rolled scalar 2D-indexer matmul that disagreed with the tape path (~3500x off), producing garbage predictions from correctly-trained weights. It now uses the same Engine.Tensor* ops as ForwardTape, so a trained model's inference output matches what training optimized. - Add best-checkpoint / early-stopping restore: snapshot params at the best epoch and restore at the end, so late-epoch mini-batch Adam noise cannot degrade the returned model. - Remove the dead hand-derived Backward/UpdateParameter/GetParameterNames and the unused activation caches. Public API and NHiTSOptions are unchanged. Verification on a noisy sinusoid+trend series (default hyperparameters, lookback 20 / horizon 8 / 3 stacks): windowed forecast MSE 287.08 -> 18.44 (ratio 0.064, well under 0.5), beats the repeat-last-value baseline (21.85), and probe forecasts track actuals closely (e.g. 18.13 vs 18.16, 16.87 vs 17.07). Before this change the model returned untrained output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: batched GradientTape + IEngine forward (autodiff backward, GPU-dispatchable) Re-express InformerModel<T> training with automatic differentiation and batched Engine.Tensor* ops, mirroring the NBEATSModel / NHiTSModel tape pattern, so autodiff produces the whole backward pass (no hand-derived gradients) and the model runs through the GPU engine. Model (src/TimeSeries/InformerModel.cs): - TrainCore: z-normalizes the series, stacks windows into a [B, L] batch, runs a batched tape forward (ForwardBatch), MeanSquaredErrorLoss.ComputeTapeLoss, tape.ComputeGradients, AdamOptimizer.Step. Supervises the full H-step horizon per sample; snapshots/restores best-epoch parameters. - ForwardBatch: batched [B,L,1] -> [B,H] forward built entirely from Engine.* ops (embedding, multi-head scaled-dot-product attention via ScaledDotProductAttention with the [B,H,S,headDim] layout, LayerNorm, ReLU FFN, batched distilling conv+maxpool, generative decoder, per-position output projection). Token-wise ops run on a flattened [B*S, d] matrix. - ATTENTION SIMPLIFICATION: replaced ProbSparse self-attention with full multi-head scaled-dot-product attention. ProbSparse's top-u query selection is a data-dependent host gather that neither batches nor differentiates cleanly, and for these sequence lengths it already reduces to full attention. Self-attention distilling is retained (now trained: its conv weights were added to CollectTrainableParameters). - Inference (ForwardEngine/PredictSingle/Predict/ForecastHorizon) uses the SAME batched Engine ops (B=1), normalizing the input and denormalizing the forecast — no scalar/tape divergence. Normalization stats are serialized for round-trip. - Removed the dead hand-derived gradient/scalar code: ComputeGradients/ForwardWithCache/ EmbedInput/CreateDecoderInput/ComputeOutput/AccumulateGradients/ApplyGradients and the scalar Forward/Backward/ProbSparse/FeedForward/attention in the encoder/decoder/distilling layer classes (~630 lines). Public API and InformerOptions unchanged. testconsole: informer-verify (double CPU correctness) and informer-gpu (float32 GPU util) harnesses + Program dispatch entries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(timeseries): gPU-resident training seam for NBEATS via compiled fused plan Adds a reusable GPU-residency path for the tensorized TimeSeries forecasters and wires NBEATSModel<float> to it, following NeuralNetworkBase's compiled fused-optimizer template. TimeSeriesModelBase<T>: - CanTrainOnGpu guard (float + DirectGpuTensorEngine + compilation enabled), mirroring NeuralNetworkBase.CanTrainOnGpu. - TryFusedResidentStep(...): thin reusable wrapper over CompiledTapeTrainingStep<T>.TryStepWithFusedOptimizer that compiles forward+backward+Adam into a single on-device graph (weights, activations and Adam moments stay resident; no per-op host<->device round-trip). Applies gradient clipping (maxGradNorm=1.0) to match the eager Adam path. NBEATSModel<T>: - TryTrainGpuResident: runs the doubly-residual stack through the fused compiled plan with a constant batch shape (required for capture/replay). - Correctness-first validation gate: the resident run is kept only if it improves validation MSE over the untrained baseline; on divergence or no-improvement it re-initializes the blocks and returns false so TrainCore falls back to the eager tape path. Gated to epoch-bounded mode so a rejected attempt can't consume a wall-clock training budget. - LastRunEpochLosses / LastRunUsedGpuResidentPath instrumentation (both paths). Behavior: - The double/CPU training path is unchanged (CanTrainOnGpu is float+GPU only); verified NBEATS still trains cleanly on CPU (normalized loss 0.44 -> 0.0004). - Float+GPU: the eager tape already dispatches every op to the CUDA engine; the resident attempt is validated and safely falls back to eager when the compiled plan doesn't reproduce eager gradients. Known limitation (precise blocker): on the currently-linked Tensors build the compiled fused plan does not reliably reproduce eager gradients for NBEATS's per-layer TensorPermute + TensorBroadcastAdd op graph, so the resident attempt typically validation-rejects for NBEATS and hands off to eager. NBEATS is also host-bound (small per-op tensors), which caps GPU occupancy. The seam is retained for TimeSeries models whose op graphs the compiler handles faithfully. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(timeseries): gPU-resident training seam for NHiTS via compiled fused plan Mirrors NBEATSModel.TryTrainGpuResident so N-HiTS routes forward + backward + Adam through a single on-device compiled plan (weights / activations / Adam moments resident across every step) when float + DirectGpuTensorEngine + compilation are all live. * PoolBatchedTape: batched, tape-recordable average pooling via Reshape + ReduceMean. [B, L] -> [B, L/k]. Requires L % k == 0; returns null (fused path unsupported) for configs where the pool size doesn't divide the lookback cleanly, so callers cleanly fall back to the eager scalar pool. * RunForwardBatched: full multi-rate stack on-tape via per-stack PoolBatchedTape + ForwardTape + sum. Single [B, L] input suits the fused step's constant-shape replay contract. * TryTrainGpuResident: NBEATS-parallel structure — pre-MSE baseline, batch loop through TryFusedResidentStep, divergence guard, validation gate. On reject the stacks are re-initialized so the eager fallback starts clean. * LastRunUsedGpuResidentPath: telemetry flag on the model instance. * TrainCore now attempts the resident path first (epoch-bounded only, per the same wall-clock hazard NBEATS docs) and falls through to the existing eager tape loop on failure. Builds green net471 / net8.0 / net10.0. Correctness gate rejects the run if it doesn't improve validation MSE by 2%, guaranteeing the resident path can never ship worse weights than eager. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(timeseries): gPU-resident training seam for Informer via compiled fused plan Same seam NBEATSModel + NHiTSModel use. Informer's forward is SDPA + LayerNorm + FFN + distilling — a very different op graph from NBEATS's Permute + BroadcastAdd chain that trips the divergence guard on the linked Tensors build, so the compiled fused plan should engage cleanly here even while NBEATS keeps falling back. * CollectTrainableLayers: flat ITrainableLayer<T> list over encoder + distilling + decoder layers (all LayerBase-derived). Needed by TryFusedResidentStep to ZeroGrad per step. * ValidationMseGpu: 256-window baseline for the accept/reject gate. * TryTrainGpuResident: NBEATS-parallel structure — pre-MSE, batch loop through TryFusedResidentStep, divergence guard, validation gate. On reject calls InitializeModel() so the eager fallback starts clean. * LastRunUsedGpuResidentPath telemetry flag. * TrainCore attempts the resident path first (epoch-bounded only, same rationale as NBEATS/NHiTS) and falls through to the existing eager tape+optimizer loop on failure. Builds green net471 / net8.0 / net10.0. This completes wiring for the three tape-based deep forecasters currently on the PR branch (NBEATS + NHiTS + Informer). Autoformer / DeepAR / TFT branches referenced in the PR body haven't landed on the remote yet — will be wired the same way when they do. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(tft): tape-train Temporal Fusion Transformer with batched IEngine ops Re-express the TFT forward pass entirely with GradientTape + batched Engine.Tensor* ops so autodiff produces the backward pass (no hand-derived gradients) and every op is GPU-dispatchable — matching the NHiTS/Informer tape conversions. - ForwardBatch: [B,L]->[B,H] point forecast via embedding + positional encoding, softmax-gated variable selection (GRN), static-enrichment GRN, interpretable multi-head ScaledDotProductAttention, post-attention gated skip (GRN), mean-pool + forecast head. - GatedResidualNetwork rewritten batched/tape-safe (Engine ELU/Sigmoid/ LayerNorm with learned gamma/beta); removes scalar NumOps activation loops. - TrainCore: batched tape forward -> MSE -> ComputeGradients -> Adam.Step, z-normalized windows, best-epoch snapshot/restore. Inference (ForwardEngine) uses the identical ops. - Simplifications (documented): quantile/pinball head -> single point (mean) MSE forecast; sequential LSTM -> positional encodings + attention; univariate variable selection -> learned per-channel softmax gate. GRN + variable selection + interpretable attention retained. - Remove dead scalar VariableSelectionNetwork.cs. Public API and TemporalFusionTransformerOptions unchanged. Verify (standalone harness, double, sinusoid+trend series, 1100 pts, 40 ep): MSE before 205.45 -> after 0.472 (0.23% of init, <0.5x); beats naive repeat-last baseline 39.09 by ~83x. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(autoformer): tape-based automatic-differentiation training (no hand-derived gradients) Convert AutoformerModel<T> training to a GradientTape + IEngine tensor-op forward so autodiff produces the entire backward pass and every op is GPU-dispatchable (matching the NHiTS/Informer conversions). - ForwardCore: whole forward (embedding, series-decomposition moving-avg trend/seasonal split, encoder/decoder, output projection) is now built from Engine.Tensor* ops. The DEFINING series decomposition is kept, and a tape-differentiable time-delay auto-correlation (R(lag) spectrum -> top-k softmax -> rolled-value aggregation) replaces the old scalar FFT-style attention; only the top-k index SELECTION is non-differentiable (as in the official implementation). - TrainCore rewritten: z-normalize the series, build [L]->[H] windows, mini-batch gradient by accumulating each sample's gradient across separate (immediately-disposed) tapes, then one averaged Adam step per batch; best-epoch params snapshotted and restored. Removed the output-bias level seeding (normalization handles level). - Inference (Predict/PredictSingle/PredictMultiple) uses the SAME ForwardCore ops (normalize in, denormalize out) so there is no train/predict divergence. - Removed the dead scalar/hand-derived code: ComputeGradientsMultiStep, ComputeMovingAverageNode, TopologicalSort, Accumulate/ApplyGradients, ForwardWithCache, AutoformerCache, the gradient-accumulator infrastructure, the ComputeGradients override, and all per-layer scalar forward/gradient methods (~1400 net lines removed). Layers now hold parameters + (de)serialization only. - Normalization stats persisted in Serialize/DeserializeCore. Public API and AutoformerOptions unchanged. Adds a testconsole `autoformer-verify` verb (trains on a learnable noisy sinusoid+trend, reports before/after horizon MSE vs a repeat-last baseline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(deepar): tape-based BPTT training + GPU-dispatchable forward Re-express DeepARModel<T> training with GradientTape<T> + Engine.Tensor* ops so autodiff produces the full backward pass (no hand-derived gradients) and the forward path is GPU-dispatchable, mirroring the NHiTS/Informer/NBEATS conversions. - Unroll the LSTM recurrence over the L-step lookback as tape-tracked engine ops (gate matmuls via TensorMatMul/TensorBroadcastAdd, gates split with TensorNarrow, Sigmoid/Tanh/TensorMultiply), carrying [H,B] column-major hidden/cell state across steps under the tape. Two internal LayerBase subclasses (DeepARLstmCellTape, DeepARGaussianHead) register every weight/bias via RegisterTrainableParameter so TapeTrainingStep.CollectParameters picks them up. - TrainCore: batched teacher-forced forward -> loss -> tape.ComputeGradients -> AdamOptimizer.Step, with per-epoch best-checkpoint snapshot/restore. Inference (PredictDistribution) runs the identical Step ops (B=1) so there is no scalar/tape divergence. Removed all hand-derived scalar gradient code (ComputeGradients/ApplyGradients/BackwardInternal). - Gaussian likelihood retained: mean trained on MSE (undivided gradient) and the scale trained on the Gaussian NLL term over a StopGradient-ed mean, so sigma cannot inflate to collapse the mean (verified failure mode). Mean is emitted as a residual on the current observation (mu_t = x_t + delta(h_t)), which keeps the model at the persistence prior at init and lets training learn the one-step change. - Public API + DeepAROptions unchanged; added read-only TrainingLossHistory. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(timeseries): fix review comments — visibility, serialization, input validation, checkpoint Addresses CodeRabbit review comments on PR#1841 (the clear, non-design ones; the GPU-resident gradient root cause is handled separately): - API visibility (keep the public/external-subclass surface unchanged, per the facade objective): TimeSeriesModelBase.CanTrainOnGpu and TryFusedResidentStep are now `private protected` (still usable by in-assembly models, hidden from external subclasses); NBEATS/NHiTS/Informer LastRunUsedGpuResidentPath and NBEATS LastRunEpochLosses are now `internal` (visible to tests/serving via InternalsVisibleTo). LastRunEpochLosses is exposed as an immutable snapshot (AsReadOnly) so callers cannot mutate the backing list. - InformerModel.InitializeModel is now idempotent — it clears _encoderLayers, _distillingLayers and _decoderLayers before rebuilding. TryTrainGpuResident re-invokes it on a rejected resident run; previously it APPENDED a second full layer set, doubling the model and corrupting ParameterCount/ForwardBatch/the forecast. Safe on the constructor path (lists already empty). - NHiTSModel now validates external inputs: every PoolingKernelSize must be positive (a zero divided by zero in the downsampled-length calc; a negative gave an invalid length), and a training series shorter than LookbackWindow + ForecastHorizon is rejected up front (an empty series divided by zero in the mean pass; a short series trained silently on zero windows). - NHiTSModel.SerializeCore/DeserializeCore now persist _normMean/_normStd. A reloaded model previously kept the defaults (0/1) and denormalized forecasts incorrectly, so it no longer matched the original trained model. - NHiTS best-checkpoint selection now scores each epoch on its FROZEN end-of-epoch weights (via the existing ValidationMse forward) instead of the mean of pre-update batch losses. bestLoss now measures exactly the weights bestSnapshot captures, so a late-diverging epoch can no longer be selected as best. The added pass is forward-only (no backprop). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(timeseries): resident-path review fixes — holdout gate, dead-code, failed-step fallback Addresses the remaining CodeRabbit comments on the N-BEATS / N-HiTS GPU-resident path (the compiled-plan gradient root cause is fixed separately in AiDotNet.Tensors): - [3] Accept/reject gate now validates on a time-ordered HOLDOUT. Both models reserved their windows for BOTH resident optimization AND the pre/post MSE gate, so a 2% training-loss improvement proved nothing about generalization. Reserve the latest ~20% of windows as a holdout the optimizer never trains on; train on the earlier windows; score preMse/postMse on the holdout alone. - [4] Removed the unreachable time-bounded logic from NBEATSModel.TryTrainGpuResident. TrainCore only calls it when MaxTrainingTimeSeconds <= 0, so timeBounded, the stopwatch, and both timeout branches were always dead. The loop now uses _options.Epochs directly and keeps the cancellation checks. (N-HiTS already had no such dead code.) - [5] A failed fused step after the plan engaged now diverges + breaks (→ the gate reinitializes and hands off to eager) instead of silently `continue`-ing the batch, which could leave a partially-executed resident run to be accepted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(nbeats): stop eager training divergence blowing up forecasts On noisy / fat-tailed real series the eager tape-Adam training loop diverges: the normalized training loss climbs instead of falling (measured 1.20 -> 1.55 over 15 epochs on SPY daily log-returns) and the final weights produce forecasts blown up to ~hundreds of times the target scale (a walk-forward eval measured N-BEATS prediction variance ~692x the target vs ~0.07-4x for Informer/DLinear). Two coupled guards, both on vectorized Tensor/IEngine ops (no jagged arrays, no per-element scalar loops): 1. Global-norm gradient clipping (Oreshkin et al. 2020). New NBEATSModelOptions.GradientClipNorm (default 1.0). The global L2 norm is computed with Engine.TensorSumOfSquares per gradient and the whole set is scaled in place with Engine.TensorMultiplyScalarInPlace when it exceeds the threshold. 2. Best-epoch-weights checkpointing. Adam's adaptive step makes clipping alone insufficient, so the parameters from the lowest-loss epoch are kept (as Tensor clones, refreshed via Engine.TensorMultiplyScalarInto) and restored after training — inference never runs on a diverged tail. After the fix a 6-fold walk-forward over SPY daily returns keeps every fold's prediction/target std ratio in 0.18x-0.74x (worst max|pred| 0.049 vs a target std of 0.011) — the ~692x blow-up is gone. Existing NBEATSModelTests: 15/15 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(timeseries): resolve review comments — TFT quantile head, RandomHelper, facade, resident parity Addresses the blocking review comments on the tape-converted time-series models: - TemporalFusionTransformer: restore a REAL quantile head. The tape conversion had collapsed the quantile/pinball head to a single MSE point forecast, so PredictQuantiles returned identical (degenerate) bands. Now the head projects to [H*Q] (quantile-major) and trains with the summed pinball loss over every QuantileLevels level, so PredictQuantiles returns a genuine ordered spread (verified: q0.1 < q0.5 < q0.9 across the horizon); the point forecast is the median-level head. Also validates each quantile level is in (0,1) at construction. - new Random(42) -> RandomHelper.CreateSeededRandom(42) in Informer/NBEATS/NHiTS (deterministic + the project's crypto-secure helper), matching DLinear/Autoformer. - DeepARModel.TrainingLossHistory: public IReadOnlyList (leaked the mutable backing List) -> internal + .AsReadOnly(), matching NBEATSModel.LastRunEpochLosses and the facade-only rule. - NBEATS GPU-resident path: on AiDotNet.Tensors 0.112.0 (#759 fixed the TensorPermute/TensorBroadcastAdd multi-consumer grad-buffer blowup, #764 the resident-parameter mistrain) the resident run now trains faithfully — verified on GPU that it lowers the eager-forward training MSE and improves held-out MSE, so the correctness gate accepts it. Updated the stale "does not reliably reproduce eager gradients" docs; the gate is retained as a generalization safety net. - testconsole/AutoformerVerify: seeded RandomHelper + Environment.Exit(1) on verification failure so a regression fails the process. - Directory.Packages.props: AiDotNet.Tensors + Native 0.111.1 -> 0.112.0 (brings the resident-param + N-BEATS grad-buffer fixes). Real TFT tests (unit + TS model-family) + NBEATSModelTests: 46/46 green. The Generated.* generic-NN tests feed TS models non-time-series data (1-point series) and fail pre-existing regardless of these changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: franklinic <franklin@ivorycloud.com>
1 parent 1ca524d commit e790723

14 files changed

Lines changed: 3110 additions & 4545 deletions

Directory.Packages.props

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,17 @@
229229
activations from a prior training step on the same thread) leaked straight into the concat
230230
result — an intermittent, pool-state-dependent NaN, exactly the failure mode behind the
231231
model-family shard flakes this PR targets. 0.111.2 is PUBLISHED to NuGet and is a superset of
232-
0.111.1, so all rationale above is retained. -->
233-
<PackageVersion Include="AiDotNet.Tensors" Version="0.111.2" />
234-
<PackageVersion Include="AiDotNet.Native.OneDNN" Version="0.111.2" />
235-
<PackageVersion Include="AiDotNet.Native.OpenBLAS" Version="0.111.2" />
236-
<PackageVersion Include="AiDotNet.Native.CLBlast" Version="0.111.2" />
232+
0.111.1, so all rationale above is retained.
233+
234+
Bumped 0.111.2 -> 0.112.0: brings AiDotNet.Tensors #759 (N-BEATS resident blow-up — zero only
235+
accumulating multi-consumer grad buffers) and #764 (GPU-resident-parameter fused-Adam mistrain
236+
fix), which together make this PR's TimeSeries GPU-resident training path train faithfully (the
237+
resident run now lowers the eager-forward training MSE and improves held-out MSE, so the N-BEATS
238+
correctness gate accepts it). 0.112.0 is PUBLISHED to NuGet and is a superset of 0.111.2. -->
239+
<PackageVersion Include="AiDotNet.Tensors" Version="0.112.0" />
240+
<PackageVersion Include="AiDotNet.Native.OneDNN" Version="0.112.0" />
241+
<PackageVersion Include="AiDotNet.Native.OpenBLAS" Version="0.112.0" />
242+
<PackageVersion Include="AiDotNet.Native.CLBlast" Version="0.112.0" />
237243
<!-- Microsoft / .NET -->
238244
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
239245
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />

src/Models/Options/NBEATSModelOptions.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public NBEATSModelOptions(NBEATSModelOptions<T> other)
5353
HiddenLayerSize = other.HiddenLayerSize;
5454
NumHiddenLayers = other.NumHiddenLayers;
5555
LearningRate = other.LearningRate;
56+
GradientClipNorm = other.GradientClipNorm;
5657
Epochs = other.Epochs;
5758
BatchSize = other.BatchSize;
5859
ShareWeightsInStack = other.ShareWeightsInStack;
@@ -193,6 +194,25 @@ public NBEATSModelOptions(NBEATSModelOptions<T> other)
193194
/// </remarks>
194195
public double LearningRate { get; set; } = 0.001;
195196

197+
/// <summary>
198+
/// Gets or sets the global-norm gradient-clipping threshold used during training. Set to 0 to disable.
199+
/// </summary>
200+
/// <remarks>
201+
/// <para>
202+
/// Before each optimizer step, the L2 norm of all parameter gradients is computed; if it exceeds this
203+
/// threshold every gradient is scaled down so the norm equals it. Oreshkin et al. (2020) train N-BEATS with
204+
/// gradient clipping — the deep doubly-residual stack otherwise explodes on fat-tailed real series at the
205+
/// default learning rate: the normalized training loss DIVERGES upward (e.g. 1.20 → 1.55 over 15 epochs on
206+
/// SPY daily returns) and inference predictions blow up to hundreds of times the target scale. Clipping keeps
207+
/// the step bounded and makes training robust to outlier batches without lowering the learning rate.
208+
/// </para>
209+
/// <para><b>For Beginners:</b> Occasionally a training batch produces a huge gradient (from an outlier in the
210+
/// data) that would send the model flying off course. Gradient clipping caps how big any single update can be,
211+
/// like a speed limit — it keeps learning stable. 1.0 is a standard, safe default.
212+
/// </para>
213+
/// </remarks>
214+
public double GradientClipNorm { get; set; } = 1.0;
215+
196216
/// <summary>
197217
/// Gets or sets the number of training epochs.
198218
/// </summary>

src/TimeSeries/AutoformerModel.cs

Lines changed: 343 additions & 1732 deletions
Large diffs are not rendered by default.

src/TimeSeries/DeepARModel.cs

Lines changed: 599 additions & 572 deletions
Large diffs are not rendered by default.

src/TimeSeries/InformerModel.cs

Lines changed: 465 additions & 1187 deletions
Large diffs are not rendered by default.

src/TimeSeries/NBEATSModel.cs

Lines changed: 392 additions & 5 deletions
Large diffs are not rendered by default.

src/TimeSeries/NHiTSModel.cs

Lines changed: 495 additions & 287 deletions
Large diffs are not rendered by default.

src/TimeSeries/TFT/GatedResidualNetwork.cs

Lines changed: 65 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -5,212 +5,125 @@ namespace AiDotNet.TimeSeries.TFT;
55

66
/// <summary>
77
/// Gated Residual Network (GRN) as described in Lim et al. (2021).
8-
/// GRN(a, c) = LayerNorm(a + GLU(η₁)) where η₁ = W₁·η₂ + b₁,
9-
/// η₂ = ELU(W₂·a + W₃·c + b₂), and GLU(γ) = σ(W₄·γ + b₄) ⊙ (W₅·γ + b₅).
8+
/// GRN(a) = LayerNorm(a + GLU(η₁)) where η₁ = W₁·η₂ + b₁,
9+
/// η₂ = ELU(W₂·a + b₂), and GLU(γ) = σ(W₄·γ + b₄) ⊙ (W₅·γ + b₅).
1010
/// </summary>
11+
/// <remarks>
12+
/// This implementation is expressed entirely with batched <c>Engine.Tensor*</c> ops so a
13+
/// <see cref="Tensors.Engines.Autodiff.GradientTape{T}"/> differentiates it automatically
14+
/// (no hand-derived gradients) and every op is GPU-dispatchable. All layers operate on a
15+
/// flattened <c>[N, d]</c> matrix where each of the N rows is processed independently
16+
/// (token-wise), matching the Temporal Fusion Transformer tape-training campaign.
17+
/// The final layer normalization uses learned affine parameters (γ, β).
18+
/// </remarks>
1119
internal class GatedResidualNetwork<T>
1220
{
13-
private static readonly INumericOperations<T> NumOps = MathHelper.GetNumericOperations<T>();
1421
private static IEngine Engine => AiDotNetEngine.Current;
1522

1623
private readonly int _inputSize;
1724
private readonly int _hiddenSize;
1825
private readonly int _outputSize;
19-
private readonly int _contextSize;
20-
private readonly bool _hasContext;
2126

22-
// η₂ = ELU(W₂·a + W₃·c + b₂)
23-
private Tensor<T> _w2; // [hiddenSize, inputSize]
24-
private Tensor<T> _b2; // [hiddenSize]
25-
private Tensor<T>? _w3; // [hiddenSize, contextSize] — only if context is used
27+
// η₂ = ELU(W₂·a + b₂)
28+
private readonly Tensor<T> _w2; // [hiddenSize, inputSize]
29+
private readonly Tensor<T> _b2; // [hiddenSize]
2630

2731
// η₁ = W₁·η₂ + b₁
28-
private Tensor<T> _w1; // [outputSize, hiddenSize]
29-
private Tensor<T> _b1; // [outputSize]
32+
private readonly Tensor<T> _w1; // [outputSize, hiddenSize]
33+
private readonly Tensor<T> _b1; // [outputSize]
3034

3135
// GLU: σ(W₄·γ + b₄) ⊙ (W₅·γ + b₅)
32-
private Tensor<T> _w4; // [outputSize, outputSize]
33-
private Tensor<T> _b4; // [outputSize]
34-
private Tensor<T> _w5; // [outputSize, outputSize]
35-
private Tensor<T> _b5; // [outputSize]
36+
private readonly Tensor<T> _w4; // [outputSize, outputSize]
37+
private readonly Tensor<T> _b4; // [outputSize]
38+
private readonly Tensor<T> _w5; // [outputSize, outputSize]
39+
private readonly Tensor<T> _b5; // [outputSize]
3640

37-
// Skip connection projection (when inputSize != outputSize)
38-
private Tensor<T>? _skipProjection; // [outputSize, inputSize]
41+
// Skip connection projection (only when inputSize != outputSize)
42+
private readonly Tensor<T>? _skipProjection; // [outputSize, inputSize]
3943

40-
public GatedResidualNetwork(int inputSize, int hiddenSize, int outputSize, int contextSize = 0, int? seed = null)
44+
// Learned LayerNorm affine parameters
45+
private readonly Tensor<T> _lnGamma; // [outputSize]
46+
private readonly Tensor<T> _lnBeta; // [outputSize]
47+
48+
public GatedResidualNetwork(int inputSize, int hiddenSize, int outputSize, int? seed = null)
4149
{
4250
_inputSize = inputSize;
4351
_hiddenSize = hiddenSize;
4452
_outputSize = outputSize;
45-
_contextSize = contextSize;
46-
_hasContext = contextSize > 0;
4753

4854
var random = seed.HasValue ? new Random(seed.Value) : RandomHelper.CreateSeededRandom(42);
4955

50-
// He initialization for ELU
51-
double std2 = Math.Sqrt(2.0 / _inputSize);
52-
_w2 = CreateRandomTensor([_hiddenSize, _inputSize], std2, random);
56+
// He initialization for the ELU layer
57+
_w2 = CreateRandomTensor([_hiddenSize, _inputSize], Math.Sqrt(2.0 / _inputSize), random);
5358
_b2 = new Tensor<T>([_hiddenSize]);
5459

55-
if (_hasContext)
56-
{
57-
double std3 = Math.Sqrt(2.0 / _contextSize);
58-
_w3 = CreateRandomTensor([_hiddenSize, _contextSize], std3, random);
59-
}
60-
61-
double std1 = Math.Sqrt(2.0 / _hiddenSize);
62-
_w1 = CreateRandomTensor([_outputSize, _hiddenSize], std1, random);
60+
_w1 = CreateRandomTensor([_outputSize, _hiddenSize], Math.Sqrt(2.0 / _hiddenSize), random);
6361
_b1 = new Tensor<T>([_outputSize]);
6462

65-
// GLU weights — initialize small so gate starts near 0.5 (neutral)
63+
// GLU weights — small so the gate starts near 0.5 (neutral)
6664
double stdGlu = Math.Sqrt(1.0 / _outputSize);
6765
_w4 = CreateRandomTensor([_outputSize, _outputSize], stdGlu, random);
6866
_b4 = new Tensor<T>([_outputSize]);
6967
_w5 = CreateRandomTensor([_outputSize, _outputSize], stdGlu, random);
7068
_b5 = new Tensor<T>([_outputSize]);
7169

72-
// Skip projection if dimensions don't match
7370
if (_inputSize != _outputSize)
7471
{
7572
_skipProjection = CreateRandomTensor([_outputSize, _inputSize], Math.Sqrt(1.0 / _inputSize), random);
7673
}
74+
75+
_lnGamma = CreateConstantTensor([_outputSize], 1.0);
76+
_lnBeta = new Tensor<T>([_outputSize]);
7777
}
7878

7979
/// <summary>
80-
/// Forward pass: GRN(a, c) = LayerNorm(a + GLU(η₁))
80+
/// Forward pass GRN(a) = LayerNorm(a + GLU(η₁)) on a flattened <c>[N, inputSize]</c> batch.
81+
/// Returns <c>[N, outputSize]</c>. Fully tape-differentiable.
8182
/// </summary>
82-
/// <param name="input">Primary input [batch, inputSize] or [inputSize]</param>
83-
/// <param name="context">Optional static context [batch, contextSize] or [contextSize]</param>
84-
public Tensor<T> Forward(Tensor<T> input, Tensor<T>? context = null)
83+
public Tensor<T> Forward(Tensor<T> input)
8584
{
86-
// η₂ = ELU(W₂·a + W₃·c + b₂)
87-
var eta2 = LinearProject(input, _w2, _b2);
88-
89-
if (_hasContext && context != null && _w3 != null)
90-
{
91-
var contextProj = LinearProject(context, _w3, null);
92-
eta2 = Engine.TensorAdd(eta2, contextProj);
93-
}
94-
95-
eta2 = ApplyELU(eta2);
85+
// η₂ = ELU(W₂·a + b₂)
86+
var eta2 = Engine.ELU(Linear(input, _w2, _b2), 1.0);
9687

9788
// η₁ = W₁·η₂ + b₁
98-
var eta1 = LinearProject(eta2, _w1, _b1);
89+
var eta1 = Linear(eta2, _w1, _b1);
9990

10091
// GLU(η₁) = σ(W₄·η₁ + b₄) ⊙ (W₅·η₁ + b₅)
101-
var gate = LinearProject(eta1, _w4, _b4);
102-
gate = ApplySigmoid(gate);
103-
var value = LinearProject(eta1, _w5, _b5);
92+
var gate = Engine.Sigmoid(Linear(eta1, _w4, _b4));
93+
var value = Linear(eta1, _w5, _b5);
10494
var gluOutput = Engine.TensorMultiply(gate, value);
10595

106-
// Skip connection: a (or projected a if sizes differ)
107-
var skip = (_skipProjection != null)
108-
? LinearProject(input, _skipProjection, null)
109-
: input;
110-
111-
// Residual + LayerNorm
112-
var output = Engine.TensorAdd(skip, gluOutput);
113-
output = ApplyLayerNorm(output);
96+
// Skip connection (projected when input/output dims differ)
97+
var skip = _skipProjection != null ? Linear(input, _skipProjection, null) : input;
11498

115-
return output;
99+
// Residual + LayerNorm(γ, β)
100+
var residual = Engine.TensorAdd(skip, gluOutput);
101+
return Engine.LayerNorm(residual, _lnGamma, _lnBeta, 1e-5, out _, out _);
116102
}
117103

118-
/// <summary>
119-
/// Collects all trainable parameters for tape-based autodiff.
120-
/// </summary>
104+
/// <summary>Collects all trainable parameter tensors for tape-based autodiff.</summary>
121105
public IEnumerable<Tensor<T>> GetTrainableParameters()
122106
{
123107
yield return _w2;
124108
yield return _b2;
125-
if (_w3 != null) yield return _w3;
126109
yield return _w1;
127110
yield return _b1;
128111
yield return _w4;
129112
yield return _b4;
130113
yield return _w5;
131114
yield return _b5;
132115
if (_skipProjection != null) yield return _skipProjection;
116+
yield return _lnGamma;
117+
yield return _lnBeta;
133118
}
134119

135-
private static Tensor<T> LinearProject(Tensor<T> input, Tensor<T> weight, Tensor<T>? bias)
120+
// Row-wise affine map: x[N, in] · W[out, in]^T + b[out] -> [N, out].
121+
private static Tensor<T> Linear(Tensor<T> x, Tensor<T> weight, Tensor<T>? bias)
136122
{
137-
// input: [N] or [batch, N], weight: [outSize, inSize]
138123
int outSize = weight.Shape[0];
139-
int inSize = weight.Shape[1];
140-
141-
Tensor<T> result;
142-
if (input.Shape.Length == 1 || (input.Shape.Length == 2 && input.Shape[0] == 1))
143-
{
144-
// Single vector: weight @ input
145-
var inputCol = input.Reshape(inSize, 1);
146-
result = Engine.TensorMatMul(weight, inputCol).Reshape(outSize);
147-
}
148-
else
149-
{
150-
// Batched: input @ weight^T
151-
var weightT = weight.Transpose(new[] { 1, 0 });
152-
result = Engine.TensorMatMul(input, weightT);
153-
}
154-
124+
var result = Engine.TensorMatMul(x, Engine.TensorTranspose(weight));
155125
if (bias != null)
156-
{
157-
result = Engine.TensorAdd(result, bias.Reshape(result._shape));
158-
}
159-
160-
return result;
161-
}
162-
163-
private static Tensor<T> ApplyELU(Tensor<T> x)
164-
{
165-
var result = new Tensor<T>(x._shape);
166-
var xSpan = x.Data.Span;
167-
var rSpan = result.AsWritableSpan();
168-
for (int i = 0; i < xSpan.Length; i++)
169-
{
170-
double val = NumOps.ToDouble(xSpan[i]);
171-
rSpan[i] = NumOps.FromDouble(val >= 0 ? val : Math.Exp(val) - 1.0);
172-
}
173-
return result;
174-
}
175-
176-
private static Tensor<T> ApplySigmoid(Tensor<T> x)
177-
{
178-
var result = new Tensor<T>(x._shape);
179-
var xSpan = x.Data.Span;
180-
var rSpan = result.AsWritableSpan();
181-
for (int i = 0; i < xSpan.Length; i++)
182-
{
183-
double val = NumOps.ToDouble(xSpan[i]);
184-
rSpan[i] = NumOps.FromDouble(1.0 / (1.0 + Math.Exp(-val)));
185-
}
186-
return result;
187-
}
188-
189-
private static Tensor<T> ApplyLayerNorm(Tensor<T> x)
190-
{
191-
// Simple layer normalization over the last dimension
192-
var span = x.Data.Span;
193-
int len = span.Length;
194-
if (len == 0) return x;
195-
196-
double sum = 0, sumSq = 0;
197-
for (int i = 0; i < len; i++)
198-
{
199-
double v = NumOps.ToDouble(span[i]);
200-
sum += v;
201-
sumSq += v * v;
202-
}
203-
204-
double mean = sum / len;
205-
double variance = sumSq / len - mean * mean;
206-
double invStd = 1.0 / Math.Sqrt(variance + 1e-5);
207-
208-
var result = new Tensor<T>(x._shape);
209-
var rSpan = result.AsWritableSpan();
210-
for (int i = 0; i < len; i++)
211-
{
212-
rSpan[i] = NumOps.FromDouble((NumOps.ToDouble(span[i]) - mean) * invStd);
213-
}
126+
result = Engine.TensorBroadcastAdd(result, Engine.Reshape(bias, [1, outSize]));
214127
return result;
215128
}
216129

@@ -219,14 +132,24 @@ private static Tensor<T> CreateRandomTensor(int[] shape, double stddev, Random r
219132
int size = 1;
220133
foreach (var s in shape) size *= s;
221134
var data = new T[size];
135+
var numOps = MathHelper.GetNumericOperations<T>();
222136
for (int i = 0; i < size; i++)
223137
{
224-
// Normal distribution via Box-Muller
225138
double u1 = 1.0 - random.NextDouble();
226139
double u2 = random.NextDouble();
227140
double normal = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
228-
data[i] = NumOps.FromDouble(normal * stddev);
141+
data[i] = numOps.FromDouble(normal * stddev);
229142
}
230143
return new Tensor<T>(shape, new Vector<T>(data));
231144
}
145+
146+
private static Tensor<T> CreateConstantTensor(int[] shape, double value)
147+
{
148+
int size = 1;
149+
foreach (var s in shape) size *= s;
150+
var data = new T[size];
151+
var numOps = MathHelper.GetNumericOperations<T>();
152+
for (int i = 0; i < size; i++) data[i] = numOps.FromDouble(value);
153+
return new Tensor<T>(shape, new Vector<T>(data));
154+
}
232155
}

0 commit comments

Comments
 (0)