methodlm 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
benchmark_causal.py ADDED
@@ -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()
benchmark_models.py ADDED
@@ -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()