Recurrent Neural Networks

Master sequential data processing with RNNs, LSTM, and GRU

advanced55 min
On this page

The Big Picture: Why Sequential Data Needs Special Architecture

Traditional feedforward neural networks process each input independently. They have no memory of previous inputs, making them unsuitable for sequential data where the order and history matter.

The Problem with Feedforward Networks

Consider predicting the next word in a sentence. A feedforward DNN treats each word in isolation, unable to capture the context of words that came before it. It cannot answer: "What did the user just click on?" or "What was the trend in their behavior?"

Key Insight: Sequential data has temporal dependencies. The current output depends on past inputs, not just the current input.

RNN Solution: Recurrence and Weight Sharing

Recurrent Neural Networks (RNNs) introduce a hidden state that persists across time steps. This hidden state acts as "memory," carrying information from past inputs forward.

Weight sharing means the same weights are used at every time step, drastically reducing parameters compared to unrolling a full feedforward network across a sequence.

Use Cases for RNNs

  • Sequence Prediction: Language modeling, next-word prediction, stock price forecasting
  • NLP: Machine translation, sentiment analysis, named entity recognition
  • Time Series: Anomaly detection, forecasting, trend analysis
  • AdTech: User click sequence prediction, conversion forecasting, contextual ad ranking
RNN Advantage: By maintaining a hidden state and sharing weights, RNNs efficiently process sequences of arbitrary length and capture long-range dependencies.

AdTech Example: Predicting User Actions with RNNs

In Bing Ads and similar ad platforms, understanding user behavior sequences is critical. Given a user's browsing and click history, we want to predict their next action to optimize ad placement and bidding.

The Scenario

A user visits several websites over time, interacting with ads. Each interaction is an event we can encode:

  • Impression: An ad was shown (input code: 1)
  • Click: The user clicked an ad (input code: 2)
  • Conversion: The user purchased or completed a goal (input code: 3)
  • Ignore: The user saw but ignored an ad (input code: 0)

Sequence Timeline Visualization

t=1 Impression (x₁=1) t=2 Click (x₂=2) t=3 Impression (x₃=1) t=4 Click (x₄=2) t=5 ? (y₅=?) Predict

How RNN Helps

An RNN processes this sequence step by step. At each time step:

  1. The current input (user action) and previous hidden state are combined
  2. A new hidden state is computed, capturing information about all past actions
  3. An output is predicted (next action probability)

The hidden state at step 4 contains information about all previous interactions. This allows the RNN to predict: "After seeing impressions and clicking ads, the user is likely to click again" or "The user is ready to convert."

AdTech Win: By understanding user action sequences, we can optimize bid strategies, placement decisions, and creative selection in real-time.

RNN Architecture: The Recurrence Mechanism

The Recurrence Equation

At each time step t, the RNN computes a hidden state using the recurrence relation:

h_t = tanh(W_hh · h_{t-1} + W_xh · x_t + b_h)

Where:

  • h_t = hidden state at time step t
  • h_{t-1} = hidden state from previous time step (the "memory")
  • x_t = input at time step t
  • W_hh = weights connecting hidden state to hidden state (recurrent weights)
  • W_xh = weights connecting input to hidden state
  • b_h = bias for hidden state
  • tanh = activation function (squashes values to [-1, 1])

The output at each step is computed as:

y_t = softmax(W_hy · h_t + b_y)

Folded vs Unfolded Views

Folded (Compact) Unfolded (Across Time) x_t h_t y_t Recurrent (W_hh) x₁ h₁ y₁ x₂ h₂ y₂ x₃ h₃ Same weights at each step: W_hh, W_xh, W_hy ● Input (x_t) ● Hidden state (h_t) ● Output (y_t) ⋯⋯ Recurrent connection

Weight Sharing: The Key Innovation

The same weight matrices (W_hh, W_xh, W_hy) are applied at every time step. This means:

  • Parameter Efficiency: Instead of learning separate weights for each time step, we learn a single set of weights
  • Generalization: The RNN can process sequences of any length without retraining
  • Shared Pattern Learning: The same patterns (gates, computations) apply across the entire sequence

Information Flow

Input Layer → Hidden Layer (Recurrent) → Output Layer

The hidden layer is special: it receives input from both the current input and its own previous state, creating the recurrence.

Note: For detailed DNN architecture foundation (multilayer perceptrons, activation functions, etc.), see the Deep Neural Networks guide Section 3.

Forward Pass Through Time: Step-by-Step Computation

Let's trace through a concrete example with actual numbers. We'll process a 4-step sequence through an RNN.

Setup

  • Input dimension: 3 (e.g., one-hot encoded ad action: impression, click, conversion)
  • Hidden dimension: 4 (arbitrary choice)
  • Output dimension: 3 (probabilities for 3 possible actions)
  • Initial hidden state: h_0 = [0, 0, 0, 0]

Forward Pass Walkthrough

Time Step 1 (t=1)

x_1 = [1, 0, 0] (Impression) h_1 = tanh(W_hh · h_0 + W_xh · x_1 + b_h) = tanh(W_hh · [0,0,0,0] + W_xh · [1,0,0] + b_h) ≈ [0.2, -0.15, 0.5, -0.3] (conceptual values) y_1 = softmax(W_hy · h_1 + b_y) ≈ [0.3, 0.5, 0.2] (30% next is impression, 50% click, 20% conversion)

Time Step 2 (t=2)

x_2 = [0, 1, 0] (Click) h_2 = tanh(W_hh · h_1 + W_xh · x_2 + b_h) = tanh(W_hh · [0.2,-0.15,0.5,-0.3] + W_xh · [0,1,0] + b_h) ≈ [0.4, 0.1, 0.6, -0.2] (h_1 influences the computation) y_2 = softmax(W_hy · h_2 + b_y) ≈ [0.2, 0.3, 0.5] (higher conversion probability now)

Time Step 3 (t=3)

x_3 = [1, 0, 0] (Impression) h_3 = tanh(W_hh · h_2 + W_xh · x_3 + b_h) ≈ [0.5, 0.2, 0.65, -0.1] y_3 = softmax(W_hy · h_3 + b_y) ≈ [0.15, 0.4, 0.45]

Time Step 4 (t=4)

x_4 = [0, 1, 0] (Click) h_4 = tanh(W_hh · h_3 + W_xh · x_4 + b_h) ≈ [0.55, 0.25, 0.7, 0.0] y_4 = softmax(W_hy · h_4 + b_y) ≈ [0.1, 0.2, 0.7] (70% chance of conversion!)

Visualization

Hidden State Evolution (h_t) h_0 [0,0,0,0] h_1 [.2,-.15] h_2 [.4,.1,.6] h_3 [.5,.2,.65] h_4 [.55,.25,.7] Output Probabilities (y_t) y_1 30% 50% 20% y_4 10% 20% 70% Information accumulation

Matrix Dimensions

To be concrete about shapes:

  • W_xh: (3 × 4) — maps input dimension 3 to hidden dimension 4
  • W_hh: (4 × 4) — maps hidden to hidden (recurrent)
  • W_hy: (4 × 3) — maps hidden dimension 4 to output dimension 3
  • h_t: (4,) — hidden state vector
  • y_t: (3,) — output vector (probabilities)
Key Observation: Notice how the hidden state h_t accumulates information. Earlier time steps influence later predictions through the recurrent connection h_t depends on h_{t-1}.

Backpropagation Through Time (BPTT)

Training RNNs requires a variant of backpropagation called Backpropagation Through Time (BPTT). It's the same algorithm applied to the unrolled network across time steps.

The Core Idea

When we unroll an RNN across T time steps, we get a very deep feedforward network. Backpropagation must flow backward through all T time steps, computing gradients for the shared weights at each step, then summing them.

Critical Insight: BPTT computes gradients by unrolling the entire sequence. The gradient of a loss must flow backward through every time step.

Gradient Flow in BPTT

Gradient Flow (Backprop Through Time) Forward Pass: x₁ h₁ y₁ x₂ h₂ y₂ x₃ h₃ y₃ Backward Pass (Gradients): L₁ L₂ L₃ dL/dh₁ dL/dh₂ dL/dh₃ dL/dh₁ flows to h₂ dL/dh₂ flows to h₃ Weight Gradients (sum across time): dL/dW_hh = dL/dW_hh|_{t=1} + dL/dW_hh|_{t=2} + dL/dW_hh|_{t=3} dL/dW_xh = dL/dW_xh|_{t=1} + dL/dW_xh|_{t=2} + dL/dW_xh|_{t=3} dL/dW_hy = dL/dW_hy|_{t=1} + dL/dW_hy|_{t=2} + dL/dW_hy|_{t=3}

The Chain Rule Across Time

To compute the gradient of loss with respect to a weight W_hh, we apply the chain rule across all time steps:

∂L/∂W_hh = Σ_{t=1}^{T} ∂L_t/∂W_hh Where each term requires backpropagating through the entire recurrent path at that step.

The Jacobian Product (Heart of BPTT)

The gradient of h_t with respect to h_k (where k < t) is a product of Jacobians:

∂h_t/∂h_k = ∂h_t/∂h_{t-1} · ∂h_{t-1}/∂h_{t-2} · ... · ∂h_{k+1}/∂h_k = Π_{i=k+1}^{t} ∂h_i/∂h_{i-1}

Each factor is the Jacobian of the hidden state update at that step. If the RNN uses tanh activation:

∂h_i/∂h_{i-1} ≈ W_hh^T · diag(1 - h_i²) (approximately)

Truncated BPTT

In practice, computing gradients over the entire sequence is often infeasible (very deep gradient path). Truncated BPTT limits how far back gradients flow:

Truncated BPTT: Backpropagate only τ steps into the past instead of all T steps. This is a practical approximation that reduces computation while retaining decent learning.

Typical values: τ = 20 to 100 steps. You unroll the RNN for τ steps, compute loss, backprop, then move to the next τ steps, carrying the hidden state forward.

For DNN backprop foundation: See the Deep Neural Networks guide Section 6.

The Vanishing Gradient Problem in RNNs

RNNs suffer from a critical training challenge: gradients can shrink exponentially as they backpropagate through time steps. This is worse than in feedforward networks and makes learning long-range dependencies nearly impossible.

Why It Happens: The Jacobian Product

Recall the gradient chain across time steps:

∂h_t/∂h_k = Π_{i=k+1}^{t} ∂h_i/∂h_{i-1}

Each Jacobian ∂h_i/∂h_{i-1} depends on W_hh and the activation function. For tanh activation:

∂h_i/∂h_{i-1} = W_hh^T · diag(1 - tanh(...)²)

The term (1 - tanh²(...)) is at most 1 (when the input is 0). The eigenvalues of W_hh typically determine the scaling. If the largest eigenvalue λ_max < 1:

|∂h_t/∂h_k| ≈ λ_max^{t-k}

This is exponential decay! Over many time steps, the gradient becomes vanishingly small.

Concrete Example: 50-Step Sequence

Suppose λ_max = 0.9. After 50 time steps:

Gradient at step 50 ≈ (0.9)^50 ≈ 0.0052 The gradient has decayed to less than 1% of its original magnitude!

This means the RNN essentially "forgets" information from early time steps. Weights that depend on early inputs receive tiny gradient updates and barely change.

Visualization of Gradient Decay

Gradient Magnitude Over Time Steps ||∂L/∂W|| Time Step 0 0.2 0.4 0.6 0.8 1.0 0 10 20 30 40 50 λ_max = 0.9 λ_max = 0.7 Key Insight: If |λ_max| < 1, gradients decay exponentially → RNN can't learn long-term

Consequences

  • Difficulty Learning Long-Range Dependencies: The RNN forgets patterns that span many time steps
  • Poor Performance on Long Sequences: Predictions depend mainly on recent inputs; distant past is ignored
  • Training Stalls: Early-layer weights barely update, making the RNN stagnate
  • Difficulty in NLP: Context words far from the prediction target are effectively ignored

Gradient Clipping (Quick Fix)

One technique to mitigate instability is gradient clipping: if the gradient norm exceeds a threshold, scale it down:

If ||∇|| > clip_threshold: ∇ ← (clip_threshold / ||∇||) · ∇

This prevents exploding gradients (when λ_max > 1) and helps stabilize training, but doesn't solve vanishing gradients directly.

The Motivation for LSTM and GRU

Solution: LSTM and GRU architectures include specialized gating mechanisms that allow gradients to flow with minimal decay, solving the vanishing gradient problem.

LSTM — Long Short-Term Memory

LSTM (Long Short-Term Memory) is designed to solve the vanishing gradient problem through a clever architecture that maintains a "cell state" acting as a conveyor belt carrying information across many time steps.

The LSTM Cell: Conceptual Overview

Unlike a simple RNN with one hidden state, LSTM has two state variables:

  • Cell State (C_t): The long-term memory. Information can persist unchanged for many steps.
  • Hidden State (h_t): The short-term output used for predictions.
Key Innovation: The cell state uses addition (not multiplication) to incorporate new information. This allows gradients to flow with minimal decay.

Three Gates: Controlling Information Flow

An LSTM cell has three gates, each computed with sigmoid activation (output between 0 and 1) to control what information passes through:

1. Forget Gate (f_t)

f_t = σ(W_f · [h_{t-1}, x_t] + b_f) Purpose: Decide which parts of the cell state to "forget" or discard. - Output near 1: Keep this information - Output near 0: Forget this information

2. Input Gate (i_t) & Candidate Values (C̃_t)

i_t = σ(W_i · [h_{t-1}, x_t] + b_i) C̃_t = tanh(W_C · [h_{t-1}, x_t] + b_C) Purpose: Decide which new information to store in the cell state. - i_t controls HOW MUCH of the new values to add - C̃_t is the actual candidate values

3. Output Gate (o_t)

o_t = σ(W_o · [h_{t-1}, x_t] + b_o) Purpose: Decide which parts of the cell state to expose as the hidden state h_t.

Cell State Update (The Critical Step)

The cell state is updated additively, not multiplicatively:

C_t = f_t ⊙ C_{t-1} + i_t ⊙ C̃_t Where ⊙ is element-wise multiplication (Hadamard product). Step-by-step: 1. f_t ⊙ C_{t-1}: Selectively "forget" parts of the old cell state 2. i_t ⊙ C̃_t: Selectively "remember" new candidate values 3. Add them together: C_t is the updated cell state

Why Addition Helps: The gradient of addition is 1, so when backpropagating through the cell state, gradients don't decay multiplicatively. They flow through unchanged (modulo gate gradients).

Hidden State Output

h_t = o_t ⊙ tanh(C_t) The output gate modulates which parts of the cell state become the hidden state.

LSTM Cell Diagram

LSTM Cell Architecture C_t x_t h_t-1 Forget Gate σ(W_f) Input Gate σ(W_i) Candidate tanh(W_C) Output Gate σ(W_o) + tanh(C_t) h_t Key: Cell State (C_t) flows left-to-right Gates control what info enters/leaves cell. Addition allows gradients to flow without decay. Activation: σ = sigmoid (0-1), tanh = (-1, 1), ⊙ = element-wise multiplication

Why LSTM Solves Vanishing Gradients

During backpropagation through the cell state, the gradient path has addition, not multiplication by weight matrices:

dL/dC_t = dL/dC_{t+1} + dL/dh_t · (∂h_t/∂C_t) The key: dC_t comes additively from dC_{t+1}, so there's no exponential decay!

The gates still involve weight matrices, so some gradient decay still happens, but the cell state provides a "gradient highway" that preserves information across many time steps.

LSTM Win: Can learn dependencies spanning 100+ time steps, compared to simple RNNs that struggle beyond 5-10 steps.

GRU — Gated Recurrent Unit

GRU (Gated Recurrent Unit) is a simplified version of LSTM with only 2 gates instead of 3. It often achieves comparable performance with fewer parameters and faster training.

GRU Gates: Simpler Than LSTM

1. Reset Gate (r_t)

r_t = σ(W_r · [h_{t-1}, x_t] + b_r) Purpose: Control how much of the previous hidden state to "reset" or ignore.

2. Update Gate (z_t)

z_t = σ(W_z · [h_{t-1}, x_t] + b_z) Purpose: Control the balance between old and new information.

3. Candidate Hidden State (h̃_t)

First, apply the reset gate to the previous hidden state:

h̃_t = tanh(W_h · [r_t ⊙ h_{t-1}, x_t] + b_h) The reset gate "zeroes out" parts of h_{t-1} before computing the candidate.

4. Hidden State Update

h_t = (1 - z_t) ⊙ h_{t-1} + z_t ⊙ h̃_t The update gate z_t controls the interpolation: - z_t ≈ 0: Keep old hidden state h_{t-1} - z_t ≈ 1: Use new candidate h̃_t

GRU Cell Diagram

GRU Cell Architecture x_t h_t-1 Reset Gate σ(W_r) Update Gate σ(W_z) Candidate Hidden State tanh(W_h) (1-z_t) + h_t GRU Update: h_t = (1 - z_t) ⊙ h_{t-1} + z_t ⊙ h̃_t The update gate z_t acts as a "highway" controlling how much old vs new information to use. Fewer parameters than LSTM, comparable performance on many tasks.

The "Highway" Metaphor

Think of the update gate as a highway merge:

  • When z_t ≈ 0: The old highway (h_{t-1}) is fully open; new traffic (h̃_t) is mostly blocked
  • When z_t ≈ 1: The old highway is closed; new traffic flows through
  • In between: Both old and new information merge
GRU Advantage: Simpler than LSTM (2 gates vs 3), still solves vanishing gradients, often trains faster with equivalent performance.

LSTM vs GRU: Detailed Comparison

Both LSTM and GRU solve the vanishing gradient problem, but they have different tradeoffs. Here's how they compare:

Aspect LSTM GRU
Number of Gates 3 (Forget, Input, Output) 2 (Reset, Update)
Parameters ~4 × weight matrices (higher) ~3 × weight matrices (lower)
Cell State Separate cell state (C_t) No separate state; uses h_t directly
Gradient Flow Addition in cell state preserves gradients Update gate addition also preserves gradients
Training Speed Slower (more parameters, 4 gates) Faster (fewer parameters, 2 gates)
Memory Requirement Higher (extra cell state) Lower
Interpretability Cell state explicitly carries long-term info Mechanism more implicit
Performance on Very Long Sequences Often slightly better (100+ steps) Often comparable (50-100 steps)
When to Use Very long sequences, complex patterns, when accuracy is critical Shorter sequences, limited compute, faster training needed
Real-World Example Machine translation (long dependencies), speech recognition Time series forecasting, named entity recognition

Practical Guidance

Which to Choose? Start with GRU for speed and simplicity. If performance isn't satisfactory, try LSTM. In practice, the difference is often small for sequences under 100 steps. For AdTech applications (user sequences of 5-50 actions), GRU is often sufficient and trains faster.

Empirical Findings

  • Chung et al. (2014): Found GRU and LSTM perform comparably on several datasets
  • Trend: GRU gaining popularity for its efficiency, though LSTM still preferred in deep stacks
  • Attention Mechanism: Modern systems often layer attention with LSTM/GRU rather than relying solely on gating

Loss Functions for Sequences

Training RNNs requires defining loss over sequences. The choice of loss depends on the task structure: are we predicting at every time step or just at the end?

Many-to-One Prediction

Scenario: Sequence of inputs, single output at the end.

Examples:
  • Sentiment classification: Review text → sentiment score
  • AdTech: User action sequence → likelihood to convert
  • Sequence classification: Given 20 user events, classify their account risk level

Loss Computation: Only compute loss at the final time step:

L = Loss(y_T, target) For example, with cross-entropy for binary classification (convert or not): L = -[target · log(y_T) + (1-target) · log(1-y_T)]

Many-to-Many Prediction

Scenario: Predict at every time step (e.g., output same length as input).

Examples:
  • Language modeling: Predict next word at each position
  • Sequence tagging: POS tagging, NER (predict label for each token)
  • Next-action prediction: Given user actions so far, predict probability of each possible next action at each step

Loss Computation: Sum (or average) losses across all time steps:

L = Σ_{t=1}^{T} Loss(y_t, target_t) For next-token prediction, compute cross-entropy at each position: L = Σ_t [-target_t · log(y_t)] Where target_t is one-hot encoded true next token.

Cross-Entropy Loss (Standard for Classification)

For multi-class: L = -Σ_i target_i · log(y_i) For binary: L = -[target · log(y) + (1-target) · log(1-y)] Measures how well the predicted probability distribution matches the true distribution.

Mean Squared Error (For Regression)

If predicting continuous values (e.g., next price in a time series):

L = (y - target)² Or averaged over sequence: L = (1/T) Σ_t (y_t - target_t)²

CTC Loss (Alignment-Free Sequences)

For tasks where output and input lengths don't match (e.g., speech recognition):

Connectionist Temporal Classification (CTC): Allows the network to learn the alignment between input and output without explicit alignment labels. Used in speech-to-text where output is shorter than input.

AdTech Loss Example

For predicting user conversion from a sequence of 10 ad actions:

Many-to-One Setup: - x_1, x_2, ..., x_10: Sequence of user interactions (1=impression, 2=click, 0=ignore) - y_10: Binary output at final step (converted: 1, didn't convert: 0) - Loss: Binary cross-entropy at t=10 only Binary Cross-Entropy: L = -[y_true · log(y_pred) + (1-y_true) · log(1-y_pred)] If user converted: L = -log(y_pred) (penalizes if pred < 1) If user didn't: L = -log(1-y_pred) (penalizes if pred > 0)
Key Insight: Match the loss to your task structure. Many-to-one for sequence classification, many-to-many for generation or tagging.

Python Implementation: RNN, LSTM, and GRU from Scratch

Let's implement SimpleRNN, LSTM, and GRU using NumPy to solidify understanding. These implementations are educational and not optimized for production.

Complete Class-Based Implementation

import numpy as np

class SimpleRNN:
    """
    Simple RNN cell: h_t = tanh(W_hh @ h_{t-1} + W_xh @ x_t + b_h)
                     y_t = softmax(W_hy @ h_t + b_y)
    """
    def __init__(self, input_size, hidden_size, output_size):
        self.input_size = input_size
        self.hidden_size = hidden_size
        self.output_size = output_size

        # Initialize weights (small random values)
        self.W_xh = np.random.randn(input_size, hidden_size) * 0.01
        self.W_hh = np.random.randn(hidden_size, hidden_size) * 0.01
        self.W_hy = np.random.randn(hidden_size, output_size) * 0.01
        self.b_h = np.zeros((1, hidden_size))
        self.b_y = np.zeros((1, output_size))

    def forward(self, x_sequence):
        """
        x_sequence: list of T inputs, each of shape (batch_size, input_size)
        Returns: outputs (T, batch_size, output_size), hidden states
        """
        T = len(x_sequence)
        batch_size = x_sequence[0].shape[0]

        h = np.zeros((batch_size, self.hidden_size))
        outputs = []

        for t in range(T):
            x_t = x_sequence[t]  # (batch_size, input_size)
            h = np.tanh(x_t @ self.W_xh + h @ self.W_hh + self.b_h)
            y_t = self._softmax(h @ self.W_hy + self.b_y)
            outputs.append(y_t)

        return outputs, h

    def _softmax(self, x):
        exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
        return exp_x / np.sum(exp_x, axis=1, keepdims=True)


class LSTMCell:
    """
    LSTM cell with forget, input, and output gates
    """
    def __init__(self, input_size, hidden_size):
        self.input_size = input_size
        self.hidden_size = hidden_size

        # Weight matrices for each gate
        self.W_f = np.random.randn(input_size + hidden_size, hidden_size) * 0.01
        self.W_i = np.random.randn(input_size + hidden_size, hidden_size) * 0.01
        self.W_C = np.random.randn(input_size + hidden_size, hidden_size) * 0.01
        self.W_o = np.random.randn(input_size + hidden_size, hidden_size) * 0.01

        self.b_f = np.zeros((1, hidden_size))
        self.b_i = np.zeros((1, hidden_size))
        self.b_C = np.zeros((1, hidden_size))
        self.b_o = np.zeros((1, hidden_size))

    def forward(self, x_t, h_prev, C_prev):
        """
        Single LSTM step
        x_t: (batch_size, input_size)
        h_prev: (batch_size, hidden_size)
        C_prev: (batch_size, hidden_size)
        """
        batch_size = x_t.shape[0]

        # Concatenate input and hidden state
        concat = np.hstack((h_prev, x_t))  # (batch_size, hidden_size + input_size)

        # Forget gate
        f_t = self._sigmoid(concat @ self.W_f + self.b_f)

        # Input gate and candidate
        i_t = self._sigmoid(concat @ self.W_i + self.b_i)
        C_tilde_t = np.tanh(concat @ self.W_C + self.b_C)

        # Cell state update
        C_t = f_t * C_prev + i_t * C_tilde_t

        # Output gate
        o_t = self._sigmoid(concat @ self.W_o + self.b_o)
        h_t = o_t * np.tanh(C_t)

        return h_t, C_t

    def _sigmoid(self, x):
        return 1 / (1 + np.exp(-np.clip(x, -500, 500)))


class GRUCell:
    """
    GRU cell with reset and update gates
    """
    def __init__(self, input_size, hidden_size):
        self.input_size = input_size
        self.hidden_size = hidden_size

        # Weight matrices
        self.W_r = np.random.randn(input_size + hidden_size, hidden_size) * 0.01
        self.W_z = np.random.randn(input_size + hidden_size, hidden_size) * 0.01
        self.W_h = np.random.randn(input_size + hidden_size, hidden_size) * 0.01

        self.b_r = np.zeros((1, hidden_size))
        self.b_z = np.zeros((1, hidden_size))
        self.b_h = np.zeros((1, hidden_size))

    def forward(self, x_t, h_prev):
        """
        Single GRU step
        x_t: (batch_size, input_size)
        h_prev: (batch_size, hidden_size)
        """
        concat = np.hstack((h_prev, x_t))

        # Reset and update gates
        r_t = self._sigmoid(concat @ self.W_r + self.b_r)
        z_t = self._sigmoid(concat @ self.W_z + self.b_z)

        # Candidate hidden state (with reset applied)
        concat_reset = np.hstack((r_t * h_prev, x_t))
        h_tilde_t = np.tanh(concat_reset @ self.W_h + self.b_h)

        # Hidden state update
        h_t = (1 - z_t) * h_prev + z_t * h_tilde_t

        return h_t

    def _sigmoid(self, x):
        return 1 / (1 + np.exp(-np.clip(x, -500, 500)))


# ===== Example: Character-Level Language Model =====

class CharLevelLSTM:
    """
    Character-level language model using LSTM
    """
    def __init__(self, hidden_size=128):
        self.hidden_size = hidden_size
        self.chars = set()
        self.char_to_idx = {}
        self.idx_to_char = {}
        self.lstm_cell = None
        self.W_hy = None
        self.b_y = None

    def build_vocab(self, text):
        """Build character vocabulary from text"""
        self.chars = sorted(list(set(text)))
        self.char_to_idx = {c: i for i, c in enumerate(self.chars)}
        self.idx_to_char = {i: c for i, c in enumerate(self.chars)}

        vocab_size = len(self.chars)
        self.lstm_cell = LSTMCell(vocab_size, self.hidden_size)
        self.W_hy = np.random.randn(self.hidden_size, vocab_size) * 0.01
        self.b_y = np.zeros((1, vocab_size))

    def forward_sequence(self, sequence_chars):
        """
        Forward pass through sequence of characters
        sequence_chars: list of characters
        Returns: outputs, final_h, final_C
        """
        batch_size = 1
        h = np.zeros((batch_size, self.hidden_size))
        C = np.zeros((batch_size, self.hidden_size))
        outputs = []

        for char in sequence_chars:
            char_idx = self.char_to_idx[char]
            x_t = np.zeros((1, len(self.chars)))
            x_t[0, char_idx] = 1  # one-hot encode

            h, C = self.lstm_cell.forward(x_t, h, C)
            y_t = self._softmax(h @ self.W_hy + self.b_y)
            outputs.append(y_t)

        return outputs, h, C

    def compute_loss(self, sequence_chars):
        """
        Compute cross-entropy loss for predicting next character
        """
        outputs, _, _ = self.forward_sequence(sequence_chars[:-1])
        targets = sequence_chars[1:]

        loss = 0
        for output, target_char in zip(outputs, targets):
            target_idx = self.char_to_idx[target_char]
            loss -= np.log(output[0, target_idx] + 1e-8)

        return loss / len(targets)

    def generate(self, seed_char, length=100):
        """
        Generate text given a seed character
        """
        h = np.zeros((1, self.hidden_size))
        C = np.zeros((1, self.hidden_size))
        generated = seed_char

        char = seed_char
        for _ in range(length):
            char_idx = self.char_to_idx[char]
            x_t = np.zeros((1, len(self.chars)))
            x_t[0, char_idx] = 1

            h, C = self.lstm_cell.forward(x_t, h, C)
            y_t = self._softmax(h @ self.W_hy + self.b_y)

            # Sample next character
            next_idx = np.random.choice(len(self.chars), p=y_t[0])
            char = self.idx_to_char[next_idx]
            generated += char

        return generated

    def _softmax(self, x):
        exp_x = np.exp(x - np.max(x))
        return exp_x / np.sum(exp_x)


# ===== Example Training =====

if __name__ == "__main__":
    # Simple character dataset
    text = "hello world hello world" * 10

    # Create and train model
    model = CharLevelLSTM(hidden_size=32)
    model.build_vocab(text)

    # Training loop (simplified)
    for epoch in range(100):
        loss = model.compute_loss(text[:20])
        if (epoch + 1) % 10 == 0:
            print(f"Epoch {epoch+1}, Loss: {loss:.4f}")

    # Generate text
    generated = model.generate('h', length=50)
    print(f"Generated: {generated}")
            

Usage Guide

For SimpleRNN:

# Create RNN
rnn = SimpleRNN(input_size=10, hidden_size=20, output_size=3)

# Prepare sequence (list of T arrays, each shape (batch_size, input_size))
sequence = [np.random.randn(32, 10) for _ in range(15)]  # 15 steps, batch size 32

# Forward pass
outputs, final_h = rnn.forward(sequence)

# outputs is list of 15 arrays, each (32, 3) — probabilities at each step
# final_h is (32, 20) — final hidden state
            

For CharLevelLSTM:

# Initialize model
model = CharLevelLSTM(hidden_size=128)
model.build_vocab("your text data here")

# Compute loss on a sequence
loss = model.compute_loss("hello")

# Generate new text
generated_text = model.generate('h', length=200)
            

Key Implementation Details

  • Weight Initialization: Small random values (×0.01) to start in a stable region
  • Sigmoid Clipping: np.clip prevents overflow in exponential
  • One-Hot Encoding: Characters represented as one-hot vectors before input
  • Softmax: Subtracting max for numerical stability (prevents overflow)
  • Loss: Cross-entropy: -log(predicted probability of true class)
Production Note: For real applications, use PyTorch, TensorFlow, or JAX. These provide:
  • Automatic differentiation (no manual backprop needed)
  • GPU acceleration
  • Optimized implementations
  • Built-in regularization, layer normalization, etc.

Loading Real Data (Optional)

To train on actual text data, download a dataset:

import urllib.request

# Download Penn Treebank dataset
url = "https://s3.amazonaws.com/research.metamind.io/wikitext/wikitext-2-v1.zip"
urllib.request.urlretrieve(url, "wikitext-2.zip")

# Extract and use:
with open("wikitext-2/train.txt", "r") as f:
    text = f.read()

model = CharLevelLSTM(hidden_size=256)
model.build_vocab(text)

# Training loop (with gradient updates omitted for brevity)
for epoch in range(10):
    # Divide text into sequences of length 50
    for i in range(0, len(text) - 50, 50):
        sequence = text[i:i+50]
        loss = model.compute_loss(sequence)
        # In practice, use gradient descent to update weights
            
Takeaway: These NumPy implementations demonstrate the core mechanics of RNN/LSTM/GRU. Understanding these mechanics is invaluable even when using high-level frameworks.
End of Recurrent Neural Networks