CausalScience 0.1.0a13__py3-none-any.whl
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.
- causalscience/CSCM.py +427 -0
- causalscience/ENGINE_LICENSE.txt +38 -0
- causalscience/ENGINE_SOURCES.json +47 -0
- causalscience/SBD.py +432 -0
- causalscience/SCM.py +644 -0
- causalscience/__init__.py +41 -0
- causalscience/adjustment.py +258 -0
- causalscience/bdscm/NOTICE.txt +28 -0
- causalscience/bdscm/__init__.py +11 -0
- causalscience/bdscm/covariates.py +605 -0
- causalscience/bdscm/data/tabdpt_openml.json +128 -0
- causalscience/bdscm/noise.py +45 -0
- causalscience/bdscm/outcome.py +271 -0
- causalscience/bdscm/scm.py +198 -0
- causalscience/bdscm/treatment.py +204 -0
- causalscience/bdscm/utils.py +76 -0
- causalscience/demo.py +37 -0
- causalscience/effects.py +151 -0
- causalscience/estimators.py +199 -0
- causalscience/examples.py +1051 -0
- causalscience/frontdoor.py +418 -0
- causalscience/graph.py +676 -0
- causalscience/graph_tool.py +207 -0
- causalscience/identification_engine.py +595 -0
- causalscience/identify.py +749 -0
- causalscience/indexed_graph.py +2108 -0
- causalscience/indexed_identification.py +1660 -0
- causalscience/random_generator.py +437 -0
- causalscience/scm_registry.py +192 -0
- causalscience/statmodules.py +449 -0
- causalscience/tian.py +504 -0
- causalscience/uca_compiler.py +580 -0
- causalscience/uca_estimation.py +1019 -0
- causalscience/web/NOTICE.txt +25 -0
- causalscience/web/index.html +239 -0
- causalscience/weighting.py +90 -0
- causalscience-0.1.0a13.dist-info/METADATA +169 -0
- causalscience-0.1.0a13.dist-info/RECORD +45 -0
- causalscience-0.1.0a13.dist-info/WHEEL +5 -0
- causalscience-0.1.0a13.dist-info/licenses/LICENSE +39 -0
- causalscience-0.1.0a13.dist-info/licenses/LICENSE-MIT.txt +21 -0
- causalscience-0.1.0a13.dist-info/licenses/LICENSING.md +38 -0
- causalscience-0.1.0a13.dist-info/licenses/src/causalscience/ENGINE_LICENSE.txt +38 -0
- causalscience-0.1.0a13.dist-info/licenses/src/causalscience/ENGINE_SOURCES.json +47 -0
- causalscience-0.1.0a13.dist-info/top_level.txt +1 -0
causalscience/CSCM.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Back-door-style preset with a hidden common cause of X, A, and Y."""
|
|
2
|
+
from functools import partial
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from inspect import signature
|
|
5
|
+
from numbers import Real
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.special import expit, logit, ndtr
|
|
9
|
+
from scipy import stats
|
|
10
|
+
from scipy.integrate import quad_vec
|
|
11
|
+
from scipy.optimize import minimize_scalar
|
|
12
|
+
from scipy.special import logsumexp
|
|
13
|
+
|
|
14
|
+
from .bdscm import BDSCM, IndependentNoise
|
|
15
|
+
from .bdscm.covariates import OpenMLCovariates, SyntheticCovariates
|
|
16
|
+
from .bdscm.outcome import OutcomeMechanism, CAPOOutcomeMechanism
|
|
17
|
+
from .bdscm.treatment import TreatmentOptions
|
|
18
|
+
from .bdscm.utils import EPS, _require, _integer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ConfounderOptions:
|
|
23
|
+
"""Latent law, monotone effect, and positive loading magnitudes; frozen per task."""
|
|
24
|
+
distribution: str = 'normal'
|
|
25
|
+
function: str = 'linear'
|
|
26
|
+
strength_range: tuple = (.25, .75)
|
|
27
|
+
df: float = 5.
|
|
28
|
+
probability: float = .5
|
|
29
|
+
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
_require(self.distribution in ('normal', 'uniform', 'laplace', 'student_t', 'bernoulli', 'random'),
|
|
32
|
+
'Unknown confounder distribution.')
|
|
33
|
+
_require(self.function in ('linear', 'tanh', 'signed_log', 'random'), 'Unknown confounder function.')
|
|
34
|
+
bounds = tuple(self.strength_range)
|
|
35
|
+
_require(len(bounds) == 2 and np.isfinite(bounds).all() and 0 < bounds[0] <= bounds[1],
|
|
36
|
+
'strength_range must contain positive finite ordered bounds.')
|
|
37
|
+
_require(np.isfinite(self.df) and self.df > 2, 'df must be finite and greater than 2.')
|
|
38
|
+
_require(np.isfinite(self.probability) and 0 < self.probability < 1, 'probability must lie in (0, 1).')
|
|
39
|
+
object.__setattr__(self, 'strength_range', bounds)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _latent(z, family, options):
|
|
43
|
+
if family == 'normal':
|
|
44
|
+
return z
|
|
45
|
+
if family == 'bernoulli':
|
|
46
|
+
return (z > stats.norm.isf(options.probability)).astype(np.int64)
|
|
47
|
+
probability = np.clip(ndtr(z), EPS, 1-EPS)
|
|
48
|
+
if family == 'uniform':
|
|
49
|
+
return np.sqrt(12.)*(probability-.5)
|
|
50
|
+
return (stats.laplace.ppf(probability)/np.sqrt(2.) if family == 'laplace' else
|
|
51
|
+
stats.t.ppf(probability, options.df)/np.sqrt(options.df/(options.df-2)))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _draw_latent(rng, size, family, options):
|
|
55
|
+
return _latent(rng.standard_normal(size), family, options)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _effect(u, function):
|
|
59
|
+
return u if function == 'linear' else np.tanh(u) if function == 'tanh' else np.sign(u)*np.log1p(np.abs(u))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _accepts_offset(method):
|
|
63
|
+
try:
|
|
64
|
+
signature(method).bind_partial(score_offset=None)
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _noise(n, seed, start_row, base, latent, family, options):
|
|
71
|
+
values = base(n, seed, start_row)
|
|
72
|
+
values['U'] = _latent(latent(n, seed, start_row)['U_Y'], family, options)
|
|
73
|
+
return values
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _x(U, noise, mechanism, loading, function):
|
|
77
|
+
U = _effect(U, function)
|
|
78
|
+
offset = U * loading if np.isscalar(loading) else U[:, None] * loading
|
|
79
|
+
return mechanism.encode(mechanism(noise, score_offset=offset), return_observed=True)[0]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _propensity(features, u, mechanism, mask, loading):
|
|
83
|
+
base = mechanism.propensity(features if mask.all() else features * mask)
|
|
84
|
+
return expit(logit(np.clip(base, EPS, 1-EPS)) + loading*u)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _a(X, U, noise, covariates, mechanism, mask, loading, function):
|
|
88
|
+
U = _effect(U, function)
|
|
89
|
+
return (noise < _propensity(covariates.encode(X), U, mechanism, mask, loading)).astype(np.int64)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _y(X, A, U, noise, covariates, mechanism, mask, loading, function):
|
|
93
|
+
U = _effect(U, function)
|
|
94
|
+
features = covariates.encode(X)
|
|
95
|
+
features = features if mask.all() else features * mask
|
|
96
|
+
offset = loading*U
|
|
97
|
+
return np.where(A == 1, mechanism(features, 1, noise, score_offset=offset),
|
|
98
|
+
mechanism(features, 0, noise, score_offset=offset))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CSCM(BDSCM):
|
|
102
|
+
"""Shared-engine preset with explicit-U and observed-X conditional means."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, *args, confounder=None, unconfounded=False, confounding_power=1.,
|
|
105
|
+
simpson_mode=False, **kwargs):
|
|
106
|
+
_require(type(simpson_mode) is bool, 'simpson_mode must be a bool.')
|
|
107
|
+
_require(type(unconfounded) is bool, 'unconfounded must be a bool.')
|
|
108
|
+
_require(isinstance(confounding_power, Real) and not isinstance(confounding_power, (bool, np.bool_))
|
|
109
|
+
and np.isfinite(confounding_power), 'confounding_power must be a finite real number.')
|
|
110
|
+
self.unconfounded = unconfounded or bool(confounding_power == 0)
|
|
111
|
+
self.confounding_power = 0. if self.unconfounded else float(confounding_power)
|
|
112
|
+
self.simpson_mode = simpson_mode
|
|
113
|
+
if simpson_mode:
|
|
114
|
+
treatment = kwargs.get('treatment')
|
|
115
|
+
if treatment is None:
|
|
116
|
+
kwargs['treatment'] = TreatmentOptions(mechanism='independent')
|
|
117
|
+
else:
|
|
118
|
+
_require(isinstance(treatment, TreatmentOptions) and
|
|
119
|
+
treatment.mechanism == 'independent' and
|
|
120
|
+
treatment.independent_treatment_prob == .5,
|
|
121
|
+
'simpson_mode requires independent treatment with probability 0.5.')
|
|
122
|
+
options = ConfounderOptions() if confounder is None else confounder
|
|
123
|
+
_require(isinstance(options, ConfounderOptions), 'confounder must be ConfounderOptions.')
|
|
124
|
+
super().__init__(*args, **kwargs)
|
|
125
|
+
if simpson_mode:
|
|
126
|
+
_require(type(self.f_Y) is OutcomeMechanism,
|
|
127
|
+
'simpson_mode requires a built-in outcome mechanism.')
|
|
128
|
+
active = np.flatnonzero(self.outcome_mask)
|
|
129
|
+
_require(len(active) > 0, 'simpson_mode requires an active outcome covariate column.')
|
|
130
|
+
self.f_Y.simpson_feature_index = int(active[0])
|
|
131
|
+
_require(_accepts_offset(self.f_X) and _accepts_offset(self.f_Y) and
|
|
132
|
+
_accepts_offset(getattr(self.f_Y, 'mean', None)),
|
|
133
|
+
'CSCM covariate call and outcome call/mean must accept score_offset.')
|
|
134
|
+
choices = np.random.default_rng(np.random.SeedSequence([self.task_seed, 3043]))
|
|
135
|
+
family = str(choices.choice(('normal', 'uniform', 'laplace', 'student_t', 'bernoulli')))
|
|
136
|
+
function = str(choices.choice(('linear', 'tanh', 'signed_log')))
|
|
137
|
+
self.confounder_options = options
|
|
138
|
+
self.confounder_family = family if options.distribution == 'random' else options.distribution
|
|
139
|
+
self.confounder_function = function if options.function == 'random' else options.function
|
|
140
|
+
self.confounder_metadata = dict(distribution=self.confounder_family, function=self.confounder_function,
|
|
141
|
+
strength_range=options.strength_range, df=options.df, probability=options.probability,
|
|
142
|
+
unconfounded=self.unconfounded, confounding_power=self.confounding_power)
|
|
143
|
+
rng = np.random.default_rng(np.random.SeedSequence([self.task_seed, 3042]))
|
|
144
|
+
coefficients = rng.choice((-1., 1.), self.d_X+2) * rng.uniform(*options.strength_range, self.d_X+2)
|
|
145
|
+
loading_x = coefficients[:self.d_X].copy()
|
|
146
|
+
loading_x.setflags(write=False)
|
|
147
|
+
if isinstance(self.f_X, OpenMLCovariates):
|
|
148
|
+
loading_x = float(loading_x[0])
|
|
149
|
+
self.loadings = dict(X=loading_x if np.isscalar(loading_x) else tuple(loading_x),
|
|
150
|
+
A=0. if self.unconfounded else float(coefficients[-2])*self.confounding_power,
|
|
151
|
+
Y=float(coefficients[-1]))
|
|
152
|
+
if simpson_mode:
|
|
153
|
+
self.loadings.update(A=-self.confounding_power, Y=2.)
|
|
154
|
+
self.confounder_metadata['simpson_mode'] = dict(
|
|
155
|
+
treatment_logit='-confounding_power * g(U)', outcome_loading=2.,
|
|
156
|
+
score_contrast='1 + 0.75 * tanh(encoded_X[j])',
|
|
157
|
+
feature_index=self.f_Y.simpson_feature_index, base_treatment_probability=.5)
|
|
158
|
+
_require(np.isfinite(self.loadings['A']), 'Scaled treatment loading must be finite.')
|
|
159
|
+
latent_seed = int(np.random.SeedSequence([self.task_seed, 3041]).generate_state(1)[0])
|
|
160
|
+
self.scm.add_variable('U', lambda noise: noise, observed=False,
|
|
161
|
+
variable_type='binary' if self.confounder_family == 'bernoulli' else 'continuous',
|
|
162
|
+
noise_distribution=partial(_draw_latent, family=self.confounder_family, options=options))
|
|
163
|
+
self.scm = self.scm.replace_mechanism('X', partial(_x, mechanism=self.f_X,
|
|
164
|
+
loading=loading_x,
|
|
165
|
+
function=self.confounder_function), ['U'])
|
|
166
|
+
if not self.unconfounded:
|
|
167
|
+
self.scm = self.scm.replace_mechanism('A', partial(_a, covariates=self.f_X,
|
|
168
|
+
mechanism=self.f_A, mask=self.treatment_mask.copy(), loading=self.loadings['A'],
|
|
169
|
+
function=self.confounder_function), ['X', 'U'])
|
|
170
|
+
self.scm = self.scm.replace_mechanism('Y', partial(_y, covariates=self.f_X,
|
|
171
|
+
mechanism=self.f_Y, mask=self.outcome_mask.copy(), loading=self.loadings['Y'], function=self.confounder_function), ['X', 'A', 'U'])
|
|
172
|
+
self.scm._noise_provider = partial(_noise, base=self.scm._noise_provider,
|
|
173
|
+
latent=IndependentNoise(1, latent_seed), family=self.confounder_family, options=options)
|
|
174
|
+
self.scm._provider_nodes = frozenset(('X', 'A', 'Y', 'U'))
|
|
175
|
+
self.scm._block_compatible = True
|
|
176
|
+
self.scm._variables['U']['columns'] = ['U']
|
|
177
|
+
self.graph.nodes['U'].update(observed=False, variable_type=self.scm._variables['U']['variable_type'],
|
|
178
|
+
shape=(), dimension=1, columns=['U'])
|
|
179
|
+
|
|
180
|
+
def _truth_xu(self, features, u, u_y=None):
|
|
181
|
+
outcome_features = self._masked(features, self.outcome_mask)
|
|
182
|
+
effect = _effect(u, self.confounder_function)
|
|
183
|
+
offset = self.loadings['Y']*effect
|
|
184
|
+
propensity = (self.f_A.propensity(self._masked(features, self.treatment_mask)) if self.unconfounded else
|
|
185
|
+
_propensity(features, effect, self.f_A, self.treatment_mask, self.loadings['A']))
|
|
186
|
+
result = dict(U=u.copy(), mu0_xu=self.f_Y.mean(outcome_features, 0, score_offset=offset),
|
|
187
|
+
mu1_xu=self.f_Y.mean(outcome_features, 1, score_offset=offset),
|
|
188
|
+
e_xu=propensity)
|
|
189
|
+
if u_y is not None:
|
|
190
|
+
result.update(Y0=self.f_Y(outcome_features, 0, u_y, score_offset=offset),
|
|
191
|
+
Y1=self.f_Y(outcome_features, 1, u_y, score_offset=offset))
|
|
192
|
+
_require(all(np.isfinite(v).all() for v in result.values()), 'CSCM truth must be finite.')
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
def truth_at(self, x, *, u=None, u_y=None, data_seed=None, start_row=0,
|
|
196
|
+
atol=1e-6, rtol=1e-5, max_subdivisions=200, cdf_maxpts=20000):
|
|
197
|
+
"""With u, retain latent-conditional truth; without u, integrate U|X.
|
|
198
|
+
|
|
199
|
+
The observed-X path returns mu0/mu1 and exact/numerical diagnostics.
|
|
200
|
+
It averages outcome noise, so individual-noise replay options are invalid.
|
|
201
|
+
Numerical convergence diagnostics are not certified error bounds.
|
|
202
|
+
"""
|
|
203
|
+
x = np.asarray(x, dtype=float)
|
|
204
|
+
_require(x.ndim == 2 and x.shape[1] == self.d_X and not np.isinf(x).any(),
|
|
205
|
+
f'Expected X of shape (n, {self.d_X}) without infinite values.')
|
|
206
|
+
if u is None:
|
|
207
|
+
_require(u_y is None and data_seed is None and start_row == 0,
|
|
208
|
+
'Observed-X CAPO averages outcome noise; omit u_y, data_seed and start_row.')
|
|
209
|
+
return self._observed_truth(x, atol, rtol, max_subdivisions, cdf_maxpts)
|
|
210
|
+
u = np.broadcast_to(np.asarray(u, dtype=float), (len(x),))
|
|
211
|
+
_require(np.isfinite(u).all(), 'U must be finite.')
|
|
212
|
+
_require(self.confounder_family != 'bernoulli' or np.isin(u, (0, 1)).all(),
|
|
213
|
+
'Bernoulli U must contain only 0 or 1.')
|
|
214
|
+
_require(u_y is None or data_seed is None, 'Supply u_y or data_seed, not both.')
|
|
215
|
+
_require(data_seed is not None or start_row == 0, 'start_row requires data_seed.')
|
|
216
|
+
if data_seed is not None:
|
|
217
|
+
u_y = self.f_U(len(x), data_seed, start_row)['U_Y']
|
|
218
|
+
if u_y is not None:
|
|
219
|
+
u_y = np.broadcast_to(np.asarray(u_y, dtype=float), (len(x),))
|
|
220
|
+
_require(np.isfinite(u_y).all(), 'U_Y must be finite.')
|
|
221
|
+
features = self.f_X.encode(x)
|
|
222
|
+
_require(np.isfinite(features).all(), 'X could not be encoded as finite features.')
|
|
223
|
+
return self._truth_xu(features, u, u_y)
|
|
224
|
+
|
|
225
|
+
def _observed_truth(self, x, atol, rtol, max_subdivisions, cdf_maxpts, *, latent_function=None):
|
|
226
|
+
_require(type(self.f_X) in (SyntheticCovariates, OpenMLCovariates),
|
|
227
|
+
'Observed-X CAPO supports only the built-in covariate mechanisms.')
|
|
228
|
+
_require(np.isfinite([atol,rtol]).all() and atol>0 and rtol>0,
|
|
229
|
+
'CAPO tolerances must be positive and finite.')
|
|
230
|
+
limit = _integer(max_subdivisions, 'max_subdivisions', 2)
|
|
231
|
+
budget = _integer(cdf_maxpts, 'cdf_maxpts', 128)
|
|
232
|
+
observed, features = self.f_X.encode(x, return_observed=True)
|
|
233
|
+
_require(np.isfinite(observed).all() and np.isfinite(features).all(), 'CAPO X must encode to finite values.')
|
|
234
|
+
population = (self.f_X.encode(self.f_X.table, return_observed=True)[0]
|
|
235
|
+
if type(self.f_X) is OpenMLCovariates else None)
|
|
236
|
+
means, methods, errors, refinements = [], [], [], []
|
|
237
|
+
for row, feature in zip(observed, features):
|
|
238
|
+
def solve(points):
|
|
239
|
+
likelihood, meta = self.f_X._capo_likelihood(row, self.loadings['X'], points, population=population)
|
|
240
|
+
def arm_means(u):
|
|
241
|
+
if latent_function is not None:
|
|
242
|
+
return np.full(2, latent_function(u))
|
|
243
|
+
values = self._truth_xu(feature[None,:], np.array([u]))
|
|
244
|
+
return np.array([values['mu0_xu'][0],values['mu1_xu'][0]])
|
|
245
|
+
if self.confounder_family == 'bernoulli':
|
|
246
|
+
prior = np.array([1-self.confounder_options.probability,self.confounder_options.probability])
|
|
247
|
+
logs = np.array([likelihood(_effect(u,self.confounder_function)) for u in (0.,1.)])+np.log(prior)
|
|
248
|
+
_require(np.isfinite(logsumexp(logs)), 'X has zero covariate likelihood.')
|
|
249
|
+
weights = np.exp(logs-logsumexp(logs))
|
|
250
|
+
result = sum(w*arm_means(u) for w,u in zip(weights,(0.,1.)) if w>0)
|
|
251
|
+
return result, 'finite_bayes' if not meta['numerical_rectangle'] else 'numerical_rectangle', 0., meta
|
|
252
|
+
gaussian = self.confounder_family == 'normal' and self.confounder_function == 'linear'
|
|
253
|
+
center, scale = 0., 1.
|
|
254
|
+
if gaussian:
|
|
255
|
+
scale = 1/np.sqrt(1+meta['information'])
|
|
256
|
+
center = meta['score']*scale**2
|
|
257
|
+
if latent_function is None and type(self.f_Y) is OutcomeMechanism and self.f_Y.family == 'gaussian':
|
|
258
|
+
posterior_mean,numerical=meta['gaussian_mean']()
|
|
259
|
+
meta['numerical_rectangle']=numerical
|
|
260
|
+
method=('numerical_truncated_gaussian' if numerical else
|
|
261
|
+
'analytic_gaussian' if meta['continuous_only'] else 'analytic_truncated_gaussian')
|
|
262
|
+
return arm_means(posterior_mean),method,0.,meta
|
|
263
|
+
def log_target(z):
|
|
264
|
+
u = float(_latent(np.array(z),self.confounder_family,self.confounder_options))
|
|
265
|
+
return likelihood(_effect(u,self.confounder_function))-.5*z*z
|
|
266
|
+
peaks = [center]
|
|
267
|
+
if not gaussian or not meta['continuous_only']:
|
|
268
|
+
grid = np.unique(np.r_[np.linspace(-12.,12.,49), center+scale*np.array([-4.,-1.,0.,1.,4.])])
|
|
269
|
+
values = np.array([log_target(z) for z in grid])
|
|
270
|
+
_require(np.isfinite(values).any(), 'X has zero or numerically unresolved likelihood.')
|
|
271
|
+
peaks = []
|
|
272
|
+
for j in range(1,len(grid)-1):
|
|
273
|
+
if values[j]>=values[j-1] and values[j]>=values[j+1] and np.isfinite(values[j]):
|
|
274
|
+
fit = minimize_scalar(lambda z:-log_target(z),bounds=(grid[j-1],grid[j+1]),method='bounded')
|
|
275
|
+
peaks.append(float(fit.x))
|
|
276
|
+
peaks = peaks or [float(grid[np.argmax(values)])]
|
|
277
|
+
center = max(peaks,key=log_target)
|
|
278
|
+
delta = 1e-3
|
|
279
|
+
curvature = -(log_target(center+delta)-2*log_target(center)+log_target(center-delta))/delta**2
|
|
280
|
+
if np.isfinite(curvature) and curvature>0:
|
|
281
|
+
scale = 1/np.sqrt(curvature)
|
|
282
|
+
shift = log_target(center)
|
|
283
|
+
_require(np.isfinite(shift), 'X has zero or numerically unresolved likelihood.')
|
|
284
|
+
def integrand(t):
|
|
285
|
+
z = center+scale*t
|
|
286
|
+
value = log_target(z)-shift
|
|
287
|
+
if not np.isfinite(value) or value < -745:
|
|
288
|
+
return np.zeros(3)
|
|
289
|
+
u = float(_latent(np.array(z),self.confounder_family,self.confounder_options))
|
|
290
|
+
return np.exp(value)*np.r_[1.,arm_means(u)]
|
|
291
|
+
breakpoints = [0.,-1.,1.,-4.,4.]+[(p-center)/scale for p in peaks]
|
|
292
|
+
total,error,info = quad_vec(integrand,-np.inf,np.inf,points=breakpoints,
|
|
293
|
+
epsabs=atol*.02,epsrel=rtol*.02,limit=limit,full_output=True)
|
|
294
|
+
_require(info.success and np.isfinite(total).all() and total[0]>0,
|
|
295
|
+
'CAPO quadrature did not converge; increase max_subdivisions or relax tolerances.')
|
|
296
|
+
result = total[1:]/total[0]
|
|
297
|
+
normalized_error = float(error/total[0]*(1+np.max(np.abs(result))))
|
|
298
|
+
_require(np.all(normalized_error <= atol+rtol*np.abs(result)),
|
|
299
|
+
'CAPO quadrature error is unresolved at the requested tolerance.')
|
|
300
|
+
return result,'numerical_quadrature',normalized_error,meta
|
|
301
|
+
result,method,error,meta = solve(budget)
|
|
302
|
+
change = 0.
|
|
303
|
+
if meta['numerical_rectangle']:
|
|
304
|
+
refined,_,refined_error,_ = solve(2*budget)
|
|
305
|
+
change = float(np.max(np.abs(refined-result)))
|
|
306
|
+
_require(np.all(np.abs(refined-result)<=atol+rtol*np.abs(refined)),
|
|
307
|
+
'CAPO rectangle refinement did not converge; increase cdf_maxpts.')
|
|
308
|
+
result,error = refined,max(error,refined_error)
|
|
309
|
+
if method!='numerical_truncated_gaussian':
|
|
310
|
+
method = 'numerical_rectangle_quadrature' if self.confounder_family!='bernoulli' else 'numerical_rectangle'
|
|
311
|
+
means.append(result); methods.append(method); errors.append(error); refinements.append(change)
|
|
312
|
+
result = np.asarray(means).reshape((-1,2))
|
|
313
|
+
return dict(mu0=result[:,0],mu1=result[:,1],diagnostics=dict(
|
|
314
|
+
conditioning='observed_X',methods=methods,atol=atol,rtol=rtol,
|
|
315
|
+
quadrature_error_estimates=errors,rectangle_refinement_deltas=refinements,
|
|
316
|
+
cdf_maxpts=budget,numerical_errors_certified=False))
|
|
317
|
+
|
|
318
|
+
def sample(self, n=1000, data_seed=42, start_row=0):
|
|
319
|
+
values, noise = self.scm._sample_arrays(n, data_seed, start_row, return_noise=True)
|
|
320
|
+
truth = self._truth_xu(self.f_X.encode(values['X']), values['U'], noise['Y'])
|
|
321
|
+
return {name: values[name] for name in ('X', 'A', 'Y')}, truth
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class CSCM_capo(CSCM):
|
|
325
|
+
"""CSCM with directly parameterized observed-X CAPO and unchanged X/A mechanisms.
|
|
326
|
+
|
|
327
|
+
Gaussian, Bernoulli, and Poisson outcomes use a bounded latent residual
|
|
328
|
+
centered under U|X. Sampling therefore needs numerical posterior integration;
|
|
329
|
+
truth_at(X) reads the target arm networks without integration.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
def __init__(self, *args, centering_options=None, **kwargs):
|
|
333
|
+
_require(kwargs.get('simpson_mode', False) is not True,
|
|
334
|
+
'simpson_mode is supported by CSCM, not CSCM_capo.')
|
|
335
|
+
super().__init__(*args, **kwargs)
|
|
336
|
+
_require(type(self.f_X) in (SyntheticCovariates, OpenMLCovariates),
|
|
337
|
+
'CSCM_capo requires built-in covariate likelihoods.')
|
|
338
|
+
defaults = dict(atol=1e-6, rtol=1e-5, max_subdivisions=200, cdf_maxpts=20000)
|
|
339
|
+
_require(centering_options is None or isinstance(centering_options, dict),
|
|
340
|
+
'centering_options must be a dictionary.')
|
|
341
|
+
_require(not set(centering_options or {})-set(defaults), 'Unknown centering option.')
|
|
342
|
+
self.centering_options = dict(defaults, **(centering_options or {}))
|
|
343
|
+
self.f_Y = CAPOOutcomeMechanism(self.f_Y, self.loadings['Y'])
|
|
344
|
+
self._centering_cache = None
|
|
345
|
+
self.scm = self.scm.replace_mechanism('Y', self._generate_capo_y, ['X', 'A', 'U'])
|
|
346
|
+
self.scm._block_compatible = True
|
|
347
|
+
|
|
348
|
+
def _centering(self, x):
|
|
349
|
+
cache = self._centering_cache
|
|
350
|
+
options = self.centering_options
|
|
351
|
+
if cache is not None and cache[1] == options and np.array_equal(cache[0], x, equal_nan=True):
|
|
352
|
+
return cache[2]
|
|
353
|
+
observed = self.f_X.encode(x, return_observed=True)[0]
|
|
354
|
+
unique, inverse = np.unique(observed, axis=0, return_inverse=True)
|
|
355
|
+
result = self._observed_truth(unique, **options, latent_function=
|
|
356
|
+
lambda u: np.tanh(_effect(u, self.confounder_function)))
|
|
357
|
+
center = result['mu0'][inverse]
|
|
358
|
+
_require(np.isfinite(center).all() and (np.abs(center) <= 1).all(),
|
|
359
|
+
'Conditional latent centering is outside [-1, 1].')
|
|
360
|
+
diagnostics = dict(result['diagnostics'])
|
|
361
|
+
for key in ('methods', 'quadrature_error_estimates', 'rectangle_refinement_deltas'):
|
|
362
|
+
diagnostics[key] = [diagnostics[key][j] for j in inverse]
|
|
363
|
+
error = np.asarray(diagnostics['quadrature_error_estimates']) + np.asarray(
|
|
364
|
+
diagnostics['rectangle_refinement_deltas'])
|
|
365
|
+
value = center, error, diagnostics
|
|
366
|
+
self._centering_cache = (np.array(x, copy=True), options.copy(), value)
|
|
367
|
+
return value
|
|
368
|
+
|
|
369
|
+
def _capo_context(self, x, u):
|
|
370
|
+
features = self.f_X.encode(x)
|
|
371
|
+
_require(np.isfinite(features).all(), 'X must encode to finite features.')
|
|
372
|
+
center, error, diagnostics = self._centering(x)
|
|
373
|
+
residual = (np.tanh(_effect(u, self.confounder_function))-center)/2
|
|
374
|
+
return self._masked(features, self.outcome_mask), residual, error, diagnostics
|
|
375
|
+
|
|
376
|
+
def _generate_capo_y(self, X, A, U, noise):
|
|
377
|
+
features, residual, _, _ = self._capo_context(X, U)
|
|
378
|
+
return self.f_Y(features, A, noise, residual=residual)
|
|
379
|
+
|
|
380
|
+
def truth_at(self, x, *, u=None, u_y=None, data_seed=None, start_row=0):
|
|
381
|
+
"""Return direct mu0/mu1 targets; explicit u also returns corrected conditional truth.
|
|
382
|
+
|
|
383
|
+
Explicit-u diagnostics estimate numerical centering error, not certified
|
|
384
|
+
bounds. Outcome noise replay requires u, just as for CSCM.
|
|
385
|
+
"""
|
|
386
|
+
x = np.asarray(x, dtype=float)
|
|
387
|
+
_require(x.ndim == 2 and x.shape[1] == self.d_X and not np.isinf(x).any(),
|
|
388
|
+
f'Expected X of shape (n, {self.d_X}) without infinite values.')
|
|
389
|
+
features = self.f_X.encode(x)
|
|
390
|
+
_require(np.isfinite(features).all(), 'X must encode to finite features.')
|
|
391
|
+
outcome_features = self._masked(features, self.outcome_mask)
|
|
392
|
+
result = dict(mu0=self.f_Y.mean(outcome_features, 0), mu1=self.f_Y.mean(outcome_features, 1))
|
|
393
|
+
if u is None:
|
|
394
|
+
_require(u_y is None and data_seed is None and start_row == 0,
|
|
395
|
+
'Observed-X CAPO averages outcome noise; omit u_y, data_seed and start_row.')
|
|
396
|
+
return result
|
|
397
|
+
u = np.broadcast_to(np.asarray(u, dtype=float), (len(x),))
|
|
398
|
+
_require(np.isfinite(u).all(), 'U must be finite.')
|
|
399
|
+
_require(self.confounder_family != 'bernoulli' or np.isin(u, (0, 1)).all(),
|
|
400
|
+
'Bernoulli U must contain only 0 or 1.')
|
|
401
|
+
_require(u_y is None or data_seed is None, 'Supply u_y or data_seed, not both.')
|
|
402
|
+
_require(data_seed is not None or start_row == 0, 'start_row requires data_seed.')
|
|
403
|
+
if data_seed is not None:
|
|
404
|
+
u_y = self.f_U(len(x), data_seed, start_row)['U_Y']
|
|
405
|
+
if u_y is not None:
|
|
406
|
+
u_y = np.broadcast_to(np.asarray(u_y, dtype=float), (len(x),))
|
|
407
|
+
_require(np.isfinite(u_y).all(), 'U_Y must be finite.')
|
|
408
|
+
outcome_features, residual, error, diagnostics = self._capo_context(x, u)
|
|
409
|
+
effect = _effect(u, self.confounder_function)
|
|
410
|
+
propensity = (self.f_A.propensity(self._masked(features, self.treatment_mask)) if self.unconfounded else
|
|
411
|
+
_propensity(features, effect, self.f_A, self.treatment_mask, self.loadings['A']))
|
|
412
|
+
result.update(U=u.copy(), e_xu=propensity, centering_error_estimate=error.copy())
|
|
413
|
+
for arm in (0, 1):
|
|
414
|
+
result[f'mu{arm}_xu'] = self.f_Y.conditional_mean(outcome_features, arm, residual)
|
|
415
|
+
result[f'mu{arm}_centering_error_estimate'] = np.abs(self.f_Y.residual_scale(result[f'mu{arm}']))*error/2
|
|
416
|
+
if u_y is not None:
|
|
417
|
+
result[f'Y{arm}'] = self.f_Y(outcome_features, arm, u_y, residual=residual)
|
|
418
|
+
result['diagnostics'] = dict(diagnostics, parameterization='observed_X_CAPO',
|
|
419
|
+
centering_function='tanh(g(U))', numerical_errors_certified=False)
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
def sample(self, n=1000, data_seed=42, start_row=0):
|
|
423
|
+
values, noise = self.scm._sample_arrays(n, data_seed, start_row, return_noise=True)
|
|
424
|
+
truth = self.truth_at(values['X'], u=values['U'], u_y=noise['Y'])
|
|
425
|
+
# Inherited sample_episode slices each truth field by row.
|
|
426
|
+
truth.pop('diagnostics')
|
|
427
|
+
return {name: values[name] for name in ('X', 'A', 'Y')}, truth
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Engine snapshot provenance and license exceptions
|
|
2
|
+
|
|
3
|
+
indexed_graph.py retains its original MIT header and separate MIT permissions.
|
|
4
|
+
uca_compiler.py and uca_estimation.py originally had no confirmed upstream
|
|
5
|
+
license declaration. On October 26, 2026, Yonghan Jung explicitly confirmed his
|
|
6
|
+
authority to license these bundled snapshots and authorized their public
|
|
7
|
+
distribution under the CausalEngine Noncommercial License 1.0 in the root LICENSE.
|
|
8
|
+
This present author authorization does not assert a historical upstream license
|
|
9
|
+
grant or a new MIT grant. The indexed_graph.py MIT permissions remain unchanged.
|
|
10
|
+
|
|
11
|
+
The current root LICENSE applies only to material Yonghan Jung is entitled to
|
|
12
|
+
license, subject to LICENSING.md. Prior valid MIT grants remain effective;
|
|
13
|
+
LICENSE-MIT.txt preserves their original text without regranting the entire
|
|
14
|
+
current package under MIT. Dependencies retain their own terms.
|
|
15
|
+
|
|
16
|
+
ENGINE_SOURCES.json sha256 values cover canonical package source files. Prior
|
|
17
|
+
pre-layout snapshot hashes and upstream_sha256 retain the original provenance.
|
|
18
|
+
|
|
19
|
+
2026-10-25 local uca_estimation.py patch: feature weights use exact normalized
|
|
20
|
+
minimum-L2 balance; finite weights retain the common-joint histogram/carrier
|
|
21
|
+
source law and its analytic full-indicator solution. Backward regression ridge
|
|
22
|
+
and source/evaluation splitting are unchanged. No upstream license is altered.
|
|
23
|
+
|
|
24
|
+
2026-10-26 local uca_estimation.py patch: point-score backward regressions use
|
|
25
|
+
XGBoost by default, including finite weight designs. Real-coordinate carriers
|
|
26
|
+
are auxiliary Gaussian proposals; support and finite-score moments are assumed.
|
|
27
|
+
Minimum-L2 weights and explicit finite/affine protocols are preserved. No
|
|
28
|
+
automatic causal interval or new upstream license permission is implied.
|
|
29
|
+
|
|
30
|
+
2026-10-26 local cleanup: backward fitting and one score-unit evaluation are
|
|
31
|
+
extracted into private helpers without changing random draw order. Paired
|
|
32
|
+
terminal-weight scores and shared-fit phase timings support a pilot comparison.
|
|
33
|
+
Weight mathematics, default learners and inference claims are unchanged.
|
|
34
|
+
|
|
35
|
+
2026-10-27 layout consolidation: the three engine modules now live directly in
|
|
36
|
+
src/causalengine, with package-relative imports identical to the former installed
|
|
37
|
+
payload. ENGINE_SOURCES.json records current hashes and preserves snapshot history.
|
|
38
|
+
No algorithm or licensing terms changed.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"engine": {
|
|
3
|
+
"indexed_graph.py": {
|
|
4
|
+
"origin": "research/papers/universal_causal_adjustment/code/indexed_graph.py",
|
|
5
|
+
"sha256": "66a0dfb1042be91a47b09b2dc100faf0bbfabfef967d92698c5d06782dd47aad",
|
|
6
|
+
"license": "MIT header present",
|
|
7
|
+
"package_path": "indexed_graph.py"
|
|
8
|
+
},
|
|
9
|
+
"uca_compiler.py": {
|
|
10
|
+
"origin": "research/papers/universal_causal_adjustment/code/uca_compiler.py",
|
|
11
|
+
"sha256": "68e9ef54e131b1f330d1938f2f3e1193dfc662f9460882320d19169ca9c98320",
|
|
12
|
+
"license": "CausalEngine Noncommercial License 1.0; public distribution explicitly authorized by Yonghan Jung on 2026-10-26; no historical upstream license declaration confirmed",
|
|
13
|
+
"patch_history": [
|
|
14
|
+
{
|
|
15
|
+
"sha256": "97aee7ddfe1b41131029dc9a064dc0c64540e0e4400bda22169f233328a704c9",
|
|
16
|
+
"description": "Before 2026-10-27 layout consolidation; only local imports were namespaced."
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"package_path": "uca_compiler.py"
|
|
20
|
+
},
|
|
21
|
+
"uca_estimation.py": {
|
|
22
|
+
"origin": "research/papers/universal_causal_adjustment/code/uca_estimation.py",
|
|
23
|
+
"sha256": "792039f96d7a97c2cc349e20421e83a77d30c1ca216a5aa4f7d96dc83be82198",
|
|
24
|
+
"license": "CausalEngine Noncommercial License 1.0; public distribution explicitly authorized by Yonghan Jung on 2026-10-26; no historical upstream license declaration confirmed",
|
|
25
|
+
"upstream_sha256": "e0aec3d8bf12c8040c4bfdb8086835a1a806b3548e0b2d9a1fdc3fafb9ba777c",
|
|
26
|
+
"local_patch": "2026-10-26: behavior-preserving backward/evaluation helper extraction; paired terminal-weight score and shared phase timings for pilot comparison. RNG order and minimum-L2 weights unchanged.",
|
|
27
|
+
"patch_history": [
|
|
28
|
+
{
|
|
29
|
+
"sha256": "04bbbc98419c0d3c65a7393833a3cb80a17526d2a24b89ee3510bc990eb9f870",
|
|
30
|
+
"description": "2026-10-25: exact normalized minimum-L2 feature weights; finite analytic density-ratio certificate; unchanged backward regression and source designs."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"sha256": "a0ef078995f07407338783f8caa18ce238bc51ba59182eda68eae630898658c8",
|
|
34
|
+
"description": "2026-10-26: XGBoost backward regression for point-score execution, independent of finite/Riesz minimum-L2 weights; auxiliary real-coordinate proposals and explicit point-only inference conditions."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"sha256": "bd7214bc6f97993965693c7ca9796de10c74289aa2b234595770ab5fbe73fa05",
|
|
38
|
+
"description": "Before 2026-10-27 layout consolidation; only local imports were namespaced."
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"package_path": "uca_estimation.py"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"scope": "Alpha bundle; Yonghan Jung explicitly authorized public distribution of the bundled UCA snapshots on 2026-10-26 under the root CausalEngine Noncommercial License 1.0; indexed_graph.py retains MIT permissions",
|
|
45
|
+
"hash_scope": "sha256 records canonical package files under src/causalscience; source and wheel use identical relative imports. Historical snapshot hashes are retained in patch_history/upstream_sha256.",
|
|
46
|
+
"local_license_policy": "Root LICENSE applies to material Yonghan Jung is entitled to license, including the bundled UCA snapshots expressly authorized on 2026-10-26; LICENSING.md preserves prior MIT and separately licensed rights. This present authorization does not assert a historical upstream grant."
|
|
47
|
+
}
|