Spaces:
Sleeping
Sleeping
Commit ·
26bbb13
1
Parent(s): 7911321
Add semantic representation probes and QK similarity analysis
Browse files- Add semantic_probe.py for probing internal representations via cosine similarity to concept labels
- Add QK probe to measure query-key alignment across expert cross-attention layers
- Integrate representation probes as Phase 5/9 in the diagnostic pipeline
- Update DiagnosticPanel frontend to render semantic and QK probe results
- Enhance spatial object diagnosis with probe-informed evidence
- Update report rendering with representation probe sections
- Fix gradient attribution to support probe-compatible activation extraction
- smolvla_inspect/diagnostic/agent.py +120 -14
- smolvla_inspect/diagnostic/counterfactual.py +92 -1
- smolvla_inspect/diagnostic/models.py +135 -0
- smolvla_inspect/diagnostic/prompts.py +20 -0
- smolvla_inspect/diagnostic/report.py +17 -0
- smolvla_inspect/diagnostic/semantic_probe.py +685 -0
- smolvla_inspect/diagnostic/spatial_object.py +142 -1
- smolvla_inspect/gradient.py +193 -0
- web/frontend/src/components/DiagnosticPanel.tsx +120 -1
smolvla_inspect/diagnostic/agent.py
CHANGED
|
@@ -21,6 +21,14 @@ from .spatial_object import (
|
|
| 21 |
choose_target_object,
|
| 22 |
summarize_spatial_object_diagnosis,
|
| 23 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from .prompts import (
|
| 25 |
build_hypothesis_prompt, build_synthesis_prompt, build_triage_selection_prompt,
|
| 26 |
format_diversity_summary, format_anomalies_json, format_hypotheses_with_results,
|
|
@@ -227,15 +235,16 @@ class DiagnosticAgent:
|
|
| 227 |
"""Run the full diagnostic pipeline."""
|
| 228 |
|
| 229 |
_PHASE_LABELS = {
|
| 230 |
-
"scene_understanding": ("1/
|
| 231 |
-
"dataset_diversity": ("2/
|
| 232 |
-
"triage": ("3/
|
| 233 |
-
"matrix": ("4/
|
| 234 |
-
"
|
| 235 |
-
"
|
| 236 |
-
"
|
| 237 |
-
"
|
| 238 |
-
"
|
|
|
|
| 239 |
"complete": ("OK", "Complete"),
|
| 240 |
}
|
| 241 |
_run_start = time.time()
|
|
@@ -390,11 +399,40 @@ class DiagnosticAgent:
|
|
| 390 |
data={"anomaly_types": [a.type for a in anomalies]},
|
| 391 |
))
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
preliminary_spatial_object = build_spatial_object_diagnosis(
|
| 394 |
matrix,
|
| 395 |
target_object=target_object,
|
| 396 |
cf_results=[],
|
| 397 |
dataset_diversity=diversity,
|
|
|
|
| 398 |
)
|
| 399 |
|
| 400 |
# ── Phase 5: LLM Hypothesis Formation ──────────────────
|
|
@@ -404,7 +442,7 @@ class DiagnosticAgent:
|
|
| 404 |
_progress("hypothesize", f"Forming hypotheses via {llm_label}...")
|
| 405 |
hypotheses = await self._form_hypotheses(
|
| 406 |
task_string, scene, diversity, matrix, anomalies,
|
| 407 |
-
preliminary_spatial_object)
|
| 408 |
_progress("hypothesize", f"Formed {len(hypotheses)} hypotheses:")
|
| 409 |
for h in hypotheses:
|
| 410 |
test_label = f" -> test: {h.test_type}" if h.test_type != "none" else " (no test needed)"
|
|
@@ -472,7 +510,11 @@ class DiagnosticAgent:
|
|
| 472 |
if surprising:
|
| 473 |
_progress("iteration", f"Found {len(surprising)} surprising results, forming follow-up hypotheses...")
|
| 474 |
followup_hypotheses = await self._form_hypotheses(
|
| 475 |
-
task_string, scene, diversity, matrix, anomalies
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
# Filter to only genuinely new hypotheses
|
| 477 |
existing_ids = {h.id for h in hypotheses}
|
| 478 |
existing_tests = {(h.test_type, str(h.test_params)) for h in hypotheses}
|
|
@@ -552,24 +594,78 @@ class DiagnosticAgent:
|
|
| 552 |
except Exception as e:
|
| 553 |
_progress("counterfactuals", f" -> FAILED: {e}")
|
| 554 |
|
| 555 |
-
|
|
|
|
|
|
|
| 556 |
_progress("disambiguation", "Summarizing spatial priors vs object grounding...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
spatial_object_diagnosis = build_spatial_object_diagnosis(
|
| 558 |
matrix,
|
| 559 |
target_object=target_object,
|
| 560 |
cf_results=cf_results,
|
| 561 |
dataset_diversity=diversity,
|
|
|
|
|
|
|
| 562 |
)
|
| 563 |
_progress(
|
| 564 |
"disambiguation",
|
| 565 |
summarize_spatial_object_diagnosis(spatial_object_diagnosis),
|
| 566 |
)
|
| 567 |
|
| 568 |
-
# ── Phase
|
| 569 |
_progress("synthesis", f"Synthesizing report via {llm_label}...")
|
| 570 |
findings, narrative = await self._synthesize_report(
|
| 571 |
task_string, scene, diversity, matrix, anomalies,
|
| 572 |
hypotheses, cf_results, spatial_object_diagnosis,
|
|
|
|
|
|
|
| 573 |
cf_skip_reason=cf_skip_reason)
|
| 574 |
_progress("synthesis", f"Generated {len(findings)} findings:")
|
| 575 |
for f in findings:
|
|
@@ -608,6 +704,8 @@ class DiagnosticAgent:
|
|
| 608 |
scene=scene,
|
| 609 |
dataset_diversity=diversity,
|
| 610 |
matrix=matrix,
|
|
|
|
|
|
|
| 611 |
spatial_object_diagnosis=spatial_object_diagnosis,
|
| 612 |
anomalies=anomalies,
|
| 613 |
hypotheses=hypotheses,
|
|
@@ -1121,7 +1219,9 @@ class DiagnosticAgent:
|
|
| 1121 |
async def _form_hypotheses(self, task_string: str, scene: SceneSegmentation,
|
| 1122 |
diversity, matrix: DiagnosticMatrix,
|
| 1123 |
anomalies: list[Anomaly],
|
| 1124 |
-
spatial_object_diagnosis=None
|
|
|
|
|
|
|
| 1125 |
"""Use LLM to form hypotheses from the diagnostic data."""
|
| 1126 |
prompt = build_hypothesis_prompt(
|
| 1127 |
task_string=task_string,
|
|
@@ -1130,6 +1230,8 @@ class DiagnosticAgent:
|
|
| 1130 |
matrix_markdown=matrix.to_markdown(),
|
| 1131 |
anomalies_json=format_anomalies_json(anomalies),
|
| 1132 |
spatial_object_summary=summarize_spatial_object_diagnosis(spatial_object_diagnosis),
|
|
|
|
|
|
|
| 1133 |
)
|
| 1134 |
|
| 1135 |
response = await self._call_llm(prompt)
|
|
@@ -1293,6 +1395,8 @@ class DiagnosticAgent:
|
|
| 1293 |
hypotheses: list[Hypothesis],
|
| 1294 |
cf_results: list[CounterfactualResult],
|
| 1295 |
spatial_object_diagnosis=None,
|
|
|
|
|
|
|
| 1296 |
cf_skip_reason: str = "",
|
| 1297 |
) -> tuple[list[Finding], str]:
|
| 1298 |
"""Use LLM to synthesize findings and narrative from all evidence."""
|
|
@@ -1301,6 +1405,8 @@ class DiagnosticAgent:
|
|
| 1301 |
detected_objects=scene.region_names(),
|
| 1302 |
matrix_markdown=matrix.to_markdown(),
|
| 1303 |
spatial_object_summary=summarize_spatial_object_diagnosis(spatial_object_diagnosis),
|
|
|
|
|
|
|
| 1304 |
anomalies_summary=format_anomalies_json(anomalies),
|
| 1305 |
diversity_summary=format_diversity_summary(diversity),
|
| 1306 |
hypotheses_with_results=format_hypotheses_with_results(
|
|
|
|
| 21 |
choose_target_object,
|
| 22 |
summarize_spatial_object_diagnosis,
|
| 23 |
)
|
| 24 |
+
from .semantic_probe import (
|
| 25 |
+
build_qk_probe_report,
|
| 26 |
+
build_semantic_probe_report,
|
| 27 |
+
probe_semantic_frame,
|
| 28 |
+
semantic_candidate_labels,
|
| 29 |
+
summarize_qk_probe,
|
| 30 |
+
summarize_semantic_probe,
|
| 31 |
+
)
|
| 32 |
from .prompts import (
|
| 33 |
build_hypothesis_prompt, build_synthesis_prompt, build_triage_selection_prompt,
|
| 34 |
format_diversity_summary, format_anomalies_json, format_hypotheses_with_results,
|
|
|
|
| 235 |
"""Run the full diagnostic pipeline."""
|
| 236 |
|
| 237 |
_PHASE_LABELS = {
|
| 238 |
+
"scene_understanding": ("1/9", "Scene Understanding"),
|
| 239 |
+
"dataset_diversity": ("2/9", "Dataset Diversity"),
|
| 240 |
+
"triage": ("3/9", "Signal Triage"),
|
| 241 |
+
"matrix": ("4/9", "Diagnostic Matrix"),
|
| 242 |
+
"representation": ("5/9", "Representation Probes"),
|
| 243 |
+
"hypothesize": ("6/9", "Hypothesis Formation"),
|
| 244 |
+
"counterfactuals": ("7/9", "Counterfactual Tests"),
|
| 245 |
+
"iteration": ("7/9", "Follow-up Iteration"),
|
| 246 |
+
"disambiguation": ("8/9", "Spatial vs Object Diagnosis"),
|
| 247 |
+
"synthesis": ("9/9", "Report Synthesis"),
|
| 248 |
"complete": ("OK", "Complete"),
|
| 249 |
}
|
| 250 |
_run_start = time.time()
|
|
|
|
| 399 |
data={"anomaly_types": [a.type for a in anomalies]},
|
| 400 |
))
|
| 401 |
|
| 402 |
+
primary_semantic_frame = None
|
| 403 |
+
semantic_internal = None
|
| 404 |
+
semantic_probe = None
|
| 405 |
+
qk_probe = None
|
| 406 |
+
if self.policy is not None and target_object is not None:
|
| 407 |
+
_progress("representation", "Running semantic patch-to-text probe on the primary frame...")
|
| 408 |
+
try:
|
| 409 |
+
primary_semantic_frame, semantic_internal = probe_semantic_frame(
|
| 410 |
+
self.policy,
|
| 411 |
+
sample,
|
| 412 |
+
self.dataset,
|
| 413 |
+
self.image_key,
|
| 414 |
+
self.device,
|
| 415 |
+
target_object=target_object,
|
| 416 |
+
candidate_labels=semantic_candidate_labels(scene, target_object),
|
| 417 |
+
attention_heatmap=(signals.get("attention") or [None])[0],
|
| 418 |
+
gradcam_map=(signals.get("gradcam_siglip") or [None])[0],
|
| 419 |
+
image_map=self.image_map,
|
| 420 |
+
frame_id="primary",
|
| 421 |
+
)
|
| 422 |
+
semantic_probe = build_semantic_probe_report(target_object, primary_semantic_frame, [])
|
| 423 |
+
if semantic_probe is not None:
|
| 424 |
+
_progress("representation", summarize_semantic_probe(semantic_probe))
|
| 425 |
+
except Exception as e:
|
| 426 |
+
_progress("representation", f"Semantic probe skipped: {e}")
|
| 427 |
+
else:
|
| 428 |
+
_progress("representation", "Skipping semantic probe (no model or target object)")
|
| 429 |
+
|
| 430 |
preliminary_spatial_object = build_spatial_object_diagnosis(
|
| 431 |
matrix,
|
| 432 |
target_object=target_object,
|
| 433 |
cf_results=[],
|
| 434 |
dataset_diversity=diversity,
|
| 435 |
+
semantic_probe=semantic_probe,
|
| 436 |
)
|
| 437 |
|
| 438 |
# ── Phase 5: LLM Hypothesis Formation ──────────────────
|
|
|
|
| 442 |
_progress("hypothesize", f"Forming hypotheses via {llm_label}...")
|
| 443 |
hypotheses = await self._form_hypotheses(
|
| 444 |
task_string, scene, diversity, matrix, anomalies,
|
| 445 |
+
preliminary_spatial_object, semantic_probe, None)
|
| 446 |
_progress("hypothesize", f"Formed {len(hypotheses)} hypotheses:")
|
| 447 |
for h in hypotheses:
|
| 448 |
test_label = f" -> test: {h.test_type}" if h.test_type != "none" else " (no test needed)"
|
|
|
|
| 510 |
if surprising:
|
| 511 |
_progress("iteration", f"Found {len(surprising)} surprising results, forming follow-up hypotheses...")
|
| 512 |
followup_hypotheses = await self._form_hypotheses(
|
| 513 |
+
task_string, scene, diversity, matrix, anomalies,
|
| 514 |
+
pre_qk_spatial_object if 'pre_qk_spatial_object' in locals() else preliminary_spatial_object,
|
| 515 |
+
semantic_probe,
|
| 516 |
+
qk_probe,
|
| 517 |
+
)
|
| 518 |
# Filter to only genuinely new hypotheses
|
| 519 |
existing_ids = {h.id for h in hypotheses}
|
| 520 |
existing_tests = {(h.test_type, str(h.test_params)) for h in hypotheses}
|
|
|
|
| 594 |
except Exception as e:
|
| 595 |
_progress("counterfactuals", f" -> FAILED: {e}")
|
| 596 |
|
| 597 |
+
semantic_probe = build_semantic_probe_report(target_object, primary_semantic_frame, cf_results)
|
| 598 |
+
|
| 599 |
+
# ── Phase 8: Spatial vs Object Diagnosis ─────────────────
|
| 600 |
_progress("disambiguation", "Summarizing spatial priors vs object grounding...")
|
| 601 |
+
pre_qk_spatial_object = build_spatial_object_diagnosis(
|
| 602 |
+
matrix,
|
| 603 |
+
target_object=target_object,
|
| 604 |
+
cf_results=cf_results,
|
| 605 |
+
dataset_diversity=diversity,
|
| 606 |
+
semantic_probe=semantic_probe,
|
| 607 |
+
)
|
| 608 |
+
semantic_ambiguous = (
|
| 609 |
+
primary_semantic_frame is not None
|
| 610 |
+
and (
|
| 611 |
+
primary_semantic_frame.target_margin_over_best_non_target is None
|
| 612 |
+
or abs(primary_semantic_frame.target_margin_over_best_non_target) < 0.08
|
| 613 |
+
)
|
| 614 |
+
)
|
| 615 |
+
if (
|
| 616 |
+
self.policy is not None
|
| 617 |
+
and target_object is not None
|
| 618 |
+
and semantic_internal is not None
|
| 619 |
+
and (pre_qk_spatial_object.verdict in {"mixed", "inconclusive"} or semantic_ambiguous)
|
| 620 |
+
):
|
| 621 |
+
_progress("disambiguation", "Running last-layer SigLIP QK decomposition...")
|
| 622 |
+
try:
|
| 623 |
+
relocation_result = next(
|
| 624 |
+
(
|
| 625 |
+
result for result in cf_results
|
| 626 |
+
if result.test_type == "object_relocation"
|
| 627 |
+
and (result.metrics or {}).get("target_object") == target_object
|
| 628 |
+
),
|
| 629 |
+
None,
|
| 630 |
+
)
|
| 631 |
+
qk_probe = build_qk_probe_report(
|
| 632 |
+
self.policy,
|
| 633 |
+
sample,
|
| 634 |
+
self.dataset,
|
| 635 |
+
self.image_key,
|
| 636 |
+
self.device,
|
| 637 |
+
target_object=target_object,
|
| 638 |
+
scene=scene,
|
| 639 |
+
target_semantic_map=semantic_internal["target_map"],
|
| 640 |
+
positional_baseline=signals.get("positional_baseline"),
|
| 641 |
+
relocation_result=relocation_result,
|
| 642 |
+
image_map=self.image_map,
|
| 643 |
+
)
|
| 644 |
+
if qk_probe is not None:
|
| 645 |
+
_progress("disambiguation", summarize_qk_probe(qk_probe))
|
| 646 |
+
except Exception as e:
|
| 647 |
+
_progress("disambiguation", f"QK probe skipped: {e}")
|
| 648 |
+
|
| 649 |
spatial_object_diagnosis = build_spatial_object_diagnosis(
|
| 650 |
matrix,
|
| 651 |
target_object=target_object,
|
| 652 |
cf_results=cf_results,
|
| 653 |
dataset_diversity=diversity,
|
| 654 |
+
semantic_probe=semantic_probe,
|
| 655 |
+
qk_probe=qk_probe,
|
| 656 |
)
|
| 657 |
_progress(
|
| 658 |
"disambiguation",
|
| 659 |
summarize_spatial_object_diagnosis(spatial_object_diagnosis),
|
| 660 |
)
|
| 661 |
|
| 662 |
+
# ── Phase 9: LLM Synthesis ─────────────────────────────
|
| 663 |
_progress("synthesis", f"Synthesizing report via {llm_label}...")
|
| 664 |
findings, narrative = await self._synthesize_report(
|
| 665 |
task_string, scene, diversity, matrix, anomalies,
|
| 666 |
hypotheses, cf_results, spatial_object_diagnosis,
|
| 667 |
+
semantic_probe,
|
| 668 |
+
qk_probe,
|
| 669 |
cf_skip_reason=cf_skip_reason)
|
| 670 |
_progress("synthesis", f"Generated {len(findings)} findings:")
|
| 671 |
for f in findings:
|
|
|
|
| 704 |
scene=scene,
|
| 705 |
dataset_diversity=diversity,
|
| 706 |
matrix=matrix,
|
| 707 |
+
semantic_probe=semantic_probe,
|
| 708 |
+
qk_probe=qk_probe,
|
| 709 |
spatial_object_diagnosis=spatial_object_diagnosis,
|
| 710 |
anomalies=anomalies,
|
| 711 |
hypotheses=hypotheses,
|
|
|
|
| 1219 |
async def _form_hypotheses(self, task_string: str, scene: SceneSegmentation,
|
| 1220 |
diversity, matrix: DiagnosticMatrix,
|
| 1221 |
anomalies: list[Anomaly],
|
| 1222 |
+
spatial_object_diagnosis=None,
|
| 1223 |
+
semantic_probe=None,
|
| 1224 |
+
qk_probe=None) -> list[Hypothesis]:
|
| 1225 |
"""Use LLM to form hypotheses from the diagnostic data."""
|
| 1226 |
prompt = build_hypothesis_prompt(
|
| 1227 |
task_string=task_string,
|
|
|
|
| 1230 |
matrix_markdown=matrix.to_markdown(),
|
| 1231 |
anomalies_json=format_anomalies_json(anomalies),
|
| 1232 |
spatial_object_summary=summarize_spatial_object_diagnosis(spatial_object_diagnosis),
|
| 1233 |
+
semantic_probe_summary=summarize_semantic_probe(semantic_probe),
|
| 1234 |
+
qk_probe_summary=summarize_qk_probe(qk_probe),
|
| 1235 |
)
|
| 1236 |
|
| 1237 |
response = await self._call_llm(prompt)
|
|
|
|
| 1395 |
hypotheses: list[Hypothesis],
|
| 1396 |
cf_results: list[CounterfactualResult],
|
| 1397 |
spatial_object_diagnosis=None,
|
| 1398 |
+
semantic_probe=None,
|
| 1399 |
+
qk_probe=None,
|
| 1400 |
cf_skip_reason: str = "",
|
| 1401 |
) -> tuple[list[Finding], str]:
|
| 1402 |
"""Use LLM to synthesize findings and narrative from all evidence."""
|
|
|
|
| 1405 |
detected_objects=scene.region_names(),
|
| 1406 |
matrix_markdown=matrix.to_markdown(),
|
| 1407 |
spatial_object_summary=summarize_spatial_object_diagnosis(spatial_object_diagnosis),
|
| 1408 |
+
semantic_probe_summary=summarize_semantic_probe(semantic_probe),
|
| 1409 |
+
qk_probe_summary=summarize_qk_probe(qk_probe),
|
| 1410 |
anomalies_summary=format_anomalies_json(anomalies),
|
| 1411 |
diversity_summary=format_diversity_summary(diversity),
|
| 1412 |
hypotheses_with_results=format_hypotheses_with_results(
|
smolvla_inspect/diagnostic/counterfactual.py
CHANGED
|
@@ -22,6 +22,7 @@ from ..data import build_policy_batch_from_sample
|
|
| 22 |
from ..gradient import _patch_eager_attention_bool_mask
|
| 23 |
from .models import CounterfactualResult, SceneSegmentation
|
| 24 |
from .registry import register_primitive
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
# ---------------------------------------------------------------------------
|
|
@@ -255,6 +256,38 @@ def _mask_share(heatmap: np.ndarray | None, mask: np.ndarray) -> float:
|
|
| 255 |
return float(np.sum(hm * mask.astype(np.float32)) / total)
|
| 256 |
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
def _shift_mask(mask: np.ndarray, shift_pixels: tuple[int, int]) -> np.ndarray:
|
| 259 |
"""Translate a binary mask by ``(dx, dy)`` with clipping."""
|
| 260 |
dx, dy = shift_pixels
|
|
@@ -588,6 +621,40 @@ def object_relocation(
|
|
| 588 |
"focus_shift_gap": moved_target_share - old_anchor_share,
|
| 589 |
}
|
| 590 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
return _compute_result(
|
| 592 |
baseline_actions, modified_actions,
|
| 593 |
img_hwc, modified_hwc,
|
|
@@ -1144,11 +1211,35 @@ def occlusion_targeted(
|
|
| 1144 |
policy.reset()
|
| 1145 |
modified_actions = _get_actions(policy, new_sample, dataset, image_key, device, image_map)
|
| 1146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1147 |
return _compute_result(
|
| 1148 |
baseline_actions, modified_actions,
|
| 1149 |
img_hwc, modified_hwc,
|
| 1150 |
hypothesis_id=f"occlusion_targeted_{target_object}",
|
| 1151 |
test_type="occlusion_targeted",
|
| 1152 |
affected_mask=obj_mask,
|
| 1153 |
-
metrics=
|
| 1154 |
)
|
|
|
|
| 22 |
from ..gradient import _patch_eager_attention_bool_mask
|
| 23 |
from .models import CounterfactualResult, SceneSegmentation
|
| 24 |
from .registry import register_primitive
|
| 25 |
+
from .semantic_probe import compute_patch_text_similarity
|
| 26 |
|
| 27 |
|
| 28 |
# ---------------------------------------------------------------------------
|
|
|
|
| 256 |
return float(np.sum(hm * mask.astype(np.float32)) / total)
|
| 257 |
|
| 258 |
|
| 259 |
+
def _normalize_similarity_map(similarity_map: np.ndarray, valid_mask: np.ndarray) -> np.ndarray:
|
| 260 |
+
out = np.zeros_like(similarity_map, dtype=np.float32)
|
| 261 |
+
if not valid_mask.any():
|
| 262 |
+
return out
|
| 263 |
+
valid = similarity_map[valid_mask].astype(np.float32)
|
| 264 |
+
vmin = float(valid.min())
|
| 265 |
+
vmax = float(valid.max())
|
| 266 |
+
if vmax > vmin:
|
| 267 |
+
out[valid_mask] = (valid - vmin) / (vmax - vmin)
|
| 268 |
+
return out
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _semantic_region_mean(similarity_map: np.ndarray | None, region_mask: np.ndarray,
|
| 272 |
+
grid_shape: tuple[int, int], valid_mask: np.ndarray) -> float | None:
|
| 273 |
+
if similarity_map is None:
|
| 274 |
+
return None
|
| 275 |
+
region_grid = _resize_mask(region_mask, grid_shape) & valid_mask
|
| 276 |
+
if not region_grid.any():
|
| 277 |
+
return None
|
| 278 |
+
return float(similarity_map[region_grid].mean())
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _semantic_region_peak(similarity_map: np.ndarray | None, region_mask: np.ndarray,
|
| 282 |
+
grid_shape: tuple[int, int], valid_mask: np.ndarray) -> float | None:
|
| 283 |
+
if similarity_map is None:
|
| 284 |
+
return None
|
| 285 |
+
region_grid = _resize_mask(region_mask, grid_shape) & valid_mask
|
| 286 |
+
if not region_grid.any():
|
| 287 |
+
return None
|
| 288 |
+
return float(similarity_map[region_grid].max())
|
| 289 |
+
|
| 290 |
+
|
| 291 |
def _shift_mask(mask: np.ndarray, shift_pixels: tuple[int, int]) -> np.ndarray:
|
| 292 |
"""Translate a binary mask by ``(dx, dy)`` with clipping."""
|
| 293 |
dx, dy = shift_pixels
|
|
|
|
| 621 |
"focus_shift_gap": moved_target_share - old_anchor_share,
|
| 622 |
}
|
| 623 |
|
| 624 |
+
original_semantics = compute_patch_text_similarity(
|
| 625 |
+
policy, sample, dataset, image_key, device, [target_object], image_map=image_map,
|
| 626 |
+
)
|
| 627 |
+
modified_semantics = compute_patch_text_similarity(
|
| 628 |
+
policy, new_sample, dataset, image_key, device, [target_object], image_map=image_map,
|
| 629 |
+
)
|
| 630 |
+
if original_semantics is not None and modified_semantics is not None:
|
| 631 |
+
orig_map = original_semantics["similarity_maps"].get(target_object)
|
| 632 |
+
mod_map = modified_semantics["similarity_maps"].get(target_object)
|
| 633 |
+
if orig_map is not None and mod_map is not None:
|
| 634 |
+
orig_valid = original_semantics["valid_patch_mask"]
|
| 635 |
+
mod_valid = modified_semantics["valid_patch_mask"]
|
| 636 |
+
orig_grid = original_semantics["grid_size"]
|
| 637 |
+
mod_grid = modified_semantics["grid_size"]
|
| 638 |
+
orig_peak = _semantic_region_peak(orig_map, obj_mask, orig_grid, orig_valid)
|
| 639 |
+
old_anchor_sem = _semantic_region_mean(mod_map, old_anchor_mask, mod_grid, mod_valid)
|
| 640 |
+
moved_target_sem = _semantic_region_mean(mod_map, moved_target_mask, mod_grid, mod_valid)
|
| 641 |
+
mod_norm = _normalize_similarity_map(mod_map, mod_valid)
|
| 642 |
+
old_anchor_norm = _semantic_region_mean(mod_norm, old_anchor_mask, mod_grid, mod_valid) or 0.0
|
| 643 |
+
moved_target_norm = _semantic_region_mean(mod_norm, moved_target_mask, mod_grid, mod_valid) or 0.0
|
| 644 |
+
semantic_total = old_anchor_norm + moved_target_norm + 1e-8
|
| 645 |
+
metrics.update({
|
| 646 |
+
"original_target_semantic_peak": orig_peak if orig_peak is not None else 0.0,
|
| 647 |
+
"modified_old_anchor_target_semantic": (
|
| 648 |
+
old_anchor_sem if old_anchor_sem is not None else 0.0
|
| 649 |
+
),
|
| 650 |
+
"modified_new_object_target_semantic": (
|
| 651 |
+
moved_target_sem if moved_target_sem is not None else 0.0
|
| 652 |
+
),
|
| 653 |
+
"semantic_follow_ratio": moved_target_norm / semantic_total,
|
| 654 |
+
"semantic_anchor_ratio": old_anchor_norm / semantic_total,
|
| 655 |
+
"semantic_shift_gap": moved_target_norm - old_anchor_norm,
|
| 656 |
+
})
|
| 657 |
+
|
| 658 |
return _compute_result(
|
| 659 |
baseline_actions, modified_actions,
|
| 660 |
img_hwc, modified_hwc,
|
|
|
|
| 1211 |
policy.reset()
|
| 1212 |
modified_actions = _get_actions(policy, new_sample, dataset, image_key, device, image_map)
|
| 1213 |
|
| 1214 |
+
metrics = {"target_object": target_object, "fill": fill}
|
| 1215 |
+
original_semantics = compute_patch_text_similarity(
|
| 1216 |
+
policy, sample, dataset, image_key, device, [target_object], image_map=image_map,
|
| 1217 |
+
)
|
| 1218 |
+
occluded_semantics = compute_patch_text_similarity(
|
| 1219 |
+
policy, new_sample, dataset, image_key, device, [target_object], image_map=image_map,
|
| 1220 |
+
)
|
| 1221 |
+
if original_semantics is not None and occluded_semantics is not None:
|
| 1222 |
+
orig_map = original_semantics["similarity_maps"].get(target_object)
|
| 1223 |
+
occ_map = occluded_semantics["similarity_maps"].get(target_object)
|
| 1224 |
+
if orig_map is not None and occ_map is not None:
|
| 1225 |
+
orig_valid = original_semantics["valid_patch_mask"]
|
| 1226 |
+
occ_valid = occluded_semantics["valid_patch_mask"]
|
| 1227 |
+
orig_grid = original_semantics["grid_size"]
|
| 1228 |
+
occ_grid = occluded_semantics["grid_size"]
|
| 1229 |
+
orig_peak = _semantic_region_peak(orig_map, obj_mask, orig_grid, orig_valid)
|
| 1230 |
+
occ_peak = _semantic_region_peak(occ_map, obj_mask, occ_grid, occ_valid)
|
| 1231 |
+
if orig_peak is not None and occ_peak is not None:
|
| 1232 |
+
metrics.update({
|
| 1233 |
+
"original_target_semantic_peak": orig_peak,
|
| 1234 |
+
"occluded_target_semantic_peak": occ_peak,
|
| 1235 |
+
"occlusion_target_semantic_drop": orig_peak - occ_peak,
|
| 1236 |
+
})
|
| 1237 |
+
|
| 1238 |
return _compute_result(
|
| 1239 |
baseline_actions, modified_actions,
|
| 1240 |
img_hwc, modified_hwc,
|
| 1241 |
hypothesis_id=f"occlusion_targeted_{target_object}",
|
| 1242 |
test_type="occlusion_targeted",
|
| 1243 |
affected_mask=obj_mask,
|
| 1244 |
+
metrics=metrics,
|
| 1245 |
)
|
smolvla_inspect/diagnostic/models.py
CHANGED
|
@@ -329,6 +329,70 @@ class SpatialObjectDiagnosis:
|
|
| 329 |
object_evidence: list[DiagnosticEvidence] = field(default_factory=list)
|
| 330 |
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
@dataclass
|
| 333 |
class EvidenceEntry:
|
| 334 |
"""A single piece of evidence collected during a diagnostic phase."""
|
|
@@ -380,6 +444,8 @@ class DiagnosticReport:
|
|
| 380 |
scene: SceneSegmentation
|
| 381 |
dataset_diversity: DatasetDiversityReport | None
|
| 382 |
matrix: DiagnosticMatrix
|
|
|
|
|
|
|
| 383 |
spatial_object_diagnosis: SpatialObjectDiagnosis | None
|
| 384 |
anomalies: list[Anomaly]
|
| 385 |
hypotheses: list[Hypothesis]
|
|
@@ -439,6 +505,8 @@ class DiagnosticReport:
|
|
| 439 |
"scene": self._serialisable_scene(),
|
| 440 |
"dataset_diversity": asdict(self.dataset_diversity) if self.dataset_diversity else None,
|
| 441 |
"matrix": self.matrix.to_dict(),
|
|
|
|
|
|
|
| 442 |
"spatial_object_diagnosis": (
|
| 443 |
asdict(self.spatial_object_diagnosis)
|
| 444 |
if self.spatial_object_diagnosis
|
|
@@ -665,6 +733,73 @@ class DiagnosticReport:
|
|
| 665 |
sections.append(f"- {item.summary} (score={item.score:.2f}, source={item.source})")
|
| 666 |
sections.append("")
|
| 667 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 668 |
# ── Anomalies ─────────────────────────────────────────────
|
| 669 |
sections.append("## Detected Anomalies")
|
| 670 |
sections.append("")
|
|
|
|
| 329 |
object_evidence: list[DiagnosticEvidence] = field(default_factory=list)
|
| 330 |
|
| 331 |
|
| 332 |
+
@dataclass
|
| 333 |
+
class SemanticFrameSummary:
|
| 334 |
+
"""Semantic-probe summary for a single image/frame."""
|
| 335 |
+
|
| 336 |
+
frame_id: str
|
| 337 |
+
target_object: str | None
|
| 338 |
+
candidate_labels: list[str] = field(default_factory=list)
|
| 339 |
+
best_non_target_label: str | None = None
|
| 340 |
+
target_semantic_peak: float | None = None
|
| 341 |
+
target_semantic_mean_on_causal_patches: float | None = None
|
| 342 |
+
target_margin_over_best_non_target: float | None = None
|
| 343 |
+
causal_semantic_alignment: float | None = None
|
| 344 |
+
background_semantic_gap: float | None = None
|
| 345 |
+
summary: str = ""
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
@dataclass
|
| 349 |
+
class SemanticCounterfactualSummary:
|
| 350 |
+
"""Semantic summary derived from a counterfactual probe."""
|
| 351 |
+
|
| 352 |
+
test_type: str
|
| 353 |
+
summary: str
|
| 354 |
+
metrics: dict = field(default_factory=dict)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
@dataclass
|
| 358 |
+
class SemanticProbeReport:
|
| 359 |
+
"""Top-level semantic object-vs-location probe report."""
|
| 360 |
+
|
| 361 |
+
target_object: str | None
|
| 362 |
+
summary: str
|
| 363 |
+
primary_frame: SemanticFrameSummary | None = None
|
| 364 |
+
frames: list[SemanticFrameSummary] = field(default_factory=list)
|
| 365 |
+
counterfactuals: dict[str, SemanticCounterfactualSummary] = field(default_factory=dict)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
@dataclass
|
| 369 |
+
class QKHeadSummary:
|
| 370 |
+
"""Per-head QK decomposition summary for the final SigLIP layer."""
|
| 371 |
+
|
| 372 |
+
head_index: int
|
| 373 |
+
head_type: str
|
| 374 |
+
score: float
|
| 375 |
+
semantic_map_correlation: float
|
| 376 |
+
positional_baseline_correlation: float
|
| 377 |
+
target_region_logit_mass: float
|
| 378 |
+
background_logit_mass: float
|
| 379 |
+
old_anchor_logit_mass: float | None = None
|
| 380 |
+
moved_object_logit_mass: float | None = None
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
@dataclass
|
| 384 |
+
class QKProbeReport:
|
| 385 |
+
"""Compact summary of last-layer SigLIP QK behavior."""
|
| 386 |
+
|
| 387 |
+
layer: str
|
| 388 |
+
summary: str
|
| 389 |
+
dominant_head_type: str
|
| 390 |
+
semantic_head_fraction: float
|
| 391 |
+
positional_head_fraction: float
|
| 392 |
+
mixed_head_fraction: float
|
| 393 |
+
top_heads: list[QKHeadSummary] = field(default_factory=list)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
@dataclass
|
| 397 |
class EvidenceEntry:
|
| 398 |
"""A single piece of evidence collected during a diagnostic phase."""
|
|
|
|
| 444 |
scene: SceneSegmentation
|
| 445 |
dataset_diversity: DatasetDiversityReport | None
|
| 446 |
matrix: DiagnosticMatrix
|
| 447 |
+
semantic_probe: SemanticProbeReport | None
|
| 448 |
+
qk_probe: QKProbeReport | None
|
| 449 |
spatial_object_diagnosis: SpatialObjectDiagnosis | None
|
| 450 |
anomalies: list[Anomaly]
|
| 451 |
hypotheses: list[Hypothesis]
|
|
|
|
| 505 |
"scene": self._serialisable_scene(),
|
| 506 |
"dataset_diversity": asdict(self.dataset_diversity) if self.dataset_diversity else None,
|
| 507 |
"matrix": self.matrix.to_dict(),
|
| 508 |
+
"semantic_probe": asdict(self.semantic_probe) if self.semantic_probe else None,
|
| 509 |
+
"qk_probe": asdict(self.qk_probe) if self.qk_probe else None,
|
| 510 |
"spatial_object_diagnosis": (
|
| 511 |
asdict(self.spatial_object_diagnosis)
|
| 512 |
if self.spatial_object_diagnosis
|
|
|
|
| 733 |
sections.append(f"- {item.summary} (score={item.score:.2f}, source={item.source})")
|
| 734 |
sections.append("")
|
| 735 |
|
| 736 |
+
# ── Semantic Probe ───────────────────────────────────────
|
| 737 |
+
if self.semantic_probe is not None:
|
| 738 |
+
probe = self.semantic_probe
|
| 739 |
+
sections.append("## Semantic Feature Probe")
|
| 740 |
+
sections.append("")
|
| 741 |
+
sections.append(probe.summary)
|
| 742 |
+
sections.append("")
|
| 743 |
+
|
| 744 |
+
if probe.primary_frame is not None:
|
| 745 |
+
frame = probe.primary_frame
|
| 746 |
+
sections.append("### Primary Frame")
|
| 747 |
+
sections.append("")
|
| 748 |
+
sections.append("| Metric | Value |")
|
| 749 |
+
sections.append("|---|---|")
|
| 750 |
+
sections.append(f"| Target object | {frame.target_object or 'N/A'} |")
|
| 751 |
+
sections.append(f"| Candidate labels | {', '.join(frame.candidate_labels)} |")
|
| 752 |
+
sections.append(f"| Best non-target label | {frame.best_non_target_label or 'N/A'} |")
|
| 753 |
+
for key in (
|
| 754 |
+
"target_semantic_peak",
|
| 755 |
+
"target_semantic_mean_on_causal_patches",
|
| 756 |
+
"target_margin_over_best_non_target",
|
| 757 |
+
"causal_semantic_alignment",
|
| 758 |
+
"background_semantic_gap",
|
| 759 |
+
):
|
| 760 |
+
value = getattr(frame, key)
|
| 761 |
+
rendered = f"{value:.4f}" if isinstance(value, float) else "N/A"
|
| 762 |
+
sections.append(f"| {key.replace('_', ' ').title()} | {rendered} |")
|
| 763 |
+
if frame.summary:
|
| 764 |
+
sections.append("")
|
| 765 |
+
sections.append(frame.summary)
|
| 766 |
+
sections.append("")
|
| 767 |
+
|
| 768 |
+
if probe.counterfactuals:
|
| 769 |
+
sections.append("### Counterfactual Semantic Follow-Through")
|
| 770 |
+
sections.append("")
|
| 771 |
+
for name, summary in probe.counterfactuals.items():
|
| 772 |
+
sections.append(f"- **{name.replace('_', ' ')}**: {summary.summary}")
|
| 773 |
+
sections.append("")
|
| 774 |
+
|
| 775 |
+
# ── QK Probe ─────────────────────────────────────────────
|
| 776 |
+
if self.qk_probe is not None:
|
| 777 |
+
probe = self.qk_probe
|
| 778 |
+
sections.append("## QK Decomposition")
|
| 779 |
+
sections.append("")
|
| 780 |
+
sections.append(probe.summary)
|
| 781 |
+
sections.append("")
|
| 782 |
+
sections.append("| Metric | Value |")
|
| 783 |
+
sections.append("|---|---|")
|
| 784 |
+
sections.append(f"| Layer | {probe.layer} |")
|
| 785 |
+
sections.append(f"| Dominant head type | {probe.dominant_head_type} |")
|
| 786 |
+
sections.append(f"| Semantic head fraction | {probe.semantic_head_fraction:.4f} |")
|
| 787 |
+
sections.append(f"| Positional head fraction | {probe.positional_head_fraction:.4f} |")
|
| 788 |
+
sections.append(f"| Mixed head fraction | {probe.mixed_head_fraction:.4f} |")
|
| 789 |
+
sections.append("")
|
| 790 |
+
if probe.top_heads:
|
| 791 |
+
sections.append("### Top Heads")
|
| 792 |
+
sections.append("")
|
| 793 |
+
sections.append("| Head | Type | Score | Semantic Corr | Positional Corr | Target Mass | Background Mass |")
|
| 794 |
+
sections.append("|---|---|---|---|---|---|---|")
|
| 795 |
+
for head in probe.top_heads:
|
| 796 |
+
sections.append(
|
| 797 |
+
f"| {head.head_index} | {head.head_type} | {head.score:.4f} | "
|
| 798 |
+
f"{head.semantic_map_correlation:.4f} | {head.positional_baseline_correlation:.4f} | "
|
| 799 |
+
f"{head.target_region_logit_mass:.4f} | {head.background_logit_mass:.4f} |"
|
| 800 |
+
)
|
| 801 |
+
sections.append("")
|
| 802 |
+
|
| 803 |
# ── Anomalies ─────────────────────────────────────────────
|
| 804 |
sections.append("## Detected Anomalies")
|
| 805 |
sections.append("")
|
smolvla_inspect/diagnostic/prompts.py
CHANGED
|
@@ -31,6 +31,12 @@ HYPOTHESIS_PROMPT = """You are an expert robotics ML researcher diagnosing a vis
|
|
| 31 |
**Spatial vs Object Diagnosis:**
|
| 32 |
{spatial_object_summary}
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
**Detected Anomalies:**
|
| 35 |
{anomalies_json}
|
| 36 |
|
|
@@ -91,6 +97,12 @@ SYNTHESIS_PROMPT = """You are an expert robotics ML researcher writing a diagnos
|
|
| 91 |
**Spatial vs Object Diagnosis:**
|
| 92 |
{spatial_object_summary}
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
**Anomalies detected:** {anomalies_summary}
|
| 95 |
|
| 96 |
**Dataset diversity:** {diversity_summary}
|
|
@@ -136,6 +148,8 @@ def build_hypothesis_prompt(task_string: str, detected_objects: list[str],
|
|
| 136 |
diversity_summary: str, matrix_markdown: str,
|
| 137 |
anomalies_json: str,
|
| 138 |
spatial_object_summary: str = "Spatial-vs-object diagnosis unavailable.",
|
|
|
|
|
|
|
| 139 |
) -> str:
|
| 140 |
"""Build the hypothesis formation prompt with all context filled in."""
|
| 141 |
return HYPOTHESIS_PROMPT.format(
|
|
@@ -145,6 +159,8 @@ def build_hypothesis_prompt(task_string: str, detected_objects: list[str],
|
|
| 145 |
diversity_summary=diversity_summary,
|
| 146 |
matrix_markdown=matrix_markdown,
|
| 147 |
spatial_object_summary=spatial_object_summary,
|
|
|
|
|
|
|
| 148 |
anomalies_json=anomalies_json,
|
| 149 |
)
|
| 150 |
|
|
@@ -154,6 +170,8 @@ def build_synthesis_prompt(task_string: str, detected_objects: list[str],
|
|
| 154 |
diversity_summary: str,
|
| 155 |
hypotheses_with_results: str,
|
| 156 |
spatial_object_summary: str = "Spatial-vs-object diagnosis unavailable.",
|
|
|
|
|
|
|
| 157 |
) -> str:
|
| 158 |
"""Build the synthesis/report prompt with all context filled in."""
|
| 159 |
return SYNTHESIS_PROMPT.format(
|
|
@@ -162,6 +180,8 @@ def build_synthesis_prompt(task_string: str, detected_objects: list[str],
|
|
| 162 |
detected_objects=", ".join(detected_objects),
|
| 163 |
matrix_markdown=matrix_markdown,
|
| 164 |
spatial_object_summary=spatial_object_summary,
|
|
|
|
|
|
|
| 165 |
anomalies_summary=anomalies_summary,
|
| 166 |
diversity_summary=diversity_summary,
|
| 167 |
hypotheses_with_results=hypotheses_with_results,
|
|
|
|
| 31 |
**Spatial vs Object Diagnosis:**
|
| 32 |
{spatial_object_summary}
|
| 33 |
|
| 34 |
+
**Semantic Probe:**
|
| 35 |
+
{semantic_probe_summary}
|
| 36 |
+
|
| 37 |
+
**QK Decomposition:**
|
| 38 |
+
{qk_probe_summary}
|
| 39 |
+
|
| 40 |
**Detected Anomalies:**
|
| 41 |
{anomalies_json}
|
| 42 |
|
|
|
|
| 97 |
**Spatial vs Object Diagnosis:**
|
| 98 |
{spatial_object_summary}
|
| 99 |
|
| 100 |
+
**Semantic Probe:**
|
| 101 |
+
{semantic_probe_summary}
|
| 102 |
+
|
| 103 |
+
**QK Decomposition:**
|
| 104 |
+
{qk_probe_summary}
|
| 105 |
+
|
| 106 |
**Anomalies detected:** {anomalies_summary}
|
| 107 |
|
| 108 |
**Dataset diversity:** {diversity_summary}
|
|
|
|
| 148 |
diversity_summary: str, matrix_markdown: str,
|
| 149 |
anomalies_json: str,
|
| 150 |
spatial_object_summary: str = "Spatial-vs-object diagnosis unavailable.",
|
| 151 |
+
semantic_probe_summary: str = "Semantic patch-to-text probe unavailable.",
|
| 152 |
+
qk_probe_summary: str = "QK decomposition unavailable.",
|
| 153 |
) -> str:
|
| 154 |
"""Build the hypothesis formation prompt with all context filled in."""
|
| 155 |
return HYPOTHESIS_PROMPT.format(
|
|
|
|
| 159 |
diversity_summary=diversity_summary,
|
| 160 |
matrix_markdown=matrix_markdown,
|
| 161 |
spatial_object_summary=spatial_object_summary,
|
| 162 |
+
semantic_probe_summary=semantic_probe_summary,
|
| 163 |
+
qk_probe_summary=qk_probe_summary,
|
| 164 |
anomalies_json=anomalies_json,
|
| 165 |
)
|
| 166 |
|
|
|
|
| 170 |
diversity_summary: str,
|
| 171 |
hypotheses_with_results: str,
|
| 172 |
spatial_object_summary: str = "Spatial-vs-object diagnosis unavailable.",
|
| 173 |
+
semantic_probe_summary: str = "Semantic patch-to-text probe unavailable.",
|
| 174 |
+
qk_probe_summary: str = "QK decomposition unavailable.",
|
| 175 |
) -> str:
|
| 176 |
"""Build the synthesis/report prompt with all context filled in."""
|
| 177 |
return SYNTHESIS_PROMPT.format(
|
|
|
|
| 180 |
detected_objects=", ".join(detected_objects),
|
| 181 |
matrix_markdown=matrix_markdown,
|
| 182 |
spatial_object_summary=spatial_object_summary,
|
| 183 |
+
semantic_probe_summary=semantic_probe_summary,
|
| 184 |
+
qk_probe_summary=qk_probe_summary,
|
| 185 |
anomalies_summary=anomalies_summary,
|
| 186 |
diversity_summary=diversity_summary,
|
| 187 |
hypotheses_with_results=hypotheses_with_results,
|
smolvla_inspect/diagnostic/report.py
CHANGED
|
@@ -104,6 +104,23 @@ def save_diagnostic_report(report: DiagnosticReport, run_dir: str) -> str:
|
|
| 104 |
"metrics": cf.metrics,
|
| 105 |
})
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
if report.spatial_object_diagnosis is not None:
|
| 108 |
diag = report.spatial_object_diagnosis
|
| 109 |
evidence_data.append({
|
|
|
|
| 104 |
"metrics": cf.metrics,
|
| 105 |
})
|
| 106 |
|
| 107 |
+
if report.semantic_probe is not None:
|
| 108 |
+
evidence_data.append({
|
| 109 |
+
"phase": "representation",
|
| 110 |
+
"type": "semantic_probe",
|
| 111 |
+
"target_object": report.semantic_probe.target_object,
|
| 112 |
+
"summary": report.semantic_probe.summary,
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
if report.qk_probe is not None:
|
| 116 |
+
evidence_data.append({
|
| 117 |
+
"phase": "representation",
|
| 118 |
+
"type": "qk_probe",
|
| 119 |
+
"layer": report.qk_probe.layer,
|
| 120 |
+
"summary": report.qk_probe.summary,
|
| 121 |
+
"dominant_head_type": report.qk_probe.dominant_head_type,
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
if report.spatial_object_diagnosis is not None:
|
| 125 |
diag = report.spatial_object_diagnosis
|
| 126 |
evidence_data.append({
|
smolvla_inspect/diagnostic/semantic_probe.py
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic and QK probes for spatial-vs-object diagnostics."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from ..gradient import compute_gradcam_map, extract_siglip_last_layer_features
|
| 9 |
+
from .models import (
|
| 10 |
+
CounterfactualResult,
|
| 11 |
+
QKHeadSummary,
|
| 12 |
+
QKProbeReport,
|
| 13 |
+
SceneSegmentation,
|
| 14 |
+
SemanticCounterfactualSummary,
|
| 15 |
+
SemanticFrameSummary,
|
| 16 |
+
SemanticProbeReport,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
_DEFAULT_SIGLIP_MODEL = "google/siglip-base-patch16-512"
|
| 20 |
+
_SIGLIP_TEXT_CACHE: dict[tuple[str, str], tuple[object, object]] = {}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def semantic_candidate_labels(
|
| 24 |
+
scene: SceneSegmentation,
|
| 25 |
+
target_object: str | None = None,
|
| 26 |
+
) -> list[str]:
|
| 27 |
+
"""Collect unique non-gripper labels, keeping *target_object* first."""
|
| 28 |
+
labels = []
|
| 29 |
+
if target_object is not None:
|
| 30 |
+
labels.append(target_object)
|
| 31 |
+
for obj in scene.objects:
|
| 32 |
+
label = obj.label
|
| 33 |
+
if "gripper" in label.lower():
|
| 34 |
+
continue
|
| 35 |
+
if label not in labels:
|
| 36 |
+
labels.append(label)
|
| 37 |
+
return labels
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _infer_siglip_model_id(policy) -> str:
|
| 41 |
+
"""Best-effort guess of the matching SigLIP text tower."""
|
| 42 |
+
try:
|
| 43 |
+
from ..data import find_vision_encoder
|
| 44 |
+
|
| 45 |
+
vision_encoder = find_vision_encoder(policy)
|
| 46 |
+
except Exception:
|
| 47 |
+
vision_encoder = None
|
| 48 |
+
|
| 49 |
+
if vision_encoder is None:
|
| 50 |
+
return _DEFAULT_SIGLIP_MODEL
|
| 51 |
+
|
| 52 |
+
cfg = getattr(vision_encoder, "config", None)
|
| 53 |
+
image_size = getattr(cfg, "image_size", 512)
|
| 54 |
+
patch_size = getattr(cfg, "patch_size", 16)
|
| 55 |
+
hidden_size = getattr(cfg, "hidden_size", 768)
|
| 56 |
+
if hidden_size >= 1024:
|
| 57 |
+
return f"google/siglip-large-patch{patch_size}-{image_size}"
|
| 58 |
+
return f"google/siglip-base-patch{patch_size}-{image_size}"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _text_device(device: str | torch.device) -> str:
|
| 62 |
+
device_str = str(device)
|
| 63 |
+
if device_str.startswith(("cuda", "cpu")):
|
| 64 |
+
return device_str
|
| 65 |
+
return "cpu"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _get_siglip_text_encoder(model_id: str, device: str):
|
| 69 |
+
key = (model_id, device)
|
| 70 |
+
if key in _SIGLIP_TEXT_CACHE:
|
| 71 |
+
return _SIGLIP_TEXT_CACHE[key]
|
| 72 |
+
|
| 73 |
+
from transformers import AutoModel, AutoTokenizer
|
| 74 |
+
|
| 75 |
+
tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 76 |
+
model = AutoModel.from_pretrained(model_id).to(device).eval()
|
| 77 |
+
_SIGLIP_TEXT_CACHE[key] = (tokenizer, model)
|
| 78 |
+
return tokenizer, model
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _unique_labels(labels: list[str]) -> list[str]:
|
| 82 |
+
seen: set[str] = set()
|
| 83 |
+
result = []
|
| 84 |
+
for label in labels:
|
| 85 |
+
if not label or label in seen:
|
| 86 |
+
continue
|
| 87 |
+
seen.add(label)
|
| 88 |
+
result.append(label)
|
| 89 |
+
return result
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _resize_map(arr: np.ndarray, target_shape: tuple[int, int]) -> np.ndarray:
|
| 93 |
+
if arr.shape == target_shape:
|
| 94 |
+
return arr.astype(np.float32, copy=False)
|
| 95 |
+
tensor = torch.from_numpy(arr.astype(np.float32)).unsqueeze(0).unsqueeze(0)
|
| 96 |
+
resized = torch.nn.functional.interpolate(
|
| 97 |
+
tensor, size=target_shape, mode="bilinear", align_corners=False,
|
| 98 |
+
)
|
| 99 |
+
return resized.squeeze(0).squeeze(0).cpu().numpy()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _resize_mask(mask: np.ndarray, target_shape: tuple[int, int]) -> np.ndarray:
|
| 103 |
+
if mask.shape == target_shape:
|
| 104 |
+
return mask.astype(bool, copy=False)
|
| 105 |
+
tensor = torch.from_numpy(mask.astype(np.float32)).unsqueeze(0).unsqueeze(0)
|
| 106 |
+
resized = torch.nn.functional.interpolate(
|
| 107 |
+
tensor, size=target_shape, mode="nearest",
|
| 108 |
+
)
|
| 109 |
+
return resized.squeeze(0).squeeze(0).cpu().numpy() > 0.5
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _normalize_map(values: np.ndarray, valid_mask: np.ndarray) -> np.ndarray:
|
| 113 |
+
out = np.zeros_like(values, dtype=np.float32)
|
| 114 |
+
if not valid_mask.any():
|
| 115 |
+
return out
|
| 116 |
+
valid = values[valid_mask].astype(np.float32)
|
| 117 |
+
vmin = float(valid.min())
|
| 118 |
+
vmax = float(valid.max())
|
| 119 |
+
if vmax > vmin:
|
| 120 |
+
out[valid_mask] = (valid - vmin) / (vmax - vmin)
|
| 121 |
+
return out
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _topk_mask(values: np.ndarray, valid_mask: np.ndarray, pct: float = 0.05) -> np.ndarray:
|
| 125 |
+
mask = np.zeros_like(values, dtype=bool)
|
| 126 |
+
if not valid_mask.any():
|
| 127 |
+
return mask
|
| 128 |
+
flat = values[valid_mask]
|
| 129 |
+
k = max(4, int(np.ceil(flat.size * pct)))
|
| 130 |
+
k = min(k, flat.size)
|
| 131 |
+
threshold = np.partition(flat, flat.size - k)[flat.size - k]
|
| 132 |
+
mask[valid_mask] = values[valid_mask] >= threshold
|
| 133 |
+
return mask
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _masked_mean(values: np.ndarray, mask: np.ndarray) -> float | None:
|
| 137 |
+
region = mask.astype(bool)
|
| 138 |
+
if not region.any():
|
| 139 |
+
return None
|
| 140 |
+
return float(values[region].mean())
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _masked_max(values: np.ndarray, mask: np.ndarray) -> float | None:
|
| 144 |
+
region = mask.astype(bool)
|
| 145 |
+
if not region.any():
|
| 146 |
+
return None
|
| 147 |
+
return float(values[region].max())
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _weighted_mean(values: np.ndarray, weights: np.ndarray, valid_mask: np.ndarray) -> float | None:
|
| 151 |
+
weights = np.where(valid_mask, np.maximum(weights, 0.0), 0.0).astype(np.float32)
|
| 152 |
+
total = float(weights.sum())
|
| 153 |
+
if total <= 1e-8:
|
| 154 |
+
return None
|
| 155 |
+
return float((values * weights).sum() / total)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _pearson_corr(a: np.ndarray, b: np.ndarray, valid_mask: np.ndarray) -> float:
|
| 159 |
+
mask = valid_mask.astype(bool)
|
| 160 |
+
if mask.sum() < 4:
|
| 161 |
+
return 0.0
|
| 162 |
+
av = a[mask].astype(np.float32)
|
| 163 |
+
bv = b[mask].astype(np.float32)
|
| 164 |
+
av = av - av.mean()
|
| 165 |
+
bv = bv - bv.mean()
|
| 166 |
+
denom = float(np.linalg.norm(av) * np.linalg.norm(bv))
|
| 167 |
+
if denom <= 1e-8:
|
| 168 |
+
return 0.0
|
| 169 |
+
return float(np.dot(av, bv) / denom)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _map_mass(values: np.ndarray, region_mask: np.ndarray, valid_mask: np.ndarray) -> float:
|
| 173 |
+
if not valid_mask.any():
|
| 174 |
+
return 0.0
|
| 175 |
+
norm = _normalize_map(values, valid_mask)
|
| 176 |
+
total = float(norm[valid_mask].sum())
|
| 177 |
+
if total <= 1e-8:
|
| 178 |
+
return 0.0
|
| 179 |
+
return float(norm[region_mask & valid_mask].sum() / total)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _clone_sample(sample: dict, image_key: str) -> dict:
|
| 183 |
+
cloned = dict(sample)
|
| 184 |
+
cloned[image_key] = sample[image_key].clone()
|
| 185 |
+
return cloned
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _tensor_to_hwc(tensor: torch.Tensor) -> np.ndarray:
|
| 189 |
+
arr = tensor.detach().cpu().numpy()
|
| 190 |
+
if arr.ndim == 3 and arr.shape[0] in (1, 3):
|
| 191 |
+
arr = np.transpose(arr, (1, 2, 0))
|
| 192 |
+
return arr
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _hwc_to_tensor(arr: np.ndarray, device: str = "cpu") -> torch.Tensor:
|
| 196 |
+
if arr.ndim == 3 and arr.shape[2] in (1, 3):
|
| 197 |
+
arr = np.transpose(arr, (2, 0, 1))
|
| 198 |
+
return torch.from_numpy(arr.astype(np.float32)).to(device)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _shift_mask(mask: np.ndarray, shift_pixels: tuple[int, int]) -> np.ndarray:
|
| 202 |
+
dx, dy = shift_pixels
|
| 203 |
+
shifted = np.zeros_like(mask, dtype=bool)
|
| 204 |
+
ys, xs = np.where(mask)
|
| 205 |
+
if len(ys) == 0:
|
| 206 |
+
return shifted
|
| 207 |
+
ny = ys + dy
|
| 208 |
+
nx = xs + dx
|
| 209 |
+
valid = (ny >= 0) & (ny < mask.shape[0]) & (nx >= 0) & (nx < mask.shape[1])
|
| 210 |
+
shifted[ny[valid], nx[valid]] = True
|
| 211 |
+
return shifted
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _make_relocated_sample(sample: dict, image_key: str, segmentation: SceneSegmentation,
|
| 215 |
+
target_object: str, shift_pixels: tuple[int, int]) -> tuple[dict | None, dict]:
|
| 216 |
+
img_tensor = sample[image_key]
|
| 217 |
+
img_hwc = _tensor_to_hwc(img_tensor)
|
| 218 |
+
h, w = img_hwc.shape[:2]
|
| 219 |
+
obj_mask = segmentation.get_mask(target_object)
|
| 220 |
+
if obj_mask is None:
|
| 221 |
+
return None, {}
|
| 222 |
+
obj_mask = _resize_mask(obj_mask, (h, w))
|
| 223 |
+
dx, dy = shift_pixels
|
| 224 |
+
modified_hwc = img_hwc.copy()
|
| 225 |
+
ys, xs = np.where(obj_mask)
|
| 226 |
+
if len(ys) == 0:
|
| 227 |
+
return None, {}
|
| 228 |
+
|
| 229 |
+
fill_color = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
| 230 |
+
modified_hwc[obj_mask] = fill_color
|
| 231 |
+
for oy, ox in zip(ys, xs):
|
| 232 |
+
ny = oy + dy
|
| 233 |
+
nx = ox + dx
|
| 234 |
+
if 0 <= ny < h and 0 <= nx < w:
|
| 235 |
+
modified_hwc[ny, nx] = img_hwc[oy, ox]
|
| 236 |
+
modified_hwc = np.clip(modified_hwc, 0.0, 1.0)
|
| 237 |
+
|
| 238 |
+
moved_mask = _shift_mask(obj_mask, shift_pixels)
|
| 239 |
+
old_anchor_mask = obj_mask & ~moved_mask
|
| 240 |
+
moved_target_mask = moved_mask & ~obj_mask
|
| 241 |
+
if not old_anchor_mask.any():
|
| 242 |
+
old_anchor_mask = obj_mask
|
| 243 |
+
if not moved_target_mask.any():
|
| 244 |
+
moved_target_mask = moved_mask
|
| 245 |
+
|
| 246 |
+
new_sample = _clone_sample(sample, image_key)
|
| 247 |
+
new_sample[image_key] = _hwc_to_tensor(modified_hwc, device=str(img_tensor.device))
|
| 248 |
+
return new_sample, {
|
| 249 |
+
"original_mask": obj_mask,
|
| 250 |
+
"old_anchor_mask": old_anchor_mask,
|
| 251 |
+
"moved_target_mask": moved_target_mask,
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def compute_patch_text_similarity(
|
| 256 |
+
policy,
|
| 257 |
+
sample,
|
| 258 |
+
dataset,
|
| 259 |
+
image_key,
|
| 260 |
+
device,
|
| 261 |
+
labels: list[str],
|
| 262 |
+
*,
|
| 263 |
+
image_map=None,
|
| 264 |
+
capture_qk: bool = False,
|
| 265 |
+
):
|
| 266 |
+
"""Extract final-layer patch features and cosine maps for *labels*."""
|
| 267 |
+
labels = _unique_labels(labels)
|
| 268 |
+
if not labels:
|
| 269 |
+
return None
|
| 270 |
+
|
| 271 |
+
features = extract_siglip_last_layer_features(
|
| 272 |
+
policy, sample, dataset, image_key, device,
|
| 273 |
+
image_map=image_map, capture_qk=capture_qk,
|
| 274 |
+
)
|
| 275 |
+
if features is None:
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
model_id = _infer_siglip_model_id(policy)
|
| 279 |
+
device_str = _text_device(device)
|
| 280 |
+
try:
|
| 281 |
+
tokenizer, text_model = _get_siglip_text_encoder(model_id, device_str)
|
| 282 |
+
except Exception as exc:
|
| 283 |
+
if model_id != _DEFAULT_SIGLIP_MODEL:
|
| 284 |
+
try:
|
| 285 |
+
model_id = _DEFAULT_SIGLIP_MODEL
|
| 286 |
+
tokenizer, text_model = _get_siglip_text_encoder(model_id, device_str)
|
| 287 |
+
except Exception:
|
| 288 |
+
print(f" WARNING: SigLIP text encoder unavailable for semantic probe ({exc})")
|
| 289 |
+
return None
|
| 290 |
+
else:
|
| 291 |
+
print(f" WARNING: SigLIP text encoder unavailable for semantic probe ({exc})")
|
| 292 |
+
return None
|
| 293 |
+
|
| 294 |
+
with torch.no_grad():
|
| 295 |
+
inputs = tokenizer(labels, padding=True, truncation=True, return_tensors="pt")
|
| 296 |
+
inputs = {key: value.to(device_str) for key, value in inputs.items()}
|
| 297 |
+
text_embeddings = text_model.get_text_features(**inputs).float()
|
| 298 |
+
text_embeddings = torch.nn.functional.normalize(text_embeddings, dim=-1)
|
| 299 |
+
|
| 300 |
+
patch_features = torch.from_numpy(features["patch_features"]).float()
|
| 301 |
+
if patch_features.shape[-1] != text_embeddings.shape[-1]:
|
| 302 |
+
print(
|
| 303 |
+
" WARNING: SigLIP patch/text dimensions do not match for semantic probe "
|
| 304 |
+
f"({patch_features.shape[-1]} vs {text_embeddings.shape[-1]})"
|
| 305 |
+
)
|
| 306 |
+
return None
|
| 307 |
+
similarity_maps: dict[str, np.ndarray] = {}
|
| 308 |
+
for idx, label in enumerate(labels):
|
| 309 |
+
sim = torch.tensordot(patch_features, text_embeddings[idx].cpu(), dims=([-1], [0]))
|
| 310 |
+
similarity_maps[label] = sim.cpu().numpy()
|
| 311 |
+
|
| 312 |
+
result = dict(features)
|
| 313 |
+
result["labels"] = labels
|
| 314 |
+
result["model_id"] = model_id
|
| 315 |
+
result["similarity_maps"] = similarity_maps
|
| 316 |
+
return result
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def probe_semantic_frame(
|
| 320 |
+
policy,
|
| 321 |
+
sample,
|
| 322 |
+
dataset,
|
| 323 |
+
image_key,
|
| 324 |
+
device,
|
| 325 |
+
*,
|
| 326 |
+
target_object: str | None,
|
| 327 |
+
candidate_labels: list[str],
|
| 328 |
+
attention_heatmap: np.ndarray | None = None,
|
| 329 |
+
gradcam_map: np.ndarray | None = None,
|
| 330 |
+
image_map=None,
|
| 331 |
+
frame_id: str = "primary",
|
| 332 |
+
):
|
| 333 |
+
"""Build a semantic summary for one frame and return internal artifacts."""
|
| 334 |
+
if target_object is None:
|
| 335 |
+
return None, None
|
| 336 |
+
|
| 337 |
+
artifact = compute_patch_text_similarity(
|
| 338 |
+
policy, sample, dataset, image_key, device, candidate_labels,
|
| 339 |
+
image_map=image_map, capture_qk=False,
|
| 340 |
+
)
|
| 341 |
+
if artifact is None:
|
| 342 |
+
return None, None
|
| 343 |
+
|
| 344 |
+
similarity_maps = artifact["similarity_maps"]
|
| 345 |
+
valid_mask = artifact["valid_patch_mask"]
|
| 346 |
+
grid_shape = artifact["grid_size"]
|
| 347 |
+
target_map = similarity_maps.get(target_object)
|
| 348 |
+
if target_map is None:
|
| 349 |
+
return None, None
|
| 350 |
+
|
| 351 |
+
if gradcam_map is None:
|
| 352 |
+
try:
|
| 353 |
+
gradcam_map = compute_gradcam_map(
|
| 354 |
+
policy, sample, dataset, image_key, device, image_map=image_map,
|
| 355 |
+
)
|
| 356 |
+
except Exception:
|
| 357 |
+
gradcam_map = None
|
| 358 |
+
|
| 359 |
+
gradcam_grid = _resize_map(gradcam_map, grid_shape) if gradcam_map is not None else None
|
| 360 |
+
attention_grid = _resize_map(attention_heatmap, grid_shape) if attention_heatmap is not None else None
|
| 361 |
+
|
| 362 |
+
if gradcam_grid is not None:
|
| 363 |
+
gradcam_top = _topk_mask(gradcam_grid, valid_mask)
|
| 364 |
+
else:
|
| 365 |
+
gradcam_top = np.zeros(grid_shape, dtype=bool)
|
| 366 |
+
if attention_grid is not None:
|
| 367 |
+
attention_top = _topk_mask(attention_grid, valid_mask)
|
| 368 |
+
else:
|
| 369 |
+
attention_top = np.zeros(grid_shape, dtype=bool)
|
| 370 |
+
|
| 371 |
+
causal_mask = np.zeros(grid_shape, dtype=bool)
|
| 372 |
+
if gradcam_top.any() and attention_top.any():
|
| 373 |
+
causal_mask = gradcam_top & attention_top
|
| 374 |
+
if not causal_mask.any():
|
| 375 |
+
causal_mask = gradcam_top
|
| 376 |
+
elif gradcam_top.any():
|
| 377 |
+
causal_mask = gradcam_top
|
| 378 |
+
elif attention_top.any():
|
| 379 |
+
causal_mask = attention_top
|
| 380 |
+
else:
|
| 381 |
+
causal_mask = valid_mask.copy()
|
| 382 |
+
|
| 383 |
+
if gradcam_grid is not None:
|
| 384 |
+
causal_weights = _normalize_map(gradcam_grid, valid_mask)
|
| 385 |
+
elif attention_grid is not None:
|
| 386 |
+
causal_weights = _normalize_map(attention_grid, valid_mask)
|
| 387 |
+
else:
|
| 388 |
+
causal_weights = valid_mask.astype(np.float32)
|
| 389 |
+
causal_weights = np.where(causal_mask, causal_weights, 0.0)
|
| 390 |
+
|
| 391 |
+
target_peak = _masked_max(target_map, valid_mask)
|
| 392 |
+
target_mean_causal = _masked_mean(target_map, causal_mask & valid_mask)
|
| 393 |
+
causal_alignment = _weighted_mean(target_map, causal_weights, valid_mask)
|
| 394 |
+
background_mask = valid_mask & ~causal_mask
|
| 395 |
+
background_mean = _masked_mean(target_map, background_mask)
|
| 396 |
+
background_gap = None
|
| 397 |
+
if target_mean_causal is not None and background_mean is not None:
|
| 398 |
+
background_gap = float(target_mean_causal - background_mean)
|
| 399 |
+
|
| 400 |
+
non_target_scores = []
|
| 401 |
+
for label, sim_map in similarity_maps.items():
|
| 402 |
+
if label == target_object:
|
| 403 |
+
continue
|
| 404 |
+
score = _masked_mean(sim_map, causal_mask & valid_mask)
|
| 405 |
+
if score is not None:
|
| 406 |
+
non_target_scores.append((label, score))
|
| 407 |
+
|
| 408 |
+
best_non_target_label = None
|
| 409 |
+
target_margin = None
|
| 410 |
+
if target_mean_causal is not None and non_target_scores:
|
| 411 |
+
best_non_target_label, best_non_target_score = max(non_target_scores, key=lambda item: item[1])
|
| 412 |
+
target_margin = float(target_mean_causal - best_non_target_score)
|
| 413 |
+
|
| 414 |
+
summary_bits = []
|
| 415 |
+
if target_margin is not None:
|
| 416 |
+
if target_margin > 0.10:
|
| 417 |
+
summary_bits.append(
|
| 418 |
+
f"Causal patches align more strongly with '{target_object}' than other detected objects "
|
| 419 |
+
f"(margin={target_margin:.3f})."
|
| 420 |
+
)
|
| 421 |
+
elif target_margin < 0.0:
|
| 422 |
+
summary_bits.append(
|
| 423 |
+
f"Causal patches are not semantically distinctive for '{target_object}' "
|
| 424 |
+
f"(margin={target_margin:.3f})."
|
| 425 |
+
)
|
| 426 |
+
if background_gap is not None:
|
| 427 |
+
if background_gap > 0.10:
|
| 428 |
+
summary_bits.append(
|
| 429 |
+
f"Target semantics concentrate on causal patches rather than the low-causal background "
|
| 430 |
+
f"(gap={background_gap:.3f})."
|
| 431 |
+
)
|
| 432 |
+
elif background_gap < 0.0:
|
| 433 |
+
summary_bits.append(
|
| 434 |
+
f"Target semantics are no stronger on causal patches than on the low-causal background "
|
| 435 |
+
f"(gap={background_gap:.3f})."
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
frame = SemanticFrameSummary(
|
| 439 |
+
frame_id=frame_id,
|
| 440 |
+
target_object=target_object,
|
| 441 |
+
candidate_labels=candidate_labels,
|
| 442 |
+
best_non_target_label=best_non_target_label,
|
| 443 |
+
target_semantic_peak=target_peak,
|
| 444 |
+
target_semantic_mean_on_causal_patches=target_mean_causal,
|
| 445 |
+
target_margin_over_best_non_target=target_margin,
|
| 446 |
+
causal_semantic_alignment=causal_alignment,
|
| 447 |
+
background_semantic_gap=background_gap,
|
| 448 |
+
summary=" ".join(summary_bits) if summary_bits else "Semantic evidence was weak or unavailable on the causal patches.",
|
| 449 |
+
)
|
| 450 |
+
internal = {
|
| 451 |
+
"target_map": target_map,
|
| 452 |
+
"valid_patch_mask": valid_mask,
|
| 453 |
+
"causal_mask": causal_mask,
|
| 454 |
+
"causal_weights": causal_weights,
|
| 455 |
+
"similarity_maps": similarity_maps,
|
| 456 |
+
"artifact": artifact,
|
| 457 |
+
}
|
| 458 |
+
return frame, internal
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def summarize_semantic_probe(report: SemanticProbeReport | None) -> str:
|
| 462 |
+
"""Short summary string for prompts and logs."""
|
| 463 |
+
if report is None or report.primary_frame is None:
|
| 464 |
+
return "Semantic patch-to-text probe unavailable."
|
| 465 |
+
frame = report.primary_frame
|
| 466 |
+
bits = [f"Target object: {report.target_object or 'N/A'}."]
|
| 467 |
+
if frame.target_margin_over_best_non_target is not None:
|
| 468 |
+
bits.append(f"Target margin over best non-target: {frame.target_margin_over_best_non_target:.3f}.")
|
| 469 |
+
if frame.background_semantic_gap is not None:
|
| 470 |
+
bits.append(f"Background gap: {frame.background_semantic_gap:.3f}.")
|
| 471 |
+
bits.append(frame.summary)
|
| 472 |
+
return " ".join(bits)
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def build_semantic_probe_report(
|
| 476 |
+
target_object: str | None,
|
| 477 |
+
primary_frame: SemanticFrameSummary | None,
|
| 478 |
+
cf_results: list[CounterfactualResult],
|
| 479 |
+
) -> SemanticProbeReport | None:
|
| 480 |
+
"""Assemble the primary-frame and counterfactual semantic summaries."""
|
| 481 |
+
if target_object is None and primary_frame is None:
|
| 482 |
+
return None
|
| 483 |
+
|
| 484 |
+
counterfactuals: dict[str, SemanticCounterfactualSummary] = {}
|
| 485 |
+
for result in cf_results:
|
| 486 |
+
metrics = result.metrics or {}
|
| 487 |
+
if result.test_type == "object_relocation" and "semantic_follow_ratio" in metrics:
|
| 488 |
+
follow = float(metrics.get("semantic_follow_ratio", 0.0))
|
| 489 |
+
anchor = float(metrics.get("semantic_anchor_ratio", 0.0))
|
| 490 |
+
summary = (
|
| 491 |
+
f"After relocation, target semantics follow the moved object "
|
| 492 |
+
f"(follow={follow:.2f}, anchor={anchor:.2f})."
|
| 493 |
+
if follow >= anchor
|
| 494 |
+
else f"After relocation, target semantics stay closer to the old anchor "
|
| 495 |
+
f"(anchor={anchor:.2f}, follow={follow:.2f})."
|
| 496 |
+
)
|
| 497 |
+
counterfactuals[result.test_type] = SemanticCounterfactualSummary(
|
| 498 |
+
test_type=result.test_type,
|
| 499 |
+
summary=summary,
|
| 500 |
+
metrics=metrics,
|
| 501 |
+
)
|
| 502 |
+
elif result.test_type == "occlusion_targeted" and "occlusion_target_semantic_drop" in metrics:
|
| 503 |
+
drop = float(metrics.get("occlusion_target_semantic_drop", 0.0))
|
| 504 |
+
summary = (
|
| 505 |
+
f"Occluding the target reduces target-semantic evidence in the target region "
|
| 506 |
+
f"(drop={drop:.3f})."
|
| 507 |
+
if drop > 0
|
| 508 |
+
else f"Occluding the target does not reduce target-semantic evidence "
|
| 509 |
+
f"(drop={drop:.3f})."
|
| 510 |
+
)
|
| 511 |
+
counterfactuals[result.test_type] = SemanticCounterfactualSummary(
|
| 512 |
+
test_type=result.test_type,
|
| 513 |
+
summary=summary,
|
| 514 |
+
metrics=metrics,
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
if primary_frame is not None:
|
| 518 |
+
summary = primary_frame.summary
|
| 519 |
+
elif counterfactuals:
|
| 520 |
+
summary = "Semantic evidence is available only through counterfactual follow-through probes."
|
| 521 |
+
else:
|
| 522 |
+
summary = "Semantic patch-to-text probe unavailable."
|
| 523 |
+
|
| 524 |
+
frames = [primary_frame] if primary_frame is not None else []
|
| 525 |
+
return SemanticProbeReport(
|
| 526 |
+
target_object=target_object,
|
| 527 |
+
summary=summary,
|
| 528 |
+
primary_frame=primary_frame,
|
| 529 |
+
frames=frames,
|
| 530 |
+
counterfactuals=counterfactuals,
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def build_qk_probe_report(
|
| 535 |
+
policy,
|
| 536 |
+
sample,
|
| 537 |
+
dataset,
|
| 538 |
+
image_key,
|
| 539 |
+
device,
|
| 540 |
+
*,
|
| 541 |
+
target_object: str | None,
|
| 542 |
+
scene: SceneSegmentation,
|
| 543 |
+
target_semantic_map: np.ndarray | None,
|
| 544 |
+
positional_baseline: np.ndarray | None = None,
|
| 545 |
+
relocation_result: CounterfactualResult | None = None,
|
| 546 |
+
image_map=None,
|
| 547 |
+
) -> QKProbeReport | None:
|
| 548 |
+
"""Summarize last-layer SigLIP QK behavior with semantic-vs-positional evidence."""
|
| 549 |
+
if target_object is None or target_semantic_map is None:
|
| 550 |
+
return None
|
| 551 |
+
|
| 552 |
+
artifact = compute_patch_text_similarity(
|
| 553 |
+
policy, sample, dataset, image_key, device, [target_object],
|
| 554 |
+
image_map=image_map, capture_qk=True,
|
| 555 |
+
)
|
| 556 |
+
if artifact is None or "qk_key_maps" not in artifact:
|
| 557 |
+
return None
|
| 558 |
+
|
| 559 |
+
valid_mask = artifact["valid_patch_mask"]
|
| 560 |
+
grid_shape = artifact["grid_size"]
|
| 561 |
+
qk_maps = artifact["qk_key_maps"]
|
| 562 |
+
target_map = _resize_map(target_semantic_map, grid_shape)
|
| 563 |
+
positional_grid = (
|
| 564 |
+
_resize_map(positional_baseline, grid_shape)
|
| 565 |
+
if positional_baseline is not None
|
| 566 |
+
else np.zeros(grid_shape, dtype=np.float32)
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
+
target_mask = scene.get_mask(target_object)
|
| 570 |
+
target_grid_mask = (
|
| 571 |
+
_resize_mask(target_mask, grid_shape) & valid_mask
|
| 572 |
+
if target_mask is not None
|
| 573 |
+
else np.zeros(grid_shape, dtype=bool)
|
| 574 |
+
)
|
| 575 |
+
background_grid_mask = _resize_mask(scene.background_mask, grid_shape) & valid_mask
|
| 576 |
+
|
| 577 |
+
relocation_old_mask = None
|
| 578 |
+
relocation_new_mask = None
|
| 579 |
+
relocated_qk_maps = None
|
| 580 |
+
if relocation_result is not None:
|
| 581 |
+
metrics = relocation_result.metrics or {}
|
| 582 |
+
shift = metrics.get("shift_pixels")
|
| 583 |
+
if isinstance(shift, (list, tuple)) and len(shift) == 2:
|
| 584 |
+
relocated_sample, relocated_masks = _make_relocated_sample(
|
| 585 |
+
sample, image_key, scene, target_object, (int(shift[0]), int(shift[1])),
|
| 586 |
+
)
|
| 587 |
+
if relocated_sample is not None:
|
| 588 |
+
relocated_artifact = compute_patch_text_similarity(
|
| 589 |
+
policy, relocated_sample, dataset, image_key, device, [target_object],
|
| 590 |
+
image_map=image_map, capture_qk=True,
|
| 591 |
+
)
|
| 592 |
+
if relocated_artifact is not None and "qk_key_maps" in relocated_artifact:
|
| 593 |
+
relocated_qk_maps = relocated_artifact["qk_key_maps"]
|
| 594 |
+
relocation_old_mask = _resize_mask(relocated_masks["old_anchor_mask"], grid_shape) & valid_mask
|
| 595 |
+
relocation_new_mask = _resize_mask(relocated_masks["moved_target_mask"], grid_shape) & valid_mask
|
| 596 |
+
|
| 597 |
+
head_summaries: list[QKHeadSummary] = []
|
| 598 |
+
for head_index, head_map in enumerate(qk_maps):
|
| 599 |
+
semantic_corr = _pearson_corr(head_map, target_map, valid_mask)
|
| 600 |
+
positional_corr = _pearson_corr(head_map, positional_grid, valid_mask)
|
| 601 |
+
target_mass = _map_mass(head_map, target_grid_mask, valid_mask)
|
| 602 |
+
background_mass = _map_mass(head_map, background_grid_mask, valid_mask)
|
| 603 |
+
|
| 604 |
+
old_anchor_mass = None
|
| 605 |
+
moved_object_mass = None
|
| 606 |
+
relocation_bias = 0.0
|
| 607 |
+
if relocated_qk_maps is not None and relocation_old_mask is not None and relocation_new_mask is not None:
|
| 608 |
+
old_anchor_mass = _map_mass(relocated_qk_maps[head_index], relocation_old_mask, valid_mask)
|
| 609 |
+
moved_object_mass = _map_mass(relocated_qk_maps[head_index], relocation_new_mask, valid_mask)
|
| 610 |
+
relocation_bias = moved_object_mass - old_anchor_mass
|
| 611 |
+
|
| 612 |
+
semantic_adv = semantic_corr - positional_corr
|
| 613 |
+
if relocation_bias > 0.05:
|
| 614 |
+
semantic_adv += 0.10
|
| 615 |
+
elif relocation_bias < -0.05:
|
| 616 |
+
semantic_adv -= 0.10
|
| 617 |
+
|
| 618 |
+
if semantic_adv > 0.08:
|
| 619 |
+
head_type = "semantic"
|
| 620 |
+
elif semantic_adv < -0.08:
|
| 621 |
+
head_type = "positional"
|
| 622 |
+
else:
|
| 623 |
+
head_type = "mixed"
|
| 624 |
+
|
| 625 |
+
score = max(abs(semantic_adv), abs(relocation_bias), abs(target_mass - background_mass))
|
| 626 |
+
head_summaries.append(QKHeadSummary(
|
| 627 |
+
head_index=head_index,
|
| 628 |
+
head_type=head_type,
|
| 629 |
+
score=score,
|
| 630 |
+
semantic_map_correlation=semantic_corr,
|
| 631 |
+
positional_baseline_correlation=positional_corr,
|
| 632 |
+
target_region_logit_mass=target_mass,
|
| 633 |
+
background_logit_mass=background_mass,
|
| 634 |
+
old_anchor_logit_mass=old_anchor_mass,
|
| 635 |
+
moved_object_logit_mass=moved_object_mass,
|
| 636 |
+
))
|
| 637 |
+
|
| 638 |
+
if not head_summaries:
|
| 639 |
+
return None
|
| 640 |
+
|
| 641 |
+
semantic_count = sum(1 for head in head_summaries if head.head_type == "semantic")
|
| 642 |
+
positional_count = sum(1 for head in head_summaries if head.head_type == "positional")
|
| 643 |
+
mixed_count = sum(1 for head in head_summaries if head.head_type == "mixed")
|
| 644 |
+
total = len(head_summaries)
|
| 645 |
+
|
| 646 |
+
semantic_fraction = semantic_count / total
|
| 647 |
+
positional_fraction = positional_count / total
|
| 648 |
+
mixed_fraction = mixed_count / total
|
| 649 |
+
|
| 650 |
+
if semantic_fraction > positional_fraction + 0.15:
|
| 651 |
+
dominant = "semantic"
|
| 652 |
+
summary = (
|
| 653 |
+
f"Most last-layer SigLIP heads align more with target semantics than with the positional baseline "
|
| 654 |
+
f"({semantic_fraction:.0%} semantic vs {positional_fraction:.0%} positional)."
|
| 655 |
+
)
|
| 656 |
+
elif positional_fraction > semantic_fraction + 0.15:
|
| 657 |
+
dominant = "positional"
|
| 658 |
+
summary = (
|
| 659 |
+
f"Most last-layer SigLIP heads align more with the positional baseline than with target semantics "
|
| 660 |
+
f"({positional_fraction:.0%} positional vs {semantic_fraction:.0%} semantic)."
|
| 661 |
+
)
|
| 662 |
+
else:
|
| 663 |
+
dominant = "mixed"
|
| 664 |
+
summary = (
|
| 665 |
+
f"Last-layer SigLIP heads split between semantic and positional signals "
|
| 666 |
+
f"({semantic_fraction:.0%} semantic, {positional_fraction:.0%} positional, {mixed_fraction:.0%} mixed)."
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
top_heads = sorted(head_summaries, key=lambda head: head.score, reverse=True)[:3]
|
| 670 |
+
return QKProbeReport(
|
| 671 |
+
layer="siglip_last",
|
| 672 |
+
summary=summary,
|
| 673 |
+
dominant_head_type=dominant,
|
| 674 |
+
semantic_head_fraction=semantic_fraction,
|
| 675 |
+
positional_head_fraction=positional_fraction,
|
| 676 |
+
mixed_head_fraction=mixed_fraction,
|
| 677 |
+
top_heads=top_heads,
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
def summarize_qk_probe(report: QKProbeReport | None) -> str:
|
| 682 |
+
"""Short summary string for prompts and logs."""
|
| 683 |
+
if report is None:
|
| 684 |
+
return "QK decomposition unavailable."
|
| 685 |
+
return report.summary
|
smolvla_inspect/diagnostic/spatial_object.py
CHANGED
|
@@ -7,7 +7,9 @@ from .models import (
|
|
| 7 |
DatasetDiversityReport,
|
| 8 |
DiagnosticEvidence,
|
| 9 |
DiagnosticMatrix,
|
|
|
|
| 10 |
SceneSegmentation,
|
|
|
|
| 11 |
SpatialObjectDiagnosis,
|
| 12 |
)
|
| 13 |
|
|
@@ -100,6 +102,8 @@ def build_spatial_object_diagnosis(
|
|
| 100 |
target_object: str | None,
|
| 101 |
cf_results: list[CounterfactualResult],
|
| 102 |
dataset_diversity: DatasetDiversityReport | None = None,
|
|
|
|
|
|
|
| 103 |
) -> SpatialObjectDiagnosis:
|
| 104 |
"""Summarize whether the policy is reading a location or an object."""
|
| 105 |
spatial_evidence: list[DiagnosticEvidence] = []
|
|
@@ -182,6 +186,72 @@ def build_spatial_object_diagnosis(
|
|
| 182 |
{"target_object": target_object, "target_attention_share": attention_share},
|
| 183 |
)
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
for traj in matrix.temporal_trajectories or []:
|
| 186 |
corr = float(traj.object_tracking_correlation)
|
| 187 |
key_metrics["object_tracking_correlation"] = max(
|
|
@@ -228,10 +298,16 @@ def build_spatial_object_diagnosis(
|
|
| 228 |
metrics = relocation.metrics or {}
|
| 229 |
follow_ratio = metrics.get("focus_follow_ratio")
|
| 230 |
anchor_ratio = metrics.get("anchor_retention_ratio")
|
|
|
|
|
|
|
| 231 |
if follow_ratio is not None:
|
| 232 |
key_metrics["relocation_follow_ratio"] = float(follow_ratio)
|
| 233 |
if anchor_ratio is not None:
|
| 234 |
key_metrics["relocation_anchor_ratio"] = float(anchor_ratio)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
if follow_ratio is not None and anchor_ratio is not None:
|
| 237 |
add_object(
|
|
@@ -248,6 +324,23 @@ def build_spatial_object_diagnosis(
|
|
| 248 |
f"After relocation, GradCAM stays anchored to the old location more than the moved object (anchor={float(anchor_ratio):.2f}, follow={float(follow_ratio):.2f}).",
|
| 249 |
metrics,
|
| 250 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
occlusion = _pick_counterfactual(
|
| 253 |
cf_results,
|
|
@@ -257,6 +350,7 @@ def build_spatial_object_diagnosis(
|
|
| 257 |
if occlusion is not None:
|
| 258 |
delta = float(occlusion.action_delta_l2)
|
| 259 |
key_metrics["occlusion_action_delta"] = delta
|
|
|
|
| 260 |
add_object(
|
| 261 |
"target_occlusion_response",
|
| 262 |
_scale(delta, 0.02, 0.10),
|
|
@@ -271,6 +365,25 @@ def build_spatial_object_diagnosis(
|
|
| 271 |
f"Occluding '{target_object}' barely changes the predicted action (delta={delta:.4f}).",
|
| 272 |
{"target_object": target_object, "action_delta_l2": delta},
|
| 273 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
background = _pick_counterfactual(cf_results, "background_substitution")
|
| 276 |
if background is not None:
|
|
@@ -300,6 +413,34 @@ def build_spatial_object_diagnosis(
|
|
| 300 |
{"target_object": target_object, "action_delta_l2": delta},
|
| 301 |
)
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
spatial_score = spatial_sum / spatial_weight if spatial_weight > 1e-8 else 0.0
|
| 304 |
object_score = object_sum / object_weight if object_weight > 1e-8 else 0.0
|
| 305 |
|
|
@@ -340,7 +481,7 @@ def build_spatial_object_diagnosis(
|
|
| 340 |
summary = (
|
| 341 |
f"The available evidence is not strong enough to cleanly separate spatial-prior use from "
|
| 342 |
f"object grounding for '{target_object}'. The model may be weakly grounded, weakly shortcut-driven, "
|
| 343 |
-
|
| 344 |
)
|
| 345 |
|
| 346 |
return SpatialObjectDiagnosis(
|
|
|
|
| 7 |
DatasetDiversityReport,
|
| 8 |
DiagnosticEvidence,
|
| 9 |
DiagnosticMatrix,
|
| 10 |
+
QKProbeReport,
|
| 11 |
SceneSegmentation,
|
| 12 |
+
SemanticProbeReport,
|
| 13 |
SpatialObjectDiagnosis,
|
| 14 |
)
|
| 15 |
|
|
|
|
| 102 |
target_object: str | None,
|
| 103 |
cf_results: list[CounterfactualResult],
|
| 104 |
dataset_diversity: DatasetDiversityReport | None = None,
|
| 105 |
+
semantic_probe: SemanticProbeReport | None = None,
|
| 106 |
+
qk_probe: QKProbeReport | None = None,
|
| 107 |
) -> SpatialObjectDiagnosis:
|
| 108 |
"""Summarize whether the policy is reading a location or an object."""
|
| 109 |
spatial_evidence: list[DiagnosticEvidence] = []
|
|
|
|
| 186 |
{"target_object": target_object, "target_attention_share": attention_share},
|
| 187 |
)
|
| 188 |
|
| 189 |
+
if semantic_probe is not None and semantic_probe.primary_frame is not None:
|
| 190 |
+
frame = semantic_probe.primary_frame
|
| 191 |
+
if frame.target_semantic_peak is not None:
|
| 192 |
+
key_metrics["target_semantic_peak"] = float(frame.target_semantic_peak)
|
| 193 |
+
if frame.target_semantic_mean_on_causal_patches is not None:
|
| 194 |
+
key_metrics["target_semantic_mean_on_causal_patches"] = float(
|
| 195 |
+
frame.target_semantic_mean_on_causal_patches
|
| 196 |
+
)
|
| 197 |
+
if frame.target_margin_over_best_non_target is not None:
|
| 198 |
+
margin = float(frame.target_margin_over_best_non_target)
|
| 199 |
+
key_metrics["target_margin_over_best_non_target"] = margin
|
| 200 |
+
add_object(
|
| 201 |
+
"semantic_target_margin",
|
| 202 |
+
_scale(margin, 0.02, 0.18),
|
| 203 |
+
0.18,
|
| 204 |
+
f"Causal patches look more like '{target_object}' than other detected objects "
|
| 205 |
+
f"(margin={margin:.3f}).",
|
| 206 |
+
{
|
| 207 |
+
"target_object": target_object,
|
| 208 |
+
"best_non_target_label": frame.best_non_target_label,
|
| 209 |
+
"target_margin_over_best_non_target": margin,
|
| 210 |
+
},
|
| 211 |
+
)
|
| 212 |
+
add_spatial(
|
| 213 |
+
"weak_semantic_target_margin",
|
| 214 |
+
_scale(0.05 - margin, 0.01, 0.10),
|
| 215 |
+
0.14,
|
| 216 |
+
f"Causal patches are not semantically distinctive for '{target_object}' "
|
| 217 |
+
f"(margin={margin:.3f}).",
|
| 218 |
+
{
|
| 219 |
+
"target_object": target_object,
|
| 220 |
+
"best_non_target_label": frame.best_non_target_label,
|
| 221 |
+
"target_margin_over_best_non_target": margin,
|
| 222 |
+
},
|
| 223 |
+
)
|
| 224 |
+
if frame.causal_semantic_alignment is not None:
|
| 225 |
+
alignment = float(frame.causal_semantic_alignment)
|
| 226 |
+
key_metrics["causal_semantic_alignment"] = alignment
|
| 227 |
+
add_object(
|
| 228 |
+
"causal_semantic_alignment",
|
| 229 |
+
_scale(alignment, 0.15, 0.45),
|
| 230 |
+
0.10,
|
| 231 |
+
f"High-causal patches contain clear target semantics for '{target_object}' "
|
| 232 |
+
f"(alignment={alignment:.3f}).",
|
| 233 |
+
{"target_object": target_object, "causal_semantic_alignment": alignment},
|
| 234 |
+
)
|
| 235 |
+
if frame.background_semantic_gap is not None:
|
| 236 |
+
bg_gap = float(frame.background_semantic_gap)
|
| 237 |
+
key_metrics["background_semantic_gap"] = bg_gap
|
| 238 |
+
add_object(
|
| 239 |
+
"background_semantic_gap",
|
| 240 |
+
_scale(bg_gap, 0.03, 0.18),
|
| 241 |
+
0.10,
|
| 242 |
+
f"Target semantics are stronger on high-causal patches than on the low-causal background "
|
| 243 |
+
f"(gap={bg_gap:.3f}).",
|
| 244 |
+
{"target_object": target_object, "background_semantic_gap": bg_gap},
|
| 245 |
+
)
|
| 246 |
+
add_spatial(
|
| 247 |
+
"low_background_semantic_gap",
|
| 248 |
+
_scale(0.04 - bg_gap, 0.01, 0.08),
|
| 249 |
+
0.10,
|
| 250 |
+
f"Target semantics are no stronger on causal patches than on the surrounding background "
|
| 251 |
+
f"(gap={bg_gap:.3f}).",
|
| 252 |
+
{"target_object": target_object, "background_semantic_gap": bg_gap},
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
for traj in matrix.temporal_trajectories or []:
|
| 256 |
corr = float(traj.object_tracking_correlation)
|
| 257 |
key_metrics["object_tracking_correlation"] = max(
|
|
|
|
| 298 |
metrics = relocation.metrics or {}
|
| 299 |
follow_ratio = metrics.get("focus_follow_ratio")
|
| 300 |
anchor_ratio = metrics.get("anchor_retention_ratio")
|
| 301 |
+
semantic_follow_ratio = metrics.get("semantic_follow_ratio")
|
| 302 |
+
semantic_anchor_ratio = metrics.get("semantic_anchor_ratio")
|
| 303 |
if follow_ratio is not None:
|
| 304 |
key_metrics["relocation_follow_ratio"] = float(follow_ratio)
|
| 305 |
if anchor_ratio is not None:
|
| 306 |
key_metrics["relocation_anchor_ratio"] = float(anchor_ratio)
|
| 307 |
+
if semantic_follow_ratio is not None:
|
| 308 |
+
key_metrics["relocation_semantic_follow_ratio"] = float(semantic_follow_ratio)
|
| 309 |
+
if semantic_anchor_ratio is not None:
|
| 310 |
+
key_metrics["relocation_semantic_anchor_ratio"] = float(semantic_anchor_ratio)
|
| 311 |
|
| 312 |
if follow_ratio is not None and anchor_ratio is not None:
|
| 313 |
add_object(
|
|
|
|
| 324 |
f"After relocation, GradCAM stays anchored to the old location more than the moved object (anchor={float(anchor_ratio):.2f}, follow={float(follow_ratio):.2f}).",
|
| 325 |
metrics,
|
| 326 |
)
|
| 327 |
+
if semantic_follow_ratio is not None and semantic_anchor_ratio is not None:
|
| 328 |
+
add_object(
|
| 329 |
+
"semantic_relocation_follow_probe",
|
| 330 |
+
_scale(float(semantic_follow_ratio) - float(semantic_anchor_ratio), 0.05, 0.35),
|
| 331 |
+
0.20,
|
| 332 |
+
f"After relocation, target semantics follow the moved object more than the old anchor "
|
| 333 |
+
f"(follow={float(semantic_follow_ratio):.2f}, anchor={float(semantic_anchor_ratio):.2f}).",
|
| 334 |
+
metrics,
|
| 335 |
+
)
|
| 336 |
+
add_spatial(
|
| 337 |
+
"semantic_relocation_anchor_probe",
|
| 338 |
+
_scale(float(semantic_anchor_ratio) - float(semantic_follow_ratio), 0.05, 0.35),
|
| 339 |
+
0.20,
|
| 340 |
+
f"After relocation, target semantics stay more anchored to the old location than to the moved object "
|
| 341 |
+
f"(anchor={float(semantic_anchor_ratio):.2f}, follow={float(semantic_follow_ratio):.2f}).",
|
| 342 |
+
metrics,
|
| 343 |
+
)
|
| 344 |
|
| 345 |
occlusion = _pick_counterfactual(
|
| 346 |
cf_results,
|
|
|
|
| 350 |
if occlusion is not None:
|
| 351 |
delta = float(occlusion.action_delta_l2)
|
| 352 |
key_metrics["occlusion_action_delta"] = delta
|
| 353 |
+
semantic_drop = occlusion.metrics.get("occlusion_target_semantic_drop") if occlusion.metrics else None
|
| 354 |
add_object(
|
| 355 |
"target_occlusion_response",
|
| 356 |
_scale(delta, 0.02, 0.10),
|
|
|
|
| 365 |
f"Occluding '{target_object}' barely changes the predicted action (delta={delta:.4f}).",
|
| 366 |
{"target_object": target_object, "action_delta_l2": delta},
|
| 367 |
)
|
| 368 |
+
if semantic_drop is not None:
|
| 369 |
+
semantic_drop = float(semantic_drop)
|
| 370 |
+
key_metrics["occlusion_target_semantic_drop"] = semantic_drop
|
| 371 |
+
add_object(
|
| 372 |
+
"target_occlusion_semantic_drop",
|
| 373 |
+
_scale(semantic_drop, 0.03, 0.20),
|
| 374 |
+
0.12,
|
| 375 |
+
f"Occluding '{target_object}' removes target-semantic evidence from the target region "
|
| 376 |
+
f"(drop={semantic_drop:.3f}).",
|
| 377 |
+
{"target_object": target_object, "occlusion_target_semantic_drop": semantic_drop},
|
| 378 |
+
)
|
| 379 |
+
add_spatial(
|
| 380 |
+
"target_occlusion_semantic_insensitivity",
|
| 381 |
+
_scale(0.02 - semantic_drop, 0.005, 0.02),
|
| 382 |
+
0.08,
|
| 383 |
+
f"Occluding '{target_object}' does not materially reduce target-semantic evidence "
|
| 384 |
+
f"(drop={semantic_drop:.3f}).",
|
| 385 |
+
{"target_object": target_object, "occlusion_target_semantic_drop": semantic_drop},
|
| 386 |
+
)
|
| 387 |
|
| 388 |
background = _pick_counterfactual(cf_results, "background_substitution")
|
| 389 |
if background is not None:
|
|
|
|
| 413 |
{"target_object": target_object, "action_delta_l2": delta},
|
| 414 |
)
|
| 415 |
|
| 416 |
+
if qk_probe is not None:
|
| 417 |
+
key_metrics["qk_semantic_head_fraction"] = qk_probe.semantic_head_fraction
|
| 418 |
+
key_metrics["qk_positional_head_fraction"] = qk_probe.positional_head_fraction
|
| 419 |
+
if qk_probe.dominant_head_type == "semantic":
|
| 420 |
+
add_object(
|
| 421 |
+
"qk_semantic_heads",
|
| 422 |
+
_scale(
|
| 423 |
+
qk_probe.semantic_head_fraction - qk_probe.positional_head_fraction,
|
| 424 |
+
0.05,
|
| 425 |
+
0.40,
|
| 426 |
+
),
|
| 427 |
+
0.14,
|
| 428 |
+
qk_probe.summary,
|
| 429 |
+
{"dominant_head_type": qk_probe.dominant_head_type},
|
| 430 |
+
)
|
| 431 |
+
elif qk_probe.dominant_head_type == "positional":
|
| 432 |
+
add_spatial(
|
| 433 |
+
"qk_positional_heads",
|
| 434 |
+
_scale(
|
| 435 |
+
qk_probe.positional_head_fraction - qk_probe.semantic_head_fraction,
|
| 436 |
+
0.05,
|
| 437 |
+
0.40,
|
| 438 |
+
),
|
| 439 |
+
0.14,
|
| 440 |
+
qk_probe.summary,
|
| 441 |
+
{"dominant_head_type": qk_probe.dominant_head_type},
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
spatial_score = spatial_sum / spatial_weight if spatial_weight > 1e-8 else 0.0
|
| 445 |
object_score = object_sum / object_weight if object_weight > 1e-8 else 0.0
|
| 446 |
|
|
|
|
| 481 |
summary = (
|
| 482 |
f"The available evidence is not strong enough to cleanly separate spatial-prior use from "
|
| 483 |
f"object grounding for '{target_object}'. The model may be weakly grounded, weakly shortcut-driven, "
|
| 484 |
+
"or the available probes may be too noisy."
|
| 485 |
)
|
| 486 |
|
| 487 |
return SpatialObjectDiagnosis(
|
smolvla_inspect/gradient.py
CHANGED
|
@@ -11,6 +11,7 @@ import warnings
|
|
| 11 |
|
| 12 |
import numpy as np
|
| 13 |
import torch
|
|
|
|
| 14 |
|
| 15 |
try:
|
| 16 |
from tqdm import tqdm
|
|
@@ -25,6 +26,8 @@ from .data import (
|
|
| 25 |
_resolve_task_string,
|
| 26 |
find_vision_encoder,
|
| 27 |
)
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
# ---------------------------------------------------------------------------
|
|
@@ -234,6 +237,196 @@ def _gradcam_from_captured(activations, gradients, spatial_shape=None):
|
|
| 234 |
return cam.detach().float().cpu().numpy()
|
| 235 |
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
# ---------------------------------------------------------------------------
|
| 238 |
# Saliency map (vanilla input-gradient)
|
| 239 |
# ---------------------------------------------------------------------------
|
|
|
|
| 11 |
|
| 12 |
import numpy as np
|
| 13 |
import torch
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
|
| 16 |
try:
|
| 17 |
from tqdm import tqdm
|
|
|
|
| 26 |
_resolve_task_string,
|
| 27 |
find_vision_encoder,
|
| 28 |
)
|
| 29 |
+
from ._compat import resize_with_pad
|
| 30 |
+
from .heatmap import compute_padding_patches
|
| 31 |
|
| 32 |
|
| 33 |
# ---------------------------------------------------------------------------
|
|
|
|
| 237 |
return cam.detach().float().cpu().numpy()
|
| 238 |
|
| 239 |
|
| 240 |
+
def _prepare_siglip_vision_inputs(sample, image_key, vision_encoder, device):
|
| 241 |
+
"""Normalize a sample image for direct SigLIP vision-encoder forward passes."""
|
| 242 |
+
img = sample[image_key].unsqueeze(0).to(device)
|
| 243 |
+
if img.max() > 1.0:
|
| 244 |
+
img = img.float() / 255.0
|
| 245 |
+
|
| 246 |
+
original_hw = (int(img.shape[-2]), int(img.shape[-1]))
|
| 247 |
+
target_size = getattr(
|
| 248 |
+
getattr(vision_encoder, "config", None), "image_size", None,
|
| 249 |
+
) or getattr(vision_encoder, "image_size", 384)
|
| 250 |
+
if img.shape[-2] != target_size or img.shape[-1] != target_size:
|
| 251 |
+
img_resized = resize_with_pad(img, target_size, target_size, pad_value=0)
|
| 252 |
+
else:
|
| 253 |
+
img_resized = img
|
| 254 |
+
img_resized = img_resized * 2.0 - 1.0
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
enc_dtype = next(vision_encoder.parameters()).dtype
|
| 258 |
+
img_resized = img_resized.to(enc_dtype)
|
| 259 |
+
except StopIteration:
|
| 260 |
+
pass
|
| 261 |
+
|
| 262 |
+
patch_size = getattr(vision_encoder, "patch_size", None) or getattr(
|
| 263 |
+
getattr(vision_encoder, "config", None), "patch_size", 14,
|
| 264 |
+
)
|
| 265 |
+
grid_h = int(img_resized.size(2) // patch_size)
|
| 266 |
+
grid_w = int(img_resized.size(3) // patch_size)
|
| 267 |
+
patch_mask = torch.ones(1, grid_h, grid_w, dtype=torch.bool, device=device)
|
| 268 |
+
|
| 269 |
+
crop_h, crop_w = compute_padding_patches(original_hw, target_size, patch_size)
|
| 270 |
+
valid_patch_mask = np.ones((grid_h, grid_w), dtype=bool)
|
| 271 |
+
if crop_h > 0:
|
| 272 |
+
valid_patch_mask[:crop_h, :] = False
|
| 273 |
+
if crop_w > 0:
|
| 274 |
+
valid_patch_mask[:, :crop_w] = False
|
| 275 |
+
|
| 276 |
+
return img_resized, patch_mask, {
|
| 277 |
+
"grid_size": (grid_h, grid_w),
|
| 278 |
+
"image_shape": original_hw,
|
| 279 |
+
"patch_size": patch_size,
|
| 280 |
+
"content_crop": (crop_h, crop_w),
|
| 281 |
+
"valid_patch_mask": valid_patch_mask,
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _forward_siglip_vision_encoder(vision_encoder, img_resized, patch_mask):
|
| 286 |
+
"""Run a direct forward pass through the SigLIP vision encoder."""
|
| 287 |
+
if hasattr(vision_encoder, "embeddings") and hasattr(vision_encoder, "encoder"):
|
| 288 |
+
embeddings = vision_encoder.embeddings(img_resized, patch_mask)
|
| 289 |
+
return vision_encoder.encoder(embeddings)
|
| 290 |
+
if hasattr(vision_encoder, "forward"):
|
| 291 |
+
return vision_encoder(img_resized)
|
| 292 |
+
return vision_encoder(pixel_values=img_resized, patch_attention_mask=patch_mask)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def extract_siglip_last_layer_features(
|
| 296 |
+
policy,
|
| 297 |
+
sample,
|
| 298 |
+
dataset,
|
| 299 |
+
image_key,
|
| 300 |
+
device,
|
| 301 |
+
*,
|
| 302 |
+
image_map=None,
|
| 303 |
+
capture_qk: bool = False,
|
| 304 |
+
):
|
| 305 |
+
"""Extract final-layer SigLIP patch features, with optional QK key-logit maps."""
|
| 306 |
+
vision_encoder = find_vision_encoder(policy)
|
| 307 |
+
if vision_encoder is None:
|
| 308 |
+
print(" WARNING: Could not find vision encoder for semantic probe")
|
| 309 |
+
return None
|
| 310 |
+
|
| 311 |
+
try:
|
| 312 |
+
last_layer = vision_encoder.encoder.layers[-1]
|
| 313 |
+
except (AttributeError, IndexError):
|
| 314 |
+
print(" WARNING: Could not access last SigLIP encoder layer")
|
| 315 |
+
return None
|
| 316 |
+
|
| 317 |
+
last_attn = getattr(last_layer, "self_attn", None) or getattr(last_layer, "attention", None)
|
| 318 |
+
if capture_qk and last_attn is None:
|
| 319 |
+
print(" WARNING: Could not access last-layer attention module for QK probe")
|
| 320 |
+
capture_qk = False
|
| 321 |
+
|
| 322 |
+
img_resized, patch_mask, meta = _prepare_siglip_vision_inputs(
|
| 323 |
+
sample, image_key, vision_encoder, device,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
activations: dict[str, torch.Tensor] = {}
|
| 327 |
+
q_capture: dict[str, torch.Tensor] = {}
|
| 328 |
+
k_capture: dict[str, torch.Tensor] = {}
|
| 329 |
+
attn_input: dict[str, torch.Tensor] = {}
|
| 330 |
+
handles = []
|
| 331 |
+
|
| 332 |
+
def _layer_hook(module, input_args, output):
|
| 333 |
+
out = output[0] if isinstance(output, tuple) else output
|
| 334 |
+
activations["value"] = out.detach()
|
| 335 |
+
return output
|
| 336 |
+
|
| 337 |
+
handles.append(last_layer.register_forward_hook(_layer_hook))
|
| 338 |
+
|
| 339 |
+
if capture_qk:
|
| 340 |
+
if hasattr(last_attn, "q_proj") and hasattr(last_attn, "k_proj"):
|
| 341 |
+
def _q_hook(module, input_args, output):
|
| 342 |
+
q_capture.setdefault("value", output.detach())
|
| 343 |
+
return output
|
| 344 |
+
|
| 345 |
+
def _k_hook(module, input_args, output):
|
| 346 |
+
k_capture.setdefault("value", output.detach())
|
| 347 |
+
return output
|
| 348 |
+
|
| 349 |
+
handles.append(last_attn.q_proj.register_forward_hook(_q_hook))
|
| 350 |
+
handles.append(last_attn.k_proj.register_forward_hook(_k_hook))
|
| 351 |
+
elif isinstance(last_attn, torch.nn.MultiheadAttention):
|
| 352 |
+
def _pre_hook(module, input_args):
|
| 353 |
+
attn_input.setdefault("value", input_args[0].detach())
|
| 354 |
+
return None
|
| 355 |
+
|
| 356 |
+
handles.append(last_attn.register_forward_pre_hook(_pre_hook))
|
| 357 |
+
|
| 358 |
+
try:
|
| 359 |
+
with torch.no_grad():
|
| 360 |
+
try:
|
| 361 |
+
_forward_siglip_vision_encoder(vision_encoder, img_resized, patch_mask)
|
| 362 |
+
except Exception as direct_error:
|
| 363 |
+
batch, _ = build_policy_batch_from_sample(
|
| 364 |
+
sample, policy, device, batch_size=1,
|
| 365 |
+
image_key_for_grad=None, dataset=dataset,
|
| 366 |
+
image_map=image_map,
|
| 367 |
+
)
|
| 368 |
+
try:
|
| 369 |
+
policy.reset()
|
| 370 |
+
policy.select_action(batch)
|
| 371 |
+
except Exception as fallback_error:
|
| 372 |
+
print(
|
| 373 |
+
" WARNING: SigLIP feature extraction failed "
|
| 374 |
+
f"(direct={direct_error}, fallback={fallback_error})"
|
| 375 |
+
)
|
| 376 |
+
return None
|
| 377 |
+
|
| 378 |
+
if "value" not in activations:
|
| 379 |
+
print(" WARNING: Last-layer feature hook did not fire")
|
| 380 |
+
return None
|
| 381 |
+
|
| 382 |
+
patch_features = activations["value"]
|
| 383 |
+
if patch_features.dim() == 3:
|
| 384 |
+
patch_features = patch_features[0]
|
| 385 |
+
patch_features = F.normalize(patch_features.float(), dim=-1)
|
| 386 |
+
|
| 387 |
+
grid_h, grid_w = meta["grid_size"]
|
| 388 |
+
patch_grid = patch_features.reshape(grid_h, grid_w, -1).detach().cpu().numpy()
|
| 389 |
+
result = {
|
| 390 |
+
"patch_features": patch_grid,
|
| 391 |
+
"grid_size": meta["grid_size"],
|
| 392 |
+
"image_shape": meta["image_shape"],
|
| 393 |
+
"patch_size": meta["patch_size"],
|
| 394 |
+
"content_crop": meta["content_crop"],
|
| 395 |
+
"valid_patch_mask": meta["valid_patch_mask"],
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
if capture_qk:
|
| 399 |
+
q_tensor = q_capture.get("value")
|
| 400 |
+
k_tensor = k_capture.get("value")
|
| 401 |
+
if q_tensor is None or k_tensor is None:
|
| 402 |
+
hidden = attn_input.get("value")
|
| 403 |
+
if hidden is not None and isinstance(last_attn, torch.nn.MultiheadAttention):
|
| 404 |
+
qkv = F.linear(hidden, last_attn.in_proj_weight, last_attn.in_proj_bias)
|
| 405 |
+
q_tensor, k_tensor, _ = qkv.chunk(3, dim=-1)
|
| 406 |
+
|
| 407 |
+
if q_tensor is not None and k_tensor is not None:
|
| 408 |
+
if q_tensor.dim() == 3:
|
| 409 |
+
q_tensor = q_tensor[0]
|
| 410 |
+
if k_tensor.dim() == 3:
|
| 411 |
+
k_tensor = k_tensor[0]
|
| 412 |
+
num_heads = getattr(last_attn, "num_heads", None) or getattr(
|
| 413 |
+
last_attn, "num_attention_heads", None,
|
| 414 |
+
)
|
| 415 |
+
if num_heads is None or num_heads <= 0:
|
| 416 |
+
num_heads = 1
|
| 417 |
+
head_dim = int(q_tensor.shape[-1] // num_heads)
|
| 418 |
+
q = q_tensor.reshape(-1, num_heads, head_dim).permute(1, 0, 2).float()
|
| 419 |
+
k = k_tensor.reshape(-1, num_heads, head_dim).permute(1, 0, 2).float()
|
| 420 |
+
scores = torch.matmul(q, k.transpose(1, 2)) * (head_dim ** -0.5)
|
| 421 |
+
key_maps = scores.mean(dim=1).detach().cpu().numpy()
|
| 422 |
+
result["qk_key_maps"] = key_maps.reshape(num_heads, grid_h, grid_w)
|
| 423 |
+
|
| 424 |
+
return result
|
| 425 |
+
finally:
|
| 426 |
+
for handle in handles:
|
| 427 |
+
handle.remove()
|
| 428 |
+
|
| 429 |
+
|
| 430 |
# ---------------------------------------------------------------------------
|
| 431 |
# Saliency map (vanilla input-gradient)
|
| 432 |
# ---------------------------------------------------------------------------
|
web/frontend/src/components/DiagnosticPanel.tsx
CHANGED
|
@@ -6,6 +6,46 @@ import CounterfactualComparison from "./CounterfactualComparison";
|
|
| 6 |
|
| 7 |
interface DiagnosticReport {
|
| 8 |
metadata: Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
spatial_object_diagnosis?: {
|
| 10 |
target_object: string | null;
|
| 11 |
verdict: string;
|
|
@@ -290,7 +330,11 @@ export default function DiagnosticPanel() {
|
|
| 290 |
</div>
|
| 291 |
|
| 292 |
{report.spatial_object_diagnosis && (
|
| 293 |
-
<SpatialObjectDiagnosisCard
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
)}
|
| 295 |
|
| 296 |
{/* Scene Understanding */}
|
|
@@ -426,8 +470,12 @@ export default function DiagnosticPanel() {
|
|
| 426 |
|
| 427 |
function SpatialObjectDiagnosisCard({
|
| 428 |
diagnosis,
|
|
|
|
|
|
|
| 429 |
}: {
|
| 430 |
diagnosis: NonNullable<DiagnosticReport["spatial_object_diagnosis"]>;
|
|
|
|
|
|
|
| 431 |
}) {
|
| 432 |
const verdictLabel: Record<string, string> = {
|
| 433 |
spatial_prior: "Spatial Prior",
|
|
@@ -480,6 +528,72 @@ function SpatialObjectDiagnosisCard({
|
|
| 480 |
<EvidenceList title="Evidence For Spatial Priors" items={diagnosis.spatial_evidence} emptyText="No strong spatial-prior evidence." />
|
| 481 |
<EvidenceList title="Evidence For Object Grounding" items={diagnosis.object_evidence} emptyText="No strong object-grounding evidence." />
|
| 482 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
</div>
|
| 484 |
);
|
| 485 |
}
|
|
@@ -536,6 +650,11 @@ function formatMetricValue(value: number | string) {
|
|
| 536 |
return String(value);
|
| 537 |
}
|
| 538 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
function SeverityBadge({ severity }: { severity: string }) {
|
| 540 |
const styles: Record<string, { bg: string; color: string }> = {
|
| 541 |
critical: { bg: "#dc3545", color: "#fff" },
|
|
|
|
| 6 |
|
| 7 |
interface DiagnosticReport {
|
| 8 |
metadata: Record<string, unknown>;
|
| 9 |
+
semantic_probe?: {
|
| 10 |
+
target_object: string | null;
|
| 11 |
+
summary: string;
|
| 12 |
+
primary_frame?: {
|
| 13 |
+
frame_id: string;
|
| 14 |
+
target_object: string | null;
|
| 15 |
+
candidate_labels: string[];
|
| 16 |
+
best_non_target_label: string | null;
|
| 17 |
+
target_semantic_peak: number | null;
|
| 18 |
+
target_semantic_mean_on_causal_patches: number | null;
|
| 19 |
+
target_margin_over_best_non_target: number | null;
|
| 20 |
+
causal_semantic_alignment: number | null;
|
| 21 |
+
background_semantic_gap: number | null;
|
| 22 |
+
summary: string;
|
| 23 |
+
} | null;
|
| 24 |
+
counterfactuals?: Record<string, {
|
| 25 |
+
test_type: string;
|
| 26 |
+
summary: string;
|
| 27 |
+
metrics: Record<string, number | string | boolean | number[] | string[]>;
|
| 28 |
+
}>;
|
| 29 |
+
};
|
| 30 |
+
qk_probe?: {
|
| 31 |
+
layer: string;
|
| 32 |
+
summary: string;
|
| 33 |
+
dominant_head_type: string;
|
| 34 |
+
semantic_head_fraction: number;
|
| 35 |
+
positional_head_fraction: number;
|
| 36 |
+
mixed_head_fraction: number;
|
| 37 |
+
top_heads: Array<{
|
| 38 |
+
head_index: number;
|
| 39 |
+
head_type: string;
|
| 40 |
+
score: number;
|
| 41 |
+
semantic_map_correlation: number;
|
| 42 |
+
positional_baseline_correlation: number;
|
| 43 |
+
target_region_logit_mass: number;
|
| 44 |
+
background_logit_mass: number;
|
| 45 |
+
old_anchor_logit_mass?: number | null;
|
| 46 |
+
moved_object_logit_mass?: number | null;
|
| 47 |
+
}>;
|
| 48 |
+
};
|
| 49 |
spatial_object_diagnosis?: {
|
| 50 |
target_object: string | null;
|
| 51 |
verdict: string;
|
|
|
|
| 330 |
</div>
|
| 331 |
|
| 332 |
{report.spatial_object_diagnosis && (
|
| 333 |
+
<SpatialObjectDiagnosisCard
|
| 334 |
+
diagnosis={report.spatial_object_diagnosis}
|
| 335 |
+
semanticProbe={report.semantic_probe}
|
| 336 |
+
qkProbe={report.qk_probe}
|
| 337 |
+
/>
|
| 338 |
)}
|
| 339 |
|
| 340 |
{/* Scene Understanding */}
|
|
|
|
| 470 |
|
| 471 |
function SpatialObjectDiagnosisCard({
|
| 472 |
diagnosis,
|
| 473 |
+
semanticProbe,
|
| 474 |
+
qkProbe,
|
| 475 |
}: {
|
| 476 |
diagnosis: NonNullable<DiagnosticReport["spatial_object_diagnosis"]>;
|
| 477 |
+
semanticProbe?: DiagnosticReport["semantic_probe"];
|
| 478 |
+
qkProbe?: DiagnosticReport["qk_probe"];
|
| 479 |
}) {
|
| 480 |
const verdictLabel: Record<string, string> = {
|
| 481 |
spatial_prior: "Spatial Prior",
|
|
|
|
| 528 |
<EvidenceList title="Evidence For Spatial Priors" items={diagnosis.spatial_evidence} emptyText="No strong spatial-prior evidence." />
|
| 529 |
<EvidenceList title="Evidence For Object Grounding" items={diagnosis.object_evidence} emptyText="No strong object-grounding evidence." />
|
| 530 |
</div>
|
| 531 |
+
|
| 532 |
+
{(semanticProbe || qkProbe) && (
|
| 533 |
+
<div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 12 }}>
|
| 534 |
+
{semanticProbe && (
|
| 535 |
+
<div style={{ background: "#FCFDFD", border: "1px solid #DBE4E8", borderRadius: 8, padding: 12 }}>
|
| 536 |
+
<div style={{ fontWeight: 600, color: "var(--text-heading)", fontSize: 12, marginBottom: 8 }}>
|
| 537 |
+
Semantic Probe
|
| 538 |
+
</div>
|
| 539 |
+
<div style={{ color: "var(--text-body)", fontSize: 13, lineHeight: 1.5, marginBottom: 8 }}>
|
| 540 |
+
{semanticProbe.summary}
|
| 541 |
+
</div>
|
| 542 |
+
{semanticProbe.primary_frame && (
|
| 543 |
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: 8, marginBottom: 8 }}>
|
| 544 |
+
<MetricPill label="Semantic peak" value={formatOptionalMetric(semanticProbe.primary_frame.target_semantic_peak)} />
|
| 545 |
+
<MetricPill label="Causal mean" value={formatOptionalMetric(semanticProbe.primary_frame.target_semantic_mean_on_causal_patches)} />
|
| 546 |
+
<MetricPill label="Target margin" value={formatOptionalMetric(semanticProbe.primary_frame.target_margin_over_best_non_target)} />
|
| 547 |
+
<MetricPill label="Background gap" value={formatOptionalMetric(semanticProbe.primary_frame.background_semantic_gap)} />
|
| 548 |
+
</div>
|
| 549 |
+
)}
|
| 550 |
+
{semanticProbe.counterfactuals && Object.keys(semanticProbe.counterfactuals).length > 0 && (
|
| 551 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 552 |
+
{Object.entries(semanticProbe.counterfactuals).map(([key, value]) => (
|
| 553 |
+
<div key={key} style={{ fontSize: 12 }}>
|
| 554 |
+
<div style={{ color: "var(--text-heading)", fontWeight: 500 }}>
|
| 555 |
+
{key.replace(/_/g, " ")}
|
| 556 |
+
</div>
|
| 557 |
+
<div style={{ color: "var(--text-body)" }}>{value.summary}</div>
|
| 558 |
+
</div>
|
| 559 |
+
))}
|
| 560 |
+
</div>
|
| 561 |
+
)}
|
| 562 |
+
</div>
|
| 563 |
+
)}
|
| 564 |
+
|
| 565 |
+
{qkProbe && (
|
| 566 |
+
<div style={{ background: "#FCFDFD", border: "1px solid #DBE4E8", borderRadius: 8, padding: 12 }}>
|
| 567 |
+
<div style={{ fontWeight: 600, color: "var(--text-heading)", fontSize: 12, marginBottom: 8 }}>
|
| 568 |
+
QK Decomposition
|
| 569 |
+
</div>
|
| 570 |
+
<div style={{ color: "var(--text-body)", fontSize: 13, lineHeight: 1.5, marginBottom: 8 }}>
|
| 571 |
+
{qkProbe.summary}
|
| 572 |
+
</div>
|
| 573 |
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 8, marginBottom: 8 }}>
|
| 574 |
+
<MetricPill label="Layer" value={qkProbe.layer} />
|
| 575 |
+
<MetricPill label="Dominant" value={qkProbe.dominant_head_type} />
|
| 576 |
+
<MetricPill label="Semantic heads" value={qkProbe.semantic_head_fraction.toFixed(3)} />
|
| 577 |
+
<MetricPill label="Positional heads" value={qkProbe.positional_head_fraction.toFixed(3)} />
|
| 578 |
+
</div>
|
| 579 |
+
{qkProbe.top_heads.length > 0 && (
|
| 580 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 581 |
+
{qkProbe.top_heads.map((head) => (
|
| 582 |
+
<div key={head.head_index} style={{ fontSize: 12 }}>
|
| 583 |
+
<div style={{ color: "var(--text-heading)", fontWeight: 500 }}>
|
| 584 |
+
Head {head.head_index} · {head.head_type}
|
| 585 |
+
</div>
|
| 586 |
+
<div style={{ color: "var(--text-body)" }}>
|
| 587 |
+
semantic corr={head.semantic_map_correlation.toFixed(3)} · positional corr={head.positional_baseline_correlation.toFixed(3)} · score={head.score.toFixed(3)}
|
| 588 |
+
</div>
|
| 589 |
+
</div>
|
| 590 |
+
))}
|
| 591 |
+
</div>
|
| 592 |
+
)}
|
| 593 |
+
</div>
|
| 594 |
+
)}
|
| 595 |
+
</div>
|
| 596 |
+
)}
|
| 597 |
</div>
|
| 598 |
);
|
| 599 |
}
|
|
|
|
| 650 |
return String(value);
|
| 651 |
}
|
| 652 |
|
| 653 |
+
function formatOptionalMetric(value: number | null | undefined) {
|
| 654 |
+
if (typeof value !== "number") return "N/A";
|
| 655 |
+
return Math.abs(value) >= 1 ? value.toFixed(3) : value.toFixed(4);
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
function SeverityBadge({ severity }: { severity: string }) {
|
| 659 |
const styles: Record<string, { bg: string; color: string }> = {
|
| 660 |
critical: { bg: "#dc3545", color: "#fff" },
|