ddl-subir-m commited on
Commit
7d94ffc
·
1 Parent(s): ad1499b

Add fast single-counterfactual test runner

Browse files

- run_test.sh / run_single_counterfactual.py: run one counterfactual
test on an existing run dir without the full diagnostic pipeline.
Reloads model, dataset, and scene from saved state.
- Supports --param overrides, --viz-only for chart regeneration, --list
- Save baseline/modified actions in task_string_swap metrics for
offline visualization regeneration

run_single_counterfactual.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run a single counterfactual test on an existing run directory.
3
+
4
+ Loads model, dataset, and scene data from a previous run, then executes
5
+ one counterfactual test and saves the result + comparison image.
6
+ Much faster than re-running the full diagnostic pipeline.
7
+
8
+ Usage:
9
+ python run_single_counterfactual.py <run_dir> <test_name> [--param key=value ...]
10
+ python run_single_counterfactual.py --list
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ import time
20
+
21
+
22
+ def main():
23
+ parser = argparse.ArgumentParser(
24
+ description="Run a single counterfactual test on an existing run directory",
25
+ formatter_class=argparse.RawDescriptionHelpFormatter,
26
+ epilog="""
27
+ Examples:
28
+ %(prog)s outputs/mthirumalai/finetuned_model background_substitution
29
+ %(prog)s outputs/mthirumalai/finetuned_model distractor_insertion --param position='[200,200]'
30
+ %(prog)s outputs/mthirumalai/finetuned_model task_string_swap --viz-only
31
+ %(prog)s --list
32
+ """,
33
+ )
34
+ parser.add_argument("run_dir", nargs="?", help="Path to existing run directory")
35
+ parser.add_argument("test_name", nargs="?", help="Counterfactual test name")
36
+ parser.add_argument("--list", action="store_true", help="List available tests")
37
+ parser.add_argument("--param", action="append", default=[],
38
+ help="Test parameter as key=value (repeatable)")
39
+ parser.add_argument("--viz-only", action="store_true",
40
+ help="Only regenerate visualization from existing result.json")
41
+ parser.add_argument("--device", default="auto", help="Device (cuda/cpu/auto)")
42
+ parser.add_argument("--episode", type=int, default=None,
43
+ help="Episode index (default: from manifest)")
44
+
45
+ args = parser.parse_args()
46
+
47
+ # Import here so --list/--help are fast
48
+ _ensure_imports()
49
+
50
+ if args.list:
51
+ _list_tests()
52
+ return
53
+
54
+ if not args.run_dir or not args.test_name:
55
+ parser.error("run_dir and test_name are required (or use --list)")
56
+
57
+ run_dir = args.run_dir
58
+ test_name = args.test_name
59
+
60
+ # Validate run directory
61
+ if not os.path.isdir(run_dir):
62
+ print(f"ERROR: Run directory not found: {run_dir}")
63
+ sys.exit(1)
64
+
65
+ manifest_path = os.path.join(run_dir, "run_manifest.json")
66
+ if not os.path.exists(manifest_path):
67
+ print(f"ERROR: No run_manifest.json in {run_dir}")
68
+ sys.exit(1)
69
+
70
+ # Parse test params
71
+ test_params = _parse_params(args.param)
72
+
73
+ if args.viz_only:
74
+ _regenerate_viz(run_dir, test_name)
75
+ return
76
+
77
+ # Load manifest for model/dataset info
78
+ with open(manifest_path) as f:
79
+ manifest = json.load(f)
80
+
81
+ cli_args = manifest.get("cli_args", {})
82
+ model_id = cli_args.get("model")
83
+ dataset_id = manifest.get("dataset_info", {}).get("dataset_id") or cli_args.get("dataset")
84
+ image_key = cli_args.get("image_key")
85
+ image_map_str = cli_args.get("image_map")
86
+ episode_idx = args.episode if args.episode is not None else cli_args.get("episode", 0)
87
+
88
+ if not model_id:
89
+ print("ERROR: Cannot determine model from manifest. Specify --model?")
90
+ sys.exit(1)
91
+ if not dataset_id:
92
+ print("ERROR: Cannot determine dataset from manifest.")
93
+ sys.exit(1)
94
+
95
+ # Device
96
+ import torch
97
+ device = args.device
98
+ if device == "auto":
99
+ if torch.cuda.is_available():
100
+ device = "cuda"
101
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
102
+ device = "mps"
103
+ else:
104
+ device = "cpu"
105
+
106
+ print(f"\n{'=' * 50}")
107
+ print(f" Quick Counterfactual Test")
108
+ print(f"{'=' * 50}")
109
+ print(f" Run dir: {run_dir}")
110
+ print(f" Test: {test_name}")
111
+ print(f" Model: {model_id}")
112
+ print(f" Dataset: {dataset_id}")
113
+ print(f" Device: {device}")
114
+ if test_params:
115
+ print(f" Params: {test_params}")
116
+ print(f"{'=' * 50}\n")
117
+
118
+ # ── Load model ──
119
+ t0 = time.time()
120
+ print(" Loading model...", end="", flush=True)
121
+ from lerobot.policies.smolvla.modeling_smolvla import SmolVLAPolicy
122
+ policy = SmolVLAPolicy.from_pretrained(model_id)
123
+ policy.to(device)
124
+ policy.eval()
125
+ print(f" done ({time.time() - t0:.1f}s)")
126
+
127
+ # ── Load dataset ──
128
+ t0 = time.time()
129
+ print(" Loading dataset...", end="", flush=True)
130
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
131
+ dataset = LeRobotDataset(dataset_id)
132
+ print(f" done ({time.time() - t0:.1f}s)")
133
+
134
+ # Resolve image key
135
+ if image_key is None:
136
+ from smolvla_inspect.data import find_image_keys
137
+ image_keys = find_image_keys(dataset)
138
+ image_key = image_keys[0] if image_keys else "observation.images.top"
139
+
140
+ # Parse image map
141
+ image_map = None
142
+ if image_map_str:
143
+ from smolvla_inspect.data import parse_image_map
144
+ image_map = parse_image_map(image_map_str)
145
+
146
+ # ── Load scene data ──
147
+ print(" Loading scene data...", end="", flush=True)
148
+ scene = _load_scene(run_dir)
149
+ if scene is None:
150
+ print("\n WARNING: No scene data found — running scene detection...")
151
+ scene = _detect_scene(dataset, episode_idx, image_key, device)
152
+ else:
153
+ print(" done")
154
+
155
+ # ── Get sample ──
156
+ print(" Loading sample...", end="", flush=True)
157
+ first_frame_idx = _get_first_frame_idx(dataset, episode_idx)
158
+ sample = dataset[first_frame_idx]
159
+ print(f" done (frame {first_frame_idx})")
160
+
161
+ # ── Validate test name ──
162
+ from smolvla_inspect.diagnostic.registry import REGISTRY
163
+ primitive_name = f"counterfactual.{test_name}"
164
+ if primitive_name not in REGISTRY:
165
+ print(f"\n ERROR: Unknown test '{test_name}'")
166
+ print(f" Available: {', '.join(n.removeprefix('counterfactual.') for n in REGISTRY if n.startswith('counterfactual.'))}")
167
+ sys.exit(1)
168
+
169
+ # ── Apply default params if not provided ──
170
+ test_params = _apply_defaults(test_name, test_params, scene)
171
+
172
+ # ── Run the test ──
173
+ spec = REGISTRY[primitive_name]
174
+ params = dict(test_params)
175
+ params.update({
176
+ "policy": policy,
177
+ "sample": sample,
178
+ "dataset": dataset,
179
+ "image_key": image_key,
180
+ "device": device,
181
+ "image_map": image_map,
182
+ })
183
+ if "segmentation" in spec.fn.__code__.co_varnames:
184
+ params["segmentation"] = scene
185
+ if "episode_idx" in spec.fn.__code__.co_varnames:
186
+ params["episode_idx"] = episode_idx
187
+
188
+ print(f"\n Running {test_name}...", flush=True)
189
+ t0 = time.time()
190
+ result = spec.fn(**params)
191
+ elapsed = time.time() - t0
192
+
193
+ # ── Save result ──
194
+ import numpy as np
195
+
196
+ cf_dir = os.path.join(run_dir, "diagnostic", "counterfactuals", test_name)
197
+ os.makedirs(cf_dir, exist_ok=True)
198
+
199
+ # Save result.json
200
+ result_dict = {
201
+ "hypothesis_id": result.hypothesis_id,
202
+ "test_type": result.test_type,
203
+ "action_delta_l2": float(result.action_delta_l2),
204
+ "action_delta_per_dim": [float(x) for x in result.action_delta_per_dim],
205
+ "gradcam_shift": float(result.gradcam_shift) if result.gradcam_shift else 0.0,
206
+ "attribution_shift_per_region": result.attribution_shift_per_region or {},
207
+ "confirmed": result.confirmed,
208
+ "metrics": result.metrics or {},
209
+ }
210
+ result_path = os.path.join(cf_dir, "result.json")
211
+ with open(result_path, "w") as f:
212
+ json.dump(result_dict, f, indent=2)
213
+
214
+ # Save comparison image
215
+ if result.visual_comparison is not None:
216
+ from PIL import Image
217
+ comp_path = os.path.join(cf_dir, "comparison.png")
218
+ Image.fromarray(result.visual_comparison).save(comp_path)
219
+ print(f" Saved: {comp_path}")
220
+
221
+ print(f"\n Result ({elapsed:.1f}s):")
222
+ print(f" Action delta (L2): {result.action_delta_l2:.4f}")
223
+ print(f" Confirmed: {result.confirmed}")
224
+ print(f" Saved to: {result_path}")
225
+ print(f"{'=' * 50}\n")
226
+
227
+
228
+ def _ensure_imports():
229
+ """Check that the package is importable."""
230
+ try:
231
+ import smolvla_inspect # noqa: F401
232
+ except ImportError:
233
+ # Try adding the project root to sys.path
234
+ root = os.path.dirname(os.path.abspath(__file__))
235
+ sys.path.insert(0, root)
236
+
237
+
238
+ def _list_tests():
239
+ """Print available counterfactual tests."""
240
+ _ensure_imports()
241
+ # Force registry population by importing the counterfactual module
242
+ import smolvla_inspect.diagnostic.counterfactual # noqa: F401
243
+ from smolvla_inspect.diagnostic.registry import list_primitives
244
+
245
+ print("\nAvailable counterfactual tests:\n")
246
+ for spec in list_primitives(category="counterfactual"):
247
+ name = spec.name.removeprefix("counterfactual.")
248
+ print(f" {name}")
249
+ print(f" {spec.description}")
250
+ if spec.param_schema:
251
+ print(f" Params: {spec.param_schema}")
252
+ print()
253
+
254
+
255
+ def _parse_params(param_list: list[str]) -> dict:
256
+ """Parse --param key=value arguments into a dict."""
257
+ params = {}
258
+ for p in param_list:
259
+ if "=" not in p:
260
+ print(f"ERROR: Invalid param '{p}' — expected key=value")
261
+ sys.exit(1)
262
+ key, val = p.split("=", 1)
263
+ # Try JSON parsing for lists, numbers, bools
264
+ try:
265
+ params[key] = json.loads(val)
266
+ except (json.JSONDecodeError, ValueError):
267
+ params[key] = val
268
+ return params
269
+
270
+
271
+ def _load_scene(run_dir: str):
272
+ """Reconstruct SceneSegmentation from saved files."""
273
+ import numpy as np
274
+ from smolvla_inspect.diagnostic.models import SceneSegmentation, DetectedObject
275
+
276
+ scene_dir = os.path.join(run_dir, "diagnostic", "scene")
277
+ det_path = os.path.join(scene_dir, "detections.json")
278
+ seg_path = os.path.join(scene_dir, "segmentation.npz")
279
+
280
+ if not os.path.exists(det_path):
281
+ return None
282
+
283
+ with open(det_path) as f:
284
+ det_data = json.load(f)
285
+
286
+ seg_data = {}
287
+ if os.path.exists(seg_path):
288
+ seg_data = dict(np.load(seg_path))
289
+
290
+ objects = []
291
+ for det in det_data["objects"]:
292
+ mask_key = det["label"].replace(" ", "_")
293
+ mask = seg_data.get(mask_key)
294
+ if mask is not None:
295
+ mask = mask.astype(bool)
296
+ objects.append(DetectedObject(
297
+ label=det["label"],
298
+ box=tuple(det["box"]),
299
+ score=det["score"],
300
+ mask=mask,
301
+ ))
302
+
303
+ bg_mask = seg_data.get("background")
304
+ if bg_mask is not None:
305
+ bg_mask = bg_mask.astype(bool)
306
+
307
+ h, w = det_data["image_shape"]
308
+ return SceneSegmentation(
309
+ objects=objects,
310
+ background_mask=bg_mask,
311
+ image_shape=(h, w),
312
+ )
313
+
314
+
315
+ def _get_first_frame_idx(dataset, episode_idx: int) -> int:
316
+ """Get the dataset index of the first frame in an episode."""
317
+ try:
318
+ return dataset.meta.episodes["dataset_from_index"][episode_idx]
319
+ except (AttributeError, KeyError):
320
+ try:
321
+ return dataset.episode_data_index["from"][episode_idx].item()
322
+ except (AttributeError, KeyError):
323
+ return episode_idx * 200
324
+
325
+
326
+ def _detect_scene(dataset, episode_idx: int, image_key: str, device: str):
327
+ """Run scene detection from scratch (fallback when no saved scene)."""
328
+ from smolvla_inspect.diagnostic.scene import detect_scene
329
+
330
+ first_idx = _get_first_frame_idx(dataset, episode_idx)
331
+ sample = dataset[first_idx]
332
+ return detect_scene(sample, image_key, device)
333
+
334
+
335
+ def _apply_defaults(test_name: str, params: dict, scene) -> dict:
336
+ """Fill in sensible defaults for test params that weren't provided."""
337
+ if test_name == "background_substitution":
338
+ params.setdefault("replacement", "gray")
339
+ elif test_name == "object_relocation":
340
+ if "target_object" not in params and scene:
341
+ params["target_object"] = _pick_target(scene)
342
+ params.setdefault("shift_pixels", [100, -80])
343
+ elif test_name == "object_recolor":
344
+ if "target_object" not in params and scene:
345
+ params["target_object"] = _pick_target(scene)
346
+ params.setdefault("hue_shift", 0.5)
347
+ elif test_name == "occlusion_targeted":
348
+ if "target_object" not in params and scene:
349
+ params["target_object"] = _pick_target(scene)
350
+ params.setdefault("fill", "gray")
351
+ elif test_name == "distractor_insertion":
352
+ params.setdefault("position", [256, 256])
353
+ params.setdefault("distractor_size", 80)
354
+ elif test_name == "task_string_swap":
355
+ params.setdefault("replacement_task", "do nothing")
356
+ elif test_name == "lighting_perturbation":
357
+ params.setdefault("brightness_delta", 0.3)
358
+ params.setdefault("contrast_delta", 0.3)
359
+ elif test_name == "temporal_consistency":
360
+ params.setdefault("perturbation_type", "background_substitution")
361
+ params.setdefault("num_frames", 5)
362
+ return params
363
+
364
+
365
+ def _pick_target(scene) -> str:
366
+ """Pick the most likely manipulation target from scene objects."""
367
+ skip = {"robot gripper", "robot arm", "gripper", "arm"}
368
+ for obj in scene.objects:
369
+ if obj.label.lower() not in skip and obj.mask is not None:
370
+ return obj.label
371
+ # Fallback to first object with a mask
372
+ for obj in scene.objects:
373
+ if obj.mask is not None:
374
+ return obj.label
375
+ return scene.objects[0].label if scene.objects else "object"
376
+
377
+
378
+ def _regenerate_viz(run_dir: str, test_name: str):
379
+ """Regenerate only the visualization from an existing result.json."""
380
+ import numpy as np
381
+
382
+ cf_dir = os.path.join(run_dir, "diagnostic", "counterfactuals", test_name)
383
+ result_path = os.path.join(cf_dir, "result.json")
384
+
385
+ if not os.path.exists(result_path):
386
+ print(f"ERROR: No result.json at {result_path}")
387
+ print(f" Run the test first (without --viz-only)")
388
+ sys.exit(1)
389
+
390
+ with open(result_path) as f:
391
+ result_data = json.load(f)
392
+
393
+ # For task_string_swap, regenerate the action delta chart
394
+ if test_name == "task_string_swap":
395
+ from smolvla_inspect.diagnostic.counterfactual import _make_action_delta_chart
396
+ metrics = result_data.get("metrics", {})
397
+ baseline = metrics.get("baseline_actions")
398
+ modified = metrics.get("modified_actions")
399
+
400
+ if baseline is None or modified is None:
401
+ # Reconstruct from deltas (approximate — modified = baseline + delta)
402
+ # but we don't have absolute values, so the grouped-bar top panel
403
+ # won't render. Re-run the test without --viz-only instead.
404
+ print(" WARNING: result.json does not contain baseline/modified actions.")
405
+ print(" Re-run the test without --viz-only to get the two-panel chart.")
406
+ print(" (Older results lack this data; only delta bars will be shown.)")
407
+ return
408
+
409
+ baseline = np.array(baseline)
410
+ modified = np.array(modified)
411
+ original_task = metrics.get("original_task", "original task")
412
+ replacement_task = metrics.get("replacement_task", "replacement task")
413
+
414
+ chart = _make_action_delta_chart(baseline, modified, original_task, replacement_task)
415
+ from PIL import Image
416
+ comp_path = os.path.join(cf_dir, "comparison.png")
417
+ Image.fromarray(chart).save(comp_path)
418
+ print(f" Regenerated chart: {comp_path}")
419
+ else:
420
+ print(f" --viz-only currently supports: task_string_swap")
421
+ print(f" For image-based tests, re-run the test (model needed for comparison).")
422
+ sys.exit(1)
423
+
424
+
425
+ if __name__ == "__main__":
426
+ main()
run_test.sh ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Fast test runner — re-run individual counterfactual tests or regenerate
3
+ # visualizations on an existing run directory WITHOUT the full pipeline.
4
+ #
5
+ # Usage:
6
+ # ./run_test.sh <run_dir> <test_name> [--param key=value ...] [--viz-only]
7
+ #
8
+ # Examples:
9
+ # # Re-run background_substitution on the finetuned model
10
+ # ./run_test.sh outputs/mthirumalai/finetuned_model background_substitution
11
+ #
12
+ # # Re-run distractor with custom params
13
+ # ./run_test.sh outputs/mthirumalai/finetuned_model distractor_insertion \
14
+ # --param position='[200,200]' --param distractor_size=60
15
+ #
16
+ # # Re-run task_string_swap with a different replacement task
17
+ # ./run_test.sh outputs/mthirumalai/finetuned_model task_string_swap \
18
+ # --param replacement_task='pick up the red cube'
19
+ #
20
+ # # Just regenerate the visualization from existing result (no model needed)
21
+ # ./run_test.sh outputs/mthirumalai/finetuned_model task_string_swap --viz-only
22
+ #
23
+ # # List available counterfactual tests
24
+ # ./run_test.sh --list
25
+
26
+ set -e
27
+ cd "$(dirname "$0")"
28
+
29
+ # macOS: Homebrew ffmpeg@6 for TorchCodec compatibility
30
+ FFMPEG6_LIB="/opt/homebrew/opt/ffmpeg@6/lib"
31
+ if [[ -d "$FFMPEG6_LIB" && -f "$FFMPEG6_LIB/libavutil.58.dylib" ]]; then
32
+ export DYLD_LIBRARY_PATH="${FFMPEG6_LIB}${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}"
33
+ fi
34
+
35
+ # Linux: Ensure system FFmpeg 4.x libs load first
36
+ SYS_FFMPEG="/lib/x86_64-linux-gnu"
37
+ if [[ -f "$SYS_FFMPEG/libavutil.so.56" ]]; then
38
+ export LD_LIBRARY_PATH="${SYS_FFMPEG}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
39
+ fi
40
+
41
+ exec python3 run_single_counterfactual.py "$@"
smolvla_inspect/diagnostic/counterfactual.py CHANGED
@@ -1122,6 +1122,12 @@ def task_string_swap(
1122
  attribution_shift_per_region={},
1123
  confirmed=delta_l2 > 0.01,
1124
  visual_comparison=comparison,
 
 
 
 
 
 
1125
  )
1126
 
1127
 
 
1122
  attribution_shift_per_region={},
1123
  confirmed=delta_l2 > 0.01,
1124
  visual_comparison=comparison,
1125
+ metrics={
1126
+ "original_task": original_task,
1127
+ "replacement_task": replacement_task,
1128
+ "baseline_actions": baseline_actions.tolist(),
1129
+ "modified_actions": modified_actions.tolist(),
1130
+ },
1131
  )
1132
 
1133