How to Implement GRU for Time Series in Python

Gated Recurrent Units (GRU) are a variant of recurrent neural networks designed to capture temporal dependencies in sequential data. Unlike traditional RNNs that suffer from vanishing gradients...

Key Insights

  • GRU networks reduce computational complexity compared to LSTMs while maintaining similar performance for time series forecasting, making them ideal for resource-constrained environments
  • Proper sequence creation with sliding windows and 3D data reshaping (samples, timesteps, features) is critical—getting this wrong is the most common implementation mistake
  • Start with a single GRU layer and 32-64 units, then add complexity only if validation loss plateaus; overfitting is more common than underfitting in time series problems

Introduction to GRU and Time Series Forecasting

Gated Recurrent Units (GRU) are a variant of recurrent neural networks designed to capture temporal dependencies in sequential data. Unlike traditional RNNs that suffer from vanishing gradients during backpropagation, GRUs use gating mechanisms to control information flow, allowing them to learn long-term dependencies effectively.

The key difference between GRUs and LSTMs lies in their architecture. While LSTMs use three gates (input, output, and forget), GRUs simplify this to two gates: a reset gate and an update gate. This reduction in parameters makes GRUs faster to train and less prone to overfitting on smaller datasets, while maintaining comparable performance for most time series tasks.

For time series forecasting, GRUs excel because they maintain an internal state that evolves as they process sequences. This allows them to recognize patterns across different time scales—essential for predicting future values based on historical trends, seasonality, and cyclical patterns.

Setting Up the Environment and Data Preparation

Let’s start with a concrete implementation using real-world data. We’ll forecast stock prices, though the same approach applies to any time series problem.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import mean_squared_error, mean_absolute_error
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import GRU, Dense, Dropout
from tensorflow.keras.optimizers import Adam
import yfinance as yf

# Download stock price data
ticker = "AAPL"
data = yf.download(ticker, start="2020-01-01", end="2023-12-31")
df = data[['Close']].copy()

# Visualize the raw data
plt.figure(figsize=(14, 5))
plt.plot(df.index, df['Close'])
plt.title(f'{ticker} Stock Price')
plt.xlabel('Date')
plt.ylabel('Price ($)')
plt.grid(True)
plt.show()

print(f"Dataset shape: {df.shape}")
print(df.head())

For time series, never use random train-test splits. Temporal order matters. Always split chronologically:

# Split data: 80% train, 20% test
train_size = int(len(df) * 0.8)
train_data = df[:train_size]
test_data = df[train_size:]

print(f"Training samples: {len(train_data)}")
print(f"Testing samples: {len(test_data)}")

Data Preprocessing for GRU

Neural networks perform better with normalized inputs. For time series, MinMaxScaler is preferred over StandardScaler because it preserves the shape of the distribution and doesn’t introduce negative values.

# Normalize the data
scaler = MinMaxScaler(feature_range=(0, 1))
train_scaled = scaler.fit_transform(train_data)
test_scaled = scaler.transform(test_data)

Critical step: creating sequences. GRUs don’t process individual data points—they need sequences. We use a sliding window approach where each input is a sequence of n previous timesteps, and the output is the next value.

def create_sequences(data, seq_length):
    """
    Create sequences from time series data.
    
    Args:
        data: Normalized time series array
        seq_length: Number of timesteps to look back
    
    Returns:
        X: Input sequences (samples, timesteps, features)
        y: Target values (samples,)
    """
    X, y = [], []
    
    for i in range(seq_length, len(data)):
        X.append(data[i-seq_length:i, 0])
        y.append(data[i, 0])
    
    X = np.array(X)
    y = np.array(y)
    
    # Reshape X to 3D: (samples, timesteps, features)
    X = np.reshape(X, (X.shape[0], X.shape[1], 1))
    
    return X, y

# Create sequences with 60-day lookback
sequence_length = 60

X_train, y_train = create_sequences(train_scaled, sequence_length)
X_test, y_test = create_sequences(test_scaled, sequence_length)

print(f"X_train shape: {X_train.shape}")
print(f"y_train shape: {y_train.shape}")
print(f"X_test shape: {X_test.shape}")

The output will show something like (samples, 60, 1) for X_train. This 3D structure is mandatory: samples (number of sequences), timesteps (lookback period), and features (number of variables per timestep).

Building the GRU Model

Start simple. A single GRU layer with 50 units handles most time series problems effectively. Add complexity only when validation metrics demand it.

def build_gru_model(sequence_length, n_features=1):
    """
    Build a GRU model for time series forecasting.
    
    Args:
        sequence_length: Number of timesteps in input sequences
        n_features: Number of features per timestep
    
    Returns:
        Compiled Keras model
    """
    model = Sequential([
        GRU(units=50, 
            return_sequences=False,  # False for single-step prediction
            input_shape=(sequence_length, n_features)),
        Dense(units=1)  # Single output for regression
    ])
    
    model.compile(
        optimizer=Adam(learning_rate=0.001),
        loss='mean_squared_error'
    )
    
    return model

model = build_gru_model(sequence_length)
model.summary()

Key parameters explained:

  • units=50: Number of GRU cells. More units = more capacity but higher overfitting risk
  • return_sequences=False: Returns only the last output. Set to True when stacking GRU layers
  • input_shape=(sequence_length, n_features): Must match your data dimensions
  • loss='mean_squared_error': Standard for regression tasks

Training and Evaluation

Train with a validation split to monitor overfitting. For time series, use the last portion of training data as validation—never shuffle.

# Train the model
history = model.fit(
    X_train, y_train,
    epochs=50,
    batch_size=32,
    validation_split=0.1,
    shuffle=False,  # Critical: preserve temporal order
    verbose=1
)

# Plot training history
plt.figure(figsize=(12, 4))
plt.plot(history.history['loss'], label='Training Loss')
plt.plot(history.history['val_loss'], label='Validation Loss')
plt.title('Model Loss During Training')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend()
plt.grid(True)
plt.show()

Make predictions and inverse transform to get actual price values:

# Predict on test set
predictions = model.predict(X_test)

# Inverse transform to get actual prices
predictions = scaler.inverse_transform(predictions)
y_test_actual = scaler.inverse_transform(y_test.reshape(-1, 1))

# Calculate metrics
rmse = np.sqrt(mean_squared_error(y_test_actual, predictions))
mae = mean_absolute_error(y_test_actual, predictions)

print(f"Test RMSE: ${rmse:.2f}")
print(f"Test MAE: ${mae:.2f}")

# Plot predictions vs actual
plt.figure(figsize=(14, 5))
plt.plot(y_test_actual, label='Actual Price', color='blue')
plt.plot(predictions, label='Predicted Price', color='red', alpha=0.7)
plt.title('GRU Model: Actual vs Predicted Stock Prices')
plt.xlabel('Time')
plt.ylabel('Price ($)')
plt.legend()
plt.grid(True)
plt.show()

Hyperparameter Tuning and Best Practices

If your model underfits (high training and validation loss), try these improvements:

Add dropout to prevent overfitting:

model = Sequential([
    GRU(units=50, return_sequences=True, input_shape=(sequence_length, 1)),
    Dropout(0.2),  # Drop 20% of connections
    GRU(units=50, return_sequences=False),
    Dropout(0.2),
    Dense(units=1)
])

model.compile(optimizer=Adam(learning_rate=0.001), loss='mse')

Use stacked GRU layers for complex patterns:

model = Sequential([
    GRU(units=100, return_sequences=True, input_shape=(sequence_length, 1)),
    Dropout(0.2),
    GRU(units=100, return_sequences=True),
    Dropout(0.2),
    GRU(units=50, return_sequences=False),
    Dropout(0.2),
    Dense(units=25),
    Dense(units=1)
])

model.compile(optimizer=Adam(learning_rate=0.001), loss='mse')

Best practices I’ve learned from production deployments:

  1. Sequence length matters: 60 timesteps works well for daily financial data. For hourly data, try 168 (one week). Too short misses patterns; too long increases noise.

  2. Batch size impacts convergence: Start with 32. Larger batches (64-128) train faster but may miss optimal solutions. Smaller batches (8-16) are noisier but explore better.

  3. Early stopping prevents overfitting: Use Keras callbacks to stop training when validation loss stops improving.

from tensorflow.keras.callbacks import EarlyStopping

early_stop = EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True)

history = model.fit(
    X_train, y_train,
    epochs=100,
    batch_size=32,
    validation_split=0.1,
    callbacks=[early_stop],
    shuffle=False,
    verbose=1
)
  1. Feature engineering beats architecture: Adding technical indicators (moving averages, RSI, MACD) as additional features often improves performance more than adding layers.

  2. Multivariate forecasting: For multiple features, change n_features and reshape your data accordingly. The GRU automatically handles multiple input features per timestep.

GRUs provide an excellent balance between performance and computational efficiency for time series forecasting. Start with the simple single-layer architecture shown here, validate on held-out data, and add complexity incrementally based on metrics—not intuition. Most production time series models I’ve deployed use surprisingly simple architectures with good feature engineering rather than deep stacked networks.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.