Normalizing Flows for Multi-Target Regression Example¶
This example demonstrates how to use normalizing flows for multi-target regression with complex, potentially multimodal distributions.
→ API: NormalizingFlowLoss. Guide: Normalizing flows.
Task-First Context¶
Use this example when you need multi-target and multimodal/non-Gaussian predictive
distributions. Start with the Task-First Method Selection Matrix
to compare against MDN, multivariate Gaussian (full/low-rank), and ensemble-based options.
Comparison / Fairness Notes¶
- This page is a single-method deep dive, not a full decision-grade comparison.
- For fair comparison vs
MDN/Gaussian baselines, keep backbone capacity, training epochs, and evaluation metrics aligned. - Report runtime and calibration in addition to point metrics (
MSE,MAE,R2) because expressive models can overfit tails. - Use fixed seeds and the same synthetic data generation parameters when comparing flow types.
When This Approach Fails¶
- Flows add complexity and optional dependencies; use
MDNfirst unless Gaussian/MDN clearly miss structure. - Small datasets can make flow models unstable or poorly calibrated.
- If latency/throughput is constrained, benchmark against Gaussian/MDN alternatives before adopting flows.
Code¶
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from torchregress.losses.nflows import NormalizingFlowLoss, create_flow_model
# ============================================================================
# 1. Generate Synthetic Multi-Target Data with Complex Distribution
# ============================================================================
def generate_complex_multitarget_data(n_samples=2000, noise_level=0.1):
"""
Generate synthetic 2D target data with input-dependent multimodal distribution.
The data has:
- 2D targets (y1, y2) that are correlated
- Distribution that changes with input x
- Mixture of modes for some input regions
"""
# Input features
x = torch.linspace(-3, 3, n_samples).unsqueeze(1)
# Create two correlated targets with complex relationship
# Target 1: Sinusoidal with input-dependent amplitude
y1 = torch.sin(x * 2) * (1 + 0.3 * x**2)
# Target 2: Correlated with y1 but with quadratic relationship
y2 = 0.5 * y1**2 + torch.cos(x * 3) * 0.5
# Add input-dependent noise (aleatoric uncertainty)
# Noise increases in certain regions
noise_scale = noise_level * (1 + 0.5 * torch.abs(x))
# Add multimodal noise in middle region
multimodal_mask = (x.abs() < 1.5).float()
mode_shift = (
torch.where(torch.rand(n_samples, 1) < 0.3, torch.tensor(0.5), torch.tensor(-0.5))
* multimodal_mask
)
# Combine targets with noise
y1_noisy = y1 + torch.randn_like(y1) * noise_scale + mode_shift * 0.3
y2_noisy = y2 + torch.randn_like(y2) * noise_scale + mode_shift * 0.2
# Stack into [n_samples, 2] target matrix
y = torch.cat([y1_noisy, y2_noisy], dim=1)
return x, y
# ============================================================================
# 2. Build Model with Normalizing Flow
# ============================================================================
class MultiTargetFlowModel(nn.Module):
"""
Neural network that outputs context for conditioning the normalizing flow.
Architecture:
Input -> Hidden Layers -> Context -> Flow -> Distribution over Targets
"""
def __init__(self, input_dim, context_dim, hidden_dims=[64, 64]):
super().__init__()
# Feature extractor that outputs context vectors
layers = []
prev_dim = input_dim
for hidden_dim in hidden_dims:
layers.extend([nn.Linear(prev_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.1)])
prev_dim = hidden_dim
# Final layer outputs context for flow
layers.append(nn.Linear(prev_dim, context_dim))
self.feature_extractor = nn.Sequential(*layers)
def forward(self, x):
"""Output context vectors that condition the flow."""
return self.feature_extractor(x)
def create_multitarget_flow_model(
input_dim=1, n_targets=2, context_dim=16, flow_type="nsf", n_transforms=5
):
"""
Create a complete model for multi-target distributional regression.
Args:
input_dim: Dimension of input features
n_targets: Number of target variables to predict
context_dim: Dimension of context vectors for conditioning flow
flow_type: Type of normalizing flow ('nsf', 'maf', 'realnvp')
n_transforms: Number of transformation blocks in flow
Returns:
model: Neural network that outputs context
flow: Normalizing flow conditioned on context
loss_fn: Loss function combining model and flow
"""
# Create the model that produces context
model = MultiTargetFlowModel(
input_dim=input_dim, context_dim=context_dim, hidden_dims=[128, 64]
)
# Create conditional normalizing flow
# The flow learns to map simple Gaussian to complex target distribution
flow = create_flow_model(
n_features=n_targets,
context_dim=context_dim,
flow_type=flow_type,
n_transforms=n_transforms,
hidden_features=64,
n_hidden_layers=2,
)
# Create loss function
loss_fn = NormalizingFlowLoss(flow=flow, reduction="mean")
return model, flow, loss_fn
# ============================================================================
# 3. Training
# ============================================================================
def train_flow_model(model, flow, loss_fn, train_loader, n_epochs=100, lr=1e-3):
"""
Train the model and flow jointly.
Both model parameters and flow parameters are optimized together.
"""
# Combine parameters from both model and flow
optimizer = torch.optim.Adam(list(model.parameters()) + list(flow.parameters()), lr=lr)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode="min", factor=0.5, patience=10
)
losses = []
for epoch in range(n_epochs):
model.train()
flow.train()
epoch_loss = 0.0
for batch_x, batch_y in train_loader:
optimizer.zero_grad()
# Model outputs context
context = model(batch_x)
# Flow computes negative log-likelihood given context
loss = loss_fn(context, batch_y)
loss.backward()
optimizer.step()
epoch_loss += loss.item()
avg_loss = epoch_loss / len(train_loader)
losses.append(avg_loss)
scheduler.step(avg_loss)
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch+1}/{n_epochs}, Loss: {avg_loss:.4f}")
return losses
# ============================================================================
# 4. Prediction and Sampling
# ============================================================================
def predict_with_flow(model, flow, loss_fn, x_test, n_samples=100):
"""
Generate predictions from the learned distribution.
For each input, the flow can:
1. Sample multiple predictions from the learned distribution
2. Compute the mean and uncertainty
3. Capture multimodal distributions
Args:
model: Trained neural network
flow: Trained normalizing flow
loss_fn: Loss function containing the flow
x_test: Test inputs [n_test, input_dim]
n_samples: Number of samples to draw per input
Returns:
samples: Samples from predictive distribution [n_test, n_samples, n_targets]
mean: Mean prediction [n_test, n_targets]
std: Standard deviation [n_test, n_targets]
"""
model.eval()
flow.eval()
with torch.no_grad():
# Get context from model
context = model(x_test)
# Sample from flow's conditional distribution
samples = loss_fn.sample(context, n_samples=n_samples) # [n_test, n_samples, 2]
# Compute statistics
mean = samples.mean(dim=1) # [n_test, 2]
std = samples.std(dim=1) # [n_test, 2]
return samples, mean, std
# ============================================================================
# 5. Visualization
# ============================================================================
def visualize_predictions(x_train, y_train, x_test, y_test, samples, mean, std):
"""Visualize multi-target predictions with uncertainty."""
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# Plot each target dimension
for i, target_name in enumerate(["Target 1", "Target 2"]):
ax = axes[i, 0]
# Training data
ax.scatter(
x_train.numpy(),
y_train[:, i].numpy(),
alpha=0.3,
s=10,
label="Training data",
color="gray",
)
# Test data
ax.scatter(
x_test.numpy(),
y_test[:, i].numpy(),
alpha=0.5,
s=20,
label="True test data",
color="blue",
)
# Predictions with uncertainty
ax.plot(x_test.numpy(), mean[:, i].numpy(), "r-", linewidth=2, label="Mean prediction")
ax.fill_between(
x_test.numpy().flatten(),
(mean[:, i] - 2 * std[:, i]).numpy().flatten(),
(mean[:, i] + 2 * std[:, i]).numpy().flatten(),
alpha=0.3,
color="red",
label="±2 std",
)
ax.set_xlabel("Input x")
ax.set_ylabel(target_name)
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_title(f"{target_name} Predictions")
# 2D scatter: True vs Predicted
ax = axes[0, 1]
ax.scatter(y_test[:, 0].numpy(), mean[:, 0].numpy(), alpha=0.5)
lim = [y_test[:, 0].min().item(), y_test[:, 0].max().item()]
ax.plot(lim, lim, "r--", label="Perfect prediction")
ax.set_xlabel("True Target 1")
ax.set_ylabel("Predicted Target 1")
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_title("Target 1: True vs Predicted")
ax = axes[1, 1]
ax.scatter(y_test[:, 1].numpy(), mean[:, 1].numpy(), alpha=0.5)
lim = [y_test[:, 1].min().item(), y_test[:, 1].max().item()]
ax.plot(lim, lim, "r--", label="Perfect prediction")
ax.set_xlabel("True Target 2")
ax.set_ylabel("Predicted Target 2")
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_title("Target 2: True vs Predicted")
plt.tight_layout()
plt.savefig("normalizing_flows_multitarget_predictions.png", dpi=150)
print("Saved predictions plot to 'normalizing_flows_multitarget_predictions.png'")
# Joint distribution visualization
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# Select a few interesting points to visualize their distributions
test_indices = [50, 100, 150]
for idx, test_idx in enumerate(test_indices):
ax = axes[idx]
# Get samples for this input
input_samples = samples[test_idx].numpy() # [n_samples, 2]
# 2D scatter of samples
ax.scatter(input_samples[:, 0], input_samples[:, 1], alpha=0.3, s=10, label="Samples")
# True point
ax.scatter(
y_test[test_idx, 0].numpy(),
y_test[test_idx, 1].numpy(),
color="red",
s=100,
marker="x",
linewidths=3,
label="True",
)
# Mean prediction
ax.scatter(
mean[test_idx, 0].numpy(),
mean[test_idx, 1].numpy(),
color="green",
s=100,
marker="o",
label="Mean",
)
ax.set_xlabel("Target 1")
ax.set_ylabel("Target 2")
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_title(f"Joint Distribution at x={x_test[test_idx].item():.2f}")
plt.tight_layout()
plt.savefig("normalizing_flows_joint_distributions.png", dpi=150)
print("Saved joint distributions plot to 'normalizing_flows_joint_distributions.png'")
# ============================================================================
# 6. Main Execution
# ============================================================================
def main():
"""Run the complete multi-target normalizing flow example."""
print("=" * 70)
print("Multi-Target Distributional Regression with Normalizing Flows")
print("=" * 70)
# Set random seed for reproducibility
torch.manual_seed(42)
np.random.seed(42)
# 1. Generate data
print("\n1. Generating synthetic multi-target data...")
x_train, y_train = generate_complex_multitarget_data(n_samples=1500)
x_test, y_test = generate_complex_multitarget_data(n_samples=500)
print(f" Training set: {x_train.shape[0]} samples")
print(f" Test set: {x_test.shape[0]} samples")
print(f" Input dim: {x_train.shape[1]}")
print(f" Target dim: {y_train.shape[1]}")
# Create data loader
train_dataset = TensorDataset(x_train, y_train)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
# 2. Create model
print("\n2. Creating multi-target flow model...")
model, flow, loss_fn = create_multitarget_flow_model(
input_dim=1,
n_targets=2,
context_dim=16,
flow_type="nsf", # Neural Spline Flow - most flexible
n_transforms=5,
)
n_model_params = sum(p.numel() for p in model.parameters())
n_flow_params = sum(p.numel() for p in flow.parameters())
print(f" Model parameters: {n_model_params:,}")
print(f" Flow parameters: {n_flow_params:,}")
print(f" Total parameters: {n_model_params + n_flow_params:,}")
# 3. Train
print("\n3. Training model and flow jointly...")
print(" (Both model and flow parameters are optimized together)")
losses = train_flow_model(model, flow, loss_fn, train_loader, n_epochs=100, lr=1e-3)
# 4. Evaluate
print("\n4. Generating predictions on test set...")
samples, mean, std = predict_with_flow(model, flow, loss_fn, x_test, n_samples=200)
# Compute metrics
mse_target1 = ((mean[:, 0] - y_test[:, 0]) ** 2).mean().item()
mse_target2 = ((mean[:, 1] - y_test[:, 1]) ** 2).mean().item()
print(f"\n Test MSE (Target 1): {mse_target1:.4f}")
print(f" Test MSE (Target 2): {mse_target2:.4f}")
print(f" Mean uncertainty (Target 1): {std[:, 0].mean().item():.4f}")
print(f" Mean uncertainty (Target 2): {std[:, 1].mean().item():.4f}")
# 5. Visualize
print("\n5. Creating visualizations...")
visualize_predictions(x_train, y_train, x_test, y_test, samples, mean, std)
print("\n" + "=" * 70)
print("Key Advantages of Normalizing Flows for Multi-Target Regression:")
print("=" * 70)
print("✓ Models complex joint distributions over multiple targets")
print("✓ Captures correlations between target variables")
print("✓ Handles multimodal and non-Gaussian distributions")
print("✓ Provides calibrated uncertainty estimates")
print("✓ Input-dependent distributions (aleatoric uncertainty)")
print("✓ Can sample from the learned distribution")
print("✓ Gradients flow properly for end-to-end training")
print("=" * 70)
print("\nExample completed successfully!")
if __name__ == "__main__":
main()