#!/usr/bin/env python3 """Runtime architecture probe for RAVEL/CLARA models. Requires project ML dependencies: pip install -r requirements.txt This script instantiates a selected pipeline model, runs one dummy forward pass, and writes: - actual tensor shapes collected by hooks/manual probes; - parameter breakdown from named_parameters(); - trainable/frozen component summary. It is intentionally separate from revision_priority0_audit.py because this one downloads/loads Hugging Face model weights through from_pretrained(). """ from __future__ import annotations import argparse import csv import sys from collections import defaultdict from pathlib import Path from typing import Any, Dict, Iterable, List, Tuple PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Probe runtime architecture and parameters.") parser.add_argument( "--pipeline", choices=["mvsa_single", "mvsa_multiple", "hfm", "reference_model"], default="mvsa_multiple", ) parser.add_argument("--output-dir", default="ravel_revision_results/architecture") parser.add_argument("--batch-size", type=int, default=2) parser.add_argument("--seq-len", type=int, default=16) parser.add_argument("--image-size", type=int, default=224) parser.add_argument("--device", default="cpu") return parser.parse_args() def write_csv(path: Path, rows: Iterable[Dict[str, Any]], fieldnames: List[str]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for row in rows: writer.writerow(row) def component_for_name(name: str) -> str: while name.startswith("impl."): name = name[len("impl.") :] if name.startswith(("vision_lora", "vision.", "vision_encoder")): if "lora_" in name: return "Vision LoRA" return "Vision backbone frozen/trainable weights" if name.startswith(("text.", "text_encoder")): if "lora_" in name: return "Text LoRA" if ".layer." in name and "lora_" not in name: return "Text backbone/unfrozen DeBERTa layers" return "Text backbone other weights" if name.startswith("fusion."): if ".cross." in name or ".blocks." in name or ".layers." in name: return "Co-attention" if ".v_proj." in name or ".t_proj." in name: return "Projection layers" return "Fusion other" if name.startswith(("visual_pool_proj.", "text_pool_proj.")): return "Projection layers" if name.startswith(("visual_head.", "text_head.")): return "Unimodal heads" if name.startswith(("pred.", "prediction_head")): return "Primary prediction head" if name.startswith(("veri.", "verification")): return "Verification head" if name.startswith(("feed.", "feedback", "refinement.")): return "Refinement/feed module" if name.startswith("extra_mlp_control."): return "Parameter-matched control head" if name.startswith("final."): return "Final classifier" if name.startswith("unc."): return "Uncertainty estimator" if "vision_projection" in name or "text_projection" in name: return "Projection layers" if "co_attention" in name: return "Co-attention" return "Other" def parameter_breakdown(model: Any) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: by_component: Dict[str, Dict[str, int]] = defaultdict(lambda: {"total": 0, "trainable": 0}) detail_rows: List[Dict[str, Any]] = [] for name, parameter in model.named_parameters(): total = int(parameter.numel()) trainable = int(parameter.numel()) if parameter.requires_grad else 0 component = component_for_name(name) by_component[component]["total"] += total by_component[component]["trainable"] += trainable detail_rows.append( { "name": name, "component": component, "shape": list(parameter.shape), "total_parameters": total, "trainable_parameters": trainable, "requires_grad": bool(parameter.requires_grad), } ) rows: List[Dict[str, Any]] = [] total_all = sum(item["total"] for item in by_component.values()) trainable_all = sum(item["trainable"] for item in by_component.values()) for component, item in sorted(by_component.items()): pct = (100.0 * item["trainable"] / item["total"]) if item["total"] else 0.0 rows.append( { "component": component, "total_parameters": item["total"], "trainable_parameters": item["trainable"], "trainable_percentage": f"{pct:.6f}", } ) rows.append( { "component": "Total", "total_parameters": total_all, "trainable_parameters": trainable_all, "trainable_percentage": f"{100.0 * trainable_all / total_all:.6f}" if total_all else "0.000000", } ) return rows, detail_rows def load_model(pipeline: str) -> Any: if pipeline == "mvsa_single": from src.mvsa_single_pipeline import CLARAModel, DEFAULT_MVSA_SINGLE_CONFIG return CLARAModel(dict(DEFAULT_MVSA_SINGLE_CONFIG)) if pipeline == "mvsa_multiple": from src.mvsa_multiple_pipeline import CLARAModel, DEFAULT_MVSA_MULTIPLE_CONFIG return CLARAModel(dict(DEFAULT_MVSA_MULTIPLE_CONFIG)) if pipeline == "hfm": from src.hfm_pipeline import CLARAModel, DEFAULT_HFM_CONFIG return CLARAModel(dict(DEFAULT_HFM_CONFIG)) if pipeline == "reference_model": from src.model import CLARAConfig, CLARAModel return CLARAModel(CLARAConfig()) raise ValueError(pipeline) def shape_of(value: Any) -> str: if hasattr(value, "shape"): return str(list(value.shape)) if isinstance(value, (tuple, list)): return "[" + ", ".join(shape_of(item) for item in value) + "]" if isinstance(value, dict): return "{" + ", ".join(f"{key}: {shape_of(item)}" for key, item in value.items()) + "}" if hasattr(value, "last_hidden_state"): return f"last_hidden_state={shape_of(value.last_hidden_state)}" return type(value).__name__ def collect_shapes(model: Any, pipeline: str, batch_size: int, seq_len: int, image_size: int, device: str) -> List[Dict[str, str]]: import torch model.to(device) model.eval() rows: List[Dict[str, str]] = [] hooks = [] def add_row(name: str, tensor: Any, note: str = "") -> None: rows.append({"name": name, "shape": shape_of(tensor), "note": note}) def hook(name: str): def _hook(_module: Any, inputs: Tuple[Any, ...], output: Any) -> None: rows.append( { "name": name, "shape": shape_of(output), "note": "forward hook output", } ) if inputs: rows.append( { "name": name + ".input", "shape": shape_of(inputs), "note": "forward hook input", } ) return _hook for name in [ "vision", "text", "fusion", "visual_head", "text_head", "pred", "veri", "feed", "refinement", "extra_mlp_control", "final", "unc", ]: module = getattr(model, name, None) if module is not None: hooks.append(module.register_forward_hook(hook(name))) pixel_values = torch.zeros(batch_size, 3, image_size, image_size, device=device) input_ids = torch.ones(batch_size, seq_len, dtype=torch.long, device=device) attention_mask = torch.ones(batch_size, seq_len, dtype=torch.long, device=device) if seq_len > 4: attention_mask[:, -2:] = 0 add_row("input_images", pixel_values) add_row("input_ids", input_ids) add_row("attention_mask", attention_mask) with torch.no_grad(): try: out = model( pixel_values=pixel_values, input_ids=input_ids, attention_mask=attention_mask, return_attention=True, ) except TypeError: out = model(pixel_values=pixel_values, input_ids=input_ids, attention_mask=attention_mask) add_row("model_output", out) if isinstance(out, dict): for key in [ "projected_visual_tokens", "projected_text_tokens", "fused", "visual_logits", "text_logits", "pred_logits", "disagreement", "logits", ]: if key in out: add_row(key, out[key], "manual output key") for handle in hooks: handle.remove() return rows def collect_attention_stats( model: Any, batch_size: int, seq_len: int, image_size: int, device: str, ) -> List[Dict[str, Any]]: import torch model.to(device) model.eval() torch.manual_seed(1234) pixel_values = torch.randn(batch_size, 3, image_size, image_size, device=device) input_ids = torch.randint(1, 1000, (batch_size, seq_len), dtype=torch.long, device=device) attention_mask = torch.ones(batch_size, seq_len, dtype=torch.long, device=device) if seq_len > 4: attention_mask[:, -2:] = 0 with torch.no_grad(): try: out = model( pixel_values=pixel_values, input_ids=input_ids, attention_mask=attention_mask, return_attention=True, ) except TypeError: return [] if not isinstance(out, dict): return [] rows: List[Dict[str, Any]] = [] padding_mask = ~attention_mask.bool() for direction, key in [("v2t", "attention_v2t"), ("t2v", "attention_t2v")]: for layer_idx, attn in enumerate(out.get(key, []) or []): if attn is None: continue attn = attn.detach() entropy = -(attn.clamp_min(1e-12) * attn.clamp_min(1e-12).log()).sum(dim=-1) for head_idx in range(attn.shape[1]): head = attn[:, head_idx] padding_mass: Any = "" if direction == "v2t" and padding_mask.any(): padding_mass = float(head.masked_select(padding_mask[:, None, :]).sum().item()) rows.append( { "dataset": "", "seed": "", "sample_id": "dummy_batch", "layer": layer_idx, "direction": direction, "head": head_idx, "attention_mean": float(head.mean().item()), "attention_std": float(head.std().item()), "attention_entropy": float(entropy[:, head_idx].mean().item()), "max_attention": float(head.max().item()), "padding_attention_mass": padding_mass, } ) return rows def collect_gradient_probe( model: Any, batch_size: int, seq_len: int, image_size: int, device: str, ) -> List[Dict[str, Any]]: import torch import torch.nn as nn try: from src.revised_ravel_model import token_loss except Exception: token_loss = None model.to(device) model.train() model.zero_grad(set_to_none=True) torch.manual_seed(4321) pixel_values = torch.randn(batch_size, 3, image_size, image_size, device=device) input_ids = torch.randint(1, 1000, (batch_size, seq_len), dtype=torch.long, device=device) attention_mask = torch.ones(batch_size, seq_len, dtype=torch.long, device=device) if seq_len > 4: attention_mask[:, -2:] = 0 labels = torch.arange(batch_size, device=device) % int(getattr(model, "num_classes", 2)) out = model(pixel_values=pixel_values, input_ids=input_ids, attention_mask=attention_mask) criterion = nn.CrossEntropyLoss() if isinstance(out, dict) and {"visual_logits", "text_logits", "pred_logits", "logits"}.issubset(out): if token_loss is None: loss = criterion(out["logits"], labels) else: loss, _ = token_loss(out, labels, criterion) elif isinstance(out, dict): loss = criterion(out["logits"], labels) if "pred_logits" in out: loss = loss + 0.1 * criterion(out["pred_logits"], labels) if "verify_logits" in out: loss = loss + 0.1 * criterion(out["verify_logits"], labels) else: return [] loss.backward() rows: List[Dict[str, Any]] = [] for name, parameter in model.named_parameters(): grad = parameter.grad rows.append( { "component": component_for_name(name), "parameter_name": name, "requires_grad": bool(parameter.requires_grad), "gradient_present": grad is not None, "gradient_norm": "" if grad is None else float(grad.detach().norm().item()), } ) model.zero_grad(set_to_none=True) return rows def collect_modality_dependency_tests( model: Any, batch_size: int, seq_len: int, image_size: int, device: str, ) -> List[Dict[str, Any]]: import torch model.to(device) model.eval() torch.manual_seed(5678) pixel_values = torch.randn(batch_size, 3, image_size, image_size, device=device) replacement_pixels = torch.randn(batch_size, 3, image_size, image_size, device=device) blank_pixels = torch.zeros_like(pixel_values) input_ids = torch.randint(1, 1000, (batch_size, seq_len), dtype=torch.long, device=device) replacement_input_ids = torch.randint( 1, 1000, (batch_size, seq_len), dtype=torch.long, device=device, ) attention_mask = torch.ones(batch_size, seq_len, dtype=torch.long, device=device) if seq_len > 4: attention_mask[:, -2:] = 0 minimal_text_ids = torch.ones_like(input_ids) minimal_text_mask = torch.zeros_like(attention_mask) minimal_text_mask[:, 0] = 1 def forward_probs( pixels: Any, ids: Any, mask: Any, ) -> Tuple[Any, Any]: with torch.no_grad(): out = model(pixel_values=pixels, input_ids=ids, attention_mask=mask) if not isinstance(out, dict) or "visual_probs" not in out or "text_probs" not in out: raise RuntimeError("Model output does not include visual_probs/text_probs.") return out["visual_probs"].detach(), out["text_probs"].detach() base_visual, base_text = forward_probs(pixel_values, input_ids, attention_mask) cases = [ ( "replace_image_only", replacement_pixels, input_ids, attention_mask, "visual posterior changes more than text posterior", ), ( "replace_text_only", pixel_values, replacement_input_ids, attention_mask, "text posterior changes more than visual posterior", ), ( "blank_image", blank_pixels, input_ids, attention_mask, "visual posterior changes more than text posterior", ), ( "empty_text", pixel_values, minimal_text_ids, minimal_text_mask, "text posterior changes more than visual posterior", ), ] rows: List[Dict[str, Any]] = [] eps = 1e-8 for test_case, pixels, ids, mask, expected in cases: visual_probs, text_probs = forward_probs(pixels, ids, mask) visual_change = float((base_visual - visual_probs).abs().mean().item()) text_change = float((base_text - text_probs).abs().mean().item()) if test_case in {"replace_image_only", "blank_image"}: passed = visual_change > text_change + eps and text_change < 1e-6 else: passed = text_change > visual_change + eps and visual_change < 1e-6 rows.append( { "test_case": test_case, "visual_posterior_change": visual_change, "text_posterior_change": text_change, "expected_behavior": expected, "passed": bool(passed), } ) return rows def main() -> None: args = parse_args() output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) model = load_model(args.pipeline) breakdown, detail = parameter_breakdown(model) write_csv( output_dir / f"parameter_breakdown_{args.pipeline}.csv", breakdown, ["component", "total_parameters", "trainable_parameters", "trainable_percentage"], ) write_csv( output_dir / f"parameter_detail_{args.pipeline}.csv", detail, ["name", "component", "shape", "total_parameters", "trainable_parameters", "requires_grad"], ) shapes = collect_shapes( model=model, pipeline=args.pipeline, batch_size=args.batch_size, seq_len=args.seq_len, image_size=args.image_size, device=args.device, ) write_csv(output_dir / f"tensor_shapes_{args.pipeline}.csv", shapes, ["name", "shape", "note"]) with (output_dir / f"tensor_shapes_{args.pipeline}.txt").open("w", encoding="utf-8") as f: for row in shapes: f.write(f"{row['name']}: {row['shape']} # {row['note']}\n") attention_rows = collect_attention_stats( model=model, batch_size=args.batch_size, seq_len=args.seq_len, image_size=args.image_size, device=args.device, ) write_csv( output_dir / f"attention_statistics_{args.pipeline}.csv", attention_rows, [ "dataset", "seed", "sample_id", "layer", "direction", "head", "attention_mean", "attention_std", "attention_entropy", "max_attention", "padding_attention_mass", ], ) gradient_rows = collect_gradient_probe( model=model, batch_size=args.batch_size, seq_len=args.seq_len, image_size=args.image_size, device=args.device, ) write_csv( output_dir / f"gradient_probe_{args.pipeline}.csv", gradient_rows, ["component", "parameter_name", "requires_grad", "gradient_present", "gradient_norm"], ) try: modality_rows = collect_modality_dependency_tests( model=model, batch_size=args.batch_size, seq_len=args.seq_len, image_size=args.image_size, device=args.device, ) except RuntimeError: modality_rows = [] write_csv( output_dir / f"modality_dependency_tests_{args.pipeline}.csv", modality_rows, [ "test_case", "visual_posterior_change", "text_posterior_change", "expected_behavior", "passed", ], ) print(f"Wrote runtime probe outputs to {output_dir}") if __name__ == "__main__": main()