Back to Deep Learning
AI/ML · Deep Learning

Temporal Convolutional Network

A convolution over time looks at a fixed window, which is too short to be useful and too expensive to widen by brute force. Two modifications fix that. Causal: pad only on the left, so a position never sees the future — without which the model cheats and the validation numbers are fiction. Dilated: skip a growing number of positions between taps at each layer, so a stack of layers with dilations 1, 2, 4, 8 reaches back sixteen steps with four layers rather than sixteen. The receptive field doubles per layer, so it grows exponentially in depth while the parameter count grows linearly. And because nothing is carried forward, every output position is computed from its own window independently — the entire sequence in one parallel pass.

SupervisedSelf-supervisedConvolutionalSequence modelingRegressionClassificationAnomaly detection

The same two framings as any sequence model — supervised against external targets, self-supervised against the sequence’s own next element. What differs from the recurrent entries is not the training but the computation: every position is computed independently, so the whole sequence is processed at once.

Also known as: TCN, Dilated causal convolution, WaveNet-style, Causal CNN

Intuition

What it actually does

A convolution over time looks at a fixed window, which is too short to be useful and too expensive to widen by brute force. Two modifications fix that. Causal: pad only on the left, so a position never sees the future — without which the model cheats and the validation numbers are fiction. Dilated: skip a growing number of positions between taps at each layer, so a stack of layers with dilations 1, 2, 4, 8 reaches back sixteen steps with four layers rather than sixteen. The receptive field doubles per layer, so it grows exponentially in depth while the parameter count grows linearly. And because nothing is carried forward, every output position is computed from its own window independently — the entire sequence in one parallel pass.

Assumptions it makes
  • The relevant history fits inside the receptive field — a hard limit, since anything beyond it is structurally invisible rather than merely difficult
  • The sequence is regularly sampled, since dilation counts positions rather than elapsed time
  • Outputs depend only on the past, which the causal padding enforces and a padding mistake silently breaks
  • Translation invariance over time is appropriate: the same filter applies at every position, so a process whose dynamics change with position needs positional input
  • Memory for the full activation stack is available, which is what bounds sequence length here rather than time
Objective

Loss function

The prediction at every position is a function of a fixed window ending at that position — nothing more. That formulation says two things at once. It is causal, so no output depends on a future input, which is what makes the training targets honest. And the window is finite and known: unlike a recurrent model, whose memory horizon is whatever the gradient happens to sustain, this one has a receptive field you can compute from the kernel size and the depth before training anything. Dependencies inside it are learnable and dependencies outside it are invisible, which is a much sharper contract than a recurrent model offers in either direction.

the receptive field — how far back the model can see, and a fixed number computed from the architecture rather than learned
kernel size, usually 2 or 3; the receptive field is linear in it and exponential in depth
dilation at layer l, doubling each layer, which is what makes R exponential in L
number of layers; adding one doubles the horizon rather than extending it by a constant
Optimization

How it is minimized

Ordinary SGD on a feedforward graph — no unrolling through time, so no backpropagation through time and no vanishing recurrent gradient

the residual connection — an additive path the gradient travels without being multiplied, the same idea gating uses
taps spaced by the dilation, so a layer reaches back k·d_l positions using only k weights
shared across every position in the sequence, so the parameter count is independent of length
a fixed-size sum with no dependence on any other output position — which is exactly why the whole sequence computes in parallel

Because there is no recurrence, this is an ordinary feedforward network and trains like one: no unrolling, no truncation, no backpropagation through time, and none of the geometric decay that comes from a repeated product of the same Jacobian. Depth is still deep, so the residual connection matters for the same reason it does anywhere — it gives the gradient an additive route through the stack. Three design points carry the weight. The dilation schedule sets the receptive field, and it should be computed against the longest dependency in the problem rather than chosen by convention. Causal padding must pad only the left; padding both sides leaks the future and produces validation numbers that are simply wrong. And weight normalization plus dropout on the residual branch is the standard stabilization, because the effective depth after stacking dilated blocks is substantial.

Hyperparameters
  • depth and dilation schedule

    Together these set the receptive field, which should be computed against the longest dependency the problem actually has rather than picked by convention (dilations doubling from 1 to 256 or 512, giving a horizon of a few hundred to a few thousand steps)

  • kernel size

    Usually 2 or 3. The receptive field is linear in it and exponential in depth, so adding a layer is far cheaper than widening the kernel

  • channels

    Width per layer, and the capacity dial. Cost is quadratic in it, so this is where the compute budget goes

  • residual and normalization

    Weight normalization on the convolutions and dropout on the residual branch. Standard because the stack is genuinely deep

  • padding

    Left-only, always. Symmetric padding leaks the future, and the resulting validation numbers look excellent and mean nothing

Convergence & cost

Trains as a feedforward network, which is the point — none of the recurrent pathologies apply, and gradient clipping is optional rather than mandatory. The failure modes are different in kind. The receptive field is a hard limit: a dependency one step beyond it is not merely hard to learn, it is structurally invisible, and no amount of training or data reaches it. Causal leakage is the failure that actually happens in practice, and it is silent — a padding mistake or an off-by-one in the shift produces a model that validates beautifully and fails in production, which is worth checking explicitly rather than assuming. And memory during training grows with sequence length times channels because activations at every position are retained, which makes long sequences a memory problem where for a recurrent model they were a time problem.

Training and inference over a whole sequence: O(T · L · k · c^2), fully parallel across positions — which on hardware built for parallel throughput is worth far more than the asymptotics suggest. Memory is O(T · L · c) for the stored activations, and that is the binding constraint rather than time. Autoregressive generation is the weak case: producing tokens one at a time recomputes overlapping windows unless activations are cached, and even cached it is O(R) per step against a recurrent model’s O(1).

Applications

How, where, and why it is used

Time-Series Forecasting

Primary fit
How

Stack dilated causal convolution blocks over the series with the receptive field sized to cover the longest dependency that matters — a full seasonal cycle, typically. Exogenous drivers enter as additional input channels at every position. The whole sequence trains in one parallel pass, and multi-horizon output is a wider final layer rather than a recursive decode.

Where
  • High-frequency series where the relevant history is long and a recurrent model’s effective memory falls short
  • Global models across many series, where the parallel training pass is what makes the dataset size tractable
  • Multi-horizon forecasting produced directly rather than by recursion, which avoids compounding errors
  • Problems with many exogenous channels, since additional inputs cost only width rather than a longer recurrence
Why

It reaches back further than a gated recurrent model reliably can, and it trains in parallel over the sequence, which on modern hardware is decisive — a TCN and a GRU of similar quality will differ by an order of magnitude in training wall-clock. The receptive field being explicit is also a genuine engineering advantage: you can state before training how far back the model sees. The costs are the mirror image. That horizon is a hard ceiling rather than a soft one, memory scales with sequence length, and autoregressive generation is slower than a recurrent model’s constant-time step.

Featurization
  • Compute the receptive field explicitly and check it covers the seasonal period — this is arithmetic, not a hyperparameter search
  • Scale inputs and targets per series, as for any network trained across series
  • Difference or detrend, since the model learns a fitted shape and extrapolates it unpredictably
  • Verify causality directly: shift the target by one and confirm performance collapses, which catches the padding bug that otherwise validates beautifully
Evaluation

Rolling-origin backtesting with MASE against seasonal-naive and against exponential smoothing. Compare against a GRU on the same setup — and if the TCN is far better, check the receptive fields, because the difference is usually horizon rather than architecture.

Pitfalls
  • Symmetric padding leaking the future, which produces excellent validation numbers and a model that fails in production
  • A receptive field shorter than the seasonal period, which makes the seasonality structurally invisible rather than merely hard
  • Scaling statistics computed over the whole series before splitting, the standard leak in neural forecasting
  • Memory: long sequences retain activations at every position and every layer, which is where a TCN runs out rather than in time

Anomaly Detection

Adapted
How

Train to predict the next observation on clean data and score by prediction error, as with any sequence model. The difference is the horizon: the receptive field is explicit, so the span of context informing each prediction is known rather than inferred — which makes it possible to say what the detector can and cannot notice.

Where
  • High-frequency telemetry where the anomaly is a break in long-range structure and the volume rules out a recurrent model
  • Multivariate sensor monitoring, where additional channels cost width rather than a longer recurrence
  • Batch scoring of historical sequences, where the parallel pass is worth far more than constant-time streaming would be
Why

It scores a long history in one parallel pass, which matters when the volume is high and the scoring is batch. And the explicit receptive field is a real advantage over a recurrent detector, whose effective memory is whatever the gradient sustained and is therefore unknown. Against it: streaming detection is the weak case, since each new point costs O(R) rather than O(1) unless activations are cached carefully — so a recurrent or state-space detector is the better fit when scoring must happen per arrival.

Featurization
  • Size the receptive field to cover the longest normal pattern; anything beyond it cannot inform the score at all
  • Fit on a confirmed-clean window, since contamination teaches the model that the anomaly is a valid continuation
  • Standardize residuals per channel, as raw error is dominated by whichever channel has the largest scale
  • Cache layer activations for streaming rather than recomputing the window, which is the difference between viable and not
Evaluation

Precision@k against confirmed incidents with the threshold from clean-period error quantiles, holding out whole anomaly episodes. PR-AUC rather than ROC-AUC at the usual imbalance.

Pitfalls
  • A receptive field shorter than the pattern being violated, so the anomaly is invisible by construction
  • Causal leakage during training, which produces a detector that cannot reproduce its validation performance on live data
  • Streaming without activation caching, which recomputes the whole window per arrival and does not meet latency

Optimization

Adapted
How

The interesting content is a design calculation rather than a solver. The receptive field is a closed-form function of kernel size, depth and dilation schedule, so sizing the architecture to a required horizon is arithmetic done before training — and the exponential growth means the trade is unusually favourable: doubling the horizon costs one layer rather than doubling the parameters. That is a rare case where an architectural choice has an exact cost model.

Where
  • Receptive-field sizing as a closed-form design calculation rather than a hyperparameter search
  • The parallel-versus-sequential trade, where a worse asymptotic profile wins because the hardware rewards parallelism
  • Residual connections as the same additive-gradient-path idea gating uses, applied to depth rather than to time
Why

It is a good demonstration that the asymptotics are not the whole cost model. A TCN does more total arithmetic than a recurrent model of comparable quality and trains an order of magnitude faster, because every position computes independently and the hardware is built for exactly that. Reasoning about which axis a computation parallelizes over — rather than counting operations — is the transferable skill, and this is one of the clearest places to see it pay.

Featurization
  • Compute the receptive field from the schedule and check it against the longest dependency before training anything
  • Prefer depth over kernel width for reach, since the field is exponential in one and linear in the other
Evaluation

Verify the receptive field empirically — perturb an input far back and confirm the output changes exactly when the arithmetic says it should. That is an exact test of the architecture, and it catches dilation-schedule mistakes that no accuracy metric would.

Pitfalls
  • Choosing a dilation schedule by convention rather than computing the horizon it produces
  • Comparing architectures on operation counts rather than wall-clock, which reverses the conclusion here
  • Assuming a deeper stack always reaches further, when a repeated dilation schedule leaves gaps in coverage
Also applied in

Natural Language

Adapted
How

Dilated causal convolutions over tokens or raw audio samples, predicting the next element. This is the WaveNet formulation, and it was the first architecture to generate convincing raw audio — where the sample rate makes the required receptive field enormous and a recurrent model has no chance of reaching it.

Where
  • Raw-audio generation and vocoding, where the receptive field must span thousands of samples
  • Character-level language modelling, as the pre-transformer parallel alternative to a recurrent model
  • Sequence labelling where the whole input is available and parallel training is the binding constraint
Why

On audio it solved a problem recurrence could not: at 16 kHz a meaningful context is tens of thousands of samples, which no gated recurrent model reaches, and an exponentially growing receptive field does. On text it was a credible parallel alternative to recurrence and was then superseded by attention, which has an unbounded receptive field rather than a fixed one and parallelizes just as well. What remains distinctive is the cost profile: linear rather than quadratic in sequence length, which is why convolutional and state-space sequence models keep reappearing for very long contexts.

Featurization
  • For audio, use a mu-law or similar companding of the amplitude, which turns a regression into a manageable categorical prediction
  • Size the receptive field against the phenomenon — phoneme, word, or musical phrase — rather than against a default schedule
  • Cache activations for generation; naive autoregressive sampling recomputes the entire window per output and is orders of magnitude slower
Evaluation

Perplexity or negative log-likelihood per element, plus generation quality where that is the deliverable. Report the receptive field alongside, since it bounds what the model could possibly have captured.

Pitfalls
  • Naive autoregressive generation without activation caching, which is the single largest performance mistake with this architecture
  • A receptive field far shorter than the structure being modelled, which caps quality regardless of capacity
  • Causal leakage from symmetric padding, which is silent and invalidates every reported number
Deployment

What it costs to run

Training

Fully parallel across sequence positions, which is the operative fact: a TCN trains far faster in wall-clock than a recurrent model of comparable quality despite doing more total arithmetic. Memory rather than time is the ceiling, since activations at every position and every layer are retained for the backward pass.

Inference

Whole-sequence scoring is one parallel pass and is fast. Autoregressive generation is the weak case: O(R) per step even with cached activations, against a recurrent model’s O(1), so a TCN is a better batch scorer and a worse token-by-token generator.

Retraining

Periodic and full, warm-started. There is no state to carry between deployments, which removes an entire class of operational error that recurrent models have — nothing to reset, nothing to leak between sequences.

Drift & monitoring
  • Track performance against dependency distance, and specifically at distances near the receptive-field boundary, where degradation appears first as the data changes
  • Verify causality after any change to padding, shifting or the dilation schedule — leakage is silent and only a deliberate test finds it
  • Monitor activation memory against sequence length, since that is the resource this architecture exhausts rather than time
  • Watch for inputs whose relevant history exceeds the receptive field, which is a modelling gap rather than a drift
Production gotchas
  • Padding must be left-only. Symmetric padding leaks the future, produces excellent validation numbers, and fails in production — the most damaging and most common error with this architecture
  • The receptive field is arithmetic, not a hyperparameter: compute it, and confirm it covers the dependency you need before training
  • Autoregressive generation needs cached activations, or each output recomputes the entire window and generation is orders of magnitude slower than it should be
  • Sequence length at inference must not exceed what the padding scheme assumed, or the leading positions see zero-padding as real content
  • Memory scales with sequence length times depth times channels, so a longer input can exhaust memory rather than merely take longer — which surprises teams used to recurrent scaling
Trade-offs

Pros and cons, in context

Strengths
  • Fully parallel across sequence positions

    Every output depends only on its own window, so training processes the whole sequence at once. This is why a TCN trains far faster in wall-clock than a recurrent model despite doing more arithmetic, and it is the main reason to choose one.

  • The receptive field is exponential in depth and known in advance

    Doubling the horizon costs one layer, and the horizon is a closed-form calculation rather than a property that emerges from training. Being able to state what the model can see before training it is unusual and genuinely useful.

  • Trains as a feedforward network

    No unrolling, no truncation, no backpropagation through time, and none of the recurrent gradient pathologies. Clipping becomes optional rather than mandatory, which removes a class of failure entirely.

  • No state to manage between sequences

    Nothing to reset and nothing to leak from one sequence into the next, which removes one of the most common operational errors with recurrent models. Worth more in production than it sounds.

Limitations
  • The receptive field is a hard ceiling

    A dependency one step beyond it is structurally invisible, not merely hard to learn. Attention has no such limit, which is the clearest single argument for it and the reason this architecture is a specialization rather than a replacement.

  • Memory scales with sequence length times depth

    Activations at every position and every layer are retained for the backward pass, so long sequences exhaust memory rather than time. The opposite failure from a recurrent model, and it surprises teams who expect recurrent scaling.

  • Autoregressive generation is slow

    O(R) per step even with cached activations, against a recurrent model’s O(1). A TCN is a good batch scorer and a poor token-by-token generator, which is a real deployment consideration rather than a benchmark artefact.

  • Causal leakage is silent and easy

    One padding mistake produces a model that validates beautifully and fails completely, with nothing raised anywhere. Verifying causality deliberately is a requirement rather than a precaution.

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""A dilated causal convolution stack - transcribed.

Two modifications turn an ordinary convolution into a sequence model. Causal:
only look left. Dilated: skip a growing gap between taps, so the receptive
field doubles per layer while the parameter count grows linearly.
"""


def receptive_field(layers, kernel):
    """R = 1 + sum over layers of (k - 1) * dilation, dilations doubling.

    This is arithmetic, not a hyperparameter. Compute it and check it covers
    the longest dependency in the problem BEFORE training anything - a
    dependency one step beyond it is structurally invisible, not merely hard.
    """
    total = 1
    for layer in range(layers):
        total += (kernel - 1) * (2 ** layer)
    return total


def causal_conv1d(sequence, weights, bias, dilation):
    """One dilated causal convolution over a single-channel sequence.

    sequence[t] contributes only to outputs at positions >= t. The loop below
    reads BACKWARDS from t, never forwards, which is what makes it causal -
    and the reason a symmetric padding scheme silently breaks the model.
    """
    kernel = len(weights)
    out = [0.0] * len(sequence)

    for t in range(len(sequence)):
        total = bias
        for i in range(kernel):
            source = t - i * dilation
            # Positions before the start are treated as zero: this is the
            # LEFT padding, and it must never be applied on the right.
            if source >= 0:
                total += weights[i] * sequence[source]
        out[t] = total

    return out


def relu(values):
    return [value if value > 0.0 else 0.0 for value in values]


def residual_block(sequence, weights_a, bias_a, weights_b, bias_b, dilation):
    """Two dilated convolutions plus a skip connection.

    The residual is the same idea gating uses in a recurrent model: an
    ADDITIVE path the gradient travels without being multiplied. Here it is
    applied to depth rather than to time, and it is what makes a deep stack
    trainable.
    """
    hidden = relu(causal_conv1d(sequence, weights_a, bias_a, dilation))
    output = causal_conv1d(hidden, weights_b, bias_b, dilation)
    return [output[t] + sequence[t] for t in range(len(sequence))]


def forward(sequence, blocks):
    """blocks is a list of (weights_a, bias_a, weights_b, bias_b) tuples.

    Dilation doubles per block, so a stack of L blocks reaches back
    exponentially far using only L * 2 * k weights.
    """
    current = list(sequence)
    for layer, (w_a, b_a, w_b, b_b) in enumerate(blocks):
        current = residual_block(current, w_a, b_a, w_b, b_b, 2 ** layer)
    return current

O(T * L * k) per sequence in interpreter loops, single-channel, with a fresh output list allocated per convolution.

2. IdiomaticNumPy

How this language actually wants it written.

"""A TCN - typed, multi-channel, causal padding verified, as one matmul."""

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray

Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
Tensor = NDArray[np.float64]


@dataclass(frozen=True)
class DilatedBlock:
    """One residual block: two dilated causal convolutions and a skip."""

    weights_a: Tensor    # (kernel, in_channels, out_channels)
    bias_a: Vector
    weights_b: Tensor
    bias_b: Vector
    dilation: int

    @property
    def kernel(self) -> int:
        return self.weights_a.shape[0]


def receptive_field(blocks: list[DilatedBlock]) -> int:
    """R = 1 + sum over layers of (k - 1) * dilation, counting BOTH
    convolutions in each residual block.

    Arithmetic, not a hyperparameter. A dependency one step beyond R is
    structurally invisible rather than merely hard to learn, so this should be
    computed and checked against the problem before training anything.
    """
    return 1 + sum(2 * (block.kernel - 1) * block.dilation for block in blocks)


def causal_conv1d(sequence: Tensor, weights: Tensor, bias: Vector, dilation: int) -> Tensor:
    """Dilated causal convolution over (batch, time, channels).

    Implemented as an explicit gather plus one matrix product rather than a
    loop over positions: the k taps are gathered into a (batch, time, k *
    in_channels) block, which is then one GEMM against the flattened kernel.
    That is how every framework implements a convolution, and it is why a
    convolution is fast at all.
    """
    if sequence.ndim != 3:
        raise ValueError(f"expected (batch, time, channels), got {sequence.shape}")

    batch, steps, in_channels = sequence.shape
    kernel = weights.shape[0]

    # LEFT padding only. Padding both sides leaks the future and produces a
    # model that validates beautifully and fails in production - the most
    # damaging and most common error with this architecture.
    padding = (kernel - 1) * dilation
    padded = np.pad(sequence, ((0, 0), (padding, 0), (0, 0)))

    # Gather the k dilated taps for every output position.
    gathered = np.empty((batch, steps, kernel * in_channels), dtype=np.float64)
    for i in range(kernel):
        start = padding - i * dilation
        gathered[:, :, i * in_channels : (i + 1) * in_channels] = padded[
            :, start : start + steps
        ]

    flat_weights = weights.reshape(kernel * in_channels, -1)
    return gathered @ flat_weights + bias


def forward(sequence: Tensor, blocks: list[DilatedBlock]) -> Tensor:
    """Run the stack. Every position is computed independently, so the whole
    sequence is processed in one pass - the property that distinguishes this
    from a recurrent model."""
    current = sequence

    for block in blocks:
        hidden = np.maximum(
            causal_conv1d(current, block.weights_a, block.bias_a, block.dilation), 0.0
        )
        output = causal_conv1d(hidden, block.weights_b, block.bias_b, block.dilation)

        # The residual: an ADDITIVE path the gradient travels without being
        # multiplied - the same idea gating uses, applied to depth rather
        # than time, and what makes a deep stack trainable.
        if output.shape[2] != current.shape[2]:
            raise ValueError(
                f"residual shape mismatch: block outputs {output.shape[2]} channels "
                f"but input has {current.shape[2]}; a 1x1 projection is needed"
            )
        current = output + current

    return current


def assert_causal(blocks: list[DilatedBlock], steps: int, channels: int) -> None:
    """Verify causality directly rather than assuming it.

    Perturb the LAST input position and confirm no earlier output changed. A
    padding or shift mistake is silent - it produces excellent validation
    numbers - so this check belongs in the test suite rather than in a
    reviewer's head.
    """
    baseline = np.zeros((1, steps, channels), dtype=np.float64)
    perturbed = baseline.copy()
    perturbed[0, -1] = 1.0

    before = forward(baseline, blocks)
    after = forward(perturbed, blocks)

    if not np.allclose(before[0, :-1], after[0, :-1]):
        raise AssertionError("causality violated: a past output changed with a future input")

What changed: The convolution stops being a loop over positions and becomes what every framework actually does: gather the k dilated taps into one block and multiply by the flattened kernel, so a convolution is a GEMM. The padding becomes explicitly left-only with the reason stated, since symmetric padding leaks the future and produces a model that validates beautifully and fails in production. And causality gets an executable check rather than a comment — perturb the last input and confirm no earlier output moved — because that failure is silent and the only way to catch it is to test for it deliberately. The receptive field is computed from the blocks rather than assumed.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

O(T * L * k * c^2) as GEMMs, fully parallel across positions, with one gathered block per convolution.

3. OptimizedNumPy

Faster, and the justification for each change.

"""A TCN - preallocated gather buffers, cached activations for streaming."""

import numpy as np
from numpy.typing import NDArray

Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
Tensor = NDArray[np.float64]


class StreamingTcn:
    """Two changes, for the two regimes this architecture is used in.

    Batch: the gather buffer for each layer is allocated once and rewritten,
    rather than allocating a (batch, time, k * channels) block per convolution
    per forward pass. On a deep stack over a long sequence that is the
    dominant allocation, and activation memory is what a TCN exhausts rather
    than time.

    Streaming: naive autoregressive generation recomputes the entire receptive
    field for every output, which is the single largest performance mistake
    with this architecture. Keeping a small ring buffer per layer - just the
    k dilated taps that layer needs - makes each new step O(L * k * c^2)
    instead of O(R * L * k * c^2).
    """

    def __init__(self, blocks: list[tuple[Tensor, Vector, Tensor, Vector, int]],
                 batch: int, max_steps: int) -> None:
        self._blocks = blocks
        # One contiguous float32 buffer per layer, sized once. float32 halves
        # the traffic on the activation stack, which is the binding resource.
        self._gathered: list[Matrix] = []
        self._rings: list[Matrix] = []

        for weights_a, _, _, _, dilation in blocks:
            kernel, in_channels, _ = weights_a.shape
            self._gathered.append(
                np.zeros((batch, max_steps, kernel * in_channels), dtype=np.float32)
            )
            # Ring buffer holding only the taps this layer reads: k positions
            # spaced by the dilation, not the whole receptive field.
            self._rings.append(
                np.zeros((batch, (kernel - 1) * dilation + 1, in_channels), dtype=np.float32)
            )

    def _conv(self, sequence: Tensor, weights: Tensor, bias: Vector, dilation: int,
              buffer: Matrix) -> Tensor:
        batch, steps, in_channels = sequence.shape
        kernel = weights.shape[0]
        padding = (kernel - 1) * dilation

        padded = np.zeros((batch, steps + padding, in_channels), dtype=np.float32)
        padded[:, padding:] = sequence      # LEFT padding only

        gathered = buffer[:batch, :steps]
        for i in range(kernel):
            start = padding - i * dilation
            # Written into the preallocated buffer rather than a new array.
            np.copyto(
                gathered[:, :, i * in_channels : (i + 1) * in_channels],
                padded[:, start : start + steps],
            )

        return gathered @ weights.reshape(kernel * in_channels, -1) + bias

    def forward(self, sequence: Tensor) -> Tensor:
        """Whole-sequence pass. Every position computes independently, which
        is what makes this fully parallel and a recurrent model not."""
        current = np.ascontiguousarray(sequence, dtype=np.float32)

        for index, (weights_a, bias_a, weights_b, bias_b, dilation) in enumerate(self._blocks):
            hidden = self._conv(current, weights_a, bias_a, dilation, self._gathered[index])
            np.maximum(hidden, 0.0, out=hidden)
            output = self._conv(hidden, weights_b, bias_b, dilation, self._gathered[index])
            current = output + current      # residual

        return current

    def step(self, x: Matrix) -> Matrix:
        """One new timestep, using cached taps.

        This is the difference between viable and unusable autoregressive
        generation: each layer keeps only the k positions it actually reads,
        so a new output costs one pass through the stack rather than a
        recomputation of the whole receptive field.
        """
        current = x

        for index, (weights_a, bias_a, weights_b, bias_b, dilation) in enumerate(self._blocks):
            ring = self._rings[index]
            # Shift in the new value; the ring holds exactly the span this
            # layer's dilated taps reach across, and nothing more.
            ring[:, :-1] = ring[:, 1:]
            ring[:, -1] = current

            kernel = weights_a.shape[0]
            taps = np.concatenate(
                [ring[:, -1 - i * dilation] for i in range(kernel)], axis=1
            )

            hidden = np.maximum(taps @ weights_a.reshape(-1, weights_a.shape[2]) + bias_a, 0.0)
            output = hidden @ weights_b.reshape(-1, weights_b.shape[2]) + bias_b
            current = output + current

        return current

What changed: Two changes for the two regimes this architecture is used in. For batch scoring, the gather buffer per layer is allocated once and rewritten rather than materializing a (batch, time, k·channels) block per convolution per pass — on a deep stack over a long sequence that is the dominant allocation, and activation memory is precisely what a TCN exhausts. For streaming, the naive autoregressive path recomputes the entire receptive field for every output, which is the single largest performance mistake with this architecture; a per-layer ring buffer holding only the taps that layer reads makes each step one pass through the stack instead. Precision drops to float32 because the activation stack is the binding resource.

  • Pre-allocate output arrays and use in-place operations

    Each layer’s gather buffer and streaming ring are allocated once and rewritten, removing a large per-convolution allocation from every forward pass.

    Trade-off: Fixes batch size and maximum sequence length at construction, and the buffers are shared mutable state — the object is not reentrant and cannot serve two streams at once.

  • Vectorize to NumPy/BLAS (@, np.dot, einsum)

    Each convolution becomes a gather plus one GEMM against the flattened kernel, which is how a convolution is made fast and what makes the whole sequence computable in one call.

    Trade-off: The gather materializes k copies of the input, so the intermediate is k times the activation size — the memory cost that buys the parallelism, and the reason long sequences run out of memory rather than time.

  • Ensure contiguous memory layout and a single dtype

    Contiguous float32 halves the traffic on the activation stack — the resource this architecture actually exhausts — and lets each GEMM read the gathered block without an internal copy.

    Trade-off: float32 accumulates error through a deep stack, and the residual additions compound it; some implementations keep the residual path in higher precision for exactly this reason.

O(T * L * k * c^2) batch, O(L * k * c^2) per streaming step versus O(R * L * k * c^2) naive. Illustrative, not a measured benchmark.