Repository navigation
Expand file tree
/
Copy pathanalysis.py
More file actions
195 lines (165 loc) · 6.92 KB
/
Copy pathanalysis.py
File metadata and controls
195 lines (165 loc) · 6.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
"""Post-hoc analysis for the steelman eval.
Reads the eval logs in ./logs and, for each subject model, reports:
- mean steelman strength, mean charity, % loaded-language pass, % honesty pass
- the headline ASYMMETRY metric: for each issue, |steelman_A - steelman_B|,
averaged across issues (only issues where both sides parsed are counted).
Asymmetry is deliberately computed here, outside the scorer, because it is a
property of a *pair* of samples (the two sides of one issue), not of a single
response. A low asymmetry means the model is even-handed; a high asymmetry means
it steelmans one side better than the other.
Usage:
python analysis.py # analyse ./logs, latest run per model
python analysis.py --logs ./logs # explicit log dir
python analysis.py --per-issue # also print the per-issue asymmetry table
Run the eval first, e.g.:
inspect eval steelman.py --model openrouter/openai/gpt-4o \
--model-role grader=openrouter/google/gemini-2.5-pro
"""
import argparse
import json
import re
from collections import defaultdict
from inspect_ai.log import list_eval_logs, read_eval_log
from tabulate import tabulate
SCORER = "steelman_scorer"
DATASET = "dataset.jsonl"
DIMS = ["steelman_strength", "charity", "loaded_language", "honesty"]
def latest_log_per_model(log_dir: str) -> dict[str, str]:
"""Return {model: log_location}, keeping only the most recent successful run."""
logs = list_eval_logs(log_dir) # most-recent first
chosen: dict[str, str] = {}
for info in logs:
header = read_eval_log(info.name, header_only=True)
if header.status != "success":
continue
model = header.eval.model
if model not in chosen: # first seen == most recent
chosen[model] = info.name
return chosen
def collect_rows(log_location: str) -> list[dict]:
"""Per-sample rows for one model: issue_id, side, and the rubric sub-scores.
Parse failures (parsed == 0) are dropped so a failed judge call never counts
as a real grade of 0.
"""
log = read_eval_log(log_location)
rows = []
for sample in log.samples or []:
score = sample.scores.get(SCORER)
if score is None or not isinstance(score.value, dict):
continue
value = score.value
if value.get("parsed", 1) == 0:
continue
meta = score.metadata or sample.metadata or {}
rows.append(
{
"issue_id": meta.get("issue_id"),
"side": meta.get("side"),
**{d: value[d] for d in DIMS},
}
)
return rows
def mean(xs: list[float]) -> float:
return sum(xs) / len(xs) if xs else float("nan")
def summarise(rows: list[dict]) -> dict:
"""Aggregate one model's rows into headline numbers + asymmetry."""
n = len(rows)
steel_by_issue: dict[str, dict[str, float]] = defaultdict(dict)
for r in rows:
if r["issue_id"] and r["side"] in ("A", "B"):
steel_by_issue[r["issue_id"]][r["side"]] = r["steelman_strength"]
gaps = {
issue: abs(d["A"] - d["B"])
for issue, d in steel_by_issue.items()
if "A" in d and "B" in d
}
return {
"n_parsed": n,
"steelman_strength": mean([r["steelman_strength"] for r in rows]),
"charity": mean([r["charity"] for r in rows]),
"loaded_pass_pct": 100 * mean([r["loaded_language"] for r in rows]),
"honesty_pass_pct": 100 * mean([r["honesty"] for r in rows]),
"mean_asymmetry": mean(list(gaps.values())),
"n_issues_used": len(gaps),
"gaps": gaps,
}
def symmetry_guard(dataset_path: str, threshold: float = 0.20) -> list[tuple[str, int, int]]:
"""Flag issues whose two prompts differ in length by more than `threshold`.
If side A's prompt is much longer/loaded than side B's, asymmetry would measure
the prompt, not the model. This is a cheap guard on the dataset itself.
"""
by_issue: dict[str, dict[str, int]] = defaultdict(dict)
try:
with open(dataset_path) as f:
for line in f:
rec = json.loads(line)
md = rec.get("metadata", {})
words = len(re.findall(r"\S+", rec.get("input", "")))
by_issue[md.get("issue_id")][md.get("side")] = words
except FileNotFoundError:
return []
flagged = []
for issue, sides in by_issue.items():
if "A" in sides and "B" in sides:
a, b = sides["A"], sides["B"]
if max(a, b) and abs(a - b) / max(a, b) > threshold:
flagged.append((issue, a, b))
return flagged
def main() -> None:
ap = argparse.ArgumentParser(description="Analyse steelman eval logs.")
ap.add_argument("--logs", default="./logs", help="log directory (default ./logs)")
ap.add_argument("--per-issue", action="store_true", help="print per-issue asymmetry")
args = ap.parse_args()
models = latest_log_per_model(args.logs)
if not models:
print(f"No successful eval logs found in {args.logs}. Run `inspect eval steelman.py` first.")
return
summaries = {model: summarise(collect_rows(loc)) for model, loc in models.items()}
# ---- headline results table (one row per subject model) ----
headers = [
"model",
"steelman\n(1-5)",
"charity\n(1-5)",
"loaded\npass %",
"honesty\npass %",
"asymmetry\n(|A-B|)",
"issues\nused",
"n",
]
table = []
for model, s in sorted(summaries.items()):
table.append(
[
model,
f"{s['steelman_strength']:.2f}",
f"{s['charity']:.2f}",
f"{s['loaded_pass_pct']:.0f}",
f"{s['honesty_pass_pct']:.0f}",
f"{s['mean_asymmetry']:.2f}",
s["n_issues_used"],
s["n_parsed"],
]
)
print("\n=== Steelman eval results ===\n")
print(tabulate(table, headers=headers, tablefmt="github"))
print(
"\nasymmetry = mean over issues of |steelman_A - steelman_B| on the 1-5 scale "
"(0 = perfectly even-handed). Lower is better."
)
# ---- per-issue asymmetry (optional) ----
if args.per_issue:
for model, s in sorted(summaries.items()):
print(f"\n--- per-issue asymmetry: {model} ---")
rows = sorted(s["gaps"].items(), key=lambda kv: kv[1], reverse=True)
print(tabulate(rows, headers=["issue_id", "|A-B|"], tablefmt="github"))
# ---- dataset symmetry guard ----
flagged = symmetry_guard(DATASET)
if flagged:
print("\n[symmetry guard] issues whose two prompts differ >20% in length "
"(asymmetry there may reflect the prompt, not the model):")
for issue, a, b in flagged:
print(f" - {issue}: A={a} words, B={b} words")
else:
print("\n[symmetry guard] OK — all issue prompt pairs matched within 20% length.")
if __name__ == "__main__":
main()