Source: scripts/inspect_vision_probe.py · Plain text

scripts/inspect_vision_probe.py

"""Post hoc resolution and feature-change diagnostics; never selects parameters."""

import argparse
import json
import sys
from pathlib import Path

import numpy as np
import torch
from PIL import Image
from torch.nn import functional as F

PROJECT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT / "src"))

from mcss.vision_probe.adaptation import Readout, materialize  # noqa: E402
from mcss.vision_probe.experiment import (  # noqa: E402
    _cached_extract,
    _run_pair,
    discover_sequences,
)


def inspect(directory, data_root):
    torch.set_num_threads(1)
    summary = json.loads((directory / "summary.json").read_text())
    assert summary["status"] == "complete"
    cache = summary["feature_cache"]
    payload = torch.load(directory / "readout.pt", weights_only=True)
    readout = Readout(*(payload[key] for key in ("mean", "basis", "scale", "decoder", "bias")))
    sequences = {seq.name: seq for seq in discover_sequences(data_root)}
    rows = [json.loads(line) for line in (directory / "raw.jsonl").read_text().splitlines()]
    methods = {"OFFOFF", "bestgloballyfixed", "residual_selector", "oracle_bestsequenceperpair"}
    rows = [row for row in rows if row["split"] == "validation" and row["method"] in methods]
    details = []
    for row in rows:
        sequence = sequences[row["sequence"]]
        frames = sequence.probe_frames()
        source, target = [
            _cached_extract(
                None,
                frame,
                directory / "cache",
                cache["weight_sha256"],
                cache["model_source_sha256"],
                cache["size"],
            )
            for frame in (frames[0], frames[row["target_slot"]])
        ]
        state, _ = _run_pair(source, target, readout, tuple(row["action_pair"]), row["eta"])
        z = readout.project(target.features)
        adapted = readout.restore(target.features, z, materialize(z, state))
        source_flat = F.normalize(source.features.flatten(0, 1), dim=-1)
        original_flat = F.normalize(target.features.flatten(0, 1), dim=-1)
        adapted_flat = F.normalize(adapted.flatten(0, 1), dim=-1)
        old_nn = (original_flat @ source_flat.T).argmax(1)
        new_nn = (adapted_flat @ source_flat.T).argmax(1)
        label_maps = []
        for path in (sequence.probe_masks()[0], sequence.probe_masks()[row["target_slot"]]):
            with Image.open(path) as image:
                label_maps.append(
                    torch.from_numpy(
                        np.asarray(image.resize((16, 16), Image.Resampling.NEAREST)).copy()
                    ).flatten()
                )
        source_labels, target_labels = label_maps
        # DAVIS train masks in this probe have no ignore pixels. Fail rather than
        # silently diverge from the production ignore-aware nearest-neighbor metric.
        assert not (source_labels == 255).any() and not (target_labels == 255).any()
        object_counts = {
            str(int(label)): int((target_labels == label).sum())
            for label in torch.unique(target_labels)
            if label != 0
        }
        details.append(
            {
                "sequence": row["sequence"],
                "target_slot": row["target_slot"],
                "method": row["method"],
                "target_object_token_counts": object_counts,
                "nn_changed_fraction": float((old_nn != new_nn).float().mean()),
                "label_changed_fraction": float(
                    (source_labels[old_nn] != source_labels[new_nn]).float().mean()
                ),
                "mean_feature_cosine_drift": float(
                    (1 - (original_flat * adapted_flat).sum(-1)).mean()
                ),
                "relative_feature_change": float(
                    (adapted - target.features).norm() / target.features.norm()
                ),
            }
        )
    result = {
        "schema": "mcss.vision_probe.posthoc_diagnostic.v1",
        "scope": "Post hoc inspection of frozen validation outputs; no fitting or tuning",
        "per_pair": details,
        "methods": {},
    }
    for method in sorted(methods):
        group = [row for row in details if row["method"] == method]
        result["methods"][method] = {
            field: float(np.mean([row[field] for row in group]))
            for field in (
                "nn_changed_fraction",
                "label_changed_fraction",
                "mean_feature_cosine_drift",
                "relative_feature_change",
            )
        }
    (directory / "posthoc_diagnostic.json").write_text(json.dumps(result, indent=2))
    print(json.dumps(result["methods"], indent=2))


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("output", type=Path)
    parser.add_argument("--data-root", type=Path, required=True)
    args = parser.parse_args()
    inspect(args.output, args.data_root)