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
skxperiments/pipeline.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Experiment composition: pipeline and comparison.
|
|
2
|
+
|
|
3
|
+
``ExperimentPipeline`` ties together the analysis-time pieces of an
|
|
4
|
+
experiment — an inference procedure (which already wraps an estimator)
|
|
5
|
+
and a set of diagnostics — and runs them against a single ``Assignment``.
|
|
6
|
+
The design is not a separate argument: it travels with the assignment
|
|
7
|
+
(``assignment.design_``), and the estimator travels inside the inference.
|
|
8
|
+
|
|
9
|
+
Diagnostics run best-effort: a diagnostic that cannot run on the given
|
|
10
|
+
assignment (raising a library ``SkxperimentsError``) is recorded as a
|
|
11
|
+
warning rather than aborting the whole analysis. By default a flagged
|
|
12
|
+
diagnostic (e.g., a Sample Ratio Mismatch) is surfaced prominently but
|
|
13
|
+
does not stop estimation; pass ``raise_on_flag=True`` to abort instead.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from skxperiments.core.assignment import BaseAssignment
|
|
21
|
+
from skxperiments.core.base import BaseInference, DiagnosticsReport
|
|
22
|
+
from skxperiments.core.exceptions import InvalidDesignError, SkxperimentsError
|
|
23
|
+
from skxperiments.core.results import Results
|
|
24
|
+
from skxperiments.diagnostics.srm import SRMTest
|
|
25
|
+
from skxperiments.inference.multiple import MultipleTestingCorrection
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class PipelineResult:
|
|
30
|
+
"""Bundle returned by ``ExperimentPipeline.run``.
|
|
31
|
+
|
|
32
|
+
Attributes
|
|
33
|
+
----------
|
|
34
|
+
results : Results
|
|
35
|
+
The inference result (ATE, SE/CI/p-value as applicable).
|
|
36
|
+
diagnostics : DiagnosticsReport
|
|
37
|
+
Merged flags and warnings across all diagnostics that ran.
|
|
38
|
+
diagnostic_results : dict
|
|
39
|
+
Mapping from diagnostic class name to its full result object
|
|
40
|
+
(e.g., ``{"SRMTest": SRMResult, "BalanceReport": BalanceResult}``).
|
|
41
|
+
Diagnostics that could not run are absent here and appear as a
|
|
42
|
+
warning in ``diagnostics`` instead.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
results: Results
|
|
46
|
+
diagnostics: DiagnosticsReport
|
|
47
|
+
diagnostic_results: dict
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def flagged(self) -> bool:
|
|
51
|
+
"""True if any diagnostic raised a flag."""
|
|
52
|
+
return len(self.diagnostics.flags) > 0
|
|
53
|
+
|
|
54
|
+
def summary(self) -> "PipelineResult":
|
|
55
|
+
"""Print diagnostics then the inference result; return self."""
|
|
56
|
+
print("Experiment Pipeline")
|
|
57
|
+
print("===================")
|
|
58
|
+
self.diagnostics.summary()
|
|
59
|
+
self.results.summary()
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ExperimentPipeline:
|
|
64
|
+
"""Compose diagnostics and inference for a single experiment.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
inference : BaseInference
|
|
69
|
+
A configured inference object (which already wraps an estimator),
|
|
70
|
+
e.g. ``NeymanCI(DifferenceInMeans("y"))`` or
|
|
71
|
+
``RandomizationTest(...)``.
|
|
72
|
+
diagnostics : list or None, optional
|
|
73
|
+
Diagnostics to run before inference. Each must expose a
|
|
74
|
+
``run(assignment)`` method returning an object with a
|
|
75
|
+
``to_diagnostics_report()`` method (e.g., ``SRMTest``,
|
|
76
|
+
``BalanceReport``). By default ``[SRMTest()]``. Pass ``[]`` to
|
|
77
|
+
skip diagnostics. ``AATest`` is not a per-assignment diagnostic
|
|
78
|
+
(it re-randomizes a design) and is not used here.
|
|
79
|
+
raise_on_flag : bool, optional
|
|
80
|
+
If True, a flagged diagnostic aborts the run with
|
|
81
|
+
``InvalidDesignError`` before inference. By default False — flags
|
|
82
|
+
are surfaced in the result but estimation proceeds.
|
|
83
|
+
|
|
84
|
+
Notes
|
|
85
|
+
-----
|
|
86
|
+
Diagnostics run best-effort: one that raises a ``SkxperimentsError``
|
|
87
|
+
(e.g., ``SRMTest`` on a design with no intended proportion) is
|
|
88
|
+
recorded as a warning and skipped. Errors from the inference itself
|
|
89
|
+
are not caught.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
inference: BaseInference,
|
|
95
|
+
diagnostics: list | None = None,
|
|
96
|
+
raise_on_flag: bool = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
if not isinstance(inference, BaseInference):
|
|
99
|
+
raise InvalidDesignError(
|
|
100
|
+
f"inference must be an instance of BaseInference, got "
|
|
101
|
+
f"{type(inference).__name__}."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if diagnostics is None:
|
|
105
|
+
diagnostics = [SRMTest()]
|
|
106
|
+
elif not isinstance(diagnostics, list):
|
|
107
|
+
raise InvalidDesignError(
|
|
108
|
+
f"diagnostics must be a list or None, got "
|
|
109
|
+
f"{type(diagnostics).__name__}."
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
for diag in diagnostics:
|
|
113
|
+
if not callable(getattr(diag, "run", None)):
|
|
114
|
+
raise InvalidDesignError(
|
|
115
|
+
"each diagnostic must expose a callable "
|
|
116
|
+
"run(assignment) method; got "
|
|
117
|
+
f"{type(diag).__name__}."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if not isinstance(raise_on_flag, bool):
|
|
121
|
+
raise InvalidDesignError(
|
|
122
|
+
f"raise_on_flag must be a bool, got "
|
|
123
|
+
f"{type(raise_on_flag).__name__}."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
self.inference = inference
|
|
127
|
+
self.diagnostics = diagnostics
|
|
128
|
+
self.raise_on_flag = raise_on_flag
|
|
129
|
+
|
|
130
|
+
def run(self, assignment: BaseAssignment) -> PipelineResult:
|
|
131
|
+
"""Run diagnostics then inference on an assignment.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
assignment : BaseAssignment
|
|
136
|
+
The (already randomized, outcome-bearing) assignment.
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
PipelineResult
|
|
141
|
+
|
|
142
|
+
Raises
|
|
143
|
+
------
|
|
144
|
+
InvalidDesignError
|
|
145
|
+
If ``raise_on_flag`` is True and a diagnostic is flagged.
|
|
146
|
+
Errors from the wrapped inference propagate unchanged.
|
|
147
|
+
"""
|
|
148
|
+
combined = DiagnosticsReport()
|
|
149
|
+
diagnostic_results: dict = {}
|
|
150
|
+
|
|
151
|
+
for diag in self.diagnostics:
|
|
152
|
+
name = type(diag).__name__
|
|
153
|
+
try:
|
|
154
|
+
diag_result = diag.run(assignment)
|
|
155
|
+
except SkxperimentsError as exc:
|
|
156
|
+
combined.warnings.append(f"{name} could not run: {exc}")
|
|
157
|
+
continue
|
|
158
|
+
diagnostic_results[name] = diag_result
|
|
159
|
+
report = diag_result.to_diagnostics_report()
|
|
160
|
+
combined.flags.extend(report.flags)
|
|
161
|
+
combined.warnings.extend(report.warnings)
|
|
162
|
+
|
|
163
|
+
if self.raise_on_flag and combined.flags:
|
|
164
|
+
joined = "\n- ".join(combined.flags)
|
|
165
|
+
raise InvalidDesignError(
|
|
166
|
+
f"ExperimentPipeline halted by diagnostic flags:\n- {joined}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
self.inference.fit(assignment)
|
|
170
|
+
results = self.inference.estimate()
|
|
171
|
+
|
|
172
|
+
return PipelineResult(
|
|
173
|
+
results=results,
|
|
174
|
+
diagnostics=combined,
|
|
175
|
+
diagnostic_results=diagnostic_results,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, eq=False)
|
|
180
|
+
class ComparisonResult:
|
|
181
|
+
"""Result of comparing several independent experiments.
|
|
182
|
+
|
|
183
|
+
Attributes
|
|
184
|
+
----------
|
|
185
|
+
corrected_results : dict
|
|
186
|
+
Mapping from experiment name to its multiple-testing-corrected
|
|
187
|
+
``Results`` (corrected ``p_value``; original recorded in
|
|
188
|
+
``Results.extra["original_p_values"]``).
|
|
189
|
+
correction : str
|
|
190
|
+
The correction method applied (``"bonferroni"``, ``"holm"``,
|
|
191
|
+
or ``"bh"``).
|
|
192
|
+
alpha : float
|
|
193
|
+
Family-wise significance level.
|
|
194
|
+
table : pd.DataFrame
|
|
195
|
+
One row per experiment with columns ``experiment``, ``ate``,
|
|
196
|
+
``se``, ``ci_lower``, ``ci_upper``, ``p_value`` (original),
|
|
197
|
+
``p_value_corrected``, and ``significant``.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
corrected_results: dict
|
|
201
|
+
correction: str
|
|
202
|
+
alpha: float
|
|
203
|
+
table: pd.DataFrame
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def significant(self) -> list[str]:
|
|
207
|
+
"""Names of experiments significant after correction."""
|
|
208
|
+
return [
|
|
209
|
+
row.experiment
|
|
210
|
+
for row in self.table.itertuples()
|
|
211
|
+
if row.significant
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
215
|
+
"""Return a copy of the comparison table."""
|
|
216
|
+
return self.table.copy()
|
|
217
|
+
|
|
218
|
+
def to_dict(self) -> dict:
|
|
219
|
+
"""Return the summary fields as a plain dictionary."""
|
|
220
|
+
return {
|
|
221
|
+
"correction": self.correction,
|
|
222
|
+
"alpha": self.alpha,
|
|
223
|
+
"n_experiments": len(self.corrected_results),
|
|
224
|
+
"significant": self.significant,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def summary(self) -> "ComparisonResult":
|
|
228
|
+
"""Print the comparison table and return self."""
|
|
229
|
+
print(f"Experiment Comparison (correction={self.correction}, "
|
|
230
|
+
f"alpha={self.alpha})")
|
|
231
|
+
print(self.table.to_string(index=False))
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ExperimentComparison:
|
|
236
|
+
"""Compare several independent experiments with multiple-testing control.
|
|
237
|
+
|
|
238
|
+
Collects the scalar ATE/p-value of each experiment and applies a
|
|
239
|
+
multiple-testing correction across the family, so the family-wise
|
|
240
|
+
error rate (Bonferroni/Holm) or false discovery rate (BH) is
|
|
241
|
+
controlled across experiments.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
correction : {"holm", "bonferroni", "bh"}, optional
|
|
246
|
+
Correction method, by default ``"holm"``.
|
|
247
|
+
alpha : float, optional
|
|
248
|
+
Family-wise significance level, by default 0.05.
|
|
249
|
+
|
|
250
|
+
Notes
|
|
251
|
+
-----
|
|
252
|
+
v1 compares **independent experiments** (one scalar ATE each).
|
|
253
|
+
Subgroup analysis (multiple effects within a single experiment) is
|
|
254
|
+
deferred to v2 (see `ROADMAP.md`); multi-effect ``Results`` are
|
|
255
|
+
rejected here.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
correction: str = "holm",
|
|
261
|
+
alpha: float = 0.05,
|
|
262
|
+
) -> None:
|
|
263
|
+
# Validates the method and alpha eagerly.
|
|
264
|
+
self._mtc = MultipleTestingCorrection(method=correction, alpha=alpha)
|
|
265
|
+
self.correction = correction
|
|
266
|
+
self.alpha = alpha
|
|
267
|
+
|
|
268
|
+
def run(self, results: dict) -> ComparisonResult:
|
|
269
|
+
"""Compare a mapping of named experiment results.
|
|
270
|
+
|
|
271
|
+
Parameters
|
|
272
|
+
----------
|
|
273
|
+
results : dict
|
|
274
|
+
Mapping from experiment name to a ``PipelineResult`` or a
|
|
275
|
+
scalar ``Results``. ``PipelineResult`` is unwrapped to its
|
|
276
|
+
``results`` field.
|
|
277
|
+
|
|
278
|
+
Returns
|
|
279
|
+
-------
|
|
280
|
+
ComparisonResult
|
|
281
|
+
|
|
282
|
+
Raises
|
|
283
|
+
------
|
|
284
|
+
InvalidDesignError
|
|
285
|
+
If ``results`` is not a non-empty dict, if any entry is not a
|
|
286
|
+
scalar ``Results``/``PipelineResult``, or if any lacks a
|
|
287
|
+
scalar p-value.
|
|
288
|
+
"""
|
|
289
|
+
if not isinstance(results, dict):
|
|
290
|
+
raise InvalidDesignError(
|
|
291
|
+
f"results must be a dict {{name: PipelineResult | Results}}, "
|
|
292
|
+
f"got {type(results).__name__}."
|
|
293
|
+
)
|
|
294
|
+
if len(results) == 0:
|
|
295
|
+
raise InvalidDesignError(
|
|
296
|
+
"results is empty; nothing to compare."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
names = list(results.keys())
|
|
300
|
+
scalar: list[Results] = []
|
|
301
|
+
for name, entry in results.items():
|
|
302
|
+
res = entry.results if isinstance(entry, PipelineResult) else entry
|
|
303
|
+
if not isinstance(res, Results):
|
|
304
|
+
raise InvalidDesignError(
|
|
305
|
+
f"Experiment '{name}' must be a PipelineResult or "
|
|
306
|
+
f"Results, got {type(entry).__name__}."
|
|
307
|
+
)
|
|
308
|
+
if res.ate is None or res.effects is not None:
|
|
309
|
+
raise InvalidDesignError(
|
|
310
|
+
f"Experiment '{name}' is not in scalar mode (ate must "
|
|
311
|
+
f"be set, effects must be None). Multi-effect/subgroup "
|
|
312
|
+
f"comparison is deferred to v2."
|
|
313
|
+
)
|
|
314
|
+
if res.p_value is None or not isinstance(res.p_value, (int, float)):
|
|
315
|
+
raise InvalidDesignError(
|
|
316
|
+
f"Experiment '{name}' has no scalar p_value; run an "
|
|
317
|
+
f"inference that produces one before comparing."
|
|
318
|
+
)
|
|
319
|
+
scalar.append(res)
|
|
320
|
+
|
|
321
|
+
corrected = self._mtc.correct(scalar)
|
|
322
|
+
corrected_results = dict(zip(names, corrected))
|
|
323
|
+
table = self._build_table(names, scalar, corrected)
|
|
324
|
+
|
|
325
|
+
return ComparisonResult(
|
|
326
|
+
corrected_results=corrected_results,
|
|
327
|
+
correction=self.correction,
|
|
328
|
+
alpha=self.alpha,
|
|
329
|
+
table=table,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def _build_table(
|
|
333
|
+
self,
|
|
334
|
+
names: list[str],
|
|
335
|
+
scalar: list[Results],
|
|
336
|
+
corrected: list[Results],
|
|
337
|
+
) -> pd.DataFrame:
|
|
338
|
+
"""Assemble the per-experiment comparison table."""
|
|
339
|
+
rows = []
|
|
340
|
+
for name, orig, corr in zip(names, scalar, corrected):
|
|
341
|
+
ci = orig.ci
|
|
342
|
+
rows.append(
|
|
343
|
+
{
|
|
344
|
+
"experiment": name,
|
|
345
|
+
"ate": orig.ate,
|
|
346
|
+
"se": orig.se,
|
|
347
|
+
"ci_lower": ci[0] if ci is not None else None,
|
|
348
|
+
"ci_upper": ci[1] if ci is not None else None,
|
|
349
|
+
"p_value": orig.p_value,
|
|
350
|
+
"p_value_corrected": corr.p_value,
|
|
351
|
+
"significant": bool(corr.p_value < self.alpha),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
return pd.DataFrame(
|
|
355
|
+
rows,
|
|
356
|
+
columns=[
|
|
357
|
+
"experiment",
|
|
358
|
+
"ate",
|
|
359
|
+
"se",
|
|
360
|
+
"ci_lower",
|
|
361
|
+
"ci_upper",
|
|
362
|
+
"p_value",
|
|
363
|
+
"p_value_corrected",
|
|
364
|
+
"significant",
|
|
365
|
+
],
|
|
366
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Reporting module for generating experiment reports and visualizations.
|
|
2
|
+
|
|
3
|
+
This module contains tools for creating plots and formatted reports
|
|
4
|
+
summarizing experimental results. Plotting requires the optional
|
|
5
|
+
``matplotlib`` dependency (``pip install skxperiments[viz]``); importing
|
|
6
|
+
this module without it is fine — the error is raised only when a plotting
|
|
7
|
+
function is called.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from skxperiments.reporting.plots import (
|
|
11
|
+
plot_balance,
|
|
12
|
+
plot_effect,
|
|
13
|
+
plot_forest,
|
|
14
|
+
plot_interaction,
|
|
15
|
+
plot_null_distribution,
|
|
16
|
+
plot_power_curve,
|
|
17
|
+
plot_srm,
|
|
18
|
+
)
|
|
19
|
+
from skxperiments.reporting.summary import ExperimentReport
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ExperimentReport",
|
|
23
|
+
"plot_balance",
|
|
24
|
+
"plot_effect",
|
|
25
|
+
"plot_forest",
|
|
26
|
+
"plot_interaction",
|
|
27
|
+
"plot_null_distribution",
|
|
28
|
+
"plot_power_curve",
|
|
29
|
+
"plot_srm",
|
|
30
|
+
]
|