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,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
+ ]