skxperiments 0.1.0.dev0__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.
- skxperiments/__init__.py +5 -0
- skxperiments/core/__init__.py +42 -0
- skxperiments/core/assignment.py +589 -0
- skxperiments/core/base.py +512 -0
- skxperiments/core/exceptions.py +145 -0
- skxperiments/core/potential_outcomes.py +168 -0
- skxperiments/core/results.py +624 -0
- skxperiments/design/__init__.py +22 -0
- skxperiments/design/balance.py +182 -0
- skxperiments/design/blocked_crd.py +157 -0
- skxperiments/design/crd.py +162 -0
- skxperiments/design/factorial.py +174 -0
- skxperiments/design/power.py +233 -0
- skxperiments/design/rerandomized_crd.py +319 -0
- skxperiments/diagnostics/__init__.py +21 -0
- skxperiments/diagnostics/aa_test.py +277 -0
- skxperiments/diagnostics/balance_report.py +224 -0
- skxperiments/diagnostics/srm.py +327 -0
- skxperiments/estimators/__init__.py +23 -0
- skxperiments/estimators/blocked_difference_in_means.py +197 -0
- skxperiments/estimators/cuped.py +280 -0
- skxperiments/estimators/difference_in_means.py +161 -0
- skxperiments/estimators/factorial_estimator.py +213 -0
- skxperiments/estimators/lin_estimator.py +298 -0
- skxperiments/inference/__init__.py +17 -0
- skxperiments/inference/bootstrap.py +450 -0
- skxperiments/inference/multiple.py +365 -0
- skxperiments/inference/neyman.py +386 -0
- skxperiments/inference/randomization_test.py +319 -0
- skxperiments/pipeline.py +366 -0
- skxperiments/reporting/__init__.py +30 -0
- skxperiments/reporting/plots.py +411 -0
- skxperiments/reporting/summary.py +185 -0
- skxperiments-0.1.0.dev0.dist-info/METADATA +272 -0
- skxperiments-0.1.0.dev0.dist-info/RECORD +36 -0
- skxperiments-0.1.0.dev0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""Uniform results object for all estimators and inference methods.
|
|
2
|
+
|
|
3
|
+
Every estimator and inference method in skxperiments returns a Results
|
|
4
|
+
object, providing a consistent interface for accessing estimates,
|
|
5
|
+
confidence intervals, p-values, and metadata.
|
|
6
|
+
|
|
7
|
+
A Results instance carries either a single ATE (scalar estimand) or a
|
|
8
|
+
dict of named effects (e.g., main effects and interactions from a
|
|
9
|
+
factorial design). Exactly one of ``ate`` or ``effects`` must be
|
|
10
|
+
provided.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from skxperiments.core.exceptions import InvalidDesignError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Type alias for effect keys: tuples of factor names.
|
|
19
|
+
# ("A",) -> main effect of A
|
|
20
|
+
# ("A", "B") -> AB interaction
|
|
21
|
+
EffectKey = tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Results:
|
|
25
|
+
"""Uniform output object for estimators and inference methods.
|
|
26
|
+
|
|
27
|
+
Carries either a single scalar ATE or a dict of named effects.
|
|
28
|
+
Exactly one of ``ate`` or ``effects`` must be provided.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
ate : float or None, optional
|
|
33
|
+
Point estimate of the Average Treatment Effect, for scalar
|
|
34
|
+
estimands. Mutually exclusive with ``effects``. By default None.
|
|
35
|
+
effects : dict or None, optional
|
|
36
|
+
Mapping of effect-name tuples to point estimates, for
|
|
37
|
+
multi-effect estimands (e.g., FactorialEstimator). Keys are
|
|
38
|
+
tuples of factor names: ``("A",)`` for main effect of A,
|
|
39
|
+
``("A", "B")`` for AB interaction. Mutually exclusive with
|
|
40
|
+
``ate``. By default None.
|
|
41
|
+
se : float or dict or None, optional
|
|
42
|
+
Standard error. ``float`` when ``ate`` is set; ``dict`` keyed
|
|
43
|
+
like ``effects`` when ``effects`` is set. By default None.
|
|
44
|
+
ci : tuple or dict or None, optional
|
|
45
|
+
Confidence interval. ``(lower, upper)`` when ``ate`` is set;
|
|
46
|
+
``dict`` keyed like ``effects`` mapping to ``(lower, upper)``
|
|
47
|
+
tuples when ``effects`` is set. By default None.
|
|
48
|
+
p_value : float or dict or None, optional
|
|
49
|
+
P-value. ``float`` when ``ate`` is set; ``dict`` keyed like
|
|
50
|
+
``effects`` when ``effects`` is set. By default None.
|
|
51
|
+
alpha : float, optional
|
|
52
|
+
Significance level, by default 0.05.
|
|
53
|
+
n_obs : int or None, optional
|
|
54
|
+
Total number of observations, by default None.
|
|
55
|
+
n_treated : int or None, optional
|
|
56
|
+
Number of treated units, by default None.
|
|
57
|
+
n_control : int or None, optional
|
|
58
|
+
Number of control units, by default None.
|
|
59
|
+
estimator_name : str or None, optional
|
|
60
|
+
Name of the estimator used, by default None.
|
|
61
|
+
design_name : str or None, optional
|
|
62
|
+
Name of the experimental design, by default None.
|
|
63
|
+
inference_name : str or None, optional
|
|
64
|
+
Name of the inference method, by default None.
|
|
65
|
+
extra : dict or None, optional
|
|
66
|
+
Additional metadata as key-value pairs (e.g., n_permutations,
|
|
67
|
+
convergence info). Not the primary result. By default None.
|
|
68
|
+
See Notes for the schema of reserved keys.
|
|
69
|
+
|
|
70
|
+
Raises
|
|
71
|
+
------
|
|
72
|
+
InvalidDesignError
|
|
73
|
+
If neither or both of ``ate`` and ``effects`` are provided,
|
|
74
|
+
or if shape/value validations fail on ci, p_value, or alpha.
|
|
75
|
+
TypeError
|
|
76
|
+
If ``ate`` is provided but is not numeric.
|
|
77
|
+
|
|
78
|
+
Notes
|
|
79
|
+
-----
|
|
80
|
+
Reserved keys in ``extra``
|
|
81
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
82
|
+
|
|
83
|
+
The following keys are reserved by skxperiments components.
|
|
84
|
+
Custom metadata may use any other key.
|
|
85
|
+
|
|
86
|
+
*Written by estimators (Phase 3):*
|
|
87
|
+
|
|
88
|
+
- ``"inference_mode"`` : str, ``"finite_population"`` or
|
|
89
|
+
``"superpopulation"``. Documentational metadata propagated by
|
|
90
|
+
``LinEstimator``. Read by inference classes in Phase 4.
|
|
91
|
+
- ``"theta"`` : float. CUPED adjustment coefficient
|
|
92
|
+
``Cov(Y, X_pre) / Var(X_pre)``.
|
|
93
|
+
- ``"correlation"`` : float. Pearson correlation between outcome
|
|
94
|
+
and pre-period covariate, written by ``CUPED``.
|
|
95
|
+
|
|
96
|
+
*Written by inference classes (Phase 4):*
|
|
97
|
+
|
|
98
|
+
- ``"n_permutations"`` : int. Number of permutations used by
|
|
99
|
+
``RandomizationTest``.
|
|
100
|
+
- ``"null_distribution"`` : np.ndarray. Array of permuted
|
|
101
|
+
statistics under the sharp null, written by
|
|
102
|
+
``RandomizationTest``. Length equals ``n_permutations``.
|
|
103
|
+
- ``"alternative"`` : str. Alternative hypothesis used by
|
|
104
|
+
``RandomizationTest``: ``"two-sided"``, ``"greater"``, or
|
|
105
|
+
``"less"``.
|
|
106
|
+
|
|
107
|
+
Examples
|
|
108
|
+
--------
|
|
109
|
+
Scalar estimand:
|
|
110
|
+
|
|
111
|
+
>>> r = Results(ate=0.142, se=0.056, p_value=0.011)
|
|
112
|
+
>>> r.is_significant()
|
|
113
|
+
True
|
|
114
|
+
|
|
115
|
+
Multi-effect estimand:
|
|
116
|
+
|
|
117
|
+
>>> r = Results(
|
|
118
|
+
... effects={("A",): 0.5, ("B",): 0.3, ("A", "B"): 0.1},
|
|
119
|
+
... p_value={("A",): 0.01, ("B",): 0.04, ("A", "B"): 0.20},
|
|
120
|
+
... )
|
|
121
|
+
>>> r.is_significant(("A",))
|
|
122
|
+
True
|
|
123
|
+
|
|
124
|
+
*Written by inference classes (Phase 4.2):*
|
|
125
|
+
|
|
126
|
+
- ``"correction_method"`` : str, ``"bonferroni"``, ``"holm"``, or
|
|
127
|
+
``"bh"``. The multiple-testing correction applied by
|
|
128
|
+
``MultipleTestingCorrection``.
|
|
129
|
+
- ``"original_p_values"`` : dict or list. The uncorrected p-values
|
|
130
|
+
before applying the correction. Same structure as the corrected
|
|
131
|
+
``p_value``: dict in multi-effect mode, list in scalar-list mode.
|
|
132
|
+
- ``"family_wise_alpha"`` : float. The family-wise alpha used by
|
|
133
|
+
``MultipleTestingCorrection``.
|
|
134
|
+
- ``"n_tests"`` : int. The size of the testing family.
|
|
135
|
+
|
|
136
|
+
*Written by inference classes (Phase 4.3):*
|
|
137
|
+
|
|
138
|
+
- ``"variance_type"`` : str, ``"neyman"`` or
|
|
139
|
+
``"neyman_stratified"``. The variance estimator used by
|
|
140
|
+
``NeymanCI``: ``"neyman"`` for CRD (two-sample conservative
|
|
141
|
+
variance) and ``"neyman_stratified"`` for blocked designs
|
|
142
|
+
(size-weighted sum of within-block variances). ``NeymanCI``
|
|
143
|
+
also propagates ``"inference_mode"`` (see Phase 3).
|
|
144
|
+
|
|
145
|
+
*Written by inference classes (Phase 4.4):*
|
|
146
|
+
|
|
147
|
+
- ``"method"`` : str, ``"percentile"`` or ``"bca"``. The bootstrap
|
|
148
|
+
interval method used by ``BootstrapCI``.
|
|
149
|
+
- ``"n_resamples"`` : int. Number of bootstrap resamples.
|
|
150
|
+
- ``"bootstrap_distribution"`` : np.ndarray. Resampled ATEs;
|
|
151
|
+
length equals ``n_resamples``.
|
|
152
|
+
- ``"bias_correction"`` : float. BCa bias-correction ``z0``
|
|
153
|
+
(present only when ``method="bca"``).
|
|
154
|
+
- ``"acceleration"`` : float. BCa acceleration ``a`` (present only
|
|
155
|
+
when ``method="bca"``). ``BootstrapCI`` always sets
|
|
156
|
+
``inference_mode="superpopulation"``.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
ate: float | None = None,
|
|
163
|
+
effects: dict[EffectKey, float] | None = None,
|
|
164
|
+
se: float | dict | None = None,
|
|
165
|
+
ci: tuple[float, float] | dict | None = None,
|
|
166
|
+
p_value: float | dict | None = None,
|
|
167
|
+
alpha: float = 0.05,
|
|
168
|
+
n_obs: int | None = None,
|
|
169
|
+
n_treated: int | None = None,
|
|
170
|
+
n_control: int | None = None,
|
|
171
|
+
estimator_name: str | None = None,
|
|
172
|
+
design_name: str | None = None,
|
|
173
|
+
inference_name: str | None = None,
|
|
174
|
+
extra: dict | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
# --- Mutual exclusivity ---
|
|
177
|
+
if ate is None and effects is None:
|
|
178
|
+
raise InvalidDesignError(
|
|
179
|
+
"Results requires exactly one of ate or effects; "
|
|
180
|
+
"both are None."
|
|
181
|
+
)
|
|
182
|
+
if ate is not None and effects is not None:
|
|
183
|
+
raise InvalidDesignError(
|
|
184
|
+
"Results requires exactly one of ate or effects; "
|
|
185
|
+
"both were provided."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# --- Validate ate (scalar mode) ---
|
|
189
|
+
if ate is not None:
|
|
190
|
+
if not isinstance(ate, (int, float)):
|
|
191
|
+
raise TypeError(
|
|
192
|
+
f"ate must be int or float, but received "
|
|
193
|
+
f"{type(ate).__name__}."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# --- Validate effects (dict mode) ---
|
|
197
|
+
if effects is not None:
|
|
198
|
+
if not isinstance(effects, dict) or len(effects) == 0:
|
|
199
|
+
raise InvalidDesignError(
|
|
200
|
+
"effects must be a non-empty dict mapping "
|
|
201
|
+
"tuple[str, ...] to float."
|
|
202
|
+
)
|
|
203
|
+
for key, value in effects.items():
|
|
204
|
+
if not isinstance(key, tuple) or not all(
|
|
205
|
+
isinstance(k, str) for k in key
|
|
206
|
+
):
|
|
207
|
+
raise InvalidDesignError(
|
|
208
|
+
f"effects keys must be tuples of strings; "
|
|
209
|
+
f"got key {key!r}."
|
|
210
|
+
)
|
|
211
|
+
if not isinstance(value, (int, float)):
|
|
212
|
+
raise InvalidDesignError(
|
|
213
|
+
f"effects values must be numeric; got "
|
|
214
|
+
f"{type(value).__name__} for key {key!r}."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# --- Validate ci ---
|
|
218
|
+
if ci is not None:
|
|
219
|
+
if effects is None:
|
|
220
|
+
# Scalar mode: ci must be a 2-tuple of numbers.
|
|
221
|
+
if (
|
|
222
|
+
not isinstance(ci, tuple)
|
|
223
|
+
or len(ci) != 2
|
|
224
|
+
or not all(isinstance(b, (int, float)) for b in ci)
|
|
225
|
+
):
|
|
226
|
+
raise InvalidDesignError(
|
|
227
|
+
"ci must be a tuple of two floats (lower, upper) "
|
|
228
|
+
"in scalar mode."
|
|
229
|
+
)
|
|
230
|
+
if ci[0] > ci[1]:
|
|
231
|
+
raise InvalidDesignError(
|
|
232
|
+
f"ci lower bound ({ci[0]}) must be <= upper "
|
|
233
|
+
f"bound ({ci[1]})."
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
# Multi-effect mode: ci must be dict keyed like effects.
|
|
237
|
+
if not isinstance(ci, dict):
|
|
238
|
+
raise InvalidDesignError(
|
|
239
|
+
"ci must be a dict in multi-effect mode."
|
|
240
|
+
)
|
|
241
|
+
for key, value in ci.items():
|
|
242
|
+
if key not in effects:
|
|
243
|
+
raise InvalidDesignError(
|
|
244
|
+
f"ci key {key!r} not in effects."
|
|
245
|
+
)
|
|
246
|
+
if (
|
|
247
|
+
not isinstance(value, tuple)
|
|
248
|
+
or len(value) != 2
|
|
249
|
+
or not all(isinstance(b, (int, float)) for b in value)
|
|
250
|
+
):
|
|
251
|
+
raise InvalidDesignError(
|
|
252
|
+
f"ci[{key!r}] must be a tuple of two floats."
|
|
253
|
+
)
|
|
254
|
+
if value[0] > value[1]:
|
|
255
|
+
raise InvalidDesignError(
|
|
256
|
+
f"ci[{key!r}] lower bound ({value[0]}) must "
|
|
257
|
+
f"be <= upper bound ({value[1]})."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# --- Validate p_value ---
|
|
261
|
+
if p_value is not None:
|
|
262
|
+
if effects is None:
|
|
263
|
+
if not isinstance(p_value, (int, float)):
|
|
264
|
+
raise InvalidDesignError(
|
|
265
|
+
f"p_value must be a float in scalar mode, "
|
|
266
|
+
f"received {type(p_value).__name__}."
|
|
267
|
+
)
|
|
268
|
+
if not (0.0 <= p_value <= 1.0):
|
|
269
|
+
raise InvalidDesignError(
|
|
270
|
+
f"p_value must be in [0.0, 1.0], received {p_value}."
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
if not isinstance(p_value, dict):
|
|
274
|
+
raise InvalidDesignError(
|
|
275
|
+
"p_value must be a dict in multi-effect mode."
|
|
276
|
+
)
|
|
277
|
+
for key, value in p_value.items():
|
|
278
|
+
if key not in effects:
|
|
279
|
+
raise InvalidDesignError(
|
|
280
|
+
f"p_value key {key!r} not in effects."
|
|
281
|
+
)
|
|
282
|
+
if not isinstance(value, (int, float)):
|
|
283
|
+
raise InvalidDesignError(
|
|
284
|
+
f"p_value[{key!r}] must be numeric."
|
|
285
|
+
)
|
|
286
|
+
if not (0.0 <= value <= 1.0):
|
|
287
|
+
raise InvalidDesignError(
|
|
288
|
+
f"p_value[{key!r}] must be in [0.0, 1.0], "
|
|
289
|
+
f"received {value}."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# --- Validate se (no range constraint, just type) ---
|
|
293
|
+
if se is not None:
|
|
294
|
+
if effects is None:
|
|
295
|
+
if not isinstance(se, (int, float)):
|
|
296
|
+
raise InvalidDesignError(
|
|
297
|
+
f"se must be a float in scalar mode, received "
|
|
298
|
+
f"{type(se).__name__}."
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
if not isinstance(se, dict):
|
|
302
|
+
raise InvalidDesignError(
|
|
303
|
+
"se must be a dict in multi-effect mode."
|
|
304
|
+
)
|
|
305
|
+
for key, value in se.items():
|
|
306
|
+
if key not in effects:
|
|
307
|
+
raise InvalidDesignError(
|
|
308
|
+
f"se key {key!r} not in effects."
|
|
309
|
+
)
|
|
310
|
+
if not isinstance(value, (int, float)):
|
|
311
|
+
raise InvalidDesignError(
|
|
312
|
+
f"se[{key!r}] must be numeric."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# --- Validate alpha ---
|
|
316
|
+
if not isinstance(alpha, (int, float)):
|
|
317
|
+
raise InvalidDesignError(
|
|
318
|
+
f"alpha must be a float, but received {type(alpha).__name__}."
|
|
319
|
+
)
|
|
320
|
+
if not (0.0 < alpha < 1.0):
|
|
321
|
+
raise InvalidDesignError(
|
|
322
|
+
f"alpha must be in (0.0, 1.0), but received {alpha}."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# --- Store ---
|
|
326
|
+
self.ate = ate
|
|
327
|
+
self.effects = effects
|
|
328
|
+
self.se = se
|
|
329
|
+
self.ci = ci
|
|
330
|
+
self.p_value = p_value
|
|
331
|
+
self.alpha = alpha
|
|
332
|
+
self.n_obs = n_obs
|
|
333
|
+
self.n_treated = n_treated
|
|
334
|
+
self.n_control = n_control
|
|
335
|
+
self.estimator_name = estimator_name
|
|
336
|
+
self.design_name = design_name
|
|
337
|
+
self.inference_name = inference_name
|
|
338
|
+
self.extra = extra
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def is_multi_effect(self) -> bool:
|
|
342
|
+
"""Whether this Results carries multiple named effects."""
|
|
343
|
+
return self.effects is not None
|
|
344
|
+
|
|
345
|
+
def to_dict(self) -> dict:
|
|
346
|
+
"""Convert results to a dictionary.
|
|
347
|
+
|
|
348
|
+
In scalar mode, includes ``ate`` and other top-level fields.
|
|
349
|
+
In multi-effect mode, includes ``effects`` (and matching ``se``,
|
|
350
|
+
``ci``, ``p_value`` dicts) instead of ``ate``.
|
|
351
|
+
|
|
352
|
+
Returns
|
|
353
|
+
-------
|
|
354
|
+
dict
|
|
355
|
+
All non-None attributes. ``extra`` keys are flattened to
|
|
356
|
+
top level.
|
|
357
|
+
"""
|
|
358
|
+
result: dict = {}
|
|
359
|
+
|
|
360
|
+
if self.ate is not None:
|
|
361
|
+
result["ate"] = self.ate
|
|
362
|
+
if self.effects is not None:
|
|
363
|
+
result["effects"] = self.effects
|
|
364
|
+
|
|
365
|
+
for attr in [
|
|
366
|
+
"se", "ci", "p_value", "alpha",
|
|
367
|
+
"n_obs", "n_treated", "n_control",
|
|
368
|
+
"estimator_name", "design_name", "inference_name",
|
|
369
|
+
]:
|
|
370
|
+
value = getattr(self, attr)
|
|
371
|
+
if value is not None:
|
|
372
|
+
result[attr] = value
|
|
373
|
+
|
|
374
|
+
if self.extra is not None:
|
|
375
|
+
for key, value in self.extra.items():
|
|
376
|
+
result[key] = value
|
|
377
|
+
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
381
|
+
"""Convert results to a pandas DataFrame.
|
|
382
|
+
|
|
383
|
+
In scalar mode, returns a single-row DataFrame.
|
|
384
|
+
In multi-effect mode, returns one row per effect with columns
|
|
385
|
+
``effect``, ``estimate``, and (when present) ``se``, ``ci_lower``,
|
|
386
|
+
``ci_upper``, ``p_value``. Metadata columns (n_obs,
|
|
387
|
+
estimator_name, etc.) are repeated across rows.
|
|
388
|
+
|
|
389
|
+
Returns
|
|
390
|
+
-------
|
|
391
|
+
pd.DataFrame
|
|
392
|
+
"""
|
|
393
|
+
if self.ate is not None:
|
|
394
|
+
return pd.DataFrame([self.to_dict()])
|
|
395
|
+
|
|
396
|
+
# Multi-effect: one row per effect.
|
|
397
|
+
rows = []
|
|
398
|
+
for key, estimate in self.effects.items(): # type: ignore[union-attr]
|
|
399
|
+
row: dict = {
|
|
400
|
+
"effect": key,
|
|
401
|
+
"estimate": estimate,
|
|
402
|
+
}
|
|
403
|
+
if self.se is not None:
|
|
404
|
+
row["se"] = self.se.get(key) # type: ignore[union-attr]
|
|
405
|
+
if self.ci is not None:
|
|
406
|
+
ci_val = self.ci.get(key) # type: ignore[union-attr]
|
|
407
|
+
if ci_val is not None:
|
|
408
|
+
row["ci_lower"] = ci_val[0]
|
|
409
|
+
row["ci_upper"] = ci_val[1]
|
|
410
|
+
if self.p_value is not None:
|
|
411
|
+
row["p_value"] = self.p_value.get(key) # type: ignore[union-attr]
|
|
412
|
+
|
|
413
|
+
for attr in [
|
|
414
|
+
"alpha", "n_obs", "n_treated", "n_control",
|
|
415
|
+
"estimator_name", "design_name", "inference_name",
|
|
416
|
+
]:
|
|
417
|
+
value = getattr(self, attr)
|
|
418
|
+
if value is not None:
|
|
419
|
+
row[attr] = value
|
|
420
|
+
|
|
421
|
+
rows.append(row)
|
|
422
|
+
|
|
423
|
+
return pd.DataFrame(rows)
|
|
424
|
+
|
|
425
|
+
def to_markdown(self) -> str:
|
|
426
|
+
"""Generate a markdown table of results.
|
|
427
|
+
|
|
428
|
+
Returns
|
|
429
|
+
-------
|
|
430
|
+
str
|
|
431
|
+
"""
|
|
432
|
+
if self.ate is not None:
|
|
433
|
+
return self._to_markdown_scalar()
|
|
434
|
+
return self._to_markdown_multi()
|
|
435
|
+
|
|
436
|
+
def _to_markdown_scalar(self) -> str:
|
|
437
|
+
rows: list[tuple[str, str]] = [("ATE", f"{self.ate:.4f}")]
|
|
438
|
+
|
|
439
|
+
if self.se is not None:
|
|
440
|
+
rows.append(("SE", f"{self.se:.4f}"))
|
|
441
|
+
if self.ci is not None:
|
|
442
|
+
ci_pct = int((1 - self.alpha) * 100)
|
|
443
|
+
rows.append(
|
|
444
|
+
(f"{ci_pct}% CI", f"[{self.ci[0]:.3f}, {self.ci[1]:.3f}]")
|
|
445
|
+
)
|
|
446
|
+
if self.p_value is not None:
|
|
447
|
+
rows.append(("p-value", f"{self.p_value:.3f}"))
|
|
448
|
+
if self.n_obs is not None:
|
|
449
|
+
rows.append(("N", str(self.n_obs)))
|
|
450
|
+
if self.estimator_name is not None:
|
|
451
|
+
rows.append(("Estimator", self.estimator_name))
|
|
452
|
+
if self.design_name is not None:
|
|
453
|
+
rows.append(("Design", self.design_name))
|
|
454
|
+
if self.inference_name is not None:
|
|
455
|
+
rows.append(("Inference", self.inference_name))
|
|
456
|
+
|
|
457
|
+
return self._render_md_table(rows, ("Metric", "Value"))
|
|
458
|
+
|
|
459
|
+
def _to_markdown_multi(self) -> str:
|
|
460
|
+
df = self.to_dataframe()
|
|
461
|
+
# Build a markdown table from the DataFrame's effect rows only.
|
|
462
|
+
cols = ["effect", "estimate"]
|
|
463
|
+
if "se" in df.columns:
|
|
464
|
+
cols.append("se")
|
|
465
|
+
if "ci_lower" in df.columns:
|
|
466
|
+
cols.append("ci_lower")
|
|
467
|
+
cols.append("ci_upper")
|
|
468
|
+
if "p_value" in df.columns:
|
|
469
|
+
cols.append("p_value")
|
|
470
|
+
|
|
471
|
+
rows: list[tuple[str, ...]] = []
|
|
472
|
+
for _, r in df.iterrows():
|
|
473
|
+
row: list[str] = []
|
|
474
|
+
for c in cols:
|
|
475
|
+
v = r[c]
|
|
476
|
+
if isinstance(v, tuple):
|
|
477
|
+
row.append(":".join(v))
|
|
478
|
+
elif isinstance(v, float):
|
|
479
|
+
row.append(f"{v:.4f}")
|
|
480
|
+
else:
|
|
481
|
+
row.append(str(v))
|
|
482
|
+
rows.append(tuple(row))
|
|
483
|
+
|
|
484
|
+
return self._render_md_table(rows, tuple(cols))
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _render_md_table(
|
|
488
|
+
rows: list[tuple],
|
|
489
|
+
header: tuple[str, ...],
|
|
490
|
+
) -> str:
|
|
491
|
+
n_cols = len(header)
|
|
492
|
+
widths = [
|
|
493
|
+
max(
|
|
494
|
+
len(header[i]),
|
|
495
|
+
max((len(r[i]) for r in rows), default=0),
|
|
496
|
+
)
|
|
497
|
+
for i in range(n_cols)
|
|
498
|
+
]
|
|
499
|
+
sep = "|" + "|".join("-" * (w + 2) for w in widths) + "|"
|
|
500
|
+
head = (
|
|
501
|
+
"| "
|
|
502
|
+
+ " | ".join(f"{header[i]:<{widths[i]}}" for i in range(n_cols))
|
|
503
|
+
+ " |"
|
|
504
|
+
)
|
|
505
|
+
body = [
|
|
506
|
+
"| "
|
|
507
|
+
+ " | ".join(f"{r[i]:<{widths[i]}}" for i in range(n_cols))
|
|
508
|
+
+ " |"
|
|
509
|
+
for r in rows
|
|
510
|
+
]
|
|
511
|
+
return "\n".join([head, sep] + body)
|
|
512
|
+
|
|
513
|
+
def is_significant(self, key: EffectKey | None = None) -> bool:
|
|
514
|
+
"""Check whether a result is statistically significant.
|
|
515
|
+
|
|
516
|
+
Parameters
|
|
517
|
+
----------
|
|
518
|
+
key : tuple of str or None, optional
|
|
519
|
+
In scalar mode, must be None.
|
|
520
|
+
In multi-effect mode, the effect key to check. Required.
|
|
521
|
+
|
|
522
|
+
Returns
|
|
523
|
+
-------
|
|
524
|
+
bool
|
|
525
|
+
True if p_value (for the given key, if applicable) is not
|
|
526
|
+
None and is strictly less than alpha. False otherwise.
|
|
527
|
+
|
|
528
|
+
Raises
|
|
529
|
+
------
|
|
530
|
+
InvalidDesignError
|
|
531
|
+
If ``key`` is provided in scalar mode, or if ``key`` is
|
|
532
|
+
None in multi-effect mode, or if ``key`` is not in
|
|
533
|
+
``effects``.
|
|
534
|
+
"""
|
|
535
|
+
if self.ate is not None:
|
|
536
|
+
if key is not None:
|
|
537
|
+
raise InvalidDesignError(
|
|
538
|
+
"is_significant in scalar mode does not accept a key."
|
|
539
|
+
)
|
|
540
|
+
if self.p_value is None:
|
|
541
|
+
return False
|
|
542
|
+
return self.p_value < self.alpha
|
|
543
|
+
|
|
544
|
+
# Multi-effect mode.
|
|
545
|
+
if key is None:
|
|
546
|
+
raise InvalidDesignError(
|
|
547
|
+
"is_significant in multi-effect mode requires a key."
|
|
548
|
+
)
|
|
549
|
+
if key not in self.effects: # type: ignore[union-attr]
|
|
550
|
+
raise InvalidDesignError(
|
|
551
|
+
f"key {key!r} not in effects."
|
|
552
|
+
)
|
|
553
|
+
if self.p_value is None:
|
|
554
|
+
return False
|
|
555
|
+
pv = self.p_value.get(key) # type: ignore[union-attr]
|
|
556
|
+
if pv is None:
|
|
557
|
+
return False
|
|
558
|
+
return pv < self.alpha
|
|
559
|
+
|
|
560
|
+
def summary(self) -> "Results":
|
|
561
|
+
"""Print a formatted summary table and return self.
|
|
562
|
+
|
|
563
|
+
Returns
|
|
564
|
+
-------
|
|
565
|
+
Results
|
|
566
|
+
Returns self for method chaining.
|
|
567
|
+
"""
|
|
568
|
+
lines: list[str] = ["Results", "-------"]
|
|
569
|
+
|
|
570
|
+
if self.ate is not None:
|
|
571
|
+
lines.append(f"ATE {self.ate:.4f}")
|
|
572
|
+
if self.se is not None:
|
|
573
|
+
lines.append(f"SE {self.se:.4f}")
|
|
574
|
+
if self.ci is not None:
|
|
575
|
+
ci_pct = int((1 - self.alpha) * 100)
|
|
576
|
+
lines.append(
|
|
577
|
+
f"{ci_pct}% CI [{self.ci[0]:.3f}, "
|
|
578
|
+
f"{self.ci[1]:.3f}]"
|
|
579
|
+
)
|
|
580
|
+
if self.p_value is not None:
|
|
581
|
+
lines.append(f"p-value {self.p_value:.4f}")
|
|
582
|
+
sig = "Yes" if self.is_significant() else "No"
|
|
583
|
+
lines.append(f"Significant {sig}")
|
|
584
|
+
else:
|
|
585
|
+
lines.append("Effects:")
|
|
586
|
+
for key, value in self.effects.items(): # type: ignore[union-attr]
|
|
587
|
+
key_str = ":".join(key)
|
|
588
|
+
line = f" {key_str:<20} {value:.4f}"
|
|
589
|
+
if (
|
|
590
|
+
self.p_value is not None
|
|
591
|
+
and key in self.p_value # type: ignore[union-attr]
|
|
592
|
+
):
|
|
593
|
+
pv = self.p_value[key] # type: ignore[union-attr]
|
|
594
|
+
sig = "*" if pv < self.alpha else " "
|
|
595
|
+
line += f" p={pv:.4f} {sig}"
|
|
596
|
+
lines.append(line)
|
|
597
|
+
|
|
598
|
+
if self.n_obs is not None:
|
|
599
|
+
n_line = f"N {self.n_obs}"
|
|
600
|
+
if self.n_treated is not None and self.n_control is not None:
|
|
601
|
+
n_line += (
|
|
602
|
+
f" ({self.n_treated} treated, {self.n_control} control)"
|
|
603
|
+
)
|
|
604
|
+
lines.append(n_line)
|
|
605
|
+
|
|
606
|
+
if self.estimator_name is not None:
|
|
607
|
+
lines.append(f"Estimator {self.estimator_name}")
|
|
608
|
+
if self.design_name is not None:
|
|
609
|
+
lines.append(f"Design {self.design_name}")
|
|
610
|
+
if self.inference_name is not None:
|
|
611
|
+
lines.append(f"Inference {self.inference_name}")
|
|
612
|
+
|
|
613
|
+
print("\n".join(lines))
|
|
614
|
+
return self
|
|
615
|
+
|
|
616
|
+
def __repr__(self) -> str:
|
|
617
|
+
"""Return compact string representation."""
|
|
618
|
+
if self.ate is not None:
|
|
619
|
+
return (
|
|
620
|
+
f"Results(ate={self.ate}, ci={self.ci}, "
|
|
621
|
+
f"p_value={self.p_value})"
|
|
622
|
+
)
|
|
623
|
+
n_eff = len(self.effects) # type: ignore[arg-type]
|
|
624
|
+
return f"Results(effects={n_eff} keys)"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Experimental design module.
|
|
2
|
+
|
|
3
|
+
Provides design objects (CRD, BlockedCRD, ReRandomizedCRD, FactorialDesign)
|
|
4
|
+
and standalone diagnostic functions (check_balance, power_analysis).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from skxperiments.design.balance import check_balance
|
|
8
|
+
from skxperiments.design.blocked_crd import BlockedCRD
|
|
9
|
+
from skxperiments.design.crd import CRD
|
|
10
|
+
from skxperiments.design.factorial import FactorialDesign
|
|
11
|
+
from skxperiments.design.power import PowerResult, power_analysis
|
|
12
|
+
from skxperiments.design.rerandomized_crd import ReRandomizedCRD
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BlockedCRD",
|
|
16
|
+
"CRD",
|
|
17
|
+
"FactorialDesign",
|
|
18
|
+
"PowerResult",
|
|
19
|
+
"ReRandomizedCRD",
|
|
20
|
+
"check_balance",
|
|
21
|
+
"power_analysis",
|
|
22
|
+
]
|