methodlm 1.0.1__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.
- methodlm-1.0.1/LICENSE +21 -0
- methodlm-1.0.1/PKG-INFO +155 -0
- methodlm-1.0.1/README.md +117 -0
- methodlm-1.0.1/benchmark_causal.py +119 -0
- methodlm-1.0.1/benchmark_models.py +224 -0
- methodlm-1.0.1/benchmark_real_examples.py +452 -0
- methodlm-1.0.1/methodlm.egg-info/PKG-INFO +155 -0
- methodlm-1.0.1/methodlm.egg-info/SOURCES.txt +17 -0
- methodlm-1.0.1/methodlm.egg-info/dependency_links.txt +1 -0
- methodlm-1.0.1/methodlm.egg-info/entry_points.txt +3 -0
- methodlm-1.0.1/methodlm.egg-info/requires.txt +21 -0
- methodlm-1.0.1/methodlm.egg-info/top_level.txt +8 -0
- methodlm-1.0.1/methodlm.py +839 -0
- methodlm-1.0.1/methodlm_gui.py +103 -0
- methodlm-1.0.1/methodlm_io.py +246 -0
- methodlm-1.0.1/methodlm_models.py +294 -0
- methodlm-1.0.1/pyproject.toml +47 -0
- methodlm-1.0.1/rescore.py +17 -0
- methodlm-1.0.1/setup.cfg +4 -0
methodlm-1.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gavin Branaa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
methodlm-1.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: methodlm
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: A verifiable causal-reasoning harness: pre-registers every test, runs real backdoor adjustment / IV / refutation, and keeps an audit ledger, so any model has to prove its causal claims instead of asserting them.
|
|
5
|
+
Author-email: Gavin Branaa <gbranaa4@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tritsystem/methodlm
|
|
8
|
+
Project-URL: Repository, https://github.com/tritsystem/methodlm
|
|
9
|
+
Project-URL: Issues, https://github.com/tritsystem/methodlm/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/tritsystem/methodlm/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: causal inference,reasoning,llm,backdoor adjustment,instrumental variables,reproducibility
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Provides-Extra: frontier
|
|
24
|
+
Requires-Dist: anthropic; extra == "frontier"
|
|
25
|
+
Provides-Extra: data
|
|
26
|
+
Requires-Dist: pandas; extra == "data"
|
|
27
|
+
Requires-Dist: pyarrow; extra == "data"
|
|
28
|
+
Requires-Dist: openpyxl; extra == "data"
|
|
29
|
+
Provides-Extra: ternary
|
|
30
|
+
Requires-Dist: torch; extra == "ternary"
|
|
31
|
+
Provides-Extra: refute
|
|
32
|
+
Requires-Dist: dowhy; extra == "refute"
|
|
33
|
+
Provides-Extra: contrastive
|
|
34
|
+
Requires-Dist: transformers; extra == "contrastive"
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# MethodLM
|
|
40
|
+
|
|
41
|
+
**The method, wrapped around a language model, kept honest by a ledger.** Point it at data;
|
|
42
|
+
it **computes** on it (a ternary two-timescale readout) and **reasons** about it with the
|
|
43
|
+
gbranaa-hue research method — pre-registering every test and keeping an audit trail — so any
|
|
44
|
+
model has to *prove* its causal claims instead of asserting them.
|
|
45
|
+
|
|
46
|
+
> Part of the ternary line — sibling to **[OBSERVE / 012-trit-search](https://github.com/tritsystem/012-trit-search)**
|
|
47
|
+
> (local, private semantic code search). OBSERVE searches your code privately; MethodLM
|
|
48
|
+
> reasons about your data honestly. The optional ternary "second witness" here uses the same
|
|
49
|
+
> `tritkit` two-timescale layer.
|
|
50
|
+
|
|
51
|
+
## What it's for
|
|
52
|
+
|
|
53
|
+
Language models confuse correlation with causation constantly. MethodLM is a **verifiable
|
|
54
|
+
causal-reasoning harness**: it makes any model refuse false causation and back its answers
|
|
55
|
+
with real tests, leaving a checkable ledger. On a benchmark of confounded scenarios its
|
|
56
|
+
backdoor-adjustment test **cuts false causal claims from 100% (naive correlation) to ~1%**
|
|
57
|
+
while keeping 100% detection of the true cause (`benchmark_causal.py`).
|
|
58
|
+
|
|
59
|
+
The discipline — not the model — is the product. A weak local model and a frontier model are
|
|
60
|
+
held to the *same* standard: no `FINAL` verdict without a real, pre-registered test.
|
|
61
|
+
|
|
62
|
+
### Causal tools the copilot can run
|
|
63
|
+
|
|
64
|
+
| Tool | What it does |
|
|
65
|
+
|------|--------------|
|
|
66
|
+
| `CORR` | observational correlation (a clue, never a verdict) |
|
|
67
|
+
| `RUN` | a true controlled experiment (interventional demo world) |
|
|
68
|
+
| `STRAT` | stratified check — does the link survive inside bands of a confounder? |
|
|
69
|
+
| `ADJUST` | **backdoor adjustment + sensitivity + bias audit** — effect of X on the target controlling for named confounders, with a Cinelli–Hazlett **robustness value** (how strong a *hidden* confounder would need to be to overturn it; `RV < 0.10` = fragile), **plus a collider/mediator audit** that flags when conditioning on a variable would *introduce* bias (the "Table 2 fallacy"). It detects the data-visible danger (a collider) and honestly defers mediator-vs-confounder to your DAG — so it never tells you to blindly "adjust for everything." Its robustness value can only speak to confounders you actually measured; it can't rule out an *unmeasured* one — that's what `IV` is for. |
|
|
70
|
+
| `IV` | **instrumental variables (2SLS)** — the remedy for confounding ADJUST structurally cannot reach: an *unmeasured* common cause of X and the target. Given a genuine instrument (moves X, no direct effect on the target except through X), does a real two-stage least squares fit and reports the **first-stage F-statistic** (`F < 10` = weak instrument, the standard Stock–Yogo-adjacent rule of thumb — a weak instrument makes 2SLS *worse* than plain OLS, not better, and this tool says so plainly rather than silently reporting a bad estimate). The exclusion restriction (no direct X-free path from the instrument to the target) is stated on every call as an assumption that **cannot be verified from data alone** — same honest boundary as ADJUST's collider/mediator split. Measured: on a synthetic scenario with an unmeasured confounder that fools both naive correlation and ADJUST (whose own RV reads as *robust*, RV≈0.8, because it cannot see the hidden confound), IV recovers an estimate close to the true effect; on a deliberately weak instrument the F-statistic diagnostic reliably flags it (`F<10` on every trial across 5 reruns, with wildly unstable point estimates — proof the flag is doing real work, not decoration). |
|
|
71
|
+
| `REFUTE` | **DoWhy-backed refutation testing** (optional — needs `pip install dowhy`) — a second, independently-derived robustness check on a candidate ADJUST already found promising: DoWhy's own `backdoor.linear_regression` estimator, then three real perturbation tests (placebo treatment, random common cause, data-subset). A real effect should collapse toward 0 under the placebo and barely move under the other two. Checks numerical robustness, not causal role — it doesn't replace ADJUST's collider/mediator audit. |
|
|
72
|
+
| `ATTR` | the ternary compute gate's independent evidence per column (the optional second witness) |
|
|
73
|
+
|
|
74
|
+
Pre-registration is enforced: no `FINAL` is accepted until at least one real test
|
|
75
|
+
(`ADJUST`/`STRAT`/`RUN`/`INTERACT`/`IV`/`REFUTE`) has run.
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
pip install numpy # required — the reasoning harness + tools
|
|
81
|
+
pip install anthropic # optional — to drive a frontier model (--model opus/sonnet/haiku)
|
|
82
|
+
pip install pandas pyarrow openpyxl # optional — extra data formats (Parquet / Excel)
|
|
83
|
+
pip install torch # optional — enables the ternary second witness
|
|
84
|
+
pip install dowhy # optional — enables REFUTE (independent robustness check)
|
|
85
|
+
pip install transformers # optional — enables the contrastive local backend (--model contrastive)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- **Reasoning half** runs on `numpy` alone.
|
|
89
|
+
- **Frontier backend** needs `anthropic` + `ANTHROPIC_API_KEY` (set a low workspace spend limit).
|
|
90
|
+
- **Local backend** needs a llama.cpp `llama-completion` binary + a small GGUF (e.g. Qwen); point `methodlm_models.py` at yours. (Weights/binaries are not shipped here.)
|
|
91
|
+
- **Ternary second witness** (optional) needs `torch` + `tritkit` (from
|
|
92
|
+
[012-trit-search](https://github.com/tritsystem/012-trit-search)); set
|
|
93
|
+
`METHODLM_TRITKIT=/path/to/tritkit_parent`. Without it, MethodLM prints a note and runs the
|
|
94
|
+
reasoning half normally.
|
|
95
|
+
|
|
96
|
+
## Run it
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
python methodlm.py --demo # hidden-confound world + answer key
|
|
100
|
+
python methodlm.py --diabetes # real data: 442 diabetes patients (sklearn)
|
|
101
|
+
python methodlm.py --data FILE --target COLUMN # any tabular dataset
|
|
102
|
+
python methodlm.py --demo --race # head-to-head vs the same model, no method
|
|
103
|
+
python methodlm.py --diabetes --model opus # drive a frontier model instead of local
|
|
104
|
+
python methodlm_gui.py # desktop GUI (opens in your browser)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Reasoning backend (`--model`)
|
|
108
|
+
|
|
109
|
+
| `--model` | Backend | Notes |
|
|
110
|
+
|-----------|---------|-------|
|
|
111
|
+
| `local` (default) | a GGUF via `llama-completion` | private, offline, free (bring your own model) |
|
|
112
|
+
| `contrastive` / `cd` | real Qwen2.5-1.5B-Instruct amplified against Qwen2.5-0.5B-Instruct via [contrastive decoding](https://arxiv.org/abs/2210.15097) (`structured_attention.contrastive_next_token_logits`) | local, offline, GPU (falls back to CPU); needs `transformers` + the `structured-attention` package (`pip install -e /path/to/structured-attention` or `METHODLM_STRUCTURED_ATTENTION=/path`); tunable via `METHODLM_CD_WEAK`/`METHODLM_CD_STRONG`/`METHODLM_CD_ALPHA`/`METHODLM_CD_BETA`. Verified end-to-end (`methodlm.py --demo --model contrastive`): loads in ~16s, drives the full PREREGISTER→ADJUST→FINAL tool loop and writes a real ledger. **Not separately benchmarked yet** (no `benchmark_causal.py`/`benchmark_models.py` numbers for this backend specifically) — same discipline (pre-registration, loop guards, ledger) as every other backend, but its *causal accuracy on the benchmark suite* is unmeasured so far. |
|
|
113
|
+
| `opus` / `sonnet` / `haiku` | Claude via the Anthropic API | frontier reasoning; needs `anthropic` + key |
|
|
114
|
+
|
|
115
|
+
The GUI reads the backend from the `METHODLM_MODEL` environment variable.
|
|
116
|
+
|
|
117
|
+
## What we measured (multi-model matrix)
|
|
118
|
+
|
|
119
|
+
`benchmark_models.py` runs plain-vs-harness on the same confounded items across models. The
|
|
120
|
+
honest finding: the harness's value is **capability-dependent** — a capable model wrapped in
|
|
121
|
+
it reads its own tool output and reaches the correct, auditable driver (where the same model
|
|
122
|
+
unwrapped hedges or endorses the decoy); a weak model becomes *safe* (stops confidently
|
|
123
|
+
endorsing the bystander) but can't always synthesize a verdict. `test_judge.py` locks the
|
|
124
|
+
scorer against real verdicts; every run writes a full-verdict audit JSON for re-scoring.
|
|
125
|
+
|
|
126
|
+
## Target column
|
|
127
|
+
|
|
128
|
+
The **target** is the one thing you want explained — the outcome whose *cause* you're after.
|
|
129
|
+
MethodLM asks *"what drives the target?"* and treats every other column as a candidate. Run
|
|
130
|
+
`--data FILE` with no `--target` to list every column with its kind; the GUI shows them as
|
|
131
|
+
clickable chips.
|
|
132
|
+
|
|
133
|
+
## Data formats (`methodlm_io.py`)
|
|
134
|
+
|
|
135
|
+
CSV/TSV, JSON/JSONL, Parquet, SQLite (`--table`/`--query`), NumPy `.npz`, Excel. The
|
|
136
|
+
featurizer coerces mixed columns and **reports every step** into the ledger; free-text /
|
|
137
|
+
high-cardinality columns are dropped (stated, with the count). **Honest boundary:** a lone
|
|
138
|
+
image, raw audio, or a free-text blob isn't a "what drives Y" question until something
|
|
139
|
+
featurizes it into columns with a target.
|
|
140
|
+
|
|
141
|
+
## Why this is different from asking a chatbot
|
|
142
|
+
|
|
143
|
+
Every claim is bought with a test that was pre-registered *before* the result came back and
|
|
144
|
+
executed by real computation. When the optional ternary gate (reads gradients) and the
|
|
145
|
+
copilot (reads experiments) converge, that's two independent witnesses, not one.
|
|
146
|
+
|
|
147
|
+
## Honest limits
|
|
148
|
+
|
|
149
|
+
Small-model reasoning can misread its own results (the ledger catches it; a capable driver
|
|
150
|
+
avoids it). Ternary readouts trade precision for ~20× compression. `STRAT` is conditioning,
|
|
151
|
+
not intervention — it cannot rule out unmeasured confounds, and the copilot is told so.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT — see `LICENSE`.
|
methodlm-1.0.1/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# MethodLM
|
|
2
|
+
|
|
3
|
+
**The method, wrapped around a language model, kept honest by a ledger.** Point it at data;
|
|
4
|
+
it **computes** on it (a ternary two-timescale readout) and **reasons** about it with the
|
|
5
|
+
gbranaa-hue research method — pre-registering every test and keeping an audit trail — so any
|
|
6
|
+
model has to *prove* its causal claims instead of asserting them.
|
|
7
|
+
|
|
8
|
+
> Part of the ternary line — sibling to **[OBSERVE / 012-trit-search](https://github.com/tritsystem/012-trit-search)**
|
|
9
|
+
> (local, private semantic code search). OBSERVE searches your code privately; MethodLM
|
|
10
|
+
> reasons about your data honestly. The optional ternary "second witness" here uses the same
|
|
11
|
+
> `tritkit` two-timescale layer.
|
|
12
|
+
|
|
13
|
+
## What it's for
|
|
14
|
+
|
|
15
|
+
Language models confuse correlation with causation constantly. MethodLM is a **verifiable
|
|
16
|
+
causal-reasoning harness**: it makes any model refuse false causation and back its answers
|
|
17
|
+
with real tests, leaving a checkable ledger. On a benchmark of confounded scenarios its
|
|
18
|
+
backdoor-adjustment test **cuts false causal claims from 100% (naive correlation) to ~1%**
|
|
19
|
+
while keeping 100% detection of the true cause (`benchmark_causal.py`).
|
|
20
|
+
|
|
21
|
+
The discipline — not the model — is the product. A weak local model and a frontier model are
|
|
22
|
+
held to the *same* standard: no `FINAL` verdict without a real, pre-registered test.
|
|
23
|
+
|
|
24
|
+
### Causal tools the copilot can run
|
|
25
|
+
|
|
26
|
+
| Tool | What it does |
|
|
27
|
+
|------|--------------|
|
|
28
|
+
| `CORR` | observational correlation (a clue, never a verdict) |
|
|
29
|
+
| `RUN` | a true controlled experiment (interventional demo world) |
|
|
30
|
+
| `STRAT` | stratified check — does the link survive inside bands of a confounder? |
|
|
31
|
+
| `ADJUST` | **backdoor adjustment + sensitivity + bias audit** — effect of X on the target controlling for named confounders, with a Cinelli–Hazlett **robustness value** (how strong a *hidden* confounder would need to be to overturn it; `RV < 0.10` = fragile), **plus a collider/mediator audit** that flags when conditioning on a variable would *introduce* bias (the "Table 2 fallacy"). It detects the data-visible danger (a collider) and honestly defers mediator-vs-confounder to your DAG — so it never tells you to blindly "adjust for everything." Its robustness value can only speak to confounders you actually measured; it can't rule out an *unmeasured* one — that's what `IV` is for. |
|
|
32
|
+
| `IV` | **instrumental variables (2SLS)** — the remedy for confounding ADJUST structurally cannot reach: an *unmeasured* common cause of X and the target. Given a genuine instrument (moves X, no direct effect on the target except through X), does a real two-stage least squares fit and reports the **first-stage F-statistic** (`F < 10` = weak instrument, the standard Stock–Yogo-adjacent rule of thumb — a weak instrument makes 2SLS *worse* than plain OLS, not better, and this tool says so plainly rather than silently reporting a bad estimate). The exclusion restriction (no direct X-free path from the instrument to the target) is stated on every call as an assumption that **cannot be verified from data alone** — same honest boundary as ADJUST's collider/mediator split. Measured: on a synthetic scenario with an unmeasured confounder that fools both naive correlation and ADJUST (whose own RV reads as *robust*, RV≈0.8, because it cannot see the hidden confound), IV recovers an estimate close to the true effect; on a deliberately weak instrument the F-statistic diagnostic reliably flags it (`F<10` on every trial across 5 reruns, with wildly unstable point estimates — proof the flag is doing real work, not decoration). |
|
|
33
|
+
| `REFUTE` | **DoWhy-backed refutation testing** (optional — needs `pip install dowhy`) — a second, independently-derived robustness check on a candidate ADJUST already found promising: DoWhy's own `backdoor.linear_regression` estimator, then three real perturbation tests (placebo treatment, random common cause, data-subset). A real effect should collapse toward 0 under the placebo and barely move under the other two. Checks numerical robustness, not causal role — it doesn't replace ADJUST's collider/mediator audit. |
|
|
34
|
+
| `ATTR` | the ternary compute gate's independent evidence per column (the optional second witness) |
|
|
35
|
+
|
|
36
|
+
Pre-registration is enforced: no `FINAL` is accepted until at least one real test
|
|
37
|
+
(`ADJUST`/`STRAT`/`RUN`/`INTERACT`/`IV`/`REFUTE`) has run.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pip install numpy # required — the reasoning harness + tools
|
|
43
|
+
pip install anthropic # optional — to drive a frontier model (--model opus/sonnet/haiku)
|
|
44
|
+
pip install pandas pyarrow openpyxl # optional — extra data formats (Parquet / Excel)
|
|
45
|
+
pip install torch # optional — enables the ternary second witness
|
|
46
|
+
pip install dowhy # optional — enables REFUTE (independent robustness check)
|
|
47
|
+
pip install transformers # optional — enables the contrastive local backend (--model contrastive)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **Reasoning half** runs on `numpy` alone.
|
|
51
|
+
- **Frontier backend** needs `anthropic` + `ANTHROPIC_API_KEY` (set a low workspace spend limit).
|
|
52
|
+
- **Local backend** needs a llama.cpp `llama-completion` binary + a small GGUF (e.g. Qwen); point `methodlm_models.py` at yours. (Weights/binaries are not shipped here.)
|
|
53
|
+
- **Ternary second witness** (optional) needs `torch` + `tritkit` (from
|
|
54
|
+
[012-trit-search](https://github.com/tritsystem/012-trit-search)); set
|
|
55
|
+
`METHODLM_TRITKIT=/path/to/tritkit_parent`. Without it, MethodLM prints a note and runs the
|
|
56
|
+
reasoning half normally.
|
|
57
|
+
|
|
58
|
+
## Run it
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
python methodlm.py --demo # hidden-confound world + answer key
|
|
62
|
+
python methodlm.py --diabetes # real data: 442 diabetes patients (sklearn)
|
|
63
|
+
python methodlm.py --data FILE --target COLUMN # any tabular dataset
|
|
64
|
+
python methodlm.py --demo --race # head-to-head vs the same model, no method
|
|
65
|
+
python methodlm.py --diabetes --model opus # drive a frontier model instead of local
|
|
66
|
+
python methodlm_gui.py # desktop GUI (opens in your browser)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Reasoning backend (`--model`)
|
|
70
|
+
|
|
71
|
+
| `--model` | Backend | Notes |
|
|
72
|
+
|-----------|---------|-------|
|
|
73
|
+
| `local` (default) | a GGUF via `llama-completion` | private, offline, free (bring your own model) |
|
|
74
|
+
| `contrastive` / `cd` | real Qwen2.5-1.5B-Instruct amplified against Qwen2.5-0.5B-Instruct via [contrastive decoding](https://arxiv.org/abs/2210.15097) (`structured_attention.contrastive_next_token_logits`) | local, offline, GPU (falls back to CPU); needs `transformers` + the `structured-attention` package (`pip install -e /path/to/structured-attention` or `METHODLM_STRUCTURED_ATTENTION=/path`); tunable via `METHODLM_CD_WEAK`/`METHODLM_CD_STRONG`/`METHODLM_CD_ALPHA`/`METHODLM_CD_BETA`. Verified end-to-end (`methodlm.py --demo --model contrastive`): loads in ~16s, drives the full PREREGISTER→ADJUST→FINAL tool loop and writes a real ledger. **Not separately benchmarked yet** (no `benchmark_causal.py`/`benchmark_models.py` numbers for this backend specifically) — same discipline (pre-registration, loop guards, ledger) as every other backend, but its *causal accuracy on the benchmark suite* is unmeasured so far. |
|
|
75
|
+
| `opus` / `sonnet` / `haiku` | Claude via the Anthropic API | frontier reasoning; needs `anthropic` + key |
|
|
76
|
+
|
|
77
|
+
The GUI reads the backend from the `METHODLM_MODEL` environment variable.
|
|
78
|
+
|
|
79
|
+
## What we measured (multi-model matrix)
|
|
80
|
+
|
|
81
|
+
`benchmark_models.py` runs plain-vs-harness on the same confounded items across models. The
|
|
82
|
+
honest finding: the harness's value is **capability-dependent** — a capable model wrapped in
|
|
83
|
+
it reads its own tool output and reaches the correct, auditable driver (where the same model
|
|
84
|
+
unwrapped hedges or endorses the decoy); a weak model becomes *safe* (stops confidently
|
|
85
|
+
endorsing the bystander) but can't always synthesize a verdict. `test_judge.py` locks the
|
|
86
|
+
scorer against real verdicts; every run writes a full-verdict audit JSON for re-scoring.
|
|
87
|
+
|
|
88
|
+
## Target column
|
|
89
|
+
|
|
90
|
+
The **target** is the one thing you want explained — the outcome whose *cause* you're after.
|
|
91
|
+
MethodLM asks *"what drives the target?"* and treats every other column as a candidate. Run
|
|
92
|
+
`--data FILE` with no `--target` to list every column with its kind; the GUI shows them as
|
|
93
|
+
clickable chips.
|
|
94
|
+
|
|
95
|
+
## Data formats (`methodlm_io.py`)
|
|
96
|
+
|
|
97
|
+
CSV/TSV, JSON/JSONL, Parquet, SQLite (`--table`/`--query`), NumPy `.npz`, Excel. The
|
|
98
|
+
featurizer coerces mixed columns and **reports every step** into the ledger; free-text /
|
|
99
|
+
high-cardinality columns are dropped (stated, with the count). **Honest boundary:** a lone
|
|
100
|
+
image, raw audio, or a free-text blob isn't a "what drives Y" question until something
|
|
101
|
+
featurizes it into columns with a target.
|
|
102
|
+
|
|
103
|
+
## Why this is different from asking a chatbot
|
|
104
|
+
|
|
105
|
+
Every claim is bought with a test that was pre-registered *before* the result came back and
|
|
106
|
+
executed by real computation. When the optional ternary gate (reads gradients) and the
|
|
107
|
+
copilot (reads experiments) converge, that's two independent witnesses, not one.
|
|
108
|
+
|
|
109
|
+
## Honest limits
|
|
110
|
+
|
|
111
|
+
Small-model reasoning can misread its own results (the ledger catches it; a capable driver
|
|
112
|
+
avoids it). Ternary readouts trade precision for ~20× compression. `STRAT` is conditioning,
|
|
113
|
+
not intervention — it cannot rule out unmeasured confounds, and the copilot is told so.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT — see `LICENSE`.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Causal-reasoning benchmark: does the method reject confounded decoys?
|
|
3
|
+
|
|
4
|
+
Each scenario has a KNOWN ground truth (a confounding DAG):
|
|
5
|
+
Sea -> cause, Sea -> decoy, cause -> outcome (+ pure-noise features)
|
|
6
|
+
So `cause` truly drives `outcome`; `decoy` only correlates with it through the shared
|
|
7
|
+
latent `Sea` (the humidity/temperature structure, generalized). Ground-truth causal
|
|
8
|
+
set = {cause}.
|
|
9
|
+
|
|
10
|
+
Two statistical arms, scored over K scenarios:
|
|
11
|
+
naive : flag a feature causal if |corr(feature, outcome)| > tau
|
|
12
|
+
method : flag it if |partial corr controlling for the other features| > tau
|
|
13
|
+
(= MethodLM's ADJUST/gate logic: backdoor adjustment)
|
|
14
|
+
|
|
15
|
+
The metric that matters is the FALSE-POSITIVE RATE ON THE DECOY -- how often each arm
|
|
16
|
+
calls a confounded bystander a cause (the "temperature causes error, buy cooling"
|
|
17
|
+
mistake). Pre-registered: naive flags the decoy most of the time; method rarely does,
|
|
18
|
+
while both keep a high true-positive rate on the real cause.
|
|
19
|
+
|
|
20
|
+
Optional --llm arm: on a few scenarios, run a plain LLM vs the full MethodLM harness
|
|
21
|
+
and check which one refuses the decoy in words.
|
|
22
|
+
|
|
23
|
+
Reproducible: fixed seed. This is SYNTHETIC (known ground truth) and mirrors the
|
|
24
|
+
structure of public causal-reasoning suites (Corr2Cause / CLADDER); running on those
|
|
25
|
+
is the online next step.
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
TAU = 0.10
|
|
31
|
+
rng = np.random.default_rng(20260709)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scenario(n=600, n_noise=3):
|
|
35
|
+
sea = rng.standard_normal(n)
|
|
36
|
+
a, c, b = rng.uniform(0.6, 0.9), rng.uniform(0.6, 0.9), rng.uniform(0.7, 1.0)
|
|
37
|
+
cause = a * sea + rng.standard_normal(n)
|
|
38
|
+
decoy = c * sea + rng.standard_normal(n)
|
|
39
|
+
outcome = b * cause + rng.standard_normal(n) # outcome driven by cause ONLY
|
|
40
|
+
d = {"cause": cause, "decoy": decoy}
|
|
41
|
+
for i in range(n_noise):
|
|
42
|
+
d[f"noise{i+1}"] = rng.standard_normal(n)
|
|
43
|
+
d["outcome"] = outcome
|
|
44
|
+
return d
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def partial_all(d, x, target="outcome"):
|
|
48
|
+
Z = [c for c in d if c not in (x, target)]
|
|
49
|
+
n = len(d[target])
|
|
50
|
+
A = np.column_stack([d[c] for c in Z] + [np.ones(n)])
|
|
51
|
+
def resid(v):
|
|
52
|
+
beta, *_ = np.linalg.lstsq(A, v, rcond=None); return v - A @ beta
|
|
53
|
+
return float(np.corrcoef(resid(d[x]), resid(d[target]))[0, 1])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def stat_benchmark(K=120):
|
|
57
|
+
feats = None
|
|
58
|
+
tally = {"naive": {}, "method": {}}
|
|
59
|
+
val = {"naive": {"cause": [], "decoy": []}, "method": {"cause": [], "decoy": []}}
|
|
60
|
+
for _ in range(K):
|
|
61
|
+
d = scenario()
|
|
62
|
+
feats = [c for c in d if c != "outcome"]
|
|
63
|
+
for f in feats:
|
|
64
|
+
raw = abs(float(np.corrcoef(d[f], d["outcome"])[0, 1]))
|
|
65
|
+
par = abs(partial_all(d, f))
|
|
66
|
+
for arm, s in (("naive", raw), ("method", par)):
|
|
67
|
+
tally[arm].setdefault(f, 0)
|
|
68
|
+
tally[arm][f] += int(s > TAU)
|
|
69
|
+
role = f if f in ("cause", "decoy") else "noise"
|
|
70
|
+
if role in ("cause", "decoy"):
|
|
71
|
+
val["naive"][role].append(raw); val["method"][role].append(par)
|
|
72
|
+
noise = [f for f in feats if f.startswith("noise")]
|
|
73
|
+
def rate(arm, f): return tally[arm][f] / K
|
|
74
|
+
def noise_rate(arm): return np.mean([tally[arm][f] for f in noise]) / K
|
|
75
|
+
|
|
76
|
+
print(f"causal benchmark: {K} confounded scenarios, tau={TAU}\n")
|
|
77
|
+
print(f"{'arm':>8} | {'flags CAUSE':>11} | {'flags DECOY':>11} | {'flags noise':>11} | mean |assoc|")
|
|
78
|
+
print("-" * 74)
|
|
79
|
+
for arm in ("naive", "method"):
|
|
80
|
+
mc = np.mean(val[arm]["cause"]); md = np.mean(val[arm]["decoy"])
|
|
81
|
+
print(f"{arm:>8} | {rate(arm,'cause')*100:>10.0f}% | {rate(arm,'decoy')*100:>10.0f}% | "
|
|
82
|
+
f"{noise_rate(arm)*100:>10.0f}% | cause {mc:+.2f} · decoy {md:+.2f}")
|
|
83
|
+
dn, dm = rate("naive", "decoy"), rate("method", "decoy")
|
|
84
|
+
print(f"\nHEADLINE: naive correlation calls the confounded decoy a cause {dn*100:.0f}% of the "
|
|
85
|
+
f"time;\n the method (backdoor adjustment) does so {dm*100:.0f}% of the time"
|
|
86
|
+
+ (f" -- a {(dn-dm)/max(dn,1e-9)*100:.0f}% cut in false causal claims." if dn > dm else "."))
|
|
87
|
+
tp = rate("method", "cause")
|
|
88
|
+
print(f" Method keeps {tp*100:.0f}% true-positive on the real cause.")
|
|
89
|
+
return {"naive_decoy_fp": dn, "method_decoy_fp": dm, "method_cause_tp": tp}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def llm_arm(n_items=2):
|
|
93
|
+
import methodlm as M
|
|
94
|
+
M.BACKEND = M.methodlm_models.get_model("local", M.HERE)
|
|
95
|
+
print(f"\n--- LLM arm: plain model vs MethodLM harness ({M.BACKEND.label}) ---")
|
|
96
|
+
for i in range(n_items):
|
|
97
|
+
d = scenario()
|
|
98
|
+
rC = float(np.corrcoef(d["cause"], d["outcome"])[0, 1])
|
|
99
|
+
rD = float(np.corrcoef(d["decoy"], d["outcome"])[0, 1])
|
|
100
|
+
q = (f"Our outcome correlates with decoy (r={rD:+.2f}) and with cause (r={rC:+.2f}). "
|
|
101
|
+
"A stakeholder wants to intervene on decoy. What actually drives outcome?")
|
|
102
|
+
print(f"\n[item {i+1}] truth: cause drives outcome; decoy is a confounded bystander "
|
|
103
|
+
f"(corr decoy {rD:+.2f}, cause {rC:+.2f})")
|
|
104
|
+
van = M.vanilla_answer(q)
|
|
105
|
+
print(f" plain LLM : {van[:180]}")
|
|
106
|
+
res = M.investigate(f"bench{i+1}", d, "outcome", q, False)
|
|
107
|
+
v = res["verdict"]
|
|
108
|
+
endorses_decoy = "decoy" in v.lower() and "not" not in v.lower()[:v.lower().find("decoy")+6]
|
|
109
|
+
print(f" MethodLM : ({res['nrun']} test) {v[:180]}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
ap = argparse.ArgumentParser()
|
|
114
|
+
ap.add_argument("--llm", action="store_true", help="also run the slow LLM arm")
|
|
115
|
+
ap.add_argument("-K", type=int, default=120)
|
|
116
|
+
args = ap.parse_args()
|
|
117
|
+
stat_benchmark(args.K)
|
|
118
|
+
if args.llm:
|
|
119
|
+
llm_arm()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Many LLMs x {plain, MethodLM harness} on the SAME confounded items.
|
|
3
|
+
|
|
4
|
+
Fairness: feature names are NEUTRAL (alpha/beta/gamma/...), so no model can pattern-
|
|
5
|
+
match the answer off a label; the true cause and the confounded decoy get random names
|
|
6
|
+
each item; every model sees the identical items (fixed seed). Ground truth = the cause
|
|
7
|
+
drives y; the decoy only correlates through a shared latent.
|
|
8
|
+
|
|
9
|
+
Per (model, condition) we score each verdict as naming the true CAUSE, the confounded
|
|
10
|
+
DECOY (the costly error a stakeholder would act on), or a HEDGE. The comparison shows
|
|
11
|
+
(a) harness vs no-harness lift within a model, and (b) models against each other.
|
|
12
|
+
|
|
13
|
+
Available backends depend on the box. Frontier models (claude) need SDK + creds +
|
|
14
|
+
internet; on an offline machine they're reported unavailable. Local Qwen variants run.
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import numpy as np
|
|
18
|
+
import methodlm as M
|
|
19
|
+
from methodlm_models import get_model
|
|
20
|
+
|
|
21
|
+
HERE = M.HERE
|
|
22
|
+
NAMES = ["alpha", "beta", "gamma", "delta", "epsilon"]
|
|
23
|
+
MODELS = ["opus", "sonnet", "haiku", "qwen3b", "qwen05b", "baked"] # skip if unavailable
|
|
24
|
+
N = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def scenario(rng, n=700):
|
|
28
|
+
"""A genuinely adversarial confounded item: an OBSERVED confounder drives BOTH y and
|
|
29
|
+
the decoy, so the decoy OUT-CORRELATES the true cause in raw r -- 'pick the biggest
|
|
30
|
+
correlation' lands on the decoy (or the confounder), NEVER the cause. Only adjustment
|
|
31
|
+
(control for the confounder) recovers the truth. DAG: conf->decoy, conf->y, cause->y.
|
|
32
|
+
|
|
33
|
+
Coefficients are deliberately lopsided: the decoy tracks the confounder tightly (little
|
|
34
|
+
noise), the confounder's push on y is large, and the true cause's push is modest. So
|
|
35
|
+
raw r ranks decoy/conf above cause, while adjustment collapses the decoy and lifts the
|
|
36
|
+
cause. make_items() rejection-samples on top of this to GUARANTEE the trap."""
|
|
37
|
+
idx = rng.permutation(len(NAMES))
|
|
38
|
+
cause, decoy, conf = NAMES[idx[0]], NAMES[idx[1]], NAMES[idx[2]]
|
|
39
|
+
noises = [NAMES[i] for i in idx[3:]]
|
|
40
|
+
L = rng.standard_normal(n) # observed confounder (a real column)
|
|
41
|
+
cv = rng.standard_normal(n) # the true cause's own variation
|
|
42
|
+
p = rng.uniform(1.1, 1.4) # decoy tracks the confounder tightly
|
|
43
|
+
g = rng.uniform(1.4, 1.9) # confounder's (large) push on y
|
|
44
|
+
b = rng.uniform(0.45, 0.65) # true cause's (modest) push on y
|
|
45
|
+
d = {cause: cv, decoy: p * L + 0.25 * rng.standard_normal(n), conf: L}
|
|
46
|
+
for nm in noises:
|
|
47
|
+
d[nm] = rng.standard_normal(n)
|
|
48
|
+
d = {k: d[k] for k in NAMES if k in d}
|
|
49
|
+
d["y"] = b * cv + g * L + rng.standard_normal(n) # y driven by cause AND the confounder
|
|
50
|
+
return d, cause, decoy, conf
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def claimed_driver(v):
|
|
54
|
+
"""The single variable a verdict names as the driver, read from its CONCLUSION and its
|
|
55
|
+
actionable recommendation. Guards against: the comparative trap ("drives y more than
|
|
56
|
+
alpha" is the first term), variables named only to be dismissed, and reverse phrasings
|
|
57
|
+
("the column that drives y IS alpha", "intervention efforts on alpha")."""
|
|
58
|
+
import re
|
|
59
|
+
v = (v or "").strip()
|
|
60
|
+
if not v:
|
|
61
|
+
return None
|
|
62
|
+
low = v.lower()
|
|
63
|
+
feats = "|".join(NAMES)
|
|
64
|
+
NEG = r"\b(?:not|isn't|no longer|never|bystander|confounded|spurious|merely|only|rather than|instead of)\b"
|
|
65
|
+
# a driver-claim is void if it sits under a negation ("do NOT intervene on alpha",
|
|
66
|
+
# "don't ASSUME alpha drives y", "BEFORE intervening on gamma") -- check the run-up.
|
|
67
|
+
NEGCTX = re.compile(r"\b(?:not|n't|never|avoid|without|before|prior to|rather than|instead of|"
|
|
68
|
+
r"don't|do not|isn't|aren't|cannot|can't|won't|wouldn't|assume)\b")
|
|
69
|
+
# focus on the conclusion (NOT the 'recommendation' advice line, which often drops the claim)
|
|
70
|
+
marks = list(re.finditer(r"\b(?:final(?:\s*answer)?|conclusion|bottom line|verdict)\b\s*[:\-]?", low))
|
|
71
|
+
tail = (low[marks[-1].end():].strip() or low) if marks else low
|
|
72
|
+
|
|
73
|
+
def endorses(feat, text):
|
|
74
|
+
pats = [ # forward "<feat> drives/is the driver", reverse "drives ... is <feat>", actionable "intervene on <feat>"
|
|
75
|
+
rf"\b{feat}\b(?:(?!{NEG})[^.]){{0,55}}\b(?:drives?\b|is (?:the |a )?(?:\w+\s+){{0,3}}(?:driver|cause|lever|predictor))",
|
|
76
|
+
rf"\b(?:drives?|driver|the cause|lever)\b(?:(?!{NEG})[^.]){{0,25}}\bis\b[^.]{{0,12}}\b{feat}\b",
|
|
77
|
+
rf"\b(?:interven\w* on|intervention(?:\s+\w+)?\s+on|target|focus\w*(?:\s+\w+){{0,3}}\s+on|responsible for)\b[^.]{{0,25}}\b{feat}\b",
|
|
78
|
+
]
|
|
79
|
+
for pat in pats:
|
|
80
|
+
for m in re.finditer(pat, text):
|
|
81
|
+
if not NEGCTX.search(text[max(0, m.start() - 30):m.start()]): # not under a negation
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def scan(text):
|
|
86
|
+
return next((feat for feat in NAMES if endorses(feat, text)), None)
|
|
87
|
+
|
|
88
|
+
# 1) the conclusion LEADS with the answer as a (bare/emphasized/quoted) feature name
|
|
89
|
+
lead = re.match(rf"[\*\s>#\-\"']*({feats})\b(.*?)(?:[.;\n]|$)", tail)
|
|
90
|
+
if lead and not re.search(NEG, lead.group(2)):
|
|
91
|
+
return lead.group(1)
|
|
92
|
+
return scan(tail) or scan(low)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def judge(v, cause, decoy, conf=None):
|
|
96
|
+
import re
|
|
97
|
+
dr = claimed_driver(v)
|
|
98
|
+
if dr is None:
|
|
99
|
+
if re.search(r"not causal|no.{0,3}caus|can(?:not|'t) (?:\w+\s+){0,2}(?:tell|determine|say|identify|know)|"
|
|
100
|
+
r"correlation (?:alone|is not)|isn't caus|no single|insufficient|unknown", (v or "").lower()):
|
|
101
|
+
return "reject"
|
|
102
|
+
return "hedge"
|
|
103
|
+
if dr == cause: return "cause"
|
|
104
|
+
if dr == decoy: return "decoy"
|
|
105
|
+
if conf is not None and dr == conf: return "conf" # the confounder is itself a co-driver of y, not the costly error
|
|
106
|
+
return "wrong" # named a noise column as the driver
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def available(key):
|
|
110
|
+
try:
|
|
111
|
+
return get_model(key, HERE)
|
|
112
|
+
except SystemExit:
|
|
113
|
+
return None
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f" ({key} unavailable: {str(e)[:60]})")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
CATS = ("cause", "decoy", "conf", "wrong", "reject", "hedge")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _partial(d, x, target="y"):
|
|
123
|
+
"""Adjusted (partial) corr of x on target controlling for every other column -- the
|
|
124
|
+
same standardized-regression estimate the ADJUST tool reports, used here to certify
|
|
125
|
+
that an item is a real trap before we spend a model on it."""
|
|
126
|
+
zs = [c for c in d if c not in (x, target)]
|
|
127
|
+
n = len(d[target])
|
|
128
|
+
Z = lambda a: (np.asarray(a, float) - np.mean(a)) / (np.std(a) + 1e-9)
|
|
129
|
+
y = Z(d[target]); X = np.column_stack([Z(d[c]) for c in [x] + zs] + [np.ones(n)])
|
|
130
|
+
beta, *_ = np.linalg.lstsq(X, y, rcond=None); resid = y - X @ beta; dof = n - X.shape[1]
|
|
131
|
+
se = np.sqrt(((resid ** 2).sum() / max(dof, 1)) * np.diag(np.linalg.pinv(X.T @ X)))
|
|
132
|
+
t = beta[0] / (se[0] + 1e-12)
|
|
133
|
+
return float(t / np.sqrt(t * t + dof)) if dof > 0 else float("nan")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def make_items(seed=7):
|
|
137
|
+
"""Rejection-sample scenarios until every kept item is genuinely adversarial: the decoy
|
|
138
|
+
OUT-correlates the cause in raw r (so naive-max-correlation is tempted by the decoy),
|
|
139
|
+
yet the cause dominates on adjustment (so the discipline can still recover it)."""
|
|
140
|
+
rng = np.random.default_rng(seed)
|
|
141
|
+
items, tries = [], 0
|
|
142
|
+
while len(items) < N and tries < 2000:
|
|
143
|
+
tries += 1
|
|
144
|
+
d, c, dec, conf = scenario(rng)
|
|
145
|
+
rC = abs(float(np.corrcoef(d[c], d["y"])[0, 1]))
|
|
146
|
+
rD = abs(float(np.corrcoef(d[dec], d["y"])[0, 1]))
|
|
147
|
+
if rD > rC + 0.10 and abs(_partial(d, c)) > abs(_partial(d, dec)) + 0.20:
|
|
148
|
+
items.append((d, c, dec, conf))
|
|
149
|
+
if len(items) < N:
|
|
150
|
+
raise SystemExit(f"could only build {len(items)}/{N} adversarial items; loosen thresholds")
|
|
151
|
+
return items
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check():
|
|
155
|
+
"""Instrument validity: on the hard items, does naive-max-correlation actually
|
|
156
|
+
fail (so the benchmark measures reasoning, not luck)?"""
|
|
157
|
+
items = make_items()
|
|
158
|
+
naive_ok = 0
|
|
159
|
+
for i, (d, cause, decoy, conf) in enumerate(items, 1):
|
|
160
|
+
cs = {k: abs(float(np.corrcoef(d[k], d["y"])[0, 1])) for k in d if k != "y"}
|
|
161
|
+
top = max(cs, key=cs.get)
|
|
162
|
+
naive_ok += (top == cause)
|
|
163
|
+
print(f" item {i}: cause={cause} r={cs[cause]:+.2f} | decoy={decoy} r={cs[decoy]:+.2f} "
|
|
164
|
+
f"| conf={conf} | naive picks {top} ({'ok' if top==cause else 'WRONG'})")
|
|
165
|
+
print(f"\nnaive-max-correlation accuracy on the hard items: {naive_ok}/{N} "
|
|
166
|
+
f"(want < {N} — the trap must actually trap)")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main():
|
|
170
|
+
items = make_items()
|
|
171
|
+
print(f"multi-model causal benchmark: {N} HARD confounded items (naive fails), neutral names\n")
|
|
172
|
+
for i, (d, c, dec, conf) in enumerate(items, 1):
|
|
173
|
+
rC = float(np.corrcoef(d[c], d["y"])[0, 1]); rD = float(np.corrcoef(d[dec], d["y"])[0, 1])
|
|
174
|
+
print(f" item {i}: truth cause={c} (r={rC:+.2f}); decoy={dec} (r={rD:+.2f}) via conf={conf}")
|
|
175
|
+
|
|
176
|
+
rows = []; audit = []
|
|
177
|
+
for key in MODELS:
|
|
178
|
+
mdl = available(key)
|
|
179
|
+
if mdl is None:
|
|
180
|
+
print(f"\n=== {key}: UNAVAILABLE (skipped) ==="); continue
|
|
181
|
+
M.BACKEND = mdl
|
|
182
|
+
print(f"\n=== {mdl.label} ===")
|
|
183
|
+
for cond in ("plain", "harness"):
|
|
184
|
+
tally = {c: 0 for c in CATS}; tests = 0
|
|
185
|
+
for i, (d, cause, decoy, conf) in enumerate(items, 1):
|
|
186
|
+
rC = float(np.corrcoef(d[cause], d["y"])[0, 1])
|
|
187
|
+
rD = float(np.corrcoef(d[decoy], d["y"])[0, 1])
|
|
188
|
+
q = (f"Columns {[k for k in d if k!='y']} predict y. {decoy} correlates with y MORE "
|
|
189
|
+
f"strongly (r={rD:+.2f}) than {cause} (r={rC:+.2f}), and a stakeholder wants to "
|
|
190
|
+
f"intervene on {decoy}. Which single column actually drives y?")
|
|
191
|
+
if cond == "plain":
|
|
192
|
+
v = M.vanilla_answer(q)
|
|
193
|
+
else:
|
|
194
|
+
r = M.investigate(f"bm_{key}_{i}", d, "y", q, False); v = r["verdict"]; tests += r["nrun"]
|
|
195
|
+
j = judge(v, cause, decoy, conf); tally[j] += 1
|
|
196
|
+
audit.append({"model": mdl.label, "cond": cond, "item": i, "cause": cause,
|
|
197
|
+
"decoy": decoy, "conf": conf, "rC": round(rC, 3), "rD": round(rD, 3),
|
|
198
|
+
"judged": j, "verdict": v})
|
|
199
|
+
print(f" [{cond:>7} item{i}] -> {j:<6} | {(v or '')[:88]}")
|
|
200
|
+
rows.append((mdl.label, cond, tally, tests))
|
|
201
|
+
|
|
202
|
+
import json
|
|
203
|
+
apath = os.path.join(HERE, "benchmark_models_audit.json")
|
|
204
|
+
with open(apath, "w", encoding="utf-8") as fh:
|
|
205
|
+
json.dump(audit, fh, indent=2, ensure_ascii=False)
|
|
206
|
+
|
|
207
|
+
print("\n" + "=" * 96)
|
|
208
|
+
print(f"{'model':>24} | {'cond':>7} | {'CAUSE ok':>8} | {'DECOY err':>9} | {'conf':>4} | {'wrong':>5} | "
|
|
209
|
+
f"{'reject':>6} | {'hedge':>5} | tests")
|
|
210
|
+
print("-" * 96)
|
|
211
|
+
for label, cond, t, tests in rows:
|
|
212
|
+
print(f"{label:>24} | {cond:>7} | {t['cause']}/{N:<6} | {t['decoy']}/{N:<7} | {t['conf']}/{N:<2} | "
|
|
213
|
+
f"{t['wrong']}/{N:<3} | {t['reject']}/{N:<4} | {t['hedge']}/{N:<3} | {tests if cond=='harness' else '--'}")
|
|
214
|
+
print("\nDECOY err = endorsed the confounded decoy = the pure bystander (THE costly mistake). "
|
|
215
|
+
"CAUSE ok = named the labelled cause · conf = named the confounder, which is itself a "
|
|
216
|
+
"genuine co-driver of y (a real DAG has cause->y AND conf->y) · reject = refused causation.")
|
|
217
|
+
print(f"\nfull verdicts (for audit / re-scoring) -> {apath}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
import sys
|
|
222
|
+
if "--models" in sys.argv:
|
|
223
|
+
MODELS = sys.argv[sys.argv.index("--models") + 1].split(",")
|
|
224
|
+
check() if "--check" in sys.argv else main()
|