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.
Files changed (36) hide show
  1. skxperiments/__init__.py +5 -0
  2. skxperiments/core/__init__.py +42 -0
  3. skxperiments/core/assignment.py +589 -0
  4. skxperiments/core/base.py +512 -0
  5. skxperiments/core/exceptions.py +145 -0
  6. skxperiments/core/potential_outcomes.py +168 -0
  7. skxperiments/core/results.py +624 -0
  8. skxperiments/design/__init__.py +22 -0
  9. skxperiments/design/balance.py +182 -0
  10. skxperiments/design/blocked_crd.py +157 -0
  11. skxperiments/design/crd.py +162 -0
  12. skxperiments/design/factorial.py +174 -0
  13. skxperiments/design/power.py +233 -0
  14. skxperiments/design/rerandomized_crd.py +319 -0
  15. skxperiments/diagnostics/__init__.py +21 -0
  16. skxperiments/diagnostics/aa_test.py +277 -0
  17. skxperiments/diagnostics/balance_report.py +224 -0
  18. skxperiments/diagnostics/srm.py +327 -0
  19. skxperiments/estimators/__init__.py +23 -0
  20. skxperiments/estimators/blocked_difference_in_means.py +197 -0
  21. skxperiments/estimators/cuped.py +280 -0
  22. skxperiments/estimators/difference_in_means.py +161 -0
  23. skxperiments/estimators/factorial_estimator.py +213 -0
  24. skxperiments/estimators/lin_estimator.py +298 -0
  25. skxperiments/inference/__init__.py +17 -0
  26. skxperiments/inference/bootstrap.py +450 -0
  27. skxperiments/inference/multiple.py +365 -0
  28. skxperiments/inference/neyman.py +386 -0
  29. skxperiments/inference/randomization_test.py +319 -0
  30. skxperiments/pipeline.py +366 -0
  31. skxperiments/reporting/__init__.py +30 -0
  32. skxperiments/reporting/plots.py +411 -0
  33. skxperiments/reporting/summary.py +185 -0
  34. skxperiments-0.1.0.dev0.dist-info/METADATA +272 -0
  35. skxperiments-0.1.0.dev0.dist-info/RECORD +36 -0
  36. skxperiments-0.1.0.dev0.dist-info/WHEEL +4 -0
@@ -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
+ ]