formative-ds 1.2.2__tar.gz → 2.0.0__tar.gz

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 (88) hide show
  1. {formative_ds-1.2.2 → formative_ds-2.0.0}/CLAUDE.md +11 -3
  2. {formative_ds-1.2.2 → formative_ds-2.0.0}/PKG-INFO +3 -3
  3. {formative_ds-1.2.2 → formative_ds-2.0.0}/README.md +2 -2
  4. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/causal/decisions.rst +3 -24
  5. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/causal/estimands.rst +49 -1
  6. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/causal/estimators.rst +20 -0
  7. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/game/decision_rules.rst +7 -7
  8. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/__init__.py +6 -2
  9. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/_explain.py +44 -0
  10. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/decision.py +1 -46
  11. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/estimators/_base.py +4 -0
  12. formative_ds-2.0.0/formative/causal/estimators/_cate.py +282 -0
  13. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/estimators/ols.py +116 -2
  14. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/estimators/rct.py +104 -1
  15. formative_ds-2.0.0/formative/causal/refutations/cate.py +141 -0
  16. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/game/__init__.py +4 -4
  17. formative_ds-1.2.2/formative/game/minimax_regret.py → formative_ds-2.0.0/formative/game/minimax.py +9 -9
  18. {formative_ds-1.2.2 → formative_ds-2.0.0}/pyproject.toml +2 -2
  19. formative_ds-2.0.0/tests/test_cate.py +240 -0
  20. formative_ds-2.0.0/tests/test_cate_refutation.py +109 -0
  21. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_decision.py +0 -26
  22. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_game.py +10 -10
  23. {formative_ds-1.2.2 → formative_ds-2.0.0}/uv.lock +1 -1
  24. formative_ds-1.2.2/TODO.md +0 -51
  25. {formative_ds-1.2.2 → formative_ds-2.0.0}/.github/workflows/ci.yml +0 -0
  26. {formative_ds-1.2.2 → formative_ds-2.0.0}/.github/workflows/publish.yml +0 -0
  27. {formative_ds-1.2.2 → formative_ds-2.0.0}/.gitignore +0 -0
  28. {formative_ds-1.2.2 → formative_ds-2.0.0}/LICENSE +0 -0
  29. {formative_ds-1.2.2 → formative_ds-2.0.0}/Makefile +0 -0
  30. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/causal/dag.rst +0 -0
  31. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/causal/introduction.rst +0 -0
  32. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/causal/motivation.rst +0 -0
  33. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/causal/refutations.rst +0 -0
  34. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/causal/wizard.rst +0 -0
  35. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/conf.py +0 -0
  36. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/game/introduction.rst +0 -0
  37. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/game/single_player.rst +0 -0
  38. {formative_ds-1.2.2 → formative_ds-2.0.0}/docs/index.rst +0 -0
  39. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/decision/01_decision_basic.py +0 -0
  40. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/did/01_did_basic.py +0 -0
  41. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/iv/01_iv_basic.py +0 -0
  42. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/iv/02_iv_vs_ols_bias.py +0 -0
  43. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/iv/03_iv_with_observed_confounder.py +0 -0
  44. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/iv/04_iv_refutation.py +0 -0
  45. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/matching/01_basic_matching.py +0 -0
  46. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/matching/02_matching_vs_naive.py +0 -0
  47. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/matching/03_matching_refutation.py +0 -0
  48. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/ols/01_ols_basic.py +0 -0
  49. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/ols/02_ols_unobserved_confounder.py +0 -0
  50. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/ols/03_ols_multiple_confounders.py +0 -0
  51. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/rct/01_rct_basic.py +0 -0
  52. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/rdd/01_rdd_basic.py +0 -0
  53. {formative_ds-1.2.2 → formative_ds-2.0.0}/examples/rdd/02_rdd_positive_running_var.py +0 -0
  54. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/__init__.py +0 -0
  55. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/_exceptions.py +0 -0
  56. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/dag.py +0 -0
  57. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/estimators/__init__.py +0 -0
  58. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/estimators/did.py +0 -0
  59. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/estimators/iv.py +0 -0
  60. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/estimators/matching.py +0 -0
  61. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/estimators/rdd.py +0 -0
  62. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/refutations/__init__.py +0 -0
  63. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/refutations/_check.py +0 -0
  64. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/refutations/did.py +0 -0
  65. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/refutations/iv.py +0 -0
  66. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/refutations/matching.py +0 -0
  67. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/refutations/ols.py +0 -0
  68. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/refutations/rct.py +0 -0
  69. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/causal/refutations/rdd.py +0 -0
  70. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/game/expected_value.py +0 -0
  71. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/game/hurwicz.py +0 -0
  72. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/game/laplace.py +0 -0
  73. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/game/maximax.py +0 -0
  74. {formative_ds-1.2.2 → formative_ds-2.0.0}/formative/game/maximin.py +0 -0
  75. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_dag.py +0 -0
  76. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_did.py +0 -0
  77. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_did_refutation.py +0 -0
  78. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_iv.py +0 -0
  79. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_iv_refutation.py +0 -0
  80. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_matching.py +0 -0
  81. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_matching_refutation.py +0 -0
  82. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_ols.py +0 -0
  83. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_ols_refutation.py +0 -0
  84. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_rct.py +0 -0
  85. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_rct_refutation.py +0 -0
  86. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_rdd.py +0 -0
  87. {formative_ds-1.2.2 → formative_ds-2.0.0}/tests/test_rdd_refutation.py +0 -0
  88. {formative_ds-1.2.2 → formative_ds-2.0.0}/vercel.json +0 -0
@@ -43,17 +43,24 @@ Called as `result.refute(df)` — always pass the same `df` used for `fit()`. Re
43
43
  **Refutation seeds** — isolated to avoid collisions with test data (seeded at 42):
44
44
  - `_RCC_SEED = 54321` — random common cause column
45
45
  - `_PLACEBO_SEED = 99999` — treatment permutation in matching
46
+ - `_PLACEBO_TIME_SEED = 22222` — time permutation in DiD
47
+ - `_PLACEBO_MODIFIER_SEED = 77777` — modifier permutation in CATE checks
48
+ - `_RANDOM_MODIFIER_SEED = 31313` — noise modifier column in CATE checks
46
49
  - `_BOOTSTRAP_SEED = 42` — safe to reuse in matching's bootstrap (samples row indices, not independent values)
47
50
 
48
51
  Do not change `_RCC_SEED` or `_PLACEBO_SEED` to 42 — the generated noise collides with instrument/treatment data and produces a singular matrix in IV.
49
52
 
53
+ **Heterogeneous effects (CATE)** — `OLSObservational` and `RCT` accept `effect_modifier=` (a discrete, pre-treatment column). `fit()` then returns `OLSCATEResult`/`RCTCATEResult` with `effect_by_group`, a homogeneity F-test, and `decide_by_group()`. The headline `effect` is the sample-share-weighted average of group effects (the raw treatment coefficient under interaction coding is only the reference group's effect). Shared machinery lives in `estimators/_cate.py`; DAG validation rejects modifiers that are descendants of the treatment (mediators) or not ancestors of the outcome.
54
+
50
55
  **Package layout:**
51
56
  - `formative/causal/dag.py` — `DAG`, `_Node`
52
57
  - `formative/causal/_exceptions.py` — `IdentificationError`, `GraphError`
53
- - `formative/causal/estimators/ols.py` — `OLSObservational`, `OLSResult`
58
+ - `formative/causal/estimators/_base.py` — `_BaseResult`, `_StatsmodelsResult` (shared result plumbing: effect stats via `_param`, `decide()`, assumptions footer)
59
+ - `formative/causal/estimators/_cate.py` — `GroupEffect`, `_fit_cate`, `_CATEResultMixin`, modifier validation (shared by OLS/RCT and the CATE refutations)
60
+ - `formative/causal/estimators/ols.py` — `OLSObservational`, `OLSResult`, `OLSCATEResult`
54
61
  - `formative/causal/estimators/iv.py` — `IV2SLS`, `IVResult`
55
62
  - `formative/causal/estimators/matching.py` — `PropensityScoreMatching`, `MatchingResult`; exports `_propensity_scores`, `_att_from_ps` for use by refutations
56
- - `formative/causal/estimators/rct.py` — `RCT`, `RCTResult`
63
+ - `formative/causal/estimators/rct.py` — `RCT`, `RCTResult`, `RCTCATEResult`
57
64
  - `formative/causal/estimators/did.py` — `DiD`, `DiDResult`
58
65
  - `formative/causal/estimators/rdd.py` — `RDD`, `RDDResult`
59
66
  - `formative/causal/refutations/_check.py` — `Assumption`, `RefutationCheck`, `RefutationReport`
@@ -61,6 +68,7 @@ Do not change `_RCC_SEED` or `_PLACEBO_SEED` to 42 — the generated noise colli
61
68
  - `formative/causal/refutations/iv.py` — `IVRefutationReport`, `_check_first_stage_f`, `_check_random_common_cause`
62
69
  - `formative/causal/refutations/matching.py` — `MatchingRefutationReport`, `_check_placebo_treatment`, `_check_random_common_cause`
63
70
  - `formative/causal/refutations/rct.py` — `RCTRefutationReport`, `_check_random_common_cause`
71
+ - `formative/causal/refutations/cate.py` — `_check_placebo_modifier`, `_check_random_modifier`, `_check_random_common_cause` (used by `OLSCATEResult`/`RCTCATEResult`; reports reuse the OLS/RCT report classes)
64
72
  - `formative/causal/refutations/did.py` — `DiDRefutationReport`, `_check_placebo_group`, `_check_random_common_cause`
65
73
  - `formative/causal/refutations/rdd.py` — `RDDRefutationReport`, `_check_placebo_cutoff`, `_check_random_common_cause`
66
74
  - `formative/causal/_explain.py` — narrative rendering for all estimators
@@ -91,7 +99,7 @@ result = maximin({
91
99
  **Package layout:**
92
100
  - `formative/game/maximin.py` — `maximin`, `Maximin`, `MaximinResult`
93
101
  - `formative/game/maximax.py` — `maximax`, `Maximax`, `MaximaxResult`
94
- - `formative/game/minimax_regret.py` — `minimax_regret`, `MinimaxRegret`, `MinimaxRegretResult`
102
+ - `formative/game/minimax.py` — `minimax`, `Minimax`, `MinimaxResult`
95
103
  - `formative/game/hurwicz.py` — `hurwicz`, `Hurwicz`, `HurwiczResult`
96
104
  - `formative/game/laplace.py` — `laplace`, `Laplace`, `LaplaceResult`
97
105
  - `formative/game/expected_value.py` — `expected_value`, `ExpectedValue`, `ExpectedValueResult`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: formative-ds
3
- Version: 1.2.2
3
+ Version: 2.0.0
4
4
  Summary: A Python library for quantitative reasoning.
5
5
  Project-URL: Homepage, https://getformative.dev
6
6
  Project-URL: Documentation, https://docs.getformative.dev
@@ -68,7 +68,7 @@ Confounders declared in the DAG are controlled for automatically. If a confounde
68
68
  ### Decision rules
69
69
 
70
70
  ```python
71
- from formative.game import maximin, maximax, hurwicz, laplace, minimax_regret
71
+ from formative.game import maximin, maximax, hurwicz, laplace, minimax
72
72
 
73
73
  outcomes = {
74
74
  "stocks": {"recession": -20, "stagnation": 5, "growth": 30},
@@ -80,7 +80,7 @@ maximin(outcomes).solve() # safest choice (best worst case)
80
80
  maximax(outcomes).solve() # most optimistic (best best case)
81
81
  hurwicz(outcomes, alpha=0.5).solve() # blend of optimism and pessimism
82
82
  laplace(outcomes).solve() # highest average payoff
83
- minimax_regret(outcomes).solve() # lowest worst-case regret
83
+ minimax(outcomes).solve() # lowest worst-case regret
84
84
  ```
85
85
 
86
86
  See online documentation at [docs.getformative.dev](https://docs.getformative.dev) for more examples and details.
@@ -41,7 +41,7 @@ Confounders declared in the DAG are controlled for automatically. If a confounde
41
41
  ### Decision rules
42
42
 
43
43
  ```python
44
- from formative.game import maximin, maximax, hurwicz, laplace, minimax_regret
44
+ from formative.game import maximin, maximax, hurwicz, laplace, minimax
45
45
 
46
46
  outcomes = {
47
47
  "stocks": {"recession": -20, "stagnation": 5, "growth": 30},
@@ -53,7 +53,7 @@ maximin(outcomes).solve() # safest choice (best worst case)
53
53
  maximax(outcomes).solve() # most optimistic (best best case)
54
54
  hurwicz(outcomes, alpha=0.5).solve() # blend of optimism and pessimism
55
55
  laplace(outcomes).solve() # highest average payoff
56
- minimax_regret(outcomes).solve() # lowest worst-case regret
56
+ minimax(outcomes).solve() # lowest worst-case regret
57
57
  ```
58
58
 
59
59
  See online documentation at [docs.getformative.dev](https://docs.getformative.dev) for more examples and details.
@@ -36,7 +36,7 @@ earnings using OLS with a DAG that encodes family background as a confounder:
36
36
 
37
37
  import numpy as np
38
38
  import pandas as pd
39
- from formative import DAG, OLSObservational
39
+ from formative.causal import DAG, OLSObservational
40
40
 
41
41
  rng = np.random.default_rng(0)
42
42
  N = 2_000
@@ -95,7 +95,7 @@ to any rule in ``formative.game``:
95
95
 
96
96
  .. code-block:: python
97
97
 
98
- from formative.game import maximin, minimax_regret, hurwicz
98
+ from formative.game import maximin, minimax, hurwicz
99
99
 
100
100
  decision = result.decide(cost=8, benefit=15)
101
101
  outcomes = decision.to_outcomes()
@@ -105,7 +105,7 @@ to any rule in ``formative.game``:
105
105
  # }
106
106
 
107
107
  maximin(outcomes).solve() # best worst-case
108
- minimax_regret(outcomes).solve() # minimise maximum regret
108
+ minimax(outcomes).solve() # minimise maximum regret
109
109
  hurwicz(outcomes, alpha=0.3).solve() # weighted pessimism–optimism
110
110
 
111
111
  By default, the three scenarios correspond to the 10th, 50th, and 90th
@@ -127,27 +127,6 @@ set to the CI bounds (quantiles 0.025 and 0.975). ``to_outcomes()``
127
127
  generalises that check: different rules express different risk attitudes, and
128
128
  you can dial in your own pessimism level via ``hurwicz(alpha=...)``.
129
129
 
130
- Value of information
131
- --------------------
132
-
133
- When a decision is *not* already highly confident, it is useful to know how much
134
- more data would be needed to reach a target confidence level. Call
135
- ``value_of_information()`` on the report:
136
-
137
- .. code-block:: python
138
-
139
- print(decision.value_of_information(target_confidence=0.99))
140
-
141
- If the decision is already at or above the target confidence, formative reports
142
- that no additional data is needed. Otherwise, it reports:
143
-
144
- * how much the standard error on net benefit would need to shrink, and
145
- * approximately how many times larger the sample would need to be.
146
-
147
- This frames the cost of uncertainty concretely: collecting more data has a price,
148
- and the value of information tells you whether that price is worth paying before
149
- you commit to a decision.
150
-
151
130
  Philosophy
152
131
  ----------
153
132
 
@@ -2,7 +2,7 @@ Estimands: ATE, ATT, LATE etc.
2
2
  ==============================
3
3
 
4
4
  Every causal estimator targets a specific *estimand*. Choosing the wrong estimator for your question gives a valid answer to the wrong
5
- question. The four estimands formative works with are ATE, ATT, LATE, and LATE at the cutoff. One method can only target one estimand.
5
+ question. The five estimands formative works with are ATE, ATT, LATE, LATE at the cutoff, and CATE. One method can only target one estimand.
6
6
 
7
7
  .. list-table::
8
8
  :header-rows: 1
@@ -14,6 +14,9 @@ question. The four estimands formative works with are ATE, ATT, LATE, and LATE a
14
14
  * - **ATE**
15
15
  - Average Treatment Effect
16
16
  - :class:`~formative.OLSObservational`, :class:`~formative.RCT`
17
+ * - **CATE**
18
+ - Conditional Average Treatment Effect
19
+ - :class:`~formative.OLSObservational`, :class:`~formative.RCT` (with ``effect_modifier``)
17
20
  * - **ATT**
18
21
  - Average Treatment Effect on the Treated
19
22
  - :class:`~formative.PropensityScoreMatching`, :class:`~formative.DiD`
@@ -191,3 +194,48 @@ assumption is more defensible but the estimate has higher variance.
191
194
  - **RDD** — fits a local linear regression on both sides of the cutoff. The coefficient on
192
195
  the treatment indicator gives the jump in outcome at the threshold, controlling for the
193
196
  slope of the running variable separately on each side.
197
+
198
+ ----
199
+
200
+ CATE: Conditional Average Treatment Effect
201
+ ------------------------------------------
202
+
203
+ **What it answers:** *For whom* does the treatment work? An average effect answers "does the
204
+ intervention work?"; the CATE answers how the effect differs across known subgroups — should
205
+ a discount go to new customers or loyal ones, does the training help juniors more than
206
+ seniors?
207
+
208
+ .. math::
209
+
210
+ \text{CATE}(x) = \mathbb{E}[Y(1) - Y(0) \mid X = x]
211
+
212
+ :math:`X` is an *effect modifier*: a pre-treatment variable whose levels the effect is
213
+ allowed to differ across. Instead of one number, the estimand is a per-group effect —
214
+ one for each level of :math:`X`.
215
+
216
+ **When it makes sense:** When the decision is not "treat everyone or no one" but "who should
217
+ receive the treatment" — targeting, personalisation, prioritising a limited budget. Estimating
218
+ per-group effects also comes with a **homogeneity test** answering whether the group
219
+ differences are real or noise; without it, eyeballing subgroup estimates invites false
220
+ discoveries.
221
+
222
+ In formative, pass ``effect_modifier=`` to :class:`~formative.OLSObservational` or
223
+ :class:`~formative.RCT`. The fitted model becomes
224
+ ``outcome ~ treatment * C(modifier) + controls``, and the result exposes per-group effects
225
+ (``effect_by_group``), the homogeneity test, and a cost-benefit decision per group
226
+ (``decide_by_group``). The modifier must be measured pre-treatment: segmenting by a variable
227
+ the treatment itself affects (a mediator) manufactures artificial heterogeneity, and the DAG
228
+ validation rejects modifiers that are descendants of the treatment.
229
+
230
+ **Methods that estimate CATE:**
231
+
232
+ - **OLS Observational** (with ``effect_modifier``) — interacts the treatment with the
233
+ modifier while adjusting for confounders identified via the backdoor criterion.
234
+ - **RCT** (with ``effect_modifier``) — randomisation makes each subgroup's simple contrast
235
+ unbiased; the interaction model estimates all subgroups jointly.
236
+
237
+ **CATE vs ATE:** The ATE is the average of the CATEs, weighted by each group's share of the
238
+ population — formative reports exactly this weighted average as the headline effect when an
239
+ effect modifier is used. A homogeneous effect means CATE(x) = ATE for every group; the wider
240
+ the CATEs spread around the ATE, the more value there is in targeting rather than treating
241
+ uniformly.
@@ -14,6 +14,13 @@ OLSResult
14
14
  :members:
15
15
  :inherited-members:
16
16
 
17
+ OLSCATEResult
18
+ -------------
19
+
20
+ .. autoclass:: formative.causal.OLSCATEResult
21
+ :members:
22
+ :inherited-members:
23
+
17
24
  IV2SLS
18
25
  ------
19
26
 
@@ -53,6 +60,13 @@ RCTResult
53
60
  :members:
54
61
  :inherited-members:
55
62
 
63
+ RCTCATEResult
64
+ -------------
65
+
66
+ .. autoclass:: formative.causal.RCTCATEResult
67
+ :members:
68
+ :inherited-members:
69
+
56
70
  DiD
57
71
  ---
58
72
 
@@ -78,3 +92,9 @@ RDDResult
78
92
  .. autoclass:: formative.causal.RDDResult
79
93
  :members:
80
94
  :inherited-members:
95
+
96
+ GroupEffect
97
+ -----------
98
+
99
+ .. autoclass:: formative.causal.GroupEffect
100
+ :members:
@@ -74,8 +74,8 @@ downside.
74
74
  .. autoclass:: formative.game.MaximaxResult
75
75
  :members:
76
76
 
77
- Minimax Regret
78
- --------------
77
+ Minimax
78
+ -------
79
79
 
80
80
  *Which choice do you regret the least in hindsight?*
81
81
 
@@ -115,14 +115,14 @@ Then compute regret for every combination — how much you miss out on versus th
115
115
 
116
116
  .. code-block:: python
117
117
 
118
- from formative.game import minimax_regret
118
+ from formative.game import minimax
119
119
 
120
- result = minimax_regret(outcomes).solve()
120
+ result = minimax(outcomes).solve()
121
121
  print(result)
122
122
 
123
123
  .. code-block:: text
124
124
 
125
- MinimaxRegretResult(
125
+ MinimaxResult(
126
126
  stocks max regret: +25
127
127
  bonds max regret: +23 ← chosen
128
128
  cash max regret: +28
@@ -133,10 +133,10 @@ score zero in recession and stagnation, but 23 in growth where stocks outperform
133
133
  Cash never comes close to the best option in any scenario, giving it the highest max
134
134
  regret of 28. Bonds minimise the worst-case regret.
135
135
 
136
- .. autoclass:: formative.game.MinimaxRegret
136
+ .. autoclass:: formative.game.Minimax
137
137
  :members:
138
138
 
139
- .. autoclass:: formative.game.MinimaxRegretResult
139
+ .. autoclass:: formative.game.MinimaxResult
140
140
  :members:
141
141
 
142
142
  Hurwicz Criterion
@@ -1,10 +1,11 @@
1
1
  from .dag import DAG
2
2
  from .decision import DecisionReport
3
+ from .estimators._cate import GroupEffect
3
4
  from .estimators.did import DiD, DiDResult
4
5
  from .estimators.iv import IV2SLS, IVResult
5
6
  from .estimators.matching import MatchingResult, PropensityScoreMatching
6
- from .estimators.ols import OLSObservational, OLSResult
7
- from .estimators.rct import RCT, RCTResult
7
+ from .estimators.ols import OLSCATEResult, OLSObservational, OLSResult
8
+ from .estimators.rct import RCT, RCTCATEResult, RCTResult
8
9
  from .estimators.rdd import RDD, RDDResult
9
10
  from .refutations import (
10
11
  DiDRefutationReport,
@@ -22,12 +23,15 @@ __all__ = [
22
23
  "DAG",
23
24
  "OLSObservational",
24
25
  "OLSResult",
26
+ "OLSCATEResult",
27
+ "GroupEffect",
25
28
  "IV2SLS",
26
29
  "IVResult",
27
30
  "PropensityScoreMatching",
28
31
  "MatchingResult",
29
32
  "RCT",
30
33
  "RCTResult",
34
+ "RCTCATEResult",
31
35
  "DiD",
32
36
  "DiDResult",
33
37
  "RDD",
@@ -82,6 +82,46 @@ def _dag_section(dag, treatment: str, outcome: str, adjustment_set: set[str]) ->
82
82
  return "\n".join(lines)
83
83
 
84
84
 
85
+ def _heterogeneity_section(result) -> str | None:
86
+ """HETEROGENEITY block for results fitted with an effect modifier, else None."""
87
+ modifier = getattr(result, "_modifier", None)
88
+ if modifier is None:
89
+ return None
90
+
91
+ groups = result.effect_by_group
92
+ p = result.homogeneity_pvalue
93
+ largest = max(groups, key=lambda g: g.effect)
94
+ smallest = min(groups, key=lambda g: g.effect)
95
+
96
+ if p < 0.05:
97
+ verdict = (
98
+ f"A formal test rejects the hypothesis that the effect is the same across "
99
+ f"{modifier} groups ({_fmt_p(p)}): the treatment effect genuinely differs by {modifier}."
100
+ )
101
+ else:
102
+ verdict = (
103
+ f"A formal test does not reject the hypothesis that the effect is the same "
104
+ f"across {modifier} groups ({_fmt_p(p)}); the differences below may be noise."
105
+ )
106
+
107
+ lines = [
108
+ "HETEROGENEITY",
109
+ f"The effect was allowed to differ across levels of {modifier}. {verdict}",
110
+ "",
111
+ ]
112
+ for g in groups:
113
+ lo, hi = g.conf_int
114
+ lines.append(f" • {modifier} = {g.level}: effect = {g.effect:.4f} (95% CI: {_fmt_ci(lo, hi)}, n = {g.n:,})")
115
+ lines += [
116
+ "",
117
+ f"The largest estimated effect is for {modifier} = {largest.level} ({largest.effect:.4f}) "
118
+ f"and the smallest for {modifier} = {smallest.level} ({smallest.effect:.4f}). Group "
119
+ f"estimates are less precise than the overall average, and with many groups some "
120
+ f"extreme-looking effects are expected by chance.",
121
+ ]
122
+ return "\n".join(lines)
123
+
124
+
85
125
  def _assumptions_section(assumptions: list) -> str:
86
126
  n = len(assumptions)
87
127
  n_u = sum(1 for a in assumptions if not a.testable)
@@ -115,6 +155,7 @@ def explain_ols(result) -> str:
115
155
  adj = sorted(result._adjustment_set)
116
156
  lo, hi = result.conf_int
117
157
  bias = result.unadjusted_effect - result.effect
158
+ heterogeneity = _heterogeneity_section(result)
118
159
 
119
160
  bias_lines = []
120
161
  if adj:
@@ -161,6 +202,7 @@ def explain_ols(result) -> str:
161
202
  *bias_lines,
162
203
  ]
163
204
  ),
205
+ *([heterogeneity] if heterogeneity else []),
164
206
  "\n".join(
165
207
  [
166
208
  "CAVEATS",
@@ -247,6 +289,7 @@ def explain_iv(result) -> str:
247
289
  def explain_rct(result) -> str:
248
290
  T, Y = result._treatment, result._outcome
249
291
  lo, hi = result.conf_int
292
+ heterogeneity = _heterogeneity_section(result)
250
293
 
251
294
  blocks = [
252
295
  "\n".join(
@@ -277,6 +320,7 @@ def explain_rct(result) -> str:
277
320
  f"SE = {result.std_err:.4f}, {_fmt_p(result.pvalue)}).",
278
321
  ]
279
322
  ),
323
+ *([heterogeneity] if heterogeneity else []),
280
324
  "\n".join(
281
325
  [
282
326
  "CAVEATS",
@@ -46,51 +46,6 @@ class DecisionReport:
46
46
  robust: bool
47
47
  p_beneficial: float
48
48
 
49
- def value_of_information(self, target_confidence: float = 0.95) -> str:
50
- """
51
- Estimate how much the standard error would need to shrink — and
52
- approximately how many times larger the sample would need to be — to
53
- reach ``target_confidence`` that the optimal decision is correct.
54
-
55
- If the decision is already at or above ``target_confidence``, reports
56
- that no additional data is needed.
57
-
58
- Parameters
59
- ----------
60
- target_confidence : float
61
- Desired probability that net benefit > 0 (default 0.95).
62
-
63
- Returns
64
- -------
65
- str
66
- A human-readable description of the value of information.
67
- """
68
- if self.p_beneficial >= target_confidence:
69
- return (
70
- f"Decision confidence is already {self.p_beneficial:.1%}, "
71
- f"at or above the target of {target_confidence:.1%}. "
72
- f"No additional data is needed to be confident in this decision."
73
- )
74
-
75
- # net_benefit / (se_net * z_target) = 1 → se_net_required = net_benefit / z_target
76
- # se_net = std_err_ate * benefit → std_err_required = se_net_required / benefit
77
- z_target = norm.ppf(target_confidence)
78
- se_net_current = abs(self.net_benefit_ci[1] - self.net_benefit_ci[0]) / (2 * 1.96)
79
- if se_net_current == 0:
80
- return "Cannot compute value of information: standard error is zero."
81
-
82
- se_net_required = abs(self.net_benefit) / z_target
83
- se_ratio = se_net_current / se_net_required
84
- n_multiplier = se_ratio**2
85
-
86
- return (
87
- f"Current decision confidence: {self.p_beneficial:.1%} "
88
- f"(target: {target_confidence:.1%}).\n"
89
- f"The standard error on net benefit would need to shrink by "
90
- f"{(1 - 1 / se_ratio):.1%} to reach the target.\n"
91
- f"This requires approximately {n_multiplier:.1f}x the current sample size."
92
- )
93
-
94
49
  def to_outcomes(
95
50
  self,
96
51
  scenarios: dict[str, float] | None = None,
@@ -110,7 +65,7 @@ class DecisionReport:
110
65
  -------
111
66
  dict[str, dict[str, float]]
112
67
  A ``{choice: {scenario: payoff}}`` structure accepted by any
113
- ``formative.game`` rule (``maximin``, ``minimax_regret``,
68
+ ``formative.game`` rule (``maximin``, ``minimax``,
114
69
  ``hurwicz``, etc.). The ``"don't treat"`` payoff is 0 in every
115
70
  scenario — the status quo baseline.
116
71
 
@@ -29,6 +29,10 @@ class _BaseResult:
29
29
  lines.append("")
30
30
  return lines
31
31
 
32
+ def _extra_summary_lines(self) -> list[str]:
33
+ """Hook for subclasses to insert extra blocks before the assumptions footer."""
34
+ return []
35
+
32
36
  def decide(self, cost: float, benefit: float):
33
37
  """
34
38
  Compute a cost-benefit decision analysis from this causal estimate.