Monday, November 02, 2026

Convolutional Neural Networks for Multivariate Time Series: Architectural Foundations and Implementation

From 1D-CNN temporal filters and 2D spatial-temporal kernels to Dilated Causal Convolutions (WaveNet/TCN) and end-to-end PyTorch modeling.


While Convolutional Neural Networks (CNNs) were initially popularized in computer vision, their parameter efficiency, parallel training capabilities, and translation-invariant feature extraction make them exceptionally powerful for multivariate time series (MTS). In multi-sensor telemetry, multi-lead ECGs, audio signals, and high-frequency quantitative finance, signals arrive not as isolated scalars, but as multi-channel trajectories evolving simultaneously across time.

Unlike Recurrent Neural Networks (RNNs) and LSTMs—which process time steps sequentially and suffer from vanishing gradients across long horizons—CNNs evaluate receptive fields across time concurrently. However, applying convolutions to multivariate time series requires solving a critical structural challenge: how to extract local temporal patterns within channels while simultaneously capturing cross-channel correlations without distorting the chronological order of events.


1. Mathematical Formulation: Convolving Across Time and Channels

Consider an input sample X ∈ ℝC × T, where C denotes the number of channels (variables) and T is the sequence length in discrete time steps:

X = [x1, x2, …, xT] ∈ ℝC × T

1D Convolutions with Multi-Channel Inputs (Conv1D)

In standard 1D convolutional layers, a filter kernel W has dimensions (C, K), where K is the temporal kernel size (window length). The kernel slides strictly along the time dimension, performing a dot product across all C channels simultaneously. The feature map activation at time step t for the j-th filter is defined as:

yj, t = σ( ∑c=1C ∑k=0K−1 Wj, c, k · Xc, t − k + bj )

Where σ(·) is a non-linear activation function (such as ReLU or GELU) and bj is a scalar bias term. Notice that while Conv1D compresses all C variables into a single scalar per filter at each step, it treats the spatial ordering of channels as permutation-invariant.


2. Core Architectural Paradigms for Multivariate Time Series

Paradigm Kernel Geometry Channel Interaction Best Use Case
1D Temporal CNN (C × K) sliding across T Collapses all channels simultaneously at layer 1 General sensor classification, forecasting where all channels interact globally
2D Spatio-Temporal CNN (H × K) sliding across both C and T Preserves local channel topology (e.g., adjacent grid sensors) Gridded sensor arrays, EEG electrode topological maps, traffic networks
Temporal Convolutional Networks (TCN) Dilated causal 1D kernels with residual links Exponentially expanding receptive field without future leakage Long-range multivariate sequence forecasting and seq-to-seq tasks
Multi-Scale (InceptionTime) Parallel 1D kernels of diverse lengths (e.g., K ∈ {10, 20, 40}) Captures short-term spikes and long-term trends concurrently Complex multivariate time-series classification benchmarks

Temporal Convolutional Networks (TCNs) & Dilated Causal Convolutions

For forecasting tasks, conventional convolutions suffer from two structural flaws: they leak future information into the past, and their receptive field scales linearly with layer depth (O(L · K)). TCNs address this via two mechanisms:

  • Causal Convolutions: A filter at step t can only compute operations on inputs at step t and earlier (t, t − 1, …), ensuring no information leaks from t + 1.
  • Dilated Convolutions: An operational dilation factor d introduces gaps between kernel points. For a filter w with size K and dilation factor d:

    yt = ∑k=0K−1 wk · xt − d · k

    By doubling the dilation factor at each successive layer (d = 1, 2, 4, 8, …), the model’s effective receptive field scales exponentially:

    Receptive Field = 1 + ∑ℓ=0L−1 2 · (K − 1) · dℓ

    This allows deep CNNs to capture dependencies across thousands of steps with modest depth and no recurrence bottleneck.

3. PyTorch Implementation: 1D-CNN for Multivariate Forecasting

The following modular PyTorch implementation constructs a multi-channel 1D Convolutional Network designed to take an input tensor of shape (batch_size, channels, seq_len) and output a multi-step forecast across channels.

import torch
import torch.nn as nn

class MultivariateTimeSeriesCNN(nn.Module):
    def __init__(self, in_channels: int, out_channels: int, seq_len: int, pred_len: int):
        super().__init__()
        self.pred_len = pred_len
        self.out_channels = out_channels
        
        # 1. Feature extraction with multi-layer 1D convolutions
        self.feature_extractor = nn.Sequential(
            nn.Conv1d(in_channels=in_channels, out_channels=64, kernel_size=5, padding=2),
            nn.BatchNorm1d(64),
            nn.ReLU(),
            nn.Conv1d(in_channels=64, out_channels=128, kernel_size=5, padding=2),
            nn.BatchNorm1d(128),
            nn.ReLU(),
            nn.AdaptiveAvgPool1d(16)  # Compress temporal dimension to fixed length 16
        )
        
        # 2. Projection head to future horizon
        self.head = nn.Sequential(
            nn.Linear(128 * 16, 256),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(256, pred_len * out_channels)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Expected input shape: (batch_size, in_channels, seq_len)
        batch_size = x.size(0)
        
        features = self.feature_extractor(x)  # Shape: (batch_size, 128, 16)
        flat_features = features.view(batch_size, -1)
        
        out = self.head(flat_features)
        return out.view(batch_size, self.out_channels, self.pred_len)

# Verification & Dimension Check
if __name__ == "__main__":
    batch, channels, seq_len, pred_len = 32, 7, 96, 24
    dummy_input = torch.randn(batch, channels, seq_len)
    
    model = MultivariateTimeSeriesCNN(
        in_channels=channels,
        out_channels=channels,
        seq_len=seq_len,
        pred_len=pred_len
    )
    
    predictions = model(dummy_input)
    print(f"Input Shape  : {dummy_input.shape}")
    print(f"Output Shape : {predictions.shape}")  # (32, 7, 24)

4. Critical Engineering Considerations

  1. Data Layout (Channels First vs. Channels Last): Standard deep learning libraries differ in layout expectations. PyTorch nn.Conv1d expects (batch_size, channels, time_steps), whereas TensorFlow/Keras expects (batch_size, time_steps, channels). Transposing tensors properly before convolutional passes is essential.
  2. Dilated Causal Padding: When implementing causal convolutions manually, symmetric padding (padding = kernel_size // 2) causes future-to-past data leakage. One must use asymmetric left-padding of size (kernel_size − 1) · dilation and slice off future activations.
  3. Channel Permutation Sensitivity: While 2D-CNNs work well when channels have a physical grid geometry (such as EEG caps or weather stations), arbitrary ordering of financial or industrial variables makes 2D convolutions sensitive to row permutations. For non-spatial variables, stick to 1D multi-channel convolutions or Graph Neural Network (GNN) + CNN hybrid architectures.

Sunday, November 01, 2026

Demystifying Gini Impurity: How Decision Trees Choose the Optimal Split

A mathematical and practical deep dive into CART decision trees, Gini impurity derivations, split evaluation, and Python implementation from scratch and scikit-learn.


At the heart of every decision tree classifier lies a fundamental question: Which feature and threshold will split the data into the cleanest, most distinct subsets?

In algorithms like CART (Classification and Regression Trees), this decision is powered by a metric known as Gini Impurity. While modern ensemble methods like Random Forests and Gradient Boosted Trees obscure the math behind abstractions, understanding how Gini Impurity drives node splits is essential for diagnosing tree behavior, controlling overfitting, and interpreting classification boundaries.


1. What Is Gini Impurity?

Gini Impurity measures the likelihood that a randomly chosen element from a dataset would be incorrectly labeled if it were randomly labeled according to the class distribution in the subset.

For a node m containing samples across K distinct classes, let pk denote the proportion of training instances belonging to class k:

pk = (1 / Nm) ∑i ∈ Rm 핀(yi = k)

The Gini Impurity IG(m) is defined as:

IG(m) = ∑k=1K pk(1 − pk) = 1 − ∑k=1K pk2

Boundary Conditions

  • Maximum Purity (IG = 0): Every sample in node m belongs to a single class (pj = 1, and pk = 0 for all k ≠ j). The node is pure and requires no further splitting.
  • Maximum Impurity: Samples are equally distributed among all K classes (pk = 1 / K for all k):

    IG, max = 1 − ∑k=1K (1 / K)2 = 1 − K(1 / K2) = 1 − 1 / K

    For binary classification (K = 2), maximum impurity is 0.5. For K = 3, it is 0.667.

2. Evaluating Splits: Weighted Gini Impurity Reduction

A decision tree considers every candidate feature j and candidate threshold t, splitting node m into a left child mL and right child mR:

  • mL(j, t) = { (x, y) ∈ m ∣ xj ≤ t }
  • mR(j, t) = { (x, y) ∈ m ∣ xj > t }

The quality of the split is evaluated using the Weighted Gini Impurity:

Gsplit(j, t) = (NL / Nm) IG(mL) + (NR / Nm) IG(mR)

Equivalently, the algorithm maximizes the Gini Gain (ΔIG):

ΔIG(j, t) = IG(m) − Gsplit(j, t)

The optimal split (j*, t*) satisfies:

(j*, t*) = arg minj, t Gsplit(j, t)


3. Step-by-Step Numerical Example

Suppose a parent node contains 10 samples: 6 Class A and 4 Class B.

Step 1: Calculate Parent Gini Impurity

pA = 6 / 10 = 0.6,   pB = 4 / 10 = 0.4

IG(Parent) = 1 − (0.62 + 0.42) = 1 − (0.36 + 0.16) = 0.48

Step 2: Candidate Split 1 divides the data into:

  • Left Node (4 samples): 4 Class A, 0 Class B → IG(Left) = 1 − (1.02 + 0.02) = 0.0
  • Right Node (6 samples): 2 Class A, 4 Class B → IG(Right) = 1 − ((2/6)2 + (4/6)2) = 1 − (0.111 + 0.444) = 0.444

Gsplit = (4 / 10)(0.0) + (6 / 10)(0.444) = 0 + 0.266 = 0.266

ΔIG = 0.48 − 0.266 = 0.214

Step 3: Candidate Split 2 divides the data into:

  • Left Node (5 samples): 3 Class A, 2 Class B → IG(Left) = 1 − (0.62 + 0.42) = 0.48
  • Right Node (5 samples): 3 Class A, 2 Class B → IG(Right) = 1 − (0.62 + 0.42) = 0.48

Gsplit = (5 / 10)(0.48) + (5 / 10)(0.48) = 0.48

ΔIG = 0.48 − 0.48 = 0.000

The tree selects Split 1 because it achieves a substantially lower weighted Gini Impurity (0.266 vs. 0.480).


4. Gini Impurity vs. Information Gain (Entropy)

Criterion Mathematical Formula Computational Cost Sensitivity & Splitting Behavior
Gini Impurity 1 − ∑ pk2 Lower: Requires basic squaring and addition; highly vectorized. Favors isolating the largest class into its own pure partition quickly.
Entropy (Shannon) −∑ pk log2(pk) Higher: Involves logarithmic operations for every evaluated split. Penalizes mixed distributions slightly more aggressively, often leading to more balanced branch sizes.

In practice, Gini Impurity and Entropy yield identical split decisions roughly 97% to 99% of the time. Gini is generally preferred as the default due to its runtime efficiency.


5. Python Implementation: Verifying Scikit-Learn Splitting

import numpy as np
from sklearn.tree import DecisionTreeClassifier, export_text

# 1. Generate toy dataset matching the manual calculation
# Features: Single numeric attribute X
X = np.array([[1], [2], [3], [4], [5], [6], [7], [8], [9], [10]])
# Target: 6 instances of class 0, 4 instances of class 1
y = np.array([0, 0, 0, 0, 1, 1, 0, 0, 1, 1])

# 2. Fit CART decision tree constrained to a single decision stump
clf = DecisionTreeClassifier(criterion="gini", max_depth=1, random_state=42)
clf.fit(X, y)

# 3. Output tree split criteria
tree_rules = export_text(clf, feature_names=["Feature_X"])
print(tree_rules)

# 4. Programmatic verification of parent Gini impurity
def calculate_gini(labels):
    _, counts = np.unique(labels, return_counts=True)
    probs = counts / len(labels)
    return 1.0 - np.sum(probs ** 2)

print(f"Parent Gini Impurity: {calculate_gini(y):.4f}")

6. Summary of Practical Rules for Practitioners

  1. Early Stopping (Pre-pruning): Without constraints (such as min_samples_leaf or max_depth), a tree splits nodes until Gini = 0 everywhere, resulting in extreme overfitting.
  2. Categorical Cardinality: High-cardinality features can unfairly dominate Gini reduction because they offer an exponential number of split subsets ($2^{k-1} - 1$). Group or frequency-encode these prior to training.
  3. Ensembles: In Random Forests and Extra Trees, feature subspacing mitigates the greedy nature of individual Gini splits, turning weak individual trees into a robust ensemble.