Skip to content

Simulating Data

CCCPM ships a structural-equation-model (SEM) based simulator that builds connectome-like data with an analytically known ground truth. It lets you dial in exactly how strong the brain–outcome association is and how much of it is spurious (driven by a confound), which is what makes it useful for validating a confound-aware analysis: you know the right answer before you run the model.

The simulator lives in cccpm.simulation.simulate_sem. The two entry points are simulate_data_given_R2 and simulate_data_given_kappa.

The generative model

A single latent confound Z is a common cause of both the connectome X and the outcome y:

Text Only
        Z  (confound: e.g. age, head motion, site)
       / \
      v   v
      X    y      X also has a genuine, direct effect on y
       \___^

Because Z drives both, a naive correlation between X and y is inflated — it mixes the genuine brain–outcome effect with a spurious path through the confound. The simulator controls exactly how large each piece is.

Three quantities fully describe the setup:

Quantity Meaning
R2_X_y Naive R² of y ~ X — the apparent (inflated) brain–outcome strength.
R2_X_y_given_Z Unique / deconfounded R² of X after adjusting for Z — the true effect you hope to recover.
R2_Z_y R² of y ~ Z — how much the confound alone explains.

Feature (edge) classes

The connectome columns are split into four interpretable classes so you can see which edges an analysis keeps or drops:

Class Argument Correlated with y? Correlated with confound?
mixed n_features_informative yes yes — carries both genuine signal and confound leakage
pure-signal n_pure_signal_features yes no — genuine signal, orthogonal to the confound
confound-only n_confound_only_features only through the confound yes
noise (the remainder) no no

This separation is what exposes the limit of partial-correlation edge selection: it rejects confound-only edges (their partial association with y is null) but keeps mixed edges — whose raw values still leak confound variance into the network score. The ground-truth column indices for each class are returned in the info dict (mixed_idx, pure_signal_idx, confound_only_idx, noise_idx).

Valid connectome sizes

n_features must be a valid number of upper-triangular edges of a symmetric node-by-node matrix, i.e. n_features = n_nodes * (n_nodes - 1) / 2. For example 435 edges = 30 nodes, 1225 edges = 50 nodes. CCCPM raises a clear error (with the nearest valid sizes) if you pass an invalid count.

What you get back

Every simulator call returns a dict:

Key Shape Meaning
X (n_samples, n_features) the simulated connectome (feed as X)
y (n_samples, 1) the continuous outcome (feed as y)
Z (n_samples, n_confounds) the confounds (feed as covariates)
true_X (n_samples, 1) the latent predictor behind the mixed edges
info dict ground-truth R² targets, edge-class indices, corr_true_X_Z, etc.

So plugging the simulator into CPMAnalysis is direct:

Python
from cccpm.simulation.simulate_sem import simulate_data_given_kappa

sim = simulate_data_given_kappa(R2_X_y=0.4, kappa=0.3, n_features=435, n_samples=200)
X, y, covariates = sim["X"], sim["y"], sim["Z"]

Specifying R² directly

Use simulate_data_given_R2 when you want to pin all three R² components yourself. The simulator solves for the brain–confound coupling that reproduces your targets and warns (rather than crashing) if an exact match is infeasible:

Python
from cccpm.simulation.simulate_sem import simulate_data_given_R2, compute_r2s

sim = simulate_data_given_R2(
    R2_X_y=0.25,          # apparent (naive) brain–outcome R²
    R2_X_y_given_Z=0.15,  # true, deconfounded R²
    R2_Z_y=0.10,          # confound–outcome R²
    n_features=435,       # 30-node connectome
    n_features_informative=40,
    n_pure_signal_features=20,
    n_confound_only_features=20,
    n_samples=2000,
    random_state=0,
)

# Empirically check the generated data matches the analytic targets:
print(compute_r2s(sim))   # r2_naive ≈ 0.25, r2_unique_X ≈ 0.15, ...

Specifying the spurious fraction (kappa)

Often the cleanest knob is how much of the apparent signal is fake. kappa is the fraction of the naive R² that is confound-driven:

Text Only
R2_X_y_given_Z = (1 - kappa) * R2_X_y     # true, deconfounded value
R2_Z_y         =      kappa  * R2_X_y     # spurious part
  • kappa = 0 → no confounding (true == naive),
  • kappa = 1 → fully spurious (true effect → 0).
Python
from cccpm.simulation.simulate_sem import simulate_data_given_kappa

sim = simulate_data_given_kappa(
    R2_X_y=0.4,   # apparent brain–outcome R²
    kappa=0.3,    # 30% of it is confound-driven → true R² is 0.7 * 0.4 = 0.28
    n_features=435,
    n_samples=200,
    random_state=42,
)

This is the parameterisation used in the two quickstart examples and in examples/confound_inflation_demo.py.

Sweeping a grid of scenarios

generate_confound_grid builds a whole matrix of scenarios — rows sweep the brain–outcome strength R2_X_y, columns sweep kappa — each with an independent, reproducible seed. This is the backbone of the confound-inflation demonstration:

Python
from cccpm.simulation.simulate_sem import generate_confound_grid, compute_r2s

grid = generate_confound_grid(n_samples=2000, random_state=42)
for (r2, kappa), sim in grid.items():
    r2s = compute_r2s(sim)
    print(r2, kappa, sim["info"]["R2_X_y_given_Z"], r2s["r2_unique_X"])

For a full worked example that runs CCCPM across such a grid and shows that partial-correlation edge selection does not remove the inflation (only residualization does), see examples/confound_inflation_demo.py.

Making the target binary

The SEM simulator produces a continuous outcome. For a classification example, split it at the median to get a balanced 0/1 label (the confound remains baked into the connectome):

Python
import numpy as np

y = (sim["y"].ravel() > np.median(sim["y"])).astype(float)

API reference

See the Simulation API reference for the full signatures and every parameter.

See also