formative-ds 1.2.2__tar.gz → 2.1.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 (91) hide show
  1. {formative_ds-1.2.2 → formative_ds-2.1.0}/.github/workflows/ci.yml +1 -1
  2. {formative_ds-1.2.2 → formative_ds-2.1.0}/CLAUDE.md +11 -3
  3. {formative_ds-1.2.2 → formative_ds-2.1.0}/Makefile +3 -3
  4. {formative_ds-1.2.2 → formative_ds-2.1.0}/PKG-INFO +3 -3
  5. {formative_ds-1.2.2 → formative_ds-2.1.0}/README.md +2 -2
  6. formative_ds-2.1.0/docs/causal/benchmarks.rst +414 -0
  7. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/causal/decisions.rst +3 -24
  8. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/causal/estimands.rst +49 -1
  9. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/causal/estimators.rst +20 -0
  10. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/game/decision_rules.rst +7 -7
  11. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/index.rst +1 -0
  12. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/__init__.py +6 -2
  13. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/_explain.py +44 -0
  14. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/decision.py +1 -46
  15. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/estimators/_base.py +4 -0
  16. formative_ds-2.1.0/formative/causal/estimators/_cate.py +282 -0
  17. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/estimators/ols.py +116 -2
  18. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/estimators/rct.py +104 -1
  19. formative_ds-2.1.0/formative/causal/refutations/cate.py +141 -0
  20. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/game/__init__.py +4 -4
  21. formative_ds-1.2.2/formative/game/minimax_regret.py → formative_ds-2.1.0/formative/game/minimax.py +9 -9
  22. formative_ds-2.1.0/notebooks/card_krueger_benchmark.ipynb +452 -0
  23. formative_ds-2.1.0/notebooks/lalonde_benchmark.ipynb +538 -0
  24. {formative_ds-1.2.2 → formative_ds-2.1.0}/pyproject.toml +8 -3
  25. formative_ds-2.1.0/tests/test_cate.py +240 -0
  26. formative_ds-2.1.0/tests/test_cate_refutation.py +109 -0
  27. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_decision.py +0 -26
  28. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_game.py +10 -10
  29. {formative_ds-1.2.2 → formative_ds-2.1.0}/uv.lock +1624 -1
  30. formative_ds-1.2.2/TODO.md +0 -51
  31. {formative_ds-1.2.2 → formative_ds-2.1.0}/.github/workflows/publish.yml +0 -0
  32. {formative_ds-1.2.2 → formative_ds-2.1.0}/.gitignore +0 -0
  33. {formative_ds-1.2.2 → formative_ds-2.1.0}/LICENSE +0 -0
  34. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/causal/dag.rst +0 -0
  35. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/causal/introduction.rst +0 -0
  36. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/causal/motivation.rst +0 -0
  37. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/causal/refutations.rst +0 -0
  38. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/causal/wizard.rst +0 -0
  39. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/conf.py +0 -0
  40. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/game/introduction.rst +0 -0
  41. {formative_ds-1.2.2 → formative_ds-2.1.0}/docs/game/single_player.rst +0 -0
  42. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/decision/01_decision_basic.py +0 -0
  43. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/did/01_did_basic.py +0 -0
  44. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/iv/01_iv_basic.py +0 -0
  45. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/iv/02_iv_vs_ols_bias.py +0 -0
  46. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/iv/03_iv_with_observed_confounder.py +0 -0
  47. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/iv/04_iv_refutation.py +0 -0
  48. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/matching/01_basic_matching.py +0 -0
  49. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/matching/02_matching_vs_naive.py +0 -0
  50. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/matching/03_matching_refutation.py +0 -0
  51. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/ols/01_ols_basic.py +0 -0
  52. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/ols/02_ols_unobserved_confounder.py +0 -0
  53. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/ols/03_ols_multiple_confounders.py +0 -0
  54. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/rct/01_rct_basic.py +0 -0
  55. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/rdd/01_rdd_basic.py +0 -0
  56. {formative_ds-1.2.2 → formative_ds-2.1.0}/examples/rdd/02_rdd_positive_running_var.py +0 -0
  57. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/__init__.py +0 -0
  58. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/_exceptions.py +0 -0
  59. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/dag.py +0 -0
  60. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/estimators/__init__.py +0 -0
  61. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/estimators/did.py +0 -0
  62. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/estimators/iv.py +0 -0
  63. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/estimators/matching.py +0 -0
  64. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/estimators/rdd.py +0 -0
  65. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/refutations/__init__.py +0 -0
  66. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/refutations/_check.py +0 -0
  67. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/refutations/did.py +0 -0
  68. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/refutations/iv.py +0 -0
  69. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/refutations/matching.py +0 -0
  70. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/refutations/ols.py +0 -0
  71. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/refutations/rct.py +0 -0
  72. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/causal/refutations/rdd.py +0 -0
  73. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/game/expected_value.py +0 -0
  74. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/game/hurwicz.py +0 -0
  75. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/game/laplace.py +0 -0
  76. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/game/maximax.py +0 -0
  77. {formative_ds-1.2.2 → formative_ds-2.1.0}/formative/game/maximin.py +0 -0
  78. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_dag.py +0 -0
  79. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_did.py +0 -0
  80. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_did_refutation.py +0 -0
  81. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_iv.py +0 -0
  82. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_iv_refutation.py +0 -0
  83. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_matching.py +0 -0
  84. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_matching_refutation.py +0 -0
  85. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_ols.py +0 -0
  86. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_ols_refutation.py +0 -0
  87. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_rct.py +0 -0
  88. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_rct_refutation.py +0 -0
  89. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_rdd.py +0 -0
  90. {formative_ds-1.2.2 → formative_ds-2.1.0}/tests/test_rdd_refutation.py +0 -0
  91. {formative_ds-1.2.2 → formative_ds-2.1.0}/vercel.json +0 -0
@@ -16,7 +16,7 @@ jobs:
16
16
  run: uv sync --dev --group docs
17
17
 
18
18
  - name: Lint
19
- run: uv run ruff check . && uv run ruff format --check .
19
+ run: uv run ruff check --exclude notebooks . && uv run ruff format --check --exclude notebooks .
20
20
 
21
21
  - name: Run tests
22
22
  run: uv run pytest --cov=formative --cov-report=term-missing --cov-fail-under=80
@@ -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`
@@ -8,11 +8,11 @@ test:
8
8
  uv run pytest --cov=formative --cov-report=term-missing --cov-fail-under=80
9
9
 
10
10
  lint:
11
- uv run ruff check .
11
+ uv run ruff check --exclude notebooks .
12
12
 
13
13
  fmt:
14
- uv run ruff format .
14
+ uv run ruff format --exclude notebooks .
15
15
 
16
16
  check: lint
17
- uv run ruff format --check .
17
+ uv run ruff format --check --exclude notebooks .
18
18
  uv run pytest --cov=formative --cov-report=term-missing --cov-fail-under=80
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: formative-ds
3
- Version: 1.2.2
3
+ Version: 2.1.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.
@@ -0,0 +1,414 @@
1
+ Benchmarks
2
+ ==========
3
+
4
+ You shouldn't trust a causal estimation library at face value.
5
+ This page takes famous studies where the "right answer" is already established, runs them through
6
+ formative, and shows the results side by side with the published figures. Every code block is
7
+ runnable as-is: the data is downloaded from its canonical public source, so you can reproduce
8
+ each number yourself.
9
+
10
+ Each benchmark has three ingredients: a dataset with a known ground truth, the formative code to
11
+ estimate the effect, and a scorecard comparing our numbers against the published ones. Each is
12
+ also available as a runnable Jupyter notebook in the `notebooks/ directory of the repository
13
+ <https://github.com/maxpagels/formative/tree/main/notebooks>`_.
14
+
15
+ The LaLonde benchmark: does job training raise earnings?
16
+ ---------------------------------------------------------
17
+
18
+ The `National Supported Work Demonstration <https://en.wikipedia.org/wiki/National_Supported_Work_Demonstration>`_
19
+ (NSW) was a 1970s US programme that gave disadvantaged workers 9–18 months of subsidised work
20
+ experience. Crucially, admission was **randomised**, so the programme doubles as a randomised
21
+ controlled trial: we know the true effect of the training on 1978 earnings.
22
+
23
+ Robert LaLonde's famous 1986 paper *"Evaluating the Econometric Evaluations of Training Programs
24
+ with Experimental Data"* used this to run a devastating test. He threw away the experimental
25
+ control group, replaced it with survey respondents from the Panel Study of Income Dynamics (PSID)
26
+ and the Current Population Survey (CPS) — people who were never in the programme — and asked
27
+ whether standard econometric methods could recover the experimental answer from this
28
+ observational data. Mostly, they could not: naive comparisons were wildly wrong, and regression
29
+ adjustment didn't rescue them. Dehejia & Wahba (1999) then revisited the data and showed that
30
+ propensity score methods land close to the experimental benchmark.
31
+
32
+ This makes the NSW data the classic stress test for observational causal methods, because it lets
33
+ us check three things at once:
34
+
35
+ 1. Does :class:`~formative.causal.RCT` reproduce the published experimental effect (**$1,794** in
36
+ the Dehejia-Wahba sample)?
37
+ 2. Does the naive comparison on observational data go as badly wrong as LaLonde reported
38
+ (**−$15,205** against PSID controls)?
39
+ 3. Does :class:`~formative.causal.PropensityScoreMatching` pull the estimate back to the
40
+ neighbourhood of the experimental answer, as Dehejia & Wahba found?
41
+
42
+ Loading the data
43
+ ^^^^^^^^^^^^^^^^
44
+
45
+ The Dehejia-Wahba sample is hosted on `Rajeev Dehejia's data page
46
+ <https://users.nber.org/~rdehejia/nswdata2.html>`_. Each file has the same ten columns: a
47
+ treatment indicator, pre-treatment characteristics (age, education, race, marital status, and
48
+ real earnings in 1974 and 1975), and the outcome — real earnings in 1978 (``re78``).
49
+
50
+ .. code-block:: python
51
+
52
+ import pandas as pd
53
+
54
+ BASE = "https://users.nber.org/~rdehejia/data"
55
+ COLS = [
56
+ "treat", "age", "education", "black", "hispanic",
57
+ "married", "nodegree", "re74", "re75", "re78",
58
+ ]
59
+
60
+ def load(name):
61
+ return pd.read_csv(f"{BASE}/{name}.txt", sep=r"\s+", header=None, names=COLS)
62
+
63
+ treated = load("nswre74_treated") # 185 NSW participants
64
+ experimental_control = load("nswre74_control") # 260 randomised controls
65
+ psid = load("psid_controls") # 2,490 PSID respondents (never in NSW)
66
+
67
+ experimental = pd.concat([treated, experimental_control], ignore_index=True)
68
+ observational = pd.concat([treated, psid], ignore_index=True)
69
+
70
+ Step 1: the experimental answer
71
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
72
+
73
+ Because NSW admission was randomised, the DAG is as simple as it gets — treatment causes the
74
+ outcome, and nothing causes treatment:
75
+
76
+ .. code-block:: python
77
+
78
+ from formative.causal import DAG, RCT
79
+
80
+ rct_dag = DAG()
81
+ rct_dag.assume("treat").causes("re78")
82
+
83
+ result = RCT(rct_dag, treatment="treat", outcome="re78").fit(experimental)
84
+ print(result.summary())
85
+
86
+ .. code-block:: text
87
+
88
+ RCT Causal Effect: treat → re78
89
+ Estimand: ATE (average treatment effect)
90
+ ──────────────────────────────────────────────────
91
+ ATE estimate : 1794.3424
92
+
93
+ Std. error : 632.8534
94
+ 95% CI : [550.5745, 3038.1103]
95
+ p-value : 0.0048
96
+ N : 445
97
+
98
+ **$1,794.34** — the published experimental benchmark for this sample is $1,794 (Dehejia & Wahba
99
+ 1999, Table 3). This is the ground truth the observational methods below will be judged against.
100
+
101
+ Step 2: the naive comparison goes badly wrong
102
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
103
+
104
+ Now we discard the experimental controls and compare NSW participants against PSID respondents,
105
+ as LaLonde did. The comparison group is now nothing like the treated group — PSID respondents
106
+ are older, better educated, far more likely to be employed — so the covariates confound the
107
+ treatment/outcome relationship. The observational DAG declares this:
108
+
109
+ .. code-block:: python
110
+
111
+ from formative.causal import OLSObservational
112
+
113
+ COVARIATES = ["age", "education", "black", "hispanic",
114
+ "married", "nodegree", "re74", "re75"]
115
+
116
+ obs_dag = DAG()
117
+ for c in COVARIATES:
118
+ obs_dag.assume(c).causes("treat", "re78")
119
+ obs_dag.assume("treat").causes("re78")
120
+
121
+ result = OLSObservational(obs_dag, treatment="treat", outcome="re78").fit(observational)
122
+ print(result.summary())
123
+
124
+ .. code-block:: text
125
+
126
+ OLS Causal Effect: treat → re78
127
+ ──────────────────────────────────────────────────
128
+ Adjusted estimate : 751.9464 (controlling for: age, black, education, hispanic, married, nodegree, re74, re75)
129
+ Unadjusted estimate : -15204.7775 (no controls)
130
+ Confounding bias : -15956.7239
131
+
132
+ Std. error : 915.2572
133
+ 95% CI : [-1042.7399, 2546.6327]
134
+ p-value : 0.4114
135
+ N : 2675
136
+
137
+ This single summary reproduces LaLonde's core finding. The unadjusted estimate — the answer you
138
+ would get by simply comparing average earnings between the two groups — is **−$15,204.78**,
139
+ matching the −$15,205 raw gap in the published data. Taken at face value it says the training
140
+ programme *destroyed* fifteen thousand dollars of annual earnings; in reality it measures how
141
+ much poorer NSW participants were than average PSID respondents to begin with. Linear regression
142
+ adjustment moves the estimate to $752 — the right sign, but still less than half the experimental
143
+ benchmark and statistically indistinguishable from zero, echoing LaLonde's conclusion that
144
+ regression on a badly mismatched comparison group cannot be trusted. The adjusted-vs-unadjusted
145
+ comparison that every formative result carries exists precisely to make this kind of confounding
146
+ visible.
147
+
148
+ Step 3: matching recovers the benchmark
149
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
150
+
151
+ Dehejia & Wahba's insight was that most PSID respondents are so unlike NSW participants that a
152
+ linear model extrapolates across incomparable people. Propensity score matching instead compares
153
+ each treated person only with the most similar controls:
154
+
155
+ .. code-block:: python
156
+
157
+ from formative.causal import PropensityScoreMatching
158
+
159
+ result = PropensityScoreMatching(obs_dag, treatment="treat", outcome="re78").fit(observational)
160
+ print(result.summary())
161
+
162
+ .. code-block:: text
163
+
164
+ PSM Causal Effect: treat → re78
165
+ Estimand: ATT (average treatment effect on the treated)
166
+ ──────────────────────────────────────────────────────
167
+ ATT estimate : 2125.7131 (controlling for: age, black, education, hispanic, married, nodegree, re74, re75)
168
+ Unadjusted estimate : -15204.7775 (naive mean difference)
169
+ Confounding bias : -17330.4906
170
+
171
+ Std. error : 1079.2522 (bootstrap, N=500)
172
+ 95% CI : [-943.4393, 3438.7593] (bootstrap percentile)
173
+ p-value : 0.0489
174
+ N : 2675
175
+
176
+ From the same observational data that produced −$15,205 naively, matching estimates **$2,125.71**
177
+ — within one standard error of the experimental $1,794. Repeating the exercise with the much
178
+ larger CPS comparison group (15,992 controls, naive gap −$8,497.52) gives an ATT of
179
+ **$2,074.02**: two very different comparison groups, matched estimates a mere $50 apart, both
180
+ bracketing the experimental truth. Dehejia & Wahba's own matching estimates were $1,691 (PSID)
181
+ and $1,582 (CPS); the residual spread between their numbers and ours reflects implementation
182
+ choices (they used stratification and different matching variants; formative uses 1-to-1
183
+ nearest-neighbour matching with replacement), and later work (Smith & Todd 2005) showed estimates
184
+ in this range are sensitive to exactly such choices. All of them are within one standard error of
185
+ the experimental answer.
186
+
187
+ Scorecard
188
+ ^^^^^^^^^
189
+
190
+ .. list-table::
191
+ :header-rows: 1
192
+ :widths: 40 20 20 20
193
+
194
+ * - Quantity
195
+ - Published
196
+ - formative
197
+ - Verdict
198
+ * - Experimental ATE (RCT)
199
+ - $1,794
200
+ - $1,794.34
201
+ - exact match
202
+ * - Naive mean difference, PSID controls
203
+ - −$15,205
204
+ - −$15,204.78
205
+ - exact match
206
+ * - Naive mean difference, CPS controls
207
+ - −$8,498
208
+ - −$8,497.52
209
+ - exact match
210
+ * - PSM ATT, PSID controls
211
+ - $1,691 (DW 1999)
212
+ - $2,125.71
213
+ - within 1 SE of experimental truth
214
+ * - PSM ATT, CPS controls
215
+ - $1,582 (DW 1999)
216
+ - $2,074.02
217
+ - within 1 SE of experimental truth
218
+
219
+ The pattern is exactly what forty years of literature says it should be: the experimental
220
+ estimate is reproduced to the dollar, the naive observational comparison is catastrophically
221
+ biased (and formative's unadjusted estimate quantifies that bias to the dollar), and propensity
222
+ score matching recovers an estimate statistically indistinguishable from the experimental truth.
223
+
224
+ References
225
+ ^^^^^^^^^^
226
+
227
+ - LaLonde, R. (1986). "Evaluating the Econometric Evaluations of Training Programs with
228
+ Experimental Data." *American Economic Review*, 76(4), 604–620.
229
+ - Dehejia, R. & Wahba, S. (1999). "Causal Effects in Nonexperimental Studies: Reevaluating the
230
+ Evaluation of Training Programs." *Journal of the American Statistical Association*, 94(448),
231
+ 1053–1062.
232
+ - Smith, J. & Todd, P. (2005). "Does matching overcome LaLonde's critique of nonexperimental
233
+ estimators?" *Journal of Econometrics*, 125(1–2), 305–353.
234
+ - Data: `Rajeev Dehejia's NSW data page <https://users.nber.org/~rdehejia/nswdata2.html>`_.
235
+
236
+ The Card–Krueger benchmark: does raising the minimum wage kill jobs?
237
+ --------------------------------------------------------------------
238
+
239
+ On 1 April 1992, New Jersey raised its minimum wage from $4.25 to $5.05 an hour. Neighbouring
240
+ Pennsylvania kept its minimum at $4.25. The textbook competitive-market prediction is
241
+ unambiguous: a binding wage floor should reduce employment. Card & Krueger (1994) tested it by
242
+ surveying 410 fast-food restaurants — Burger King, KFC, Roy Rogers, and Wendy's — on both sides
243
+ of the state border, once in February–March 1992 (before the rise) and again in November–December
244
+ 1992 (after). Because the two states share a labour market and an economic climate, Pennsylvania
245
+ serves as the counterfactual for what would have happened to New Jersey employment without the
246
+ wage rise.
247
+
248
+ This is *the* canonical difference-in-differences study, and its result was famously
249
+ counterintuitive: employment in New Jersey did **not** fall relative to Pennsylvania — the
250
+ published DiD estimate is **+2.76** full-time-equivalent (FTE) jobs per store (Table 3, standard
251
+ error 1.36). The benchmark for :class:`~formative.causal.DiD` is therefore: feed it the public
252
+ survey data and check that it reproduces that coefficient.
253
+
254
+ Loading the data
255
+ ^^^^^^^^^^^^^^^^
256
+
257
+ The dataset is distributed on `David Card's data page <https://davidcard.berkeley.edu/data_sets.html>`_
258
+ as a zip containing ``public.dat`` (one row per store, both survey waves side by side) and a
259
+ codebook. Following Card & Krueger, employment is measured in full-time equivalents: full-time
260
+ staff plus managers plus half the part-time staff.
261
+
262
+ .. code-block:: python
263
+
264
+ import io
265
+ import urllib.request
266
+ import zipfile
267
+
268
+ import pandas as pd
269
+
270
+ URL = "https://davidcard.berkeley.edu/data_sets/njmin.zip"
271
+ COLS = [
272
+ "SHEET", "CHAIN", "CO_OWNED", "STATE", "SOUTHJ", "CENTRALJ", "NORTHJ",
273
+ "PA1", "PA2", "SHORE", "NCALLS", "EMPFT", "EMPPT", "NMGRS", "WAGE_ST",
274
+ "INCTIME", "FIRSTINC", "BONUS", "PCTAFF", "MEALS", "OPEN", "HRSOPEN",
275
+ "PSODA", "PFRY", "PENTREE", "NREGS", "NREGS11", "TYPE2", "STATUS2",
276
+ "DATE2", "NCALLS2", "EMPFT2", "EMPPT2", "NMGRS2", "WAGE_ST2", "INCTIME2",
277
+ "FIRSTIN2", "SPECIAL2", "MEALS2", "OPEN2R", "HRSOPEN2", "PSODA2",
278
+ "PFRY2", "PENTREE2", "NREGS2", "NREGS112",
279
+ ]
280
+
281
+ with zipfile.ZipFile(io.BytesIO(urllib.request.urlopen(URL).read())) as z:
282
+ raw = pd.read_csv(z.open("public.dat"), sep=r"\s+", header=None,
283
+ names=COLS, na_values=".")
284
+
285
+ # FTE employment = full-timers + managers + 0.5 × part-timers (CK's definition)
286
+ raw["fte_before"] = raw["EMPFT"] + raw["NMGRS"] + 0.5 * raw["EMPPT"]
287
+ raw["fte_after"] = raw["EMPFT2"] + raw["NMGRS2"] + 0.5 * raw["EMPPT2"]
288
+
289
+ Before estimating anything, we can check the raw ingredients against the paper. Card & Krueger's
290
+ Table 3 reports four cell means; formative's DiD will be built from exactly these:
291
+
292
+ .. code-block:: python
293
+
294
+ for state, name in [(1, "NJ"), (0, "PA")]:
295
+ sub = raw[raw.STATE == state]
296
+ print(f"{name}: before {sub.fte_before.mean():.2f}, after {sub.fte_after.mean():.2f}")
297
+
298
+ .. code-block:: text
299
+
300
+ NJ: before 20.44, after 21.03
301
+ PA: before 23.33, after 21.17
302
+
303
+ All four match the published Table 3 to the last digit (NJ: 20.44 → 21.03; PA: 23.33 → 21.17).
304
+ Note the shape of the problem already visible here: New Jersey stores were *smaller* than
305
+ Pennsylvania stores before the wage rise, so a naive post-period comparison would be biased
306
+ against New Jersey — the exact baseline difference DiD exists to remove.
307
+
308
+ Running DiD
309
+ ^^^^^^^^^^^
310
+
311
+ :class:`~formative.causal.DiD` expects long-format data — one row per store per period — with
312
+ binary group and time indicators:
313
+
314
+ .. code-block:: python
315
+
316
+ from formative.causal import DAG, DiD
317
+
318
+ long = pd.concat(
319
+ [
320
+ pd.DataFrame({"store": raw.SHEET, "nj": raw.STATE, "after": 0, "fte": raw.fte_before}),
321
+ pd.DataFrame({"store": raw.SHEET, "nj": raw.STATE, "after": 1, "fte": raw.fte_after}),
322
+ ],
323
+ ignore_index=True,
324
+ ).dropna(subset=["fte"])
325
+
326
+ dag = DAG()
327
+ dag.assume("nj").causes("fte")
328
+ dag.assume("after").causes("fte")
329
+
330
+ result = DiD(dag, group="nj", time="after", outcome="fte").fit(long)
331
+ print(result.summary())
332
+
333
+ .. code-block:: text
334
+
335
+ DiD Causal Effect: (nj × after) → fte
336
+ Estimand: ATT (average treatment effect on the treated)
337
+ ──────────────────────────────────────────────────────
338
+ DiD estimate : 2.7536
339
+ Naive post-diff : -0.1382 (treated post − control post)
340
+ Baseline bias removed: -2.8918
341
+
342
+ Std. error : 1.6884
343
+ 95% CI : [-0.5607, 6.0679]
344
+ p-value : 0.1033
345
+ N : 794
346
+
347
+ The DiD estimate is **+2.75** FTE jobs — Card & Krueger's published figure is 2.76, an agreement
348
+ to within a hundredth of an FTE (rounding-level noise; note that the published two-decimal cell
349
+ means themselves difference to 2.75). The naive
350
+ post-period difference of −0.14 also matches the paper's reported NJ−PA gap of −0.14, and the
351
+ summary shows DiD stripping out the −2.89 baseline difference between the states. The sign of
352
+ the result is the famous finding: no evidence that the minimum wage rise reduced employment.
353
+
354
+ Restricting to the balanced panel — the 384 stores with employment measured in both waves —
355
+ reproduces the paper's balanced-sample row exactly:
356
+
357
+ .. code-block:: python
358
+
359
+ balanced = raw.dropna(subset=["fte_before", "fte_after"]).SHEET
360
+ result_bal = DiD(dag, group="nj", time="after", outcome="fte").fit(
361
+ long[long.store.isin(balanced)]
362
+ )
363
+ print(f"balanced-sample DiD: {result_bal.effect:.4f}")
364
+
365
+ .. code-block:: text
366
+
367
+ balanced-sample DiD: 2.7500
368
+
369
+ against a published 2.75 (Table 3, row 5, where the underlying mean changes are NJ +0.47 and
370
+ PA −2.28 — both of which this data reproduces exactly).
371
+
372
+ A note on the standard error: formative reports 1.69, computed from the pooled OLS interaction
373
+ model on store-periods. Card & Krueger's published 1.36 comes from a different (and for panel
374
+ data, sharper) calculation — the variance of the store-level *changes*, which nets out
375
+ persistent store-to-store size differences before computing uncertainty. The same data
376
+ reproduces their number too: the change-score formula on the balanced panel gives an SE of
377
+ 1.3423 against their published 1.34. The point estimate is identical either way; the two SEs
378
+ answer slightly different questions about it, and formative's is the more conservative of the
379
+ two.
380
+
381
+ Scorecard
382
+ ^^^^^^^^^
383
+
384
+ .. list-table::
385
+ :header-rows: 1
386
+ :widths: 40 20 20 20
387
+
388
+ * - Quantity
389
+ - Published
390
+ - formative
391
+ - Verdict
392
+ * - Four Table 3 cell means (NJ/PA × before/after)
393
+ - 20.44, 21.03, 23.33, 21.17
394
+ - 20.44, 21.03, 23.33, 21.17
395
+ - exact match
396
+ * - Naive post-period NJ−PA difference
397
+ - −0.14
398
+ - −0.1382
399
+ - exact match
400
+ * - DiD estimate, all available stores
401
+ - +2.76 (SE 1.36)
402
+ - +2.7536 (SE 1.69)
403
+ - match (see SE note)
404
+ * - DiD estimate, balanced panel
405
+ - +2.75 (SE 1.34)
406
+ - +2.7500 (change-score SE 1.3423)
407
+ - exact match
408
+
409
+ References
410
+ ^^^^^^^^^^
411
+
412
+ - Card, D. & Krueger, A. (1994). "Minimum Wages and Employment: A Case Study of the Fast-Food
413
+ Industry in New Jersey and Pennsylvania." *American Economic Review*, 84(4), 772–793.
414
+ - Data: `David Card's data page <https://davidcard.berkeley.edu/data_sets.html>`_ (``njmin.zip``).
@@ -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.