Part IV: Deep Learning for Sensor Time Series
Chapter 14: Recurrent and Temporal Convolutional Models

Streaming/stateful inference

"They trained me on the whole recording at once, then deployed me to watch the sensor one sample at a time, forever, with no rewind button. I would like a word with whoever thought those were the same job."

A Streaming AI Agent

Why this section matters

The models of this chapter are usually trained offline on fixed windows: you hand the network a \(C \times T\) tensor, it sees the whole span, and it returns an answer. Deployment on a live sensor is a different machine entirely. Samples arrive one at a time, the stream never ends, and the device has a fixed per-sample time budget and a few kilobytes of RAM. The naive bridge, buffer the last \(T\) samples and re-run the full model on every new sample, produces correct answers and wastes almost all of the compute, because it recomputes activations that did not change. Streaming inference is the art of turning a window model into an incremental one that emits the same output per sample while doing \(O(1)\) work per sample and holding only a small, explicit state between steps. Getting this right is the difference between a model that runs on a microcontroller and one that only runs in a notebook.

This section assumes the recurrent cells of Section 14.1 and the causal dilated convolutions of Section 14.3, and it inherits the streaming-safe conditioning rules from Chapter 13. The timing vocabulary (sample period, latency, jitter) is the one established in Chapter 3. Here we care about one question: given a model that was trained to consume a whole window, how do we run it on an endless stream without either recomputing everything or changing the answer?

Two machines: offline windows versus an endless stream

Offline, you control the tensor. You pad it, you see both edges, you can even look ahead. Online, the future does not exist yet, so any layer that peeks forward is unusable and the model must be causal: the output at time \(t\) depends only on samples up to \(t\). Both architectures in this chapter can be made causal (RNNs are causal by construction; a TCN becomes causal with left-only padding), which is exactly why they stream and a bidirectional model does not. The second difference is memory. An offline forward pass allocates the full \(T\)-length activation maps; a streaming pass may keep only what the next step needs. The whole discipline reduces to identifying that minimal carried quantity, the state, and updating it in constant time.

Streaming an RNN: the state was there all along

Recurrent networks are the easy case, because recurrence is streaming. An LSTM or GRU already computes its answer through a per-step update

\[ h_t = f_\theta(h_{t-1},\, x_t), \qquad \hat{y}_t = g_\theta(h_t), \]

where \(h_t\) (for an LSTM, the hidden and cell pair \((h_t, c_t)\)) is a fixed-size vector independent of how long the stream has run. To go from batch training to streaming inference you do not touch the weights; you simply persist \(h_t\) across calls instead of re-initializing it to zero for each window. The per-sample cost is one cell update, \(O(d^2)\) in the hidden size \(d\) and, crucially, constant in stream length. The only new decisions are administrative: when does a session start (reset \(h\) to zero), and what happens when the stream has a gap.

Streaming must reproduce the offline output, bit for bit

A streaming implementation is only correct if, fed the same samples, it emits the same sequence of outputs as the offline model. This is a testable property, not a hope: run the batch model on a length-\(N\) window, run the streaming model sample by sample on the same \(N\) values, and assert the two output sequences agree to floating-point tolerance. If they diverge, you have a real bug, almost always a non-causal layer, a mishandled padding edge, or a normalization statistic computed over the window instead of carried in the state. Write this parity test before you optimize anything; it is the guardrail that lets you refactor for speed without silently changing predictions.

Streaming a TCN: cache the receptive field, do not recompute it

Temporal convolutional networks are the case where naive streaming quietly wastes almost everything. A TCN's output at time \(t\) depends on the last \(R\) samples, where \(R\) is the receptive field set by the dilation schedule of Section 14.3. The tempting deployment is: keep a buffer of the last \(R\) samples, and on every new sample re-run the full stack over that buffer. That works and costs

\[ \text{naive per sample} \;=\; O\!\Big(R \sum_{\ell} K_\ell C_\ell\Big), \]

summed over layers \(\ell\) with kernel size \(K_\ell\) and channel count \(C_\ell\). But the convolution at layer \(\ell\) for all but the newest position was already computed on the previous sample and has not changed. The fix is a per-layer ring buffer that caches exactly the dilated taps each layer needs. Each layer then performs a single kernel evaluation per new sample, and the cost drops to

\[ \text{cached per sample} \;=\; O\!\Big(\sum_{\ell} K_\ell C_\ell\Big), \]

a factor of \(R\) less work, with \(R\) often in the hundreds. The state you carry is one small first-in-first-out buffer per layer, sized to that layer's dilation times its kernel, and the total memory is proportional to the receptive field rather than to the (infinite) stream. The parallel with a Kalman filter's carried state (Chapter 9) is not a coincidence: both replace an unbounded history with a fixed-size sufficient summary.

import torch, torch.nn.functional as F

class StreamingCausalConv1d:
    """Causal dilated conv that consumes ONE sample per call, O(1) in stream length."""
    def __init__(self, conv):                       # conv: a trained nn.Conv1d
        self.conv = conv.eval()
        self.k, self.d = conv.kernel_size[0], conv.dilation[0]
        span = (self.k - 1) * self.d + 1            # samples the kernel spans
        self.buf = torch.zeros(1, conv.in_channels, span)  # ring of the receptive span

    def step(self, x_t):                            # x_t: (1, in_channels)
        self.buf = torch.roll(self.buf, -1, dims=2)
        self.buf[:, :, -1] = x_t                    # newest sample at the right edge
        taps = self.buf[:, :, ::self.d]             # pick the dilated taps only
        with torch.no_grad():
            return self.conv(taps)[:, :, -1]        # (1, out_channels): output for THIS t
A single streaming causal convolution layer. The ring buffer buf holds only the receptive span of this layer; each step shifts in one new sample, gathers the dilated taps, and returns the one output column for the current timestep. Stack these (feeding each layer's output into the next) to stream a full TCN with per-sample cost independent of how long the stream has run.

The code above is the whole idea in miniature: state is the buffer, the update is a shift-and-fill, and the output is a single column rather than a recomputed window. Chain one StreamingCausalConv1d per TCN layer and the stack streams end to end. One subtlety survives from Chapter 13: any BatchNorm must run on frozen inference statistics, never on a per-window batch mean, or your streaming output will not match the offline one and your parity test will (correctly) fail.

A bearing monitor that missed its deadline

An industrial team deployed a causal TCN for online bearing-fault detection (Chapter 37) on a vibration channel sampled at 20 kHz, running on a small edge gateway. Their first version buffered the receptive field, about 500 samples, and re-ran the entire eight-layer stack on every new sample. It was correct and hopelessly late: per-sample compute blew straight past the 50-microsecond budget, the input queue backed up, and samples were dropped, which the model then read as spurious transients. Rewriting the eight layers as cached streaming convolutions cut per-sample work by roughly the 500x receptive-field factor, the queue drained, and the same weights that failed in the notebook now held real-time on the gateway. Nothing about the model changed; only the arithmetic that was allowed to repeat.

Let the framework own the buffers

Hand-rolling a ring buffer per layer, wiring the dilations, and keeping the parity test green is easily 60 to 100 lines for a real multi-layer TCN, and every off-by-one in a dilation index is a silent accuracy bug. Libraries that ship a streaming inference mode collapse it to a flag. With pytorch-tcn, a model built with TCN(..., causal=True) exposes an inference(x_t, in_buffer=...) path that maintains the internal per-layer buffers for you:

from pytorch_tcn import TCN
net = TCN(num_inputs=1, num_channels=[16]*8, kernel_size=3, causal=True)
net.eval(); net.reset_buffers()          # zero the streaming state at session start
for x_t in stream:                       # x_t: (1, 1, 1), one sample at a time
    y_t = net.inference(x_t)             # buffers updated in place, O(1) per step
Streaming a causal TCN with pytorch-tcn: reset_buffers() starts a session and inference() advances the internal per-layer ring buffers, replacing roughly 60 to 100 lines of hand-managed buffering while preserving offline parity by construction.

The library owns the buffer plumbing and the causal padding math; you own the two decisions it cannot make, when to call reset_buffers() and how large a chunk to feed per call.

Warm-up, resets, and chunking: the operational details

Three practical questions decide whether a streaming model behaves in the field. First, warm-up: at session start the state is zero and the buffers are empty, so the first \(R\) samples (or, for an RNN, the first several dozen steps) run on partial context and are less reliable. Either mark those outputs as low-confidence or prime the model with a short lead-in before you trust it. Second, reset semantics: state carried across a genuine discontinuity is worse than useless. When a new recording begins, a wearer removes the device, or a data gap breaks temporal continuity (Section 14.6 treats irregular timing in depth), reset the state so stale history does not bleed into a fresh context. Third, chunking: strict one-sample-at-a-time streaming minimizes latency but maximizes per-call overhead. Processing short blocks of, say, 16 or 64 samples amortizes Python and kernel-launch overhead and often improves throughput on an accelerator, at the cost of adding one block of latency. The right chunk size is a latency-versus-throughput dial set by your deployment target, a tradeoff that the edge-deployment treatment of Chapter 60 makes central.

Exercise: prove parity, then break it on purpose

Take a trained three-layer causal TCN with dilations \(1, 2, 4\) and kernel size 3. (1) Compute the receptive field \(R\) and state the exact buffer size each layer must hold. (2) Run the offline model on a random length-256 window and the streaming version sample by sample on the same values; assert the output sequences match to \(10^{-5}\). (3) Now introduce three faults one at a time and confirm each breaks parity: replace the causal (left) padding with symmetric padding, let a BatchNorm layer use the current window's batch statistics, and forget to reset the buffers between two independent sessions. For each, explain in one sentence why the streaming and offline outputs diverge.

Self-check

  1. Why does an RNN require essentially no extra machinery to stream, while a TCN needs an explicit per-layer buffer? What is being carried in each case?
  2. Naive TCN streaming re-runs the stack over the buffered receptive field on every sample. By what factor does cached streaming reduce per-sample compute, and what quantity sets that factor?
  3. You must never let streaming change the prediction relative to the offline model. Name two implementation choices that silently violate this and say how a parity test would catch each.

What's Next

In Section 14.6, we drop the comfortable assumption that samples arrive on a fixed clock. Real sensor streams are irregular and asynchronous: channels report at different rates, packets are late or missing, and the gap between samples carries information. We will see how to make recurrent and convolutional models consume time itself as an input rather than pretending every step is evenly spaced, which is exactly the discontinuity that forced the state resets above.