Recurrent Modeling: BPTT, LSTM Gates & GRU
Derive Backpropagation Through Time Jacobian degradation, the Constant Error Carousel in Long Short-Term Memory cells, GRU structural simplifications, and zero-dependency implementations in Python and Rust.
Sequential data (natural language, time series, financial trajectories) possesses temporal dependencies where the probability of token depends on preceding history .
Standard Recurrent Neural Networks (RNNs) suffer exponential gradient degradation during Backpropagation Through Time. To capture long-range temporal horizons, Hochreiter and Schmidhuber introduced the Constant Error Carousel (CEC) inside the Long Short-Term Memory (LSTM) architecture.
The linear cell state preserves backpropagated gradients over hundreds of timesteps without exponential decay.
1. Backpropagation Through Time (BPTT) Gradient Decay#
In a standard vanilla RNN, hidden state evolves according to:
Let total loss over steps be .
Applying the chain rule to compute the gradient with respect to recurrence weight matrix :
Evaluating the temporal state Jacobian product:
2. LSTM Architecture & The Constant Error Carousel#
The Long Short-Term Memory (LSTM) cell resolves vanishing gradients by separating working memory from long-term memory:
- Hidden State (): Short-term working memory.
- Cell State (): Long-term memory flowing through a linear additive highway.
The Six Governing Equations#
Let denote vector concatenation.
Step 1: Forget Gate Vector ($f_t$)
Decides what fraction of historical memory to discard:
Step 2: Input Gate Vector ($i_t$)
Decides which coordinates of incoming information to update:
Step 3: Candidate Cell State ($\tilde{C}_t$)
Generates new candidate memory values bounded in :
Step 4: Cell State Accumulation ($C_t$)
Combines preserved past memory with modulated new candidates:
Step 5: Output Gate Vector ($o_t$)
Filters which components of cell state to emit to the hidden output:
Step 6: Final Hidden State ($h_t$)
Emits the activated working representation:
Proof of the Constant Error Carousel (CEC)#
Consider the partial derivative of current cell state with respect to previous cell state :
Unlike vanilla RNNs where gradients pass through non-linear matrix products, the gradient through the cell state is simply the value of the forget gate .
When , the gradient propagates completely unaltered () across arbitrary time gaps without diminishing.
3. Gated Recurrent Unit (GRU)#
Cho et al. (2014) introduced the Gated Recurrent Unit (GRU), merging cell state and hidden state into a single state vector to reduce parameter count:
- Reset Gate (): Controls how much past history to forget:
- Update Gate (): Acts simultaneously as both forget and input gate:
- Candidate Hidden State ():
- Linear Interpolation Update:
4. From-Scratch Implementation: Zero Dependencies#
The following implementations build a full LSTM Cell Forward Pass from scratch in pure Python and pure Rust.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980import math from typing import List, Tuple class LSTMCellFromScratch: def __init__(self, input_dim: int, hidden_dim: int): self.input_dim = input_dim self.hidden_dim = hidden_dim concat_dim = input_dim + hidden_dim # Initialize weights for all 4 gates with small values scale = 0.1 self.Wf = [[scale for _ in range(concat_dim)] for _ in range(hidden_dim)] self.Wi = [[scale for _ in range(concat_dim)] for _ in range(hidden_dim)] self.Wc = [[scale for _ in range(concat_dim)] for _ in range(hidden_dim)] self.Wo = [[scale for _ in range(concat_dim)] for _ in range(hidden_dim)] # Initialize forget bias to +1.0 (Jozefowicz et al. 2015 recommendation) self.bf = [1.0] * hidden_dim self.bi = [0.0] * hidden_dim self.bc = [0.0] * hidden_dim self.bo = [0.0] * hidden_dim @staticmethod def _sigmoid(x: float) -> float: if x < -700.0: return 0.0 if x > 700.0: return 1.0 return 1.0 / (1.0 + math.exp(-x)) @staticmethod def _tanh(x: float) -> float: return math.tanh(x) def _mat_vec_add(self, W: List[List[float]], v: List[float], b: List[float]) -> List[float]: return [sum(W[i][j] * v[j] for j in range(len(v))) + b[i] for i in range(len(W))] def forward( self, x_t: List[float], h_prev: List[float], c_prev: List[float] ) -> Tuple[List[float], List[float]]: # Concatenate [h_(t-1), x_t] concat_input = h_prev + x_t # 1. Forget Gate: f_t = sigma(W_f * input + b_f) f_logits = self._mat_vec_add(self.Wf, concat_input, self.bf) f_t = [self._sigmoid(val) for val in f_logits] # 2. Input Gate: i_t = sigma(W_i * input + b_i) i_logits = self._mat_vec_add(self.Wi, concat_input, self.bi) i_t = [self._sigmoid(val) for val in i_logits] # 3. Candidate Cell: c_tilde = tanh(W_c * input + b_c) c_logits = self._mat_vec_add(self.Wc, concat_input, self.bc) c_tilde = [self._tanh(val) for val in c_logits] # 4. Cell State: C_t = f_t * C_(t-1) + i_t * c_tilde c_t = [f_t[j] * c_prev[j] + i_t[j] * c_tilde[j] for j in range(self.hidden_dim)] # 5. Output Gate: o_t = sigma(W_o * input + b_o) o_logits = self._mat_vec_add(self.Wo, concat_input, self.bo) o_t = [self._sigmoid(val) for val in o_logits] # 6. Hidden State: h_t = o_t * tanh(C_t) h_t = [o_t[j] * self._tanh(c_t[j]) for j in range(self.hidden_dim)] return h_t, c_t if __name__ == "__main__": lstm = LSTMCellFromScratch(input_dim=3, hidden_dim=2) h_init = [0.0, 0.0] c_init = [0.0, 0.0] seq = [[1.0, 0.5, -0.2], [0.8, -0.4, 0.9]] h, c = h_init, c_init for step, x in enumerate(seq): h, c = lstm.forward(x, h, c) print(f"Step {step+1}:") print(f" Hidden State: {[round(v, 4) for v in h]}") print(f" Cell State: {[round(v, 4) for v in c]}")
5. Active Knowledge Checks#
Why does the derivative dC_t / dC_(t-1) = f_t in an LSTM prevent the vanishing gradient problem that plagues vanilla RNNs?
Why did Jozefowicz et al. (2015) famously recommend initializing the LSTM forget gate bias b_f to +1.0 or +2.0 at the beginning of training?