Example

Counterfactual explanations offer an intuitive way to explore how a model’s prediction could change under minimal modifications of the input. Given an instance and a target class, a counterfactual answers the question:

“What is the smallest and most plausible change in this time series that would cause the model to predict a different outcome?”

For multivariate time series, this typically means altering only a short subsequence or specific channels while keeping the overall structure intact. Counterfactuals complement traditional attribution methods by not only highlighting why a decision was made, but also illustrating how it could have been different.

This example walks through the full CONFETTI pipeline for generating such counterfactual explanations, including:

  • generating a synthetic multivariate time series dataset

  • training a lightweight FCN classifier

  • optionally extracting CAM-based feature-importance weights

  • generating counterfactual candidates

  • visualizing the resulting explanations

1. Generate a Toy Multivariate Time Series Dataset

We begin by creating a small synthetic dataset with two classes. Each class has a distinct temporal pattern so that a small FCN can achieve reasonable accuracy.

import numpy as np
from pathlib import Path
from confetti.utils import save_multivariate_ts_as_csv

def generate_toy_dataset(
    n_samples=200, timesteps=100, channels=3, random_state=123
):
    rng = np.random.default_rng(random_state)

    X = np.zeros((n_samples, timesteps, channels))
    y = np.zeros(n_samples, dtype=int)

    # Define class patterns
    for i in range(n_samples):
        if i < n_samples // 2:
            # Class 0
            base = np.linspace(0, 2, timesteps)                         # rising trend
            signal = 3.0 * np.sin(np.linspace(0, 3*np.pi, timesteps))   # low freq
            noise = 0.3 * rng.normal(size=(timesteps, channels))
            X[i] = base[:, None] + signal[:, None] + noise
            y[i] = 0
        else:
            # Class 1
            base = np.linspace(2, 0, timesteps)                         # falling trend
            signal = 3.0 * np.sin(np.linspace(0, 12*np.pi, timesteps))  # high freq
            noise = 0.3 * rng.normal(size=(timesteps, channels))
            X[i] = base[:, None] + signal[:, None] + noise
            y[i] = 1

    return X, y


# Create dataset
X, y = generate_toy_dataset()

# Shuffle before split
indices = np.arange(len(X))
np.random.shuffle(indices)
X, y = X[indices], y[indices]

# 80/20 split
split = int(0.8 * len(X))
X_train, y_train = X[:split], y[:split]
X_test, y_test = X[split:], y[split:]

print("Train:", X_train.shape, y_train.shape)
print("Test: ", X_test.shape, y_test.shape)
Train: (160, 100, 3) (160,)
Test:  (40, 100, 3) (40,)

2. Build a Lightweight FCN Classifier

Below is a small FCN-like model designed for example usage.

import keras
from keras import layers
import numpy as np
from pathlib import Path

class ToyFCN:

    def __init__(self, input_shape, num_classes=2):
        self.input_shape   = input_shape
        self.num_classes   = num_classes
        self.model_path    = Path("toy_fcn.keras")
        self.model         = self._build_model()

    def _build_model(self):
        inputs = keras.layers.Input(shape=self.input_shape)

        # Conv block 1
        x = layers.Conv1D(filters=16, kernel_size=8, padding="same")(inputs)
        x = layers.BatchNormalization()(x)
        x = layers.ReLU()(x)

        # Conv block 2
        x = layers.Conv1D(filters=32, kernel_size=5, padding="same")(x)
        x = layers.BatchNormalization()(x)
        x = layers.ReLU()(x)

        # Conv block 3
        x = layers.Conv1D(filters=16, kernel_size=3, padding="same")(x)
        x = layers.BatchNormalization()(x)
        x = layers.ReLU()(x)

        # Global average pooling
        gap = layers.GlobalAveragePooling1D()(x)

        # Output layer
        outputs = layers.Dense(self.num_classes, activation="softmax")(gap)

        model = keras.Model(inputs=inputs, outputs=outputs)
        model.compile(
            optimizer="adam",
            loss="sparse_categorical_crossentropy",
            metrics=["accuracy"],
        )
        return model

    def fit(self, X_train, y_train, epochs=20, batch_size=16, verbose=1):
        self.model.fit(
            X_train,
            y_train,
            epochs=epochs,
            batch_size=batch_size,
            verbose=verbose,
            validation_split=0.2,
        )
        self.model.save(self.model_path)
        return self.model

    def predict(self, X):
        proba = self.model.predict(X)
        return np.argmax(proba, axis=1)

    def predict_proba(self, X):
        return self.model.predict(X)

3. Train the FCN Model

input_shape = X_train.shape[1:]
num_classes = len(np.unique(y_train))

model = ToyFCN(input_shape=input_shape, num_classes=num_classes)

print("Training ToyFCN...")
model.fit(X_train, y_train, epochs=20, batch_size=16, verbose=1)

accuracy = (model.predict(X_test) == y_test).mean()
print(f"Test accuracy: {accuracy:.3f}")
Test accuracy: 1.000

4. Generate Counterfactual Explanations with CONFETTI

Lets visualize the instance we want to explain.

from confetti.visualizations import plot_time_series

instance = X_test[0:1]
plot_time_series(series=instance, title="Instance to Explain")
Instance to Explain

Now we apply CONFETTI to generate a counterfactual for a single instance.

from confetti import CONFETTI
from confetti.attribution import cam

weights = cam(model.model, X_train)

instance = X_test[0:1]

explainer = CONFETTI(model_path="toy_fcn.keras")

results = explainer.generate_counterfactuals(
    instances_to_explain=instance,
    reference_data=X_train,
    reference_weights=weights,
    alpha=0.5,
    theta=0.51,
)

cf_set = results[0]
print("Original label:     ", cf_set.original_label)
print("Counterfactual label:", cf_set.best.label)
print("Total generated:     ", len(cf_set.all_counterfactuals))
Original label:      0
Counterfactual label: 1
Total generated:      11

The returned CounterfactualResults object contains several attributes, including the original instance, generated candidates, the best counterfactual, and CAM importance values (when available).

5. Visualize the Counterfactual Explanation

Finally, we visualize the best counterfactual and optional CAM heatmap.

from confetti.visualizations import plot_counterfactual

plot_counterfactual(
    original=cf_set.original_instance,
    counterfactual=cf_set.best,
    cam_weights=cf_set.feature_importance,
    cam_mode="heatmap",
    title="Counterfactual Explanation",
)
Counterfactual Explanation Visualization

The plot shows how CONFETTI generates a counterfactual by selectively modifying only the most relevant part of the time series. The green curves represent the original instance across all channels, while the red curves show the counterfactual subsequence inserted by the method. The heatmap at the bottom corresponds to the class activation map (CAM) of the nearest unlike neighbor (NUN), which indicates the time region the model relies on most when predicting the new class. CONFETTI uses this CAM as a guide, focusing its perturbation on the segment most responsible for distinguishing the NUN’s class from the original. The alignment between the high-activation region in the CAM and the red counterfactual subsequence illustrates how the method leverages attribution to produce focused and meaningful counterfactual changes.

Alternative: PyTorch Workflow

CONFETTI also supports PyTorch models via TorchModelAdapter. The sections below mirror the Keras walkthrough above, reusing the same toy dataset generated in Section 1.

6a. Build a PyTorch Classifier

The architecture mirrors the Keras ToyFCN: three convolutional blocks (16 → 32 → 16 filters, kernel sizes 8 → 5 → 3) with batch normalization and ReLU, followed by global average pooling and a linear classifier with softmax output.

PyTorch Conv1d expects channels-first input (N, C, T), whereas CONFETTI stores data as (N, T, C). TorchModelAdapter handles this permutation automatically.

import torch
import torch.nn as nn

class ToyFCN(nn.Module):

    def __init__(self, in_channels=3, num_classes=2):
        super().__init__()
        self.conv1 = nn.Conv1d(in_channels, 16, kernel_size=8, padding="same")
        self.bn1 = nn.BatchNorm1d(16)
        self.conv2 = nn.Conv1d(16, 32, kernel_size=5, padding="same")
        self.bn2 = nn.BatchNorm1d(32)
        self.conv3 = nn.Conv1d(32, 16, kernel_size=3, padding="same")
        self.bn3 = nn.BatchNorm1d(16)
        self.classifier = nn.Linear(16, num_classes)

    def forward(self, x):
        x = torch.relu(self.bn1(self.conv1(x)))
        x = torch.relu(self.bn2(self.conv2(x)))
        x = torch.relu(self.bn3(self.conv3(x)))
        x = x.mean(dim=-1)  # global average pooling over time
        x = torch.softmax(self.classifier(x), dim=-1)
        return x

6b. Train and Save

model = ToyFCN(in_channels=3, num_classes=2)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()

# Permute to channels-first for PyTorch
X_train_t = torch.tensor(X_train, dtype=torch.float32).permute(0, 2, 1)
y_train_t = torch.tensor(y_train, dtype=torch.long)

model.train()
for epoch in range(20):
    optimizer.zero_grad()
    logits = model(X_train_t)
    loss = criterion(logits, y_train_t)
    loss.backward()
    optimizer.step()

# Save the full model (not just state_dict)
torch.save(model, "toy_fcn.pt")
Test accuracy: 1.000

Note

torch.save(model, path) serializes the full model object. CONFETTI’s internal loader (_load_model) uses torch.load(path, weights_only=False) and wraps the result in a TorchModelAdapter, so the file must contain the complete model—not just state_dict.

6c. Wrap with TorchModelAdapter

from confetti import TorchModelAdapter

adapter = TorchModelAdapter(model)

TorchModelAdapter provides a .predict() method that accepts CONFETTI’s channels-last (N, T, C) arrays and returns class probabilities as a numpy array. By default channels_last=True, so the adapter permutes inputs to (N, C, T) before calling the model.

6d. CAM with PyTorch

from confetti.attribution import cam

weights = cam(adapter, X_train)

The cam() function supports both Keras and PyTorch models. In the Keras example (Section 4), a raw keras.Model was passed; here we pass the TorchModelAdapter. Internally, CONFETTI uses forward hooks on Conv1d and Linear layers to capture activations and classifier weights.

6e. Generate Counterfactuals

from confetti import CONFETTI

explainer = CONFETTI(model_path="toy_fcn.pt")

results = explainer.generate_counterfactuals(
    instances_to_explain=instance,
    reference_data=X_train,
    reference_weights=weights,
    alpha=0.5,
    theta=0.51,
)

cf_set = results[0]
print("Original label:     ", cf_set.original_label)
print("Counterfactual label:", cf_set.best.label)
print("Total generated:     ", len(cf_set.all_counterfactuals))

When CONFETTI receives a .pt or .pth model path it automatically loads the model with torch.load and wraps it in a TorchModelAdapter—no manual wrapping is needed for the explainer.

6f. Visualize

from confetti.visualizations import plot_counterfactual

plot_counterfactual(
    original=cf_set.original_instance,
    counterfactual=cf_set.best,
    cam_weights=cf_set.feature_importance,
    cam_mode="heatmap",
    title="Counterfactual Explanation (PyTorch)",
)

The visualization API is identical regardless of framework.