cccpm 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {cccpm-0.3.1 → cccpm-0.3.2}/PKG-INFO +1 -1
  2. {cccpm-0.3.1 → cccpm-0.3.2}/pyproject.toml +1 -1
  3. cccpm-0.3.2/src/cccpm/simulation/simulate_sem.py +573 -0
  4. cccpm-0.3.1/src/cccpm/simulation/mediator_simulation.py +0 -34
  5. cccpm-0.3.1/src/cccpm/simulation/simulate_sem.py +0 -319
  6. {cccpm-0.3.1 → cccpm-0.3.2}/LICENSE +0 -0
  7. {cccpm-0.3.1 → cccpm-0.3.2}/README.md +0 -0
  8. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/__init__.py +0 -0
  9. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/constants.py +0 -0
  10. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/cpm_analysis.py +0 -0
  11. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/edge_selection.py +0 -0
  12. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/inner_fold.py +0 -0
  13. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/logging.py +0 -0
  14. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/models/__init__.py +0 -0
  15. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/models/linear_model.py +0 -0
  16. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/models/nonlinear_models.py +0 -0
  17. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/__init__.py +0 -0
  18. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/assets/CCCPM.png +0 -0
  19. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/data_loader.py +0 -0
  20. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/html_report.py +0 -0
  21. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/__init__.py +0 -0
  22. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/brain_figures.py +0 -0
  23. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/chord_v2.py +0 -0
  24. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/connectome_utils.py +0 -0
  25. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/cpm_chord_plot.py +0 -0
  26. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/figure_style.py +0 -0
  27. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/plots.py +0 -0
  28. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/utils.py +0 -0
  29. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/reporting_utils.py +0 -0
  30. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/section_builders.py +0 -0
  31. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/table_builders.py +0 -0
  32. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/templates/report.html.j2 +0 -0
  33. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/templates/styles.css +0 -0
  34. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/results_manager.py +0 -0
  35. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/scoring.py +0 -0
  36. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/simulation/__init__.py +0 -0
  37. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/simulation/simulate_multivariate.py +0 -0
  38. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/simulation/simulate_simple.py +0 -0
  39. {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cccpm
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Confound-Corrected Connectome-based Predictive Modeling Python Package
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "cccpm"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "Confound-Corrected Connectome-based Predictive Modeling Python Package"
5
5
  authors = [
6
6
  "Nils Winter <nils.r.winter@uni-muenster.de>",
@@ -0,0 +1,573 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from sklearn.linear_model import LinearRegression
4
+ from sklearn.metrics import r2_score
5
+
6
+
7
+ def _solve_rho_for_R2(r2_X_y: float, r2_X_y_given_Z: float, r2_Z_y: float,
8
+ tol: float = 1e-4) -> float:
9
+ """
10
+ Find corr(X, Z) = rho that best matches the desired R2_X_y_given_Z.
11
+ If an exact match is impossible, return the closest achievable rho
12
+ and issue a warning instead of raising an error.
13
+ """
14
+ r_x = np.sqrt(r2_X_y)
15
+ r_z = np.sqrt(r2_Z_y)
16
+
17
+ # Special case: Z has no effect → cannot adjust X→y
18
+ if r2_Z_y < tol:
19
+ if abs(r2_X_y_given_Z - r2_X_y) > tol:
20
+ print(
21
+ f"Warning: R2_Z_y≈0, so R2_X_y_given_Z must equal R2_X_y. "
22
+ f"Using R2_X_y_given_Z = {r2_X_y:.4f} instead of {r2_X_y_given_Z:.4f}."
23
+ )
24
+ return 0.0
25
+
26
+ # Search over allowable rho values
27
+ rhos = np.linspace(-0.99, 0.99, 20001)
28
+ num = (r_x - rhos * r_z) ** 2
29
+ den = 1 - rhos**2
30
+ vals = num / den
31
+
32
+ # Find closest match
33
+ idx = np.argmin(np.abs(vals - r2_X_y_given_Z))
34
+ rho_best = float(rhos[idx])
35
+ val_best = float(vals[idx])
36
+
37
+ # If we cannot achieve exact target, warn and continue
38
+ if abs(val_best - r2_X_y_given_Z) > tol:
39
+ print(
40
+ f"Warning: Could not achieve R2_X_y_given_Z={r2_X_y_given_Z:.4f}. "
41
+ f"Closest possible value is {val_best:.4f}. Using that instead."
42
+ )
43
+
44
+ return rho_best
45
+
46
+
47
+
48
+ def _build_dataset_from_corr(
49
+ corr: np.ndarray,
50
+ *,
51
+ n_features: int,
52
+ n_features_informative: int,
53
+ n_confound_only_features: int,
54
+ n_pure_signal_features: int,
55
+ n_confounds: int,
56
+ n_samples: int,
57
+ rho_informative: float,
58
+ rho_confound_only: float,
59
+ rho_pure_signal: float,
60
+ randomize_signs: bool,
61
+ rng: np.random.Generator,
62
+ info_extra: dict | None = None,
63
+ ) -> dict:
64
+ """
65
+ Sample the latent triple [true_X, Z_scalar, y] from a 3x3 correlation matrix
66
+ and build the observed features, confounds, and outcome.
67
+
68
+ Three interpretable edge classes are constructed from two orthogonal latent
69
+ sources: the confound ``u = Z_scalar`` and the **confound-orthogonalised
70
+ signal** ``s = (true_X - rho*u) / sqrt(1 - rho^2)`` (rho = corr(true_X, u)).
71
+ ``s`` is uncorrelated with ``u`` and has ``corr(s, y) = sqrt(R2_X_y_given_Z)``;
72
+ the full latent ``true_X = rho*u + sqrt(1-rho^2)*s`` mixes the two.
73
+
74
+ Feature layout (columns of X), in order:
75
+ * ``mixed`` edges (``n_features_informative``) — noisy copies of ``true_X``
76
+ (loading ``sqrt(rho_informative)``). Carry **both** genuine signal and, when
77
+ rho>0, confound variance (``corr(X_j, u) = sqrt(rho_informative)*rho``).
78
+ * ``pure_signal`` edges (``n_pure_signal_features``) — noisy copies of ``s``
79
+ (loading ``sqrt(rho_pure_signal)``). Related to ``y`` but **uncorrelated with
80
+ the confound** (``corr(X_j, u) = 0``).
81
+ * ``confound_only`` edges (``n_confound_only_features``) — noisy copies of ``u``
82
+ (loading ``sqrt(rho_confound_only)``). Correlated with ``y`` **only through
83
+ the confound**.
84
+ * remaining columns — pure N(0, 1) noise edges.
85
+
86
+ This lets an analysis separate what partial-correlation edge selection does:
87
+ it drops ``confound_only`` edges (their partial association with y is null) but
88
+ keeps ``mixed`` edges (which retain a genuine partial association) — whose raw
89
+ values still leak confound variance into the network sum score.
90
+
91
+ With ``randomize_signs`` the loading sign of ~half of each non-noise class is
92
+ flipped, so both the positive and negative networks are populated downstream.
93
+
94
+ Returns a dict with ``X``, ``Z``, ``y``, ``true_X`` (numpy arrays) and ``info``
95
+ (ground-truth indices and metadata).
96
+ """
97
+ # --- sample (true_X, Z_scalar, y) as multivariate normal ---
98
+ L = np.linalg.cholesky(corr + 1e-8 * np.eye(3)) # jitter for numeric safety
99
+ base = rng.normal(size=(n_samples, 3))
100
+ XYZ = base @ L.T
101
+ true_X = XYZ[:, 0]
102
+ Z_scalar = XYZ[:, 1]
103
+ y = XYZ[:, 2]
104
+
105
+ # Confound-orthogonalised (pure) signal: s ⟂ u, unit variance,
106
+ # corr(s, y) = sqrt(R2_X_y_given_Z).
107
+ rho = float(corr[0, 1])
108
+ pure_signal = (true_X - rho * Z_scalar) / np.sqrt(max(1.0 - rho ** 2, 1e-12))
109
+
110
+ # --- expand scalar Z into n_confounds (only conf1 carries the confound) ---
111
+ conf_cols = [Z_scalar] + [
112
+ rng.normal(0.0, 1.0, size=n_samples) for _ in range(1, n_confounds)
113
+ ]
114
+ Z = np.column_stack(conf_cols)
115
+
116
+ # --- feature-block index layout ---
117
+ n_mix = n_features_informative
118
+ n_ps = n_pure_signal_features
119
+ n_co = n_confound_only_features
120
+ mixed_idx = np.arange(0, n_mix)
121
+ pure_signal_idx = np.arange(n_mix, n_mix + n_ps)
122
+ confound_only_idx = np.arange(n_mix + n_ps, n_mix + n_ps + n_co)
123
+ noise_idx = np.arange(n_mix + n_ps + n_co, n_features)
124
+
125
+ X = np.empty((n_samples, n_features))
126
+
127
+ def _signs(k: int) -> np.ndarray:
128
+ if not randomize_signs or k == 0:
129
+ return np.ones(k)
130
+ s = np.ones(k)
131
+ s[: k // 2] = -1.0
132
+ return rng.permutation(s)
133
+
134
+ def _fill(idx, latent, communality):
135
+ """X_j = sign * sqrt(communality) * latent + sqrt(1 - communality) * noise."""
136
+ load = np.sqrt(communality)
137
+ resid = np.sqrt(1.0 - communality)
138
+ for col, sign in zip(idx, _signs(len(idx))):
139
+ X[:, col] = sign * load * latent + rng.normal(0.0, resid, size=n_samples)
140
+
141
+ _fill(mixed_idx, true_X, rho_informative) # signal + confound
142
+ _fill(pure_signal_idx, pure_signal, rho_pure_signal) # signal only (⟂ confound)
143
+ _fill(confound_only_idx, Z_scalar, rho_confound_only) # confound only
144
+
145
+ for col in noise_idx: # pure noise
146
+ X[:, col] = rng.normal(0.0, 1.0, size=n_samples)
147
+
148
+ info = {
149
+ "mixed_idx": mixed_idx,
150
+ "pure_signal_idx": pure_signal_idx,
151
+ "confound_only_idx": confound_only_idx,
152
+ "noise_idx": noise_idx,
153
+ # Backwards-compatible alias: "signal" used to mean the true_X-loading edges.
154
+ "signal_idx": mixed_idx,
155
+ "corr_true_X_Z": rho,
156
+ "corr_true_X_y": float(corr[0, 2]),
157
+ "corr_Z_y": float(corr[1, 2]),
158
+ "n_features": n_features,
159
+ "n_features_informative": n_features_informative,
160
+ "n_pure_signal_features": n_pure_signal_features,
161
+ "n_confound_only_features": n_confound_only_features,
162
+ "n_confounds": n_confounds,
163
+ "n_samples": n_samples,
164
+ "rho_informative": rho_informative,
165
+ "rho_pure_signal": rho_pure_signal,
166
+ "rho_confound_only": rho_confound_only,
167
+ "randomize_signs": randomize_signs,
168
+ }
169
+ if info_extra:
170
+ info.update(info_extra)
171
+
172
+ return {
173
+ "X": X,
174
+ "Z": Z,
175
+ "y": y.reshape(-1, 1),
176
+ "true_X": true_X.reshape(-1, 1),
177
+ "info": info,
178
+ }
179
+
180
+
181
+ def _validate_feature_args(n_features, n_features_informative,
182
+ n_confound_only_features, n_pure_signal_features,
183
+ n_confounds, rho_informative, rho_confound_only,
184
+ rho_pure_signal):
185
+ total = (n_features_informative + n_confound_only_features
186
+ + n_pure_signal_features)
187
+ if total > n_features:
188
+ raise ValueError(
189
+ "n_features_informative + n_confound_only_features + "
190
+ "n_pure_signal_features cannot be greater than n_features."
191
+ )
192
+ for name, val in [("rho_informative", rho_informative),
193
+ ("rho_confound_only", rho_confound_only),
194
+ ("rho_pure_signal", rho_pure_signal)]:
195
+ if not (0.0 <= val < 1.0):
196
+ raise ValueError(f"{name} must be in [0, 1).")
197
+ if n_confounds < 1:
198
+ raise ValueError("n_confounds must be at least 1.")
199
+
200
+
201
+ def simulate_data_given_R2(
202
+ R2_X_y: float,
203
+ R2_X_y_given_Z: float,
204
+ R2_Z_y: float,
205
+ n_features: int = 100,
206
+ n_features_informative: int = 10,
207
+ n_confound_only_features: int = 0,
208
+ n_pure_signal_features: int = 0,
209
+ n_confounds: int = 2,
210
+ n_samples: int = 10_000,
211
+ rho_informative: float = 0.5,
212
+ rho_confound_only: float = 0.5,
213
+ rho_pure_signal: float = 0.5,
214
+ randomize_signs: bool = True,
215
+ random_state: int | None = None,
216
+ ) -> dict:
217
+ """
218
+ Simulate data that matches specified R² relationships between a latent
219
+ predictor X, confounds Z, and outcome y.
220
+
221
+ Inputs (targets)
222
+ ----------------
223
+ R2_X_y : float
224
+ Naive R² from regressing y ~ X.
225
+ R2_X_y_given_Z : float
226
+ Unique / incremental R² from X after adjusting for all confounds Z:
227
+ R2_X_y_given_Z = R2(y ~ X + Z) - R2(y ~ Z).
228
+ R2_Z_y : float
229
+ Naive R² from regressing y ~ Z (all confounds jointly).
230
+
231
+ Data structure
232
+ --------------
233
+ - latent variable: true_X (standardized)
234
+ - confounds: conf1..confK (K = n_confounds); only conf1 carries the confound
235
+ - features: up to four edge classes — ``mixed`` (copies of true_X; signal +
236
+ confound), optional ``pure_signal`` (copies of the confound-orthogonalised
237
+ signal), optional ``confound_only`` (copies of the confound), and pure-noise
238
+ edges — see ``_build_dataset_from_corr``.
239
+ - outcome: y (standardized)
240
+
241
+ R² logic
242
+ --------
243
+ - Naive R²(y ~ X) ≈ R2_X_y
244
+ - Naive R²(y ~ Z) ≈ R2_Z_y
245
+ - Unique R² of X given Z ≈ R2_X_y_given_Z, so R2_full ≈ R2_Z_y + R2_X_y_given_Z
246
+
247
+ Returns
248
+ -------
249
+ dict with keys ``X``, ``Z``, ``y``, ``true_X`` (numpy arrays) and ``info``.
250
+ """
251
+ rng = np.random.default_rng(random_state)
252
+
253
+ # --- basic validity checks ---
254
+ for name, val in [
255
+ ("R2_X_y", R2_X_y),
256
+ ("R2_X_y_given_Z", R2_X_y_given_Z),
257
+ ("R2_Z_y", R2_Z_y),
258
+ ]:
259
+ if not (0.0 <= val < 1.0):
260
+ raise ValueError(f"{name} must be in [0,1), got {val}.")
261
+
262
+ if R2_X_y_given_Z > R2_X_y + 1e-8:
263
+ raise ValueError(
264
+ "R2_X_y_given_Z cannot exceed R2_X_y (unique effect cannot be "
265
+ "larger than naive effect)."
266
+ )
267
+ if R2_Z_y + R2_X_y_given_Z >= 1.0:
268
+ raise ValueError(
269
+ "R2_Z_y + R2_X_y_given_Z must be < 1 (total R² cannot exceed 1)."
270
+ )
271
+ _validate_feature_args(n_features, n_features_informative,
272
+ n_confound_only_features, n_pure_signal_features,
273
+ n_confounds, rho_informative, rho_confound_only,
274
+ rho_pure_signal)
275
+
276
+ # --- build correlation matrix for [true_X, Z, y] ---
277
+ r_x = np.sqrt(R2_X_y)
278
+ r_z = np.sqrt(R2_Z_y)
279
+
280
+ # Solve Corr(X, Z) = rho to match desired R2_X_y_given_Z
281
+ rho = _solve_rho_for_R2(R2_X_y, R2_X_y_given_Z, R2_Z_y)
282
+
283
+ corr = np.array(
284
+ [
285
+ [1.0, rho, r_x],
286
+ [rho, 1.0, r_z],
287
+ [r_x, r_z, 1.0],
288
+ ]
289
+ )
290
+
291
+ eigvals = np.linalg.eigvalsh(corr)
292
+ if np.min(eigvals) < -1e-6:
293
+ raise ValueError(
294
+ "Requested R² combination leads to an invalid correlation matrix."
295
+ )
296
+
297
+ return _build_dataset_from_corr(
298
+ corr,
299
+ n_features=n_features,
300
+ n_features_informative=n_features_informative,
301
+ n_confound_only_features=n_confound_only_features,
302
+ n_pure_signal_features=n_pure_signal_features,
303
+ n_confounds=n_confounds,
304
+ n_samples=n_samples,
305
+ rho_informative=rho_informative,
306
+ rho_confound_only=rho_confound_only,
307
+ rho_pure_signal=rho_pure_signal,
308
+ randomize_signs=randomize_signs,
309
+ rng=rng,
310
+ info_extra={
311
+ "R2_X_y": R2_X_y,
312
+ "R2_X_y_given_Z": R2_X_y_given_Z,
313
+ "R2_Z_y": R2_Z_y,
314
+ "random_state": random_state,
315
+ },
316
+ )
317
+
318
+
319
+ def simulate_data_given_kappa(
320
+ R2_X_y: float,
321
+ kappa: float,
322
+ n_features: int = 100,
323
+ n_features_informative: int = 10,
324
+ n_confound_only_features: int = 10,
325
+ n_pure_signal_features: int = 10,
326
+ n_confounds: int = 2,
327
+ n_samples: int = 10_000,
328
+ rho_informative: float = 0.5,
329
+ rho_confound_only: float = 0.5,
330
+ rho_pure_signal: float = 0.5,
331
+ randomize_signs: bool = True,
332
+ random_state: int | None = None,
333
+ ) -> dict:
334
+ """
335
+ Simulate data by sweeping the confound strength as the *spurious fraction*
336
+ ``kappa`` — the fraction of the naive R²(y~X) that is confound-driven.
337
+
338
+ Convention (the confound "steals" a fraction ``kappa`` of the brain's
339
+ explained variance, adding no net variance):
340
+
341
+ R2_X_y_given_Z = (1 - kappa) * R2_X_y (true, deconfounded value)
342
+ R2_Z_y = kappa * R2_X_y
343
+
344
+ From these the required brain–confound coupling ``rho = corr(confound, X)`` is
345
+ solved internally; it depends only on ``kappa`` (not on ``R2_X_y``):
346
+ ``rho ≈ {0, 0.45, 0.63, 0.77, 0.89, 0.99}`` for ``kappa = {0, .2, .4, .6, .8, 1}``.
347
+
348
+ Unlike pinning ``corr(Z,y) = corr(X,y)``, this parameterisation is feasible for
349
+ *all* ``R2_X_y`` (including 0.81) across the whole ``kappa`` sweep.
350
+
351
+ ``kappa = 0`` → no confounding (true == naive); ``kappa = 1`` → full confounding
352
+ (true → 0; ``rho`` approaches its clamp at 0.99).
353
+
354
+ Parameters
355
+ ----------
356
+ R2_X_y : float
357
+ Naive R²(y ~ X).
358
+ kappa : float
359
+ Spurious fraction in [0, 1].
360
+
361
+ Returns
362
+ -------
363
+ dict with keys ``X``, ``Z``, ``y``, ``true_X`` and ``info`` (the info dict also
364
+ records ``R2_X_y``, ``R2_X_y_given_Z``, ``R2_Z_y`` and ``spurious_fraction``).
365
+ """
366
+ if not (0.0 <= R2_X_y < 1.0):
367
+ raise ValueError(f"R2_X_y must be in [0,1), got {R2_X_y}.")
368
+ if not (0.0 <= kappa <= 1.0):
369
+ raise ValueError(f"kappa must be in [0, 1], got {kappa}.")
370
+
371
+ R2_X_y_given_Z = (1.0 - kappa) * R2_X_y
372
+ R2_Z_y = kappa * R2_X_y
373
+
374
+ sim = simulate_data_given_R2(
375
+ R2_X_y=R2_X_y,
376
+ R2_X_y_given_Z=R2_X_y_given_Z,
377
+ R2_Z_y=R2_Z_y,
378
+ n_features=n_features,
379
+ n_features_informative=n_features_informative,
380
+ n_confound_only_features=n_confound_only_features,
381
+ n_pure_signal_features=n_pure_signal_features,
382
+ n_confounds=n_confounds,
383
+ n_samples=n_samples,
384
+ rho_informative=rho_informative,
385
+ rho_confound_only=rho_confound_only,
386
+ rho_pure_signal=rho_pure_signal,
387
+ randomize_signs=randomize_signs,
388
+ random_state=random_state,
389
+ )
390
+ sim["info"]["spurious_fraction"] = kappa
391
+ return sim
392
+
393
+
394
+ def compute_r2s(sim: dict) -> dict:
395
+ """
396
+ Convenience function to empirically estimate the R² components from
397
+ the simulated data:
398
+
399
+ - r2_naive: R²(y ~ true_X)
400
+ - r2_conf_only: R²(y ~ all confounds)
401
+ - r2_full: R²(y ~ true_X + all confounds)
402
+ - r2_unique_X: r2_full - r2_conf_only
403
+ """
404
+ y = sim["y"]
405
+ X_naive = sim["true_X"]
406
+ X_conf = sim["Z"]
407
+
408
+ # Naive: y ~ true_X
409
+ mdl_naive = LinearRegression().fit(X_naive, y)
410
+ r2_naive = r2_score(y, mdl_naive.predict(X_naive))
411
+
412
+ # Confounds-only: y ~ conf1..confK
413
+ mdl_conf = LinearRegression().fit(X_conf, y)
414
+ r2_conf_only = r2_score(y, mdl_conf.predict(X_conf))
415
+
416
+ # Full: y ~ true_X + conf1..confK
417
+ X_full = np.column_stack([X_naive, X_conf])
418
+ mdl_full = LinearRegression().fit(X_full, y)
419
+ r2_full = r2_score(y, mdl_full.predict(X_full))
420
+
421
+ r2_unique_X = r2_full - r2_conf_only
422
+
423
+ return {
424
+ "r2_naive": r2_naive,
425
+ "r2_conf_only": r2_conf_only,
426
+ "r2_full": r2_full,
427
+ "r2_unique_X": r2_unique_X,
428
+ }
429
+
430
+
431
+ def generate_four_scenarios(
432
+ n_features: int = 100,
433
+ n_features_informative: int = 10,
434
+ n_confounds: int = 2,
435
+ n_samples: int = 10_000,
436
+ rho_informative: float = 0.5,
437
+ random_state: int | None = 123,
438
+ ) -> dict[str, pd.DataFrame]:
439
+
440
+ rng = np.random.default_rng(random_state)
441
+ seeds = rng.integers(0, 2**32 - 1, size=4)
442
+
443
+ scenarios = {}
444
+
445
+ # 1. No confounding
446
+ scenarios["No Confounding Effect"] = simulate_data_given_R2(
447
+ R2_X_y=0.25,
448
+ R2_X_y_given_Z=0.25,
449
+ R2_Z_y=0.0,
450
+ n_features=n_features,
451
+ n_features_informative=n_features_informative,
452
+ n_confounds=n_confounds,
453
+ n_samples=n_samples,
454
+ rho_informative=rho_informative,
455
+ random_state=int(seeds[0]),
456
+ )
457
+
458
+ # 2. Weak/partial confounding
459
+ scenarios["Moderate Confounding Effect"] = simulate_data_given_R2(
460
+ R2_X_y=0.25,
461
+ R2_X_y_given_Z=0.15,
462
+ R2_Z_y=0.10,
463
+ n_features=n_features,
464
+ n_features_informative=n_features_informative,
465
+ n_confounds=n_confounds,
466
+ n_samples=n_samples,
467
+ rho_informative=rho_informative,
468
+ random_state=int(seeds[1]),
469
+ )
470
+
471
+ # 3. Full confounding
472
+ scenarios["Strong Confounding Effect"] = simulate_data_given_R2(
473
+ R2_X_y=0.25,
474
+ R2_X_y_given_Z=0.05,
475
+ R2_Z_y=0.20,
476
+ n_features=n_features,
477
+ n_features_informative=n_features_informative,
478
+ n_confounds=n_confounds,
479
+ n_samples=n_samples,
480
+ rho_informative=rho_informative,
481
+ random_state=int(seeds[2]),
482
+ )
483
+
484
+ # 4. No confounding but Z explains part of y
485
+ scenarios["No Confounding Effect But Useful Confounds"] = simulate_data_given_R2(
486
+ R2_X_y=0.25,
487
+ R2_X_y_given_Z=0.25,
488
+ R2_Z_y=0.25,
489
+ n_features=n_features,
490
+ n_features_informative=n_features_informative,
491
+ n_confounds=n_confounds,
492
+ n_samples=n_samples,
493
+ rho_informative=rho_informative,
494
+ random_state=int(seeds[3]),
495
+ )
496
+ return scenarios
497
+
498
+
499
+ # Default axes for the confound-inflation demonstration.
500
+ GRID_R2_X_Y = (0.09, 0.36, 0.81) # brain–outcome strength (rows)
501
+ GRID_KAPPA = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0) # spurious fraction (columns)
502
+
503
+
504
+ def generate_confound_grid(
505
+ r2_x_y_values: tuple = GRID_R2_X_Y,
506
+ kappa_values: tuple = GRID_KAPPA,
507
+ n_features: int = 105, # 15-node connectome (valid CCCPM edge count)
508
+ n_features_informative: int = 10, # mixed edges (signal + confound)
509
+ n_confound_only_features: int = 10, # confound-only edges
510
+ n_pure_signal_features: int = 10, # pure-signal edges (⟂ confound)
511
+ n_confounds: int = 2,
512
+ n_samples: int = 5000,
513
+ rho_informative: float = 0.5,
514
+ rho_confound_only: float = 0.5,
515
+ rho_pure_signal: float = 0.5,
516
+ randomize_signs: bool = True,
517
+ random_state: int | None = 123,
518
+ ) -> dict[tuple, dict]:
519
+ """
520
+ Generate the full matrix of confound scenarios for the demonstration.
521
+
522
+ Rows sweep the brain–outcome strength ``R2_X_y`` (default {0.09, 0.36, 0.81});
523
+ columns sweep the spurious fraction ``kappa`` (default {0, 0.2, ..., 1.0}) — the
524
+ fraction of the naive R² that is confound-driven (see
525
+ :func:`simulate_data_given_kappa`). The true, deconfounded value in each cell is
526
+ ``R2_X_y_given_Z = (1 - kappa) * R2_X_y``.
527
+
528
+ Each cell contains three interpretable edge classes (default 10 each): **mixed**
529
+ (signal + confound), **pure-signal** (⟂ confound) and **confound-only**.
530
+
531
+ Returns
532
+ -------
533
+ dict keyed by ``(R2_X_y, kappa)`` → simulated-data dict (``X``, ``Z``, ``y``,
534
+ ``true_X``, ``info``). Each cell gets an independent seed derived from
535
+ ``random_state`` so cells are reproducible and mutually independent.
536
+ """
537
+ rng = np.random.default_rng(random_state)
538
+ grid = {}
539
+ for r2 in r2_x_y_values:
540
+ for kappa in kappa_values:
541
+ seed = int(rng.integers(0, 2**32 - 1))
542
+ grid[(r2, kappa)] = simulate_data_given_kappa(
543
+ R2_X_y=r2,
544
+ kappa=kappa,
545
+ n_features=n_features,
546
+ n_features_informative=n_features_informative,
547
+ n_confound_only_features=n_confound_only_features,
548
+ n_pure_signal_features=n_pure_signal_features,
549
+ n_confounds=n_confounds,
550
+ n_samples=n_samples,
551
+ rho_informative=rho_informative,
552
+ rho_confound_only=rho_confound_only,
553
+ rho_pure_signal=rho_pure_signal,
554
+ randomize_signs=randomize_signs,
555
+ random_state=seed,
556
+ )
557
+ return grid
558
+
559
+
560
+ # -------------------------------------------------------------------
561
+ # Example usage
562
+ # -------------------------------------------------------------------
563
+ if __name__ == "__main__":
564
+ grid = generate_confound_grid(n_samples=5000, random_state=43)
565
+
566
+ print(f"{'R2_X_y':>8} {'kappa':>6} {'rho':>6} {'true R2':>9} "
567
+ f"{'r2_naive':>9} {'r2_unique_X':>12}")
568
+ for (r2, kappa), sim in grid.items():
569
+ info = sim["info"]
570
+ r2s = compute_r2s(sim)
571
+ print(f"{r2:>8.2f} {kappa:>6.1f} {info['corr_true_X_Z']:>6.2f} "
572
+ f"{info['R2_X_y_given_Z']:>9.3f} {r2s['r2_naive']:>9.3f} "
573
+ f"{r2s['r2_unique_X']:>12.3f}")
@@ -1,34 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Dict, Any, Optional, Tuple
3
-
4
- import numpy as np
5
- from numpy.random import Generator
6
- from numpy.linalg import lstsq
7
- from scipy.linalg import toeplitz
8
-
9
-
10
- def generate_confound_simulation(n_samples=1000, n_features=100,
11
- sparsity=0.8, x_collinearity=0.6,
12
- target_r2=0.36, confounder_rho=0.4,
13
- random_state=None):
14
- if random_state is not None:
15
- np.random.seed(random_state)
16
- cov_row = x_collinearity ** np.arange(n_features)
17
- cov_matrix = toeplitz(cov_row)
18
- X = np.random.multivariate_normal(np.zeros(n_features), cov_matrix, size=n_samples)
19
- weights = np.random.randn(n_features)
20
- n_zero_weights = int(n_features * sparsity)
21
- zero_indices = np.random.choice(n_features, n_zero_weights, replace=False)
22
- weights[zero_indices] = 0.0
23
- signal = X @ weights
24
- var_signal = np.var(signal)
25
- if target_r2 == 0:
26
- y = np.random.randn(n_samples)
27
- else:
28
- var_noise = var_signal * ((1.0 - target_r2) / target_r2)
29
- noise = np.random.normal(0, np.sqrt(var_noise), size=n_samples)
30
- y = signal + noise
31
- y = (y - np.mean(y)) / np.std(y)
32
- noise_c = np.random.normal(0, 1, size=n_samples)
33
- c = confounder_rho * y + np.sqrt(1 - confounder_rho**2) * noise_c
34
- return X, y, c
@@ -1,319 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
- from sklearn.linear_model import LinearRegression
4
- from sklearn.metrics import r2_score
5
-
6
-
7
- def _solve_rho_for_R2(r2_X_y: float, r2_X_y_given_Z: float, r2_Z_y: float,
8
- tol: float = 1e-4) -> float:
9
- """
10
- Find corr(X, Z) = rho that best matches the desired R2_X_y_given_Z.
11
- If an exact match is impossible, return the closest achievable rho
12
- and issue a warning instead of raising an error.
13
- """
14
- r_x = np.sqrt(r2_X_y)
15
- r_z = np.sqrt(r2_Z_y)
16
-
17
- # Special case: Z has no effect → cannot adjust X→y
18
- if r2_Z_y < tol:
19
- if abs(r2_X_y_given_Z - r2_X_y) > tol:
20
- print(
21
- f"Warning: R2_Z_y≈0, so R2_X_y_given_Z must equal R2_X_y. "
22
- f"Using R2_X_y_given_Z = {r2_X_y:.4f} instead of {r2_X_y_given_Z:.4f}."
23
- )
24
- return 0.0
25
-
26
- # Search over allowable rho values
27
- rhos = np.linspace(-0.99, 0.99, 20001)
28
- num = (r_x - rhos * r_z) ** 2
29
- den = 1 - rhos**2
30
- vals = num / den
31
-
32
- # Find closest match
33
- idx = np.argmin(np.abs(vals - r2_X_y_given_Z))
34
- rho_best = float(rhos[idx])
35
- val_best = float(vals[idx])
36
-
37
- # If we cannot achieve exact target, warn and continue
38
- if abs(val_best - r2_X_y_given_Z) > tol:
39
- print(
40
- f"Warning: Could not achieve R2_X_y_given_Z={r2_X_y_given_Z:.4f}. "
41
- f"Closest possible value is {val_best:.4f}. Using that instead."
42
- )
43
-
44
- return rho_best
45
-
46
-
47
-
48
- def simulate_data_given_R2(
49
- R2_X_y: float,
50
- R2_X_y_given_Z: float,
51
- R2_Z_y: float,
52
- n_features: int = 100,
53
- n_features_informative: int = 10,
54
- n_confounds: int = 2,
55
- n_samples: int = 10_000,
56
- rho_informative: float = 0.5,
57
- random_state: int | None = None,
58
- ) -> pd.DataFrame:
59
- """
60
- Simulate data that matches specified R² relationships between a latent
61
- predictor X, confounds Z, and outcome y.
62
-
63
- Inputs (targets)
64
- ----------------
65
- R2_X_y : float
66
- Naive R² from regressing y ~ X.
67
- R2_X_y_given_Z : float
68
- Unique / incremental R² from X after adjusting for all confounds Z:
69
- R2_X_y_given_Z = R2(y ~ X + Z) - R2(y ~ Z).
70
- R2_Z_y : float
71
- Naive R² from regressing y ~ Z (all confounds jointly).
72
-
73
- Data structure
74
- --------------
75
- - latent variable: true_X (standardized)
76
- - confounds: conf1..confK (K = n_confounds)
77
- * only conf1 is used to build the desired R² (others are noise)
78
- - features: X1..Xn_features
79
- * first n_features_informative are informative: correlated with true_X
80
- * remaining are pure noise
81
- - outcome: y (standardized)
82
-
83
- R² logic
84
- --------
85
- - Naive R²(y ~ X) ≈ R2_X_y
86
- - Naive R²(y ~ Z) ≈ R2_Z_y
87
- - Unique R² of X given Z ≈ R2_X_y_given_Z
88
- so:
89
- R2_full ≈ R2_Z_y + R2_X_y_given_Z
90
-
91
- Returns
92
- -------
93
- df : pandas.DataFrame
94
- Columns:
95
- conf1..confK
96
- X1..Xn_features
97
- true_X
98
- y
99
- """
100
- rng = np.random.default_rng(random_state)
101
-
102
- # --- basic validity checks ---
103
- for name, val in [
104
- ("R2_X_y", R2_X_y),
105
- ("R2_X_y_given_Z", R2_X_y_given_Z),
106
- ("R2_Z_y", R2_Z_y),
107
- ]:
108
- if not (0.0 <= val < 1.0):
109
- raise ValueError(f"{name} must be in [0,1), got {val}.")
110
-
111
- if R2_X_y_given_Z > R2_X_y + 1e-8:
112
- raise ValueError(
113
- "R2_X_y_given_Z cannot exceed R2_X_y (unique effect cannot be "
114
- "larger than naive effect)."
115
- )
116
- if R2_Z_y + R2_X_y_given_Z >= 1.0:
117
- raise ValueError(
118
- "R2_Z_y + R2_X_y_given_Z must be < 1 (total R² cannot exceed 1)."
119
- )
120
- if n_features_informative > n_features:
121
- raise ValueError("n_features_informative cannot be greater than n_features.")
122
- if not (0.0 <= rho_informative < 1.0):
123
- raise ValueError("rho_informative must be in [0, 1).")
124
- if n_confounds < 1:
125
- raise ValueError("n_confounds must be at least 1.")
126
-
127
- # --- step 1: build correlation matrix for (X, Z, y) ---
128
- r_x = np.sqrt(R2_X_y)
129
- r_z = np.sqrt(R2_Z_y)
130
-
131
- # Solve Corr(X, Z) = rho to match desired R2_X_y_given_Z
132
- rho = _solve_rho_for_R2(R2_X_y, R2_X_y_given_Z, R2_Z_y)
133
-
134
- # correlation matrix for [X, Z, y]
135
- # order: [X, Z, y]
136
- corr = np.array(
137
- [
138
- [1.0, rho, r_x],
139
- [rho, 1.0, r_z],
140
- [r_x, r_z, 1.0],
141
- ]
142
- )
143
-
144
- # sanity check: positive semi-definite
145
- eigvals = np.linalg.eigvalsh(corr)
146
- if np.min(eigvals) < -1e-6:
147
- raise ValueError(
148
- "Requested R² combination leads to an invalid correlation matrix."
149
- )
150
-
151
- # --- step 2: sample (X, Z, y) as multivariate normal with this corr matrix ---
152
- L = np.linalg.cholesky(corr + 1e-8 * np.eye(3)) # jitter for numeric safety
153
- Z_samples = rng.normal(size=(n_samples, 3))
154
- XYZ = Z_samples @ L.T
155
- true_X = XYZ[:, 0]
156
- Z_scalar = XYZ[:, 1]
157
- y = XYZ[:, 2]
158
-
159
- # Now:
160
- # Corr(true_X, y)^2 ≈ R2_X_y
161
- # Corr(Z_scalar, y)^2 ≈ R2_Z_y
162
- # and the full regression y ~ X + Z_scalar has unique R² for X ≈ R2_X_y_given_Z.
163
-
164
- # --- step 3: expand scalar Z into n_confounds (only conf1 matters) ---
165
- conf_data = {}
166
- conf_data["conf1"] = Z_scalar
167
- for i in range(1, n_confounds):
168
- conf_data[f"conf{i+1}"] = rng.normal(0.0, 1.0, size=n_samples)
169
- conf_df = pd.DataFrame(conf_data)
170
-
171
- # --- step 4: generate X features ---
172
- # First n_features_informative are equicorrelated measures of true_X
173
- # For informative features with Var=1 and pairwise corr=rho_informative:
174
- # Xj = sqrt(rho_inf)*true_X + sqrt(1-rho_inf)*N(0,1)
175
- X_data = {}
176
- loading = np.sqrt(rho_informative)
177
- resid_sd = np.sqrt(1.0 - rho_informative)
178
-
179
- for j in range(n_features_informative):
180
- eps = rng.normal(0.0, resid_sd, size=n_samples)
181
- X_data[f"X{j+1}"] = loading * true_X + eps
182
-
183
- # Remaining features are pure noise
184
- for j in range(n_features_informative, n_features):
185
- X_data[f"X{j+1}"] = rng.normal(0.0, 1.0, size=n_samples)
186
-
187
- X_df = pd.DataFrame(X_data)
188
-
189
- # --- step 5: assemble final DataFrame ---
190
- df = pd.concat([conf_df, X_df], axis=1)
191
- df["true_X"] = true_X
192
- df["y"] = y
193
-
194
- return {'X': X_df.to_numpy(), 'Z': conf_df.to_numpy(), 'y': y.reshape(-1, 1), 'true_X': true_X.reshape(-1, 1)}
195
-
196
-
197
- def compute_r2s(sim: dict) -> dict:
198
- """
199
- Convenience function to empirically estimate the R² components from
200
- the simulated data:
201
-
202
- - r2_naive: R²(y ~ true_X)
203
- - r2_conf_only: R²(y ~ all confounds)
204
- - r2_full: R²(y ~ true_X + all confounds)
205
- - r2_unique_X: r2_full - r2_conf_only
206
- """
207
- y = sim["y"]
208
- X_naive = sim["true_X"]
209
- X_conf = sim["Z"]
210
-
211
- # Naive: y ~ true_X
212
- mdl_naive = LinearRegression().fit(X_naive, y)
213
- r2_naive = r2_score(y, mdl_naive.predict(X_naive))
214
-
215
- # Confounds-only: y ~ conf1..confK
216
- mdl_conf = LinearRegression().fit(X_conf, y)
217
- r2_conf_only = r2_score(y, mdl_conf.predict(X_conf))
218
-
219
- # Full: y ~ true_X + conf1..confK
220
- X_full = np.column_stack([X_naive, X_conf])
221
- mdl_full = LinearRegression().fit(X_full, y)
222
- r2_full = r2_score(y, mdl_full.predict(X_full))
223
-
224
- r2_unique_X = r2_full - r2_conf_only
225
-
226
- return {
227
- "r2_naive": r2_naive,
228
- "r2_conf_only": r2_conf_only,
229
- "r2_full": r2_full,
230
- "r2_unique_X": r2_unique_X,
231
- }
232
-
233
-
234
- def generate_four_scenarios(
235
- n_features: int = 100,
236
- n_features_informative: int = 10,
237
- n_confounds: int = 2,
238
- n_samples: int = 10_000,
239
- rho_informative: float = 0.5,
240
- random_state: int | None = 123,
241
- ) -> dict[str, pd.DataFrame]:
242
-
243
- rng = np.random.default_rng(random_state)
244
- seeds = rng.integers(0, 2**32 - 1, size=4)
245
-
246
- scenarios = {}
247
-
248
- # 1. No confounding
249
- scenarios["No Confounding Effect"] = simulate_data_given_R2(
250
- R2_X_y=0.25,
251
- R2_X_y_given_Z=0.25,
252
- R2_Z_y=0.0,
253
- n_features=n_features,
254
- n_features_informative=n_features_informative,
255
- n_confounds=n_confounds,
256
- n_samples=n_samples,
257
- rho_informative=rho_informative,
258
- random_state=int(seeds[0]),
259
- )
260
-
261
- # 2. Weak/partial confounding
262
- scenarios["Moderate Confounding Effect"] = simulate_data_given_R2(
263
- R2_X_y=0.25,
264
- R2_X_y_given_Z=0.15,
265
- R2_Z_y=0.10,
266
- n_features=n_features,
267
- n_features_informative=n_features_informative,
268
- n_confounds=n_confounds,
269
- n_samples=n_samples,
270
- rho_informative=rho_informative,
271
- random_state=int(seeds[1]),
272
- )
273
-
274
- # 3. Full confounding
275
- scenarios["Strong Confounding Effect"] = simulate_data_given_R2(
276
- R2_X_y=0.25,
277
- R2_X_y_given_Z=0.05,
278
- R2_Z_y=0.20,
279
- n_features=n_features,
280
- n_features_informative=n_features_informative,
281
- n_confounds=n_confounds,
282
- n_samples=n_samples,
283
- rho_informative=rho_informative,
284
- random_state=int(seeds[2]),
285
- )
286
-
287
- # 4. No confounding but Z explains part of y
288
- scenarios["No Confounding Effect But Useful Confounds"] = simulate_data_given_R2(
289
- R2_X_y=0.25,
290
- R2_X_y_given_Z=0.25,
291
- R2_Z_y=0.25,
292
- n_features=n_features,
293
- n_features_informative=n_features_informative,
294
- n_confounds=n_confounds,
295
- n_samples=n_samples,
296
- rho_informative=rho_informative,
297
- random_state=int(seeds[3]),
298
- )
299
- return scenarios
300
-
301
-
302
- # -------------------------------------------------------------------
303
- # Example usage
304
- # -------------------------------------------------------------------
305
- if __name__ == "__main__":
306
- scenarios = generate_four_scenarios(
307
- n_features=100,
308
- n_features_informative=10,
309
- n_confounds=3,
310
- n_samples=1000,
311
- rho_informative=0.5,
312
- random_state=43,
313
- )
314
-
315
- for name, df in scenarios.items():
316
- print(f"\nScenario: {name}")
317
- r2s = compute_r2s(df)
318
- for k, v in r2s.items():
319
- print(f" {k}: {v:.3f}")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes