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.
- {cccpm-0.3.1 → cccpm-0.3.2}/PKG-INFO +1 -1
- {cccpm-0.3.1 → cccpm-0.3.2}/pyproject.toml +1 -1
- cccpm-0.3.2/src/cccpm/simulation/simulate_sem.py +573 -0
- cccpm-0.3.1/src/cccpm/simulation/mediator_simulation.py +0 -34
- cccpm-0.3.1/src/cccpm/simulation/simulate_sem.py +0 -319
- {cccpm-0.3.1 → cccpm-0.3.2}/LICENSE +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/README.md +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/__init__.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/constants.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/cpm_analysis.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/edge_selection.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/inner_fold.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/logging.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/models/__init__.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/models/linear_model.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/models/nonlinear_models.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/__init__.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/assets/CCCPM.png +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/data_loader.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/html_report.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/__init__.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/brain_figures.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/chord_v2.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/connectome_utils.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/cpm_chord_plot.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/figure_style.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/plots.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/plots/utils.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/reporting_utils.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/section_builders.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/table_builders.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/templates/report.html.j2 +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/reporting/templates/styles.css +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/results_manager.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/scoring.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/simulation/__init__.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/simulation/simulate_multivariate.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/simulation/simulate_simple.py +0 -0
- {cccpm-0.3.1 → cccpm-0.3.2}/src/cccpm/utils.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|