[1]:
import fdfi
print('FDFI version:', fdfi.__version__)
FDFI version: 0.0.2

FlowExplainer: Flow-Disentangled Feature Importance

This tutorial covers the FlowExplainer, which uses normalizing flows to compute feature importance. By the end, you’ll understand:

  1. How Flow-DFI differs from OT-based methods

  2. The difference between CPI and SCPI methods

  3. How to use FlowExplainer with default and custom flow models

  4. When to choose FlowExplainer over OTExplainer

Setup

First, let’s import the necessary libraries:

[2]:
import sys
sys.path.insert(0, '../..')  # Add project root to path

import numpy as np
import warnings
warnings.filterwarnings('ignore')

from fdfi.explainers import FlowExplainer, OTExplainer

# Set random seed for reproducibility
np.random.seed(42)


Background: Why Flow-DFI?

OTExplainer and EOTExplainer use optimal transport to create a disentangled representation. However, they rely on parametric assumptions (Gaussian or kernel-based).

FlowExplainer uses a normalizing flow — a learned neural network that maps between the original feature space X and a disentangled latent space Z. This is:

  • More flexible: Learns complex non-linear transformations

  • Data-driven: No parametric assumptions

  • Invertible: Can map X → Z and Z → X

The tradeoff is that it requires training a neural network, which takes longer.

Create Test Data

Let’s create data with correlated features where only some features are truly important:

[3]:
# Create correlated features
n_samples = 500
n_features = 8

# Covariance matrix with correlations
cov = np.eye(n_features)
cov[0, 1] = cov[1, 0] = 0.7  # Features 0 and 1 are correlated
cov[2, 3] = cov[3, 2] = 0.5  # Features 2 and 3 are correlated

X = np.random.multivariate_normal(np.zeros(n_features), cov, size=n_samples)

# Model: only features 0, 1, 2 are active
def model(X):
    return X[:, 0] + 2 * X[:, 1] + 0.5 * X[:, 2]

# Split into train/test
X_train, X_test = X[:400], X[400:]

print(f"Training data: {X_train.shape}")
print(f"Test data: {X_test.shape}")
print(f"\nActive features: 0, 1, 2")
print(f"Null features: 3, 4, 5, 6, 7")
Training data: (400, 8)
Test data: (100, 8)

Active features: 0, 1, 2
Null features: 3, 4, 5, 6, 7

Basic Usage: CPI Method (Default)

The simplest way to use FlowExplainer is with the default CPI (Conditional Permutation Importance) method:

[4]:
# Create FlowExplainer with CPI method
explainer_cpi = FlowExplainer(
    model,
    data=X_train,
    method='cpi',        # Conditional Permutation Importance
    nsamples=30,         # Monte Carlo samples per feature
    num_steps=200,       # Flow training steps (use more for better results)
    random_state=42,
)

# Compute importance
results_cpi = explainer_cpi(X_test)

print("\nCPI Feature Importance:")
print("-" * 40)
for i, phi in enumerate(results_cpi['phi_X']):
    marker = "*" if i < 3 else ""  # Mark active features
    print(f"  Feature {i}: {phi:8.4f} {marker}")
print("\n* = active feature")
[FDFI][INFO] Training flow model...
Training complete: 200 steps, final loss=1.7161
[FDFI][DIAG] Flow Model Diagnostics
[FDFI][DIAG] Latent independence (median dCor): 0.083235 [GOOD]  -> lower is better
[FDFI][DIAG] Distribution fidelity (MMD):       0.000000 [GOOD]  -> lower is better

CPI Feature Importance:
----------------------------------------
  Feature 0:   8.0275 *
  Feature 1:  11.1829 *
  Feature 2:   0.4036 *
  Feature 3:   0.0956
  Feature 4:   0.0383
  Feature 5:   0.0099
  Feature 6:   0.0354
  Feature 7:   0.0120

* = active feature

CPI vs SCPI: What’s the Difference?

FlowExplainer provides two methods for computing importance in the latent Z-space:

CPI (Conditional Permutation Importance)

  • Squared difference after averaging predictions

  • Formula: \(\phi_{Z,j}^{CPI} = (Y - \mathbb{E}_b[f(\tilde{X}_b^{(j)})])^2\)

SCPI (Sobol-CPI)

  • Conditional variance of counterfactual predictions

  • Formula: \(\phi_{Z,j}^{SCPI} = \text{Var}_b[f(\tilde{X}_b^{(j)})]\)

  • Related to Sobol total-order sensitivity indices

For L2 loss with independent (disentangled) features, CPI and SCPI give similar results, since both measure how much the model output changes when feature \(j\) is permuted.

Jacobian Transformation to X-space

Both methods compute importance in Z-space (disentangled features). To attribute importance to the original features \(X_l\), FlowExplainer uses the Jacobian \(H = \frac{\partial X}{\partial Z}\):

\[\phi_{X,l} = \sum_{k=1}^{d} H_{lk}^2 \cdot \phi_{Z,k}\]

This correctly accounts for how each latent dimension affects each original feature.

[5]:
# Create FlowExplainer with SCPI method
explainer_scpi = FlowExplainer(
    model,
    data=X_train,
    method='scpi',       # Sobol-CPI
    nsamples=30,
    num_steps=200,
    random_state=42,
)

results_scpi = explainer_scpi(X_test)

# Compare CPI vs SCPI
print("Comparison: CPI vs SCPI")
print("-" * 50)
print(f"{'Feature':>8} {'CPI':>12} {'SCPI':>12} {'Active':>10}")
print("-" * 50)
for i in range(n_features):
    active = "Yes" if i < 3 else "No"
    print(f"{i:>8} {results_cpi['phi_X'][i]:>12.4f} {results_scpi['phi_X'][i]:>12.4f} {active:>10}")
[FDFI][INFO] Training flow model...
Training complete: 200 steps, final loss=1.7161
[FDFI][DIAG] Flow Model Diagnostics
[FDFI][DIAG] Latent independence (median dCor): 0.083235 [GOOD]  -> lower is better
[FDFI][DIAG] Distribution fidelity (MMD):       0.000000 [GOOD]  -> lower is better
Comparison: CPI vs SCPI
--------------------------------------------------
 Feature          CPI         SCPI     Active
--------------------------------------------------
       0       8.0275       7.3967        Yes
       1      11.1829       9.9715        Yes
       2       0.4036       0.3063        Yes
       3       0.0956       0.0727         No
       4       0.0383       0.0263         No
       5       0.0099       0.0092         No
       6       0.0354       0.0327         No
       7       0.0120       0.0108         No

Computing Both Methods at Once

Use method='both' to compute CPI and SCPI simultaneously (more efficient than running twice):

[6]:
# Compute both CPI and SCPI
explainer_both = FlowExplainer(
    model,
    data=X_train,
    method='both',
    nsamples=30,
    num_steps=200,
    random_state=42,
)

results_both = explainer_both(X_test)

print("Result keys:", list(results_both.keys()))
print("\nCPI importance (phi_Z):", results_both['phi_Z'][:3].round(4))
print("SCPI importance (phi_Z_scpi):", results_both['phi_Z_scpi'][:3].round(4))
[FDFI][INFO] Training flow model...
Training complete: 200 steps, final loss=1.7161
[FDFI][DIAG] Flow Model Diagnostics
[FDFI][DIAG] Latent independence (median dCor): 0.083235 [GOOD]  -> lower is better
[FDFI][DIAG] Distribution fidelity (MMD):       0.000000 [GOOD]  -> lower is better
Result keys: ['phi_Z', 'std_Z', 'se_Z', 'phi_X', 'std_X', 'se_X', 'phi_Z_scpi', 'std_Z_scpi', 'se_Z_scpi', 'phi_X_scpi', 'std_X_scpi', 'se_X_scpi']

CPI importance (phi_Z): [5.2    7.8715 0.329 ]
SCPI importance (phi_Z_scpi): [4.8899 6.9501 0.248 ]

Confidence Intervals and Summary

Use the built-in conf_int() and summary() methods for quick, reproducible inference. This avoids custom ad hoc diagnostics code and keeps reporting consistent across explainers.

[7]:
# Shared diagnostics (computed automatically)
flow_diag = explainer_cpi.diagnostics
print("Flow diagnostics")
print("-" * 72)
print(f"Latent independence (median dCor): {flow_diag['latent_independence_median']:.6f} [{flow_diag['latent_independence_label']}]")
print(f"Distribution fidelity (MMD):       {flow_diag['distribution_fidelity_mmd']:.6f} [{flow_diag['distribution_fidelity_label']}]")

# Standardized inference summary (v0.0.2 defaults use mixture methods)
print("\nX-space summary")
_ = explainer_cpi.summary(alpha=0.05, target='X', alternative='greater')

Flow diagnostics
------------------------------------------------------------------------
Latent independence (median dCor): 0.083235 [GOOD]
Distribution fidelity (MMD):       0.000000 [GOOD]

X-space summary
==============================================================================
Feature Importance Results
==============================================================================
Method: FlowExplainer
Number of features: 8
Significance level: 0.05
Alternative: greater
Practical margin: 0.2034
------------------------------------------------------------------------------
 Feature   Estimate    Std Err   CI Lower   CI Upper    P-value   Sig
------------------------------------------------------------------------------
       0     8.0275     0.8855     6.5710        inf     0.0000   ***
       1    11.1829     1.2737     9.0879        inf     0.0000   ***
       2     0.4036     0.0580     0.3081        inf     0.0003   ***
       3     0.0956     0.0244     0.0555        inf     1.0000
       4     0.0383     0.0231     0.0003        inf     1.0000
       5     0.0099     0.0225    -0.0270        inf     1.0000
       6     0.0354     0.0228    -0.0020        inf     1.0000
       7     0.0120     0.0225    -0.0250        inf     1.0000
==============================================================================
Significant features: 3 / 8
---
Signif. codes:  0 '***' 0.01 '**' 0.05 '*' 0.1 ' ' 1
==============================================================================

Sampling Methods

FlowExplainer supports different ways to generate counterfactual values in Z-space:

  • 'resample': Sample from background data (default)

  • 'permutation': Permute within test set

  • 'normal': Sample from standard normal

  • 'condperm': Conditional permutation (regress Z_j | Z_{-j})

[8]:
# Try different sampling methods
sampling_methods = ['resample', 'permutation', 'normal']
results_by_method = {}

for method in sampling_methods:
    exp = FlowExplainer(
        model, X_train,
        sampling_method=method,
        nsamples=30,
        num_steps=200,
        random_state=42,
    )
    results_by_method[method] = exp(X_test)

print("Importance by Sampling Method:")
print("-" * 55)
print(f"{'Feature':>8} {'resample':>12} {'permutation':>12} {'normal':>12}")
print("-" * 55)
for i in range(n_features):
    r = results_by_method['resample']['phi_X'][i]
    p = results_by_method['permutation']['phi_X'][i]
    n = results_by_method['normal']['phi_X'][i]
    print(f"{i:>8} {r:>12.4f} {p:>12.4f} {n:>12.4f}")
[FDFI][INFO] Training flow model...
Training complete: 200 steps, final loss=1.7161
[FDFI][DIAG] Flow Model Diagnostics
[FDFI][DIAG] Latent independence (median dCor): 0.083235 [GOOD]  -> lower is better
[FDFI][DIAG] Distribution fidelity (MMD):       0.000000 [GOOD]  -> lower is better
[FDFI][INFO] Training flow model...
Training complete: 200 steps, final loss=1.7161
[FDFI][DIAG] Flow Model Diagnostics
[FDFI][DIAG] Latent independence (median dCor): 0.083235 [GOOD]  -> lower is better
[FDFI][DIAG] Distribution fidelity (MMD):       0.000000 [GOOD]  -> lower is better
[FDFI][INFO] Training flow model...
Training complete: 200 steps, final loss=1.7161
[FDFI][DIAG] Flow Model Diagnostics
[FDFI][DIAG] Latent independence (median dCor): 0.083235 [GOOD]  -> lower is better
[FDFI][DIAG] Distribution fidelity (MMD):       0.000000 [GOOD]  -> lower is better
Importance by Sampling Method:
-------------------------------------------------------
 Feature     resample  permutation       normal
-------------------------------------------------------
       0       8.0275       7.6684       7.6812
       1      11.1829      10.7789      10.9221
       2       0.4036       0.3880       0.4060
       3       0.0956       0.0909       0.0978
       4       0.0383       0.0364       0.0365
       5       0.0099       0.0087       0.0093
       6       0.0354       0.0334       0.0337
       7       0.0120       0.0112       0.0118

Using a Custom Flow Model

You can train a flow model separately and pass it to FlowExplainer. This is useful when:

  • You want more control over flow training

  • You have a pre-trained flow

  • You want to use the same flow for multiple explainers

[9]:
from fdfi.models import FlowMatchingModel

# Train a custom flow model
custom_flow = FlowMatchingModel(
    X=X_train,
    dim=n_features,
    hidden_dim=64,
    num_blocks=2,
)
custom_flow.fit(num_steps=300, verbose='final')

# Use the pre-trained flow
explainer_custom = FlowExplainer(
    model,
    data=X_train,
    flow_model=custom_flow,  # Pass pre-trained flow
    fit_flow=False,          # Don't retrain
    nsamples=30,
    random_state=42,
)

results_custom = explainer_custom(X_test)
print("\nImportance with custom flow:", results_custom['phi_X'][:4].round(4))
Training complete: 300 steps, final loss=1.5140
[FDFI][DIAG] Flow Model Diagnostics
[FDFI][DIAG] Latent independence (median dCor): 0.083218 [GOOD]  -> lower is better
[FDFI][DIAG] Distribution fidelity (MMD):       0.000000 [GOOD]  -> lower is better

Importance with custom flow: [3.936  6.1696 0.4598 0.0759]

Comparison: FlowExplainer vs OTExplainer

Let’s compare FlowExplainer to OTExplainer on the same data:

[10]:
# OTExplainer for comparison
explainer_ot = OTExplainer(
    model,
    data=X_train,
    nsamples=30,
    random_state=42,
)
results_ot = explainer_ot(X_test)

print("Comparison: FlowExplainer vs OTExplainer")
print("-" * 55)
print(f"{'Feature':>8} {'Flow (CPI)':>12} {'Flow (SCPI)':>12} {'OT':>12}")
print("-" * 55)
for i in range(n_features):
    f_cpi = results_cpi['phi_X'][i]
    f_scpi = results_scpi['phi_X'][i]
    ot = results_ot['phi_X'][i]
    print(f"{i:>8} {f_cpi:>12.4f} {f_scpi:>12.4f} {ot:>12.4f}")

# Summary statistics
active_mask = np.array([True, True, True, False, False, False, False, False])

print("\n" + "=" * 55)
print("Summary: Active vs Null Feature Importance")
print("=" * 55)
for name, phi in [('Flow CPI', results_cpi['phi_X']),
                  ('Flow SCPI', results_scpi['phi_X']),
                  ('OT', results_ot['phi_X'])]:
    active_mean = phi[active_mask].mean()
    null_mean = phi[~active_mask].mean()
    ratio = active_mean / null_mean if null_mean > 0 else float('inf')
    print(f"{name:>12}: active={active_mean:.4f}, null={null_mean:.4f}, ratio={ratio:.2f}x")
Comparison: FlowExplainer vs OTExplainer
-------------------------------------------------------
 Feature   Flow (CPI)  Flow (SCPI)           OT
-------------------------------------------------------
       0       8.0275       7.3967       3.0570
       1      11.1829       9.9715       5.3006
       2       0.4036       0.3063       0.3043
       3       0.0956       0.0727       0.0372
       4       0.0383       0.0263       0.0015
       5       0.0099       0.0092       0.0036
       6       0.0354       0.0327       0.0007
       7       0.0120       0.0108       0.0049

=======================================================
Summary: Active vs Null Feature Importance
=======================================================
    Flow CPI: active=6.5380, null=0.0382, ratio=170.99x
   Flow SCPI: active=5.8915, null=0.0303, ratio=194.30x
          OT: active=2.8873, null=0.0096, ratio=301.79x

Z-space vs X-space Importance

FlowExplainer provides both:

  • phi_Z: Importance in the disentangled latent space

  • phi_X: Importance attributed to original features (via Jacobian)

Space

Meaning

Z-space (phi_Z)

Importance of independent latent factors

X-space (phi_X)

Importance of original correlated features

For linear transformations (like OTExplainer), these are related by phi_X = H^T @ H @ phi_Z where H is the Cholesky factor. For flows, the Jacobian varies with position.

When to Use FlowExplainer

Summary

In this tutorial, you learned:

  1. FlowExplainer uses normalizing flows for flexible, data-driven feature importance.

  2. CPI averages predictions first, SCPI averages squared differences (Sobol-style).

  3. Use method='both' to compute CPI and SCPI simultaneously.

  4. Different sampling methods offer different tradeoffs.

  5. You can use custom flow models for more control.

  6. Shared diagnostics and conf_int/summary provide a consistent inference workflow.

  7. Strict one-sided testing (alternative='greater') is useful for feature screening.

Next Steps

  • Try FlowExplainer on your own data.

  • Compare X-space and Z-space significance.

  • Cross-check with OT/EOT for consistency.