claude-turing 1.2.0 → 1.4.0
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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +33 -2
- package/commands/ablate.md +47 -0
- package/commands/diagnose.md +52 -0
- package/commands/frontier.md +45 -0
- package/commands/reproduce.md +48 -0
- package/commands/seed.md +47 -0
- package/commands/turing.md +10 -0
- package/package.json +1 -1
- package/src/install.js +2 -1
- package/src/verify.js +5 -0
- package/templates/config.yaml +10 -0
- package/templates/program.md +5 -0
- package/templates/scripts/__pycache__/ablation_study.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/diagnose_errors.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/generate_brief.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/generate_model_card.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/pareto_frontier.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/reproduce_experiment.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/seed_runner.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/turing_io.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/update_state.cpython-314.pyc +0 -0
- package/templates/scripts/ablation_study.py +487 -0
- package/templates/scripts/diagnose_errors.py +601 -0
- package/templates/scripts/generate_brief.py +117 -0
- package/templates/scripts/generate_model_card.py +25 -0
- package/templates/scripts/leaderboard.py +10 -0
- package/templates/scripts/pareto_frontier.py +470 -0
- package/templates/scripts/reproduce_experiment.py +548 -0
- package/templates/scripts/scaffold.py +11 -0
- package/templates/scripts/seed_runner.py +414 -0
- package/templates/scripts/show_metrics.py +17 -0
- package/templates/scripts/turing_io.py +36 -0
- package/templates/scripts/update_state.py +13 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Error analysis for ML experiments.
|
|
3
|
+
|
|
4
|
+
Goes beyond aggregate metrics to answer "where and why does this model
|
|
5
|
+
fail?" Clusters failure cases, identifies systematic failure modes, and
|
|
6
|
+
suggests targeted fixes as auto-queued hypotheses.
|
|
7
|
+
|
|
8
|
+
For classification: confusion matrix, most-confused pairs, per-class P/R.
|
|
9
|
+
For regression: high-residual analysis, feature-range bias detection.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python scripts/diagnose_errors.py # Best experiment
|
|
13
|
+
python scripts/diagnose_errors.py --exp-id exp-042 # Specific experiment
|
|
14
|
+
python scripts/diagnose_errors.py --auto-queue # Queue fix hypotheses
|
|
15
|
+
python scripts/diagnose_errors.py --top 5 # Top 5 failure modes
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import sys
|
|
24
|
+
from collections import Counter, defaultdict
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
from scripts.turing_io import load_config, load_experiments
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_experiment(experiments: list[dict], exp_id: str | None, metric: str, lower_is_better: bool) -> dict | None:
|
|
35
|
+
"""Find experiment by ID or return best kept experiment."""
|
|
36
|
+
if exp_id:
|
|
37
|
+
for exp in experiments:
|
|
38
|
+
if exp.get("experiment_id") == exp_id:
|
|
39
|
+
return exp
|
|
40
|
+
return None
|
|
41
|
+
best = None
|
|
42
|
+
best_val = float("inf") if lower_is_better else float("-inf")
|
|
43
|
+
for exp in experiments:
|
|
44
|
+
if exp.get("status") != "kept":
|
|
45
|
+
continue
|
|
46
|
+
val = exp.get("metrics", {}).get(metric)
|
|
47
|
+
if val is None:
|
|
48
|
+
continue
|
|
49
|
+
if (lower_is_better and val < best_val) or (not lower_is_better and val > best_val):
|
|
50
|
+
best_val = val
|
|
51
|
+
best = exp
|
|
52
|
+
return best
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compute_confusion_matrix(y_true: list, y_pred: list) -> dict:
|
|
56
|
+
"""Compute confusion matrix and derived metrics for classification.
|
|
57
|
+
|
|
58
|
+
Returns dict with matrix, classes, per_class metrics, and most_confused pairs.
|
|
59
|
+
"""
|
|
60
|
+
classes = sorted(set(y_true) | set(y_pred))
|
|
61
|
+
class_to_idx = {c: i for i, c in enumerate(classes)}
|
|
62
|
+
n = len(classes)
|
|
63
|
+
|
|
64
|
+
matrix = [[0] * n for _ in range(n)]
|
|
65
|
+
for true, pred in zip(y_true, y_pred):
|
|
66
|
+
matrix[class_to_idx[true]][class_to_idx[pred]] += 1
|
|
67
|
+
|
|
68
|
+
# Per-class precision, recall, F1
|
|
69
|
+
per_class = {}
|
|
70
|
+
for i, cls in enumerate(classes):
|
|
71
|
+
tp = matrix[i][i]
|
|
72
|
+
fp = sum(matrix[j][i] for j in range(n)) - tp
|
|
73
|
+
fn = sum(matrix[i][j] for j in range(n)) - tp
|
|
74
|
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
|
75
|
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
|
76
|
+
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
|
|
77
|
+
support = sum(matrix[i])
|
|
78
|
+
per_class[str(cls)] = {
|
|
79
|
+
"precision": round(precision, 4),
|
|
80
|
+
"recall": round(recall, 4),
|
|
81
|
+
"f1": round(f1, 4),
|
|
82
|
+
"support": support,
|
|
83
|
+
"tp": tp,
|
|
84
|
+
"fp": fp,
|
|
85
|
+
"fn": fn,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Most confused pairs (off-diagonal entries sorted by count)
|
|
89
|
+
confused_pairs = []
|
|
90
|
+
for i in range(n):
|
|
91
|
+
for j in range(n):
|
|
92
|
+
if i != j and matrix[i][j] > 0:
|
|
93
|
+
confused_pairs.append({
|
|
94
|
+
"true_class": str(classes[i]),
|
|
95
|
+
"predicted_class": str(classes[j]),
|
|
96
|
+
"count": matrix[i][j],
|
|
97
|
+
"pct_of_true": round(matrix[i][j] / max(sum(matrix[i]), 1) * 100, 1),
|
|
98
|
+
})
|
|
99
|
+
confused_pairs.sort(key=lambda x: -x["count"])
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"classes": [str(c) for c in classes],
|
|
103
|
+
"matrix": matrix,
|
|
104
|
+
"per_class": per_class,
|
|
105
|
+
"most_confused": confused_pairs[:10],
|
|
106
|
+
"total_errors": sum(1 for t, p in zip(y_true, y_pred) if t != p),
|
|
107
|
+
"total_samples": len(y_true),
|
|
108
|
+
"error_rate": round(sum(1 for t, p in zip(y_true, y_pred) if t != p) / max(len(y_true), 1), 4),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def analyze_regression_errors(
|
|
113
|
+
y_true: list[float],
|
|
114
|
+
y_pred: list[float],
|
|
115
|
+
features: list[dict] | None = None,
|
|
116
|
+
top_n: int = 5,
|
|
117
|
+
) -> dict:
|
|
118
|
+
"""Analyze regression errors: high-residual samples, feature-range bias.
|
|
119
|
+
|
|
120
|
+
Returns dict with residual stats, worst predictions, and feature-range analysis.
|
|
121
|
+
"""
|
|
122
|
+
residuals = [abs(t - p) for t, p in zip(y_true, y_pred)]
|
|
123
|
+
signed_residuals = [p - t for t, p in zip(y_true, y_pred)]
|
|
124
|
+
|
|
125
|
+
arr = np.array(residuals)
|
|
126
|
+
mean_error = float(np.mean(arr))
|
|
127
|
+
median_error = float(np.median(arr))
|
|
128
|
+
std_error = float(np.std(arr))
|
|
129
|
+
p90 = float(np.percentile(arr, 90))
|
|
130
|
+
p95 = float(np.percentile(arr, 95))
|
|
131
|
+
|
|
132
|
+
# Worst predictions
|
|
133
|
+
indexed = list(enumerate(residuals))
|
|
134
|
+
indexed.sort(key=lambda x: -x[1])
|
|
135
|
+
worst = []
|
|
136
|
+
for idx, res in indexed[:top_n]:
|
|
137
|
+
entry = {
|
|
138
|
+
"index": idx,
|
|
139
|
+
"true": round(y_true[idx], 4),
|
|
140
|
+
"predicted": round(y_pred[idx], 4),
|
|
141
|
+
"residual": round(res, 4),
|
|
142
|
+
}
|
|
143
|
+
if features and idx < len(features):
|
|
144
|
+
entry["features"] = features[idx]
|
|
145
|
+
worst.append(entry)
|
|
146
|
+
|
|
147
|
+
result = {
|
|
148
|
+
"mean_absolute_error": round(mean_error, 4),
|
|
149
|
+
"median_absolute_error": round(median_error, 4),
|
|
150
|
+
"std_error": round(std_error, 4),
|
|
151
|
+
"p90_error": round(p90, 4),
|
|
152
|
+
"p95_error": round(p95, 4),
|
|
153
|
+
"worst_predictions": worst,
|
|
154
|
+
"bias": round(float(np.mean(signed_residuals)), 4),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Feature-range analysis (if features provided)
|
|
158
|
+
if features and len(features) > 0:
|
|
159
|
+
feature_analysis = analyze_feature_ranges(y_true, y_pred, residuals, features)
|
|
160
|
+
result["feature_range_bias"] = feature_analysis
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def analyze_feature_ranges(
|
|
166
|
+
y_true: list[float],
|
|
167
|
+
y_pred: list[float],
|
|
168
|
+
residuals: list[float],
|
|
169
|
+
features: list[dict],
|
|
170
|
+
) -> list[dict]:
|
|
171
|
+
"""Find feature ranges where the model performs systematically worse.
|
|
172
|
+
|
|
173
|
+
For each numeric feature, split samples into quartiles and compare
|
|
174
|
+
error rates across quartiles.
|
|
175
|
+
"""
|
|
176
|
+
if not features:
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
# Collect numeric features
|
|
180
|
+
numeric_keys = set()
|
|
181
|
+
for f in features:
|
|
182
|
+
for k, v in f.items():
|
|
183
|
+
if isinstance(v, (int, float)) and not math.isnan(v):
|
|
184
|
+
numeric_keys.add(k)
|
|
185
|
+
|
|
186
|
+
results = []
|
|
187
|
+
for key in sorted(numeric_keys):
|
|
188
|
+
values = []
|
|
189
|
+
errors = []
|
|
190
|
+
for i, f in enumerate(features):
|
|
191
|
+
if key in f and isinstance(f[key], (int, float)) and i < len(residuals):
|
|
192
|
+
values.append(f[key])
|
|
193
|
+
errors.append(residuals[i])
|
|
194
|
+
|
|
195
|
+
if len(values) < 8:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
arr_v = np.array(values)
|
|
199
|
+
arr_e = np.array(errors)
|
|
200
|
+
quartiles = np.percentile(arr_v, [25, 50, 75])
|
|
201
|
+
|
|
202
|
+
bins = [
|
|
203
|
+
("Q1", arr_v <= quartiles[0]),
|
|
204
|
+
("Q2", (arr_v > quartiles[0]) & (arr_v <= quartiles[1])),
|
|
205
|
+
("Q3", (arr_v > quartiles[1]) & (arr_v <= quartiles[2])),
|
|
206
|
+
("Q4", arr_v > quartiles[2]),
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
bin_errors = {}
|
|
210
|
+
for name, mask in bins:
|
|
211
|
+
if mask.sum() > 0:
|
|
212
|
+
bin_errors[name] = round(float(np.mean(arr_e[mask])), 4)
|
|
213
|
+
|
|
214
|
+
if not bin_errors:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
max_error_bin = max(bin_errors, key=bin_errors.get)
|
|
218
|
+
min_error_bin = min(bin_errors, key=bin_errors.get)
|
|
219
|
+
ratio = bin_errors[max_error_bin] / max(bin_errors[min_error_bin], 1e-8)
|
|
220
|
+
|
|
221
|
+
if ratio > 2.0:
|
|
222
|
+
results.append({
|
|
223
|
+
"feature": key,
|
|
224
|
+
"worst_quartile": max_error_bin,
|
|
225
|
+
"best_quartile": min_error_bin,
|
|
226
|
+
"error_ratio": round(ratio, 2),
|
|
227
|
+
"bin_errors": bin_errors,
|
|
228
|
+
"description": f"Model error is {ratio:.1f}x higher in {max_error_bin} ({key}) vs {min_error_bin}",
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
results.sort(key=lambda x: -x["error_ratio"])
|
|
232
|
+
return results
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def identify_failure_modes(
|
|
236
|
+
confusion_data: dict | None = None,
|
|
237
|
+
regression_data: dict | None = None,
|
|
238
|
+
top_n: int = 5,
|
|
239
|
+
) -> list[dict]:
|
|
240
|
+
"""Extract actionable failure modes from analysis results.
|
|
241
|
+
|
|
242
|
+
Returns a list of failure mode dicts with id, description, affected_samples,
|
|
243
|
+
suggested_fix, and auto_hypothesis.
|
|
244
|
+
"""
|
|
245
|
+
modes = []
|
|
246
|
+
fm_id = 1
|
|
247
|
+
|
|
248
|
+
if confusion_data:
|
|
249
|
+
# Failure mode from confused pairs
|
|
250
|
+
for pair in confusion_data.get("most_confused", [])[:top_n]:
|
|
251
|
+
tc = pair["true_class"]
|
|
252
|
+
pc = pair["predicted_class"]
|
|
253
|
+
count = pair["count"]
|
|
254
|
+
pct = pair["pct_of_true"]
|
|
255
|
+
total_errors = confusion_data.get("total_errors", 1)
|
|
256
|
+
error_pct = round(count / max(total_errors, 1) * 100, 1)
|
|
257
|
+
|
|
258
|
+
modes.append({
|
|
259
|
+
"id": f"fm-{fm_id:03d}",
|
|
260
|
+
"type": "class_confusion",
|
|
261
|
+
"description": f"Model confuses '{tc}' and '{pc}' — {count} errors ({error_pct}% of all errors)",
|
|
262
|
+
"affected_samples": count,
|
|
263
|
+
"pct_of_errors": error_pct,
|
|
264
|
+
"suggested_fix": f"Add distinguishing features for '{tc}' vs '{pc}', or increase training data for these classes",
|
|
265
|
+
"auto_hypothesis": f"Add targeted features to distinguish class '{tc}' from '{pc}'",
|
|
266
|
+
})
|
|
267
|
+
fm_id += 1
|
|
268
|
+
|
|
269
|
+
# Failure mode from low-recall classes
|
|
270
|
+
for cls, stats in confusion_data.get("per_class", {}).items():
|
|
271
|
+
if stats["recall"] < 0.5 and stats["support"] >= 5:
|
|
272
|
+
modes.append({
|
|
273
|
+
"id": f"fm-{fm_id:03d}",
|
|
274
|
+
"type": "low_recall",
|
|
275
|
+
"description": f"Class '{cls}' has recall={stats['recall']:.2f} — model misses {stats['fn']} of {stats['support']} samples",
|
|
276
|
+
"affected_samples": stats["fn"],
|
|
277
|
+
"pct_of_errors": round(stats["fn"] / max(confusion_data.get("total_errors", 1), 1) * 100, 1),
|
|
278
|
+
"suggested_fix": f"Oversample class '{cls}' or add class-specific features",
|
|
279
|
+
"auto_hypothesis": f"Apply SMOTE or class weights to improve recall on class '{cls}'",
|
|
280
|
+
})
|
|
281
|
+
fm_id += 1
|
|
282
|
+
|
|
283
|
+
if regression_data:
|
|
284
|
+
# Failure mode from feature-range bias
|
|
285
|
+
for fb in regression_data.get("feature_range_bias", [])[:top_n]:
|
|
286
|
+
modes.append({
|
|
287
|
+
"id": f"fm-{fm_id:03d}",
|
|
288
|
+
"type": "feature_range_bias",
|
|
289
|
+
"description": f"High error when {fb['feature']} in {fb['worst_quartile']} — {fb['error_ratio']:.1f}x worse than {fb['best_quartile']}",
|
|
290
|
+
"affected_samples": 0,
|
|
291
|
+
"suggested_fix": f"Add {fb['feature']} binning or cap outliers in {fb['worst_quartile']} range",
|
|
292
|
+
"auto_hypothesis": f"Bin {fb['feature']} into quantiles instead of raw values",
|
|
293
|
+
})
|
|
294
|
+
fm_id += 1
|
|
295
|
+
|
|
296
|
+
# Failure mode from systematic bias
|
|
297
|
+
bias = regression_data.get("bias", 0)
|
|
298
|
+
if abs(bias) > regression_data.get("std_error", 0) * 0.5:
|
|
299
|
+
direction = "over-predicts" if bias > 0 else "under-predicts"
|
|
300
|
+
modes.append({
|
|
301
|
+
"id": f"fm-{fm_id:03d}",
|
|
302
|
+
"type": "systematic_bias",
|
|
303
|
+
"description": f"Model systematically {direction} by {abs(bias):.4f} on average",
|
|
304
|
+
"affected_samples": regression_data.get("worst_predictions", [{}])[0].get("index", 0),
|
|
305
|
+
"suggested_fix": f"Add bias correction or investigate data distribution skew",
|
|
306
|
+
"auto_hypothesis": f"Add target variable transformation to correct {direction} bias",
|
|
307
|
+
})
|
|
308
|
+
fm_id += 1
|
|
309
|
+
|
|
310
|
+
# Sort by affected_samples (most impactful first)
|
|
311
|
+
modes.sort(key=lambda m: -m.get("affected_samples", 0))
|
|
312
|
+
return modes[:top_n]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def generate_hypotheses_from_modes(failure_modes: list[dict]) -> list[dict]:
|
|
316
|
+
"""Convert failure modes into hypothesis queue entries.
|
|
317
|
+
|
|
318
|
+
Returns list of hypothesis dicts ready for hypotheses.yaml.
|
|
319
|
+
"""
|
|
320
|
+
hypotheses = []
|
|
321
|
+
for mode in failure_modes:
|
|
322
|
+
if not mode.get("auto_hypothesis"):
|
|
323
|
+
continue
|
|
324
|
+
hypotheses.append({
|
|
325
|
+
"id": f"hyp-diag-{mode['id'].split('-')[-1]}",
|
|
326
|
+
"description": mode["auto_hypothesis"],
|
|
327
|
+
"source": "diagnose",
|
|
328
|
+
"status": "queued",
|
|
329
|
+
"priority": "high" if mode.get("affected_samples", 0) > 10 else "normal",
|
|
330
|
+
"rationale": mode["description"],
|
|
331
|
+
"failure_mode_id": mode["id"],
|
|
332
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
333
|
+
})
|
|
334
|
+
return hypotheses
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def save_diagnosis(diagnosis: dict, output_dir: str = "experiments/diagnoses") -> Path:
|
|
338
|
+
"""Save diagnosis report to YAML file."""
|
|
339
|
+
out_path = Path(output_dir)
|
|
340
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
exp_id = diagnosis.get("experiment_id", "unknown")
|
|
342
|
+
filepath = out_path / f"{exp_id}-diagnosis.yaml"
|
|
343
|
+
with open(filepath, "w") as f:
|
|
344
|
+
yaml.dump(diagnosis, f, default_flow_style=False, sort_keys=False)
|
|
345
|
+
return filepath
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def format_diagnosis_report(diagnosis: dict) -> str:
|
|
349
|
+
"""Format diagnosis as human-readable markdown report."""
|
|
350
|
+
if "error" in diagnosis:
|
|
351
|
+
return f"ERROR: {diagnosis['error']}"
|
|
352
|
+
|
|
353
|
+
exp_id = diagnosis["experiment_id"]
|
|
354
|
+
task_type = diagnosis.get("task_type", "unknown")
|
|
355
|
+
|
|
356
|
+
lines = [
|
|
357
|
+
f"# Error Analysis: {exp_id}",
|
|
358
|
+
"",
|
|
359
|
+
f"*Task type: {task_type}*",
|
|
360
|
+
"",
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
# Classification-specific
|
|
364
|
+
confusion = diagnosis.get("confusion_matrix")
|
|
365
|
+
if confusion:
|
|
366
|
+
lines.extend([
|
|
367
|
+
"## Error Summary",
|
|
368
|
+
"",
|
|
369
|
+
f"- **Total samples:** {confusion['total_samples']}",
|
|
370
|
+
f"- **Total errors:** {confusion['total_errors']}",
|
|
371
|
+
f"- **Error rate:** {confusion['error_rate']:.2%}",
|
|
372
|
+
"",
|
|
373
|
+
"## Per-Class Performance",
|
|
374
|
+
"",
|
|
375
|
+
"| Class | Precision | Recall | F1 | Support |",
|
|
376
|
+
"|-------|-----------|--------|-----|---------|",
|
|
377
|
+
])
|
|
378
|
+
for cls, stats in confusion.get("per_class", {}).items():
|
|
379
|
+
lines.append(
|
|
380
|
+
f"| {cls} | {stats['precision']:.3f} | {stats['recall']:.3f} "
|
|
381
|
+
f"| {stats['f1']:.3f} | {stats['support']} |"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if confusion.get("most_confused"):
|
|
385
|
+
lines.extend([
|
|
386
|
+
"",
|
|
387
|
+
"## Most Confused Pairs",
|
|
388
|
+
"",
|
|
389
|
+
"| True | Predicted | Count | % of True Class |",
|
|
390
|
+
"|------|-----------|-------|-----------------|",
|
|
391
|
+
])
|
|
392
|
+
for pair in confusion["most_confused"][:5]:
|
|
393
|
+
lines.append(
|
|
394
|
+
f"| {pair['true_class']} | {pair['predicted_class']} "
|
|
395
|
+
f"| {pair['count']} | {pair['pct_of_true']}% |"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Regression-specific
|
|
399
|
+
regression = diagnosis.get("regression_analysis")
|
|
400
|
+
if regression:
|
|
401
|
+
lines.extend([
|
|
402
|
+
"## Error Distribution",
|
|
403
|
+
"",
|
|
404
|
+
f"- **Mean absolute error:** {regression['mean_absolute_error']:.4f}",
|
|
405
|
+
f"- **Median absolute error:** {regression['median_absolute_error']:.4f}",
|
|
406
|
+
f"- **P90 error:** {regression['p90_error']:.4f}",
|
|
407
|
+
f"- **P95 error:** {regression['p95_error']:.4f}",
|
|
408
|
+
f"- **Systematic bias:** {regression['bias']:.4f}",
|
|
409
|
+
])
|
|
410
|
+
|
|
411
|
+
if regression.get("feature_range_bias"):
|
|
412
|
+
lines.extend(["", "## Feature-Range Bias", ""])
|
|
413
|
+
for fb in regression["feature_range_bias"]:
|
|
414
|
+
lines.append(f"- **{fb['feature']}:** {fb['description']}")
|
|
415
|
+
|
|
416
|
+
# Failure modes
|
|
417
|
+
modes = diagnosis.get("failure_modes", [])
|
|
418
|
+
if modes:
|
|
419
|
+
lines.extend([
|
|
420
|
+
"",
|
|
421
|
+
"## Failure Modes",
|
|
422
|
+
"",
|
|
423
|
+
])
|
|
424
|
+
for mode in modes:
|
|
425
|
+
lines.append(f"### {mode['id']}: {mode['type']}")
|
|
426
|
+
lines.append("")
|
|
427
|
+
lines.append(f"- **Description:** {mode['description']}")
|
|
428
|
+
lines.append(f"- **Affected samples:** {mode['affected_samples']}")
|
|
429
|
+
lines.append(f"- **Suggested fix:** {mode['suggested_fix']}")
|
|
430
|
+
if mode.get("auto_hypothesis"):
|
|
431
|
+
lines.append(f"- **Auto-hypothesis:** {mode['auto_hypothesis']}")
|
|
432
|
+
lines.append("")
|
|
433
|
+
|
|
434
|
+
# Auto-queued hypotheses
|
|
435
|
+
hypotheses = diagnosis.get("auto_hypotheses", [])
|
|
436
|
+
if hypotheses:
|
|
437
|
+
lines.extend([
|
|
438
|
+
"## Auto-Queued Hypotheses",
|
|
439
|
+
"",
|
|
440
|
+
])
|
|
441
|
+
for hyp in hypotheses:
|
|
442
|
+
priority = f" **(HIGH)**" if hyp.get("priority") == "high" else ""
|
|
443
|
+
lines.append(f"- {hyp['id']}: {hyp['description']}{priority}")
|
|
444
|
+
|
|
445
|
+
return "\n".join(lines)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def queue_hypotheses(hypotheses: list[dict], queue_path: str = "hypotheses.yaml") -> int:
|
|
449
|
+
"""Append hypotheses to the hypothesis queue file.
|
|
450
|
+
|
|
451
|
+
Returns number of hypotheses added.
|
|
452
|
+
"""
|
|
453
|
+
path = Path(queue_path)
|
|
454
|
+
existing = []
|
|
455
|
+
if path.exists() and path.stat().st_size > 0:
|
|
456
|
+
with open(path) as f:
|
|
457
|
+
data = yaml.safe_load(f)
|
|
458
|
+
if isinstance(data, list):
|
|
459
|
+
existing = data
|
|
460
|
+
|
|
461
|
+
# Avoid duplicate IDs
|
|
462
|
+
existing_ids = {h.get("id") for h in existing}
|
|
463
|
+
new = [h for h in hypotheses if h["id"] not in existing_ids]
|
|
464
|
+
|
|
465
|
+
if new:
|
|
466
|
+
existing.extend(new)
|
|
467
|
+
with open(path, "w") as f:
|
|
468
|
+
yaml.dump(existing, f, default_flow_style=False, sort_keys=False)
|
|
469
|
+
|
|
470
|
+
return len(new)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def diagnose_experiment(
|
|
474
|
+
exp_id: str | None = None,
|
|
475
|
+
config_path: str = "config.yaml",
|
|
476
|
+
log_path: str = "experiments/log.jsonl",
|
|
477
|
+
predictions_path: str | None = None,
|
|
478
|
+
auto_queue: bool = False,
|
|
479
|
+
top_n: int = 5,
|
|
480
|
+
) -> dict:
|
|
481
|
+
"""Run error analysis on an experiment.
|
|
482
|
+
|
|
483
|
+
This function operates on pre-computed predictions. If no predictions
|
|
484
|
+
file is provided, it looks for experiments/predictions/exp-NNN-preds.yaml.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
exp_id: Experiment ID (defaults to best experiment).
|
|
488
|
+
config_path: Path to config.yaml.
|
|
489
|
+
log_path: Path to experiment log.
|
|
490
|
+
predictions_path: Path to predictions YAML file.
|
|
491
|
+
auto_queue: Whether to auto-queue hypotheses.
|
|
492
|
+
top_n: Number of failure modes to report.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Diagnosis dict with analysis results and failure modes.
|
|
496
|
+
"""
|
|
497
|
+
config = load_config(config_path)
|
|
498
|
+
eval_cfg = config.get("evaluation", {})
|
|
499
|
+
primary_metric = eval_cfg.get("primary_metric", "accuracy")
|
|
500
|
+
lower_is_better = eval_cfg.get("lower_is_better", False)
|
|
501
|
+
|
|
502
|
+
experiments = load_experiments(log_path)
|
|
503
|
+
target_exp = find_experiment(experiments, exp_id, primary_metric, lower_is_better)
|
|
504
|
+
|
|
505
|
+
if not target_exp:
|
|
506
|
+
return {"error": f"No experiment found{f' with ID {exp_id}' if exp_id else ''}", "experiment_id": exp_id}
|
|
507
|
+
|
|
508
|
+
target_id = target_exp.get("experiment_id", "unknown")
|
|
509
|
+
|
|
510
|
+
# Load predictions
|
|
511
|
+
if not predictions_path:
|
|
512
|
+
predictions_path = f"experiments/predictions/{target_id}-preds.yaml"
|
|
513
|
+
|
|
514
|
+
preds_file = Path(predictions_path)
|
|
515
|
+
if not preds_file.exists():
|
|
516
|
+
return {
|
|
517
|
+
"error": f"Predictions file not found: {predictions_path}. Run the model on the validation set first.",
|
|
518
|
+
"experiment_id": target_id,
|
|
519
|
+
"hint": "Generate predictions with: python train.py --predict-only --output experiments/predictions/",
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
with open(preds_file) as f:
|
|
523
|
+
preds_data = yaml.safe_load(f) or {}
|
|
524
|
+
|
|
525
|
+
y_true = preds_data.get("y_true", [])
|
|
526
|
+
y_pred = preds_data.get("y_pred", [])
|
|
527
|
+
features = preds_data.get("features", None)
|
|
528
|
+
task_type = preds_data.get("task_type", "classification")
|
|
529
|
+
|
|
530
|
+
if not y_true or not y_pred:
|
|
531
|
+
return {"error": "Predictions file has no y_true/y_pred data", "experiment_id": target_id}
|
|
532
|
+
|
|
533
|
+
diagnosis = {
|
|
534
|
+
"experiment_id": target_id,
|
|
535
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
536
|
+
"task_type": task_type,
|
|
537
|
+
"n_samples": len(y_true),
|
|
538
|
+
"primary_metric": primary_metric,
|
|
539
|
+
"original_metrics": target_exp.get("metrics", {}),
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if task_type == "classification":
|
|
543
|
+
confusion = compute_confusion_matrix(y_true, y_pred)
|
|
544
|
+
diagnosis["confusion_matrix"] = confusion
|
|
545
|
+
failure_modes = identify_failure_modes(confusion_data=confusion, top_n=top_n)
|
|
546
|
+
elif task_type == "regression":
|
|
547
|
+
y_true_f = [float(v) for v in y_true]
|
|
548
|
+
y_pred_f = [float(v) for v in y_pred]
|
|
549
|
+
regression = analyze_regression_errors(y_true_f, y_pred_f, features, top_n=top_n)
|
|
550
|
+
diagnosis["regression_analysis"] = regression
|
|
551
|
+
failure_modes = identify_failure_modes(regression_data=regression, top_n=top_n)
|
|
552
|
+
else:
|
|
553
|
+
failure_modes = []
|
|
554
|
+
|
|
555
|
+
diagnosis["failure_modes"] = failure_modes
|
|
556
|
+
|
|
557
|
+
# Generate and optionally queue hypotheses
|
|
558
|
+
hypotheses = generate_hypotheses_from_modes(failure_modes)
|
|
559
|
+
diagnosis["auto_hypotheses"] = hypotheses
|
|
560
|
+
|
|
561
|
+
if auto_queue and hypotheses:
|
|
562
|
+
n_added = queue_hypotheses(hypotheses)
|
|
563
|
+
diagnosis["hypotheses_queued"] = n_added
|
|
564
|
+
print(f"Queued {n_added} hypotheses from failure modes", file=sys.stderr)
|
|
565
|
+
|
|
566
|
+
return diagnosis
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def main() -> None:
|
|
570
|
+
"""CLI entry point."""
|
|
571
|
+
parser = argparse.ArgumentParser(description="Error analysis for ML experiments")
|
|
572
|
+
parser.add_argument("--exp-id", default=None, help="Experiment ID (defaults to best)")
|
|
573
|
+
parser.add_argument("--config", default="config.yaml", help="Path to config.yaml")
|
|
574
|
+
parser.add_argument("--log", default="experiments/log.jsonl", help="Path to experiment log")
|
|
575
|
+
parser.add_argument("--predictions", default=None, help="Path to predictions YAML file")
|
|
576
|
+
parser.add_argument("--auto-queue", action="store_true", help="Auto-queue hypotheses from failure modes")
|
|
577
|
+
parser.add_argument("--top", type=int, default=5, help="Number of failure modes to report")
|
|
578
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
579
|
+
args = parser.parse_args()
|
|
580
|
+
|
|
581
|
+
diagnosis = diagnose_experiment(
|
|
582
|
+
exp_id=args.exp_id,
|
|
583
|
+
config_path=args.config,
|
|
584
|
+
log_path=args.log,
|
|
585
|
+
predictions_path=args.predictions,
|
|
586
|
+
auto_queue=args.auto_queue,
|
|
587
|
+
top_n=args.top,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
if "error" not in diagnosis:
|
|
591
|
+
filepath = save_diagnosis(diagnosis)
|
|
592
|
+
print(f"Saved to {filepath}", file=sys.stderr)
|
|
593
|
+
|
|
594
|
+
if args.json:
|
|
595
|
+
print(json.dumps(diagnosis, indent=2, default=str))
|
|
596
|
+
else:
|
|
597
|
+
print(format_diagnosis_report(diagnosis))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
if __name__ == "__main__":
|
|
601
|
+
main()
|