Gaze-LIPE / scripts /kd_clean_gate_audit.py
thanhhuyvan's picture
Publish KD reproducibility investigation
178f61f
Raw
History Blame Contribute Delete
6.67 kB
"""Read-only go/no-go audit for legacy caches before a clean KD experiment.
This script never edits H5 data or checkpoints. It writes one diagnostic JSON report.
"""
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
import sys
ROOT = Path(r"E:\Gaze_estimation")
sys.path.insert(0, str(ROOT / ".codex_deps"))
import h5py
import numpy as np
SUBJECTS = ("p01", "p08", "p11")
OUT_DIR = ROOT / "artifacts" / "kd-teacher-trap-diagnostic"
LOADER_AUDIT = OUT_DIR / "teacher_loader_key_audit.json"
OUTPUT = OUT_DIR / "clean_kd_gate_audit.json"
IDENTITY_FIELDS = (
"sample_id",
"relative_frame_path",
"participant",
"day",
"frame_id",
"annotation_row",
"raw_image_sha256",
)
MODEL_FIELDS = (
"left_patches",
"right_patches",
"landmarks",
"left_gaze",
"right_gaze",
"teacher_pitch_logits",
"teacher_yaw_logits",
)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest().upper()
def expectation_deg(logits: np.ndarray) -> np.ndarray:
shifted = logits.astype(np.float64) - logits.max(axis=1, keepdims=True)
probability = np.exp(shifted)
probability /= probability.sum(axis=1, keepdims=True)
return (probability * np.arange(90, dtype=np.float64)).sum(axis=1) * 2.0 - 90.0
def gaze_vectors(pitch_deg: np.ndarray, yaw_deg: np.ndarray) -> np.ndarray:
pitch = np.deg2rad(pitch_deg)
yaw = np.deg2rad(yaw_deg)
return np.column_stack(
(-np.cos(pitch) * np.sin(yaw), -np.sin(pitch), -np.cos(pitch) * np.cos(yaw))
)
def angular_error_deg(prediction: np.ndarray, target: np.ndarray) -> np.ndarray:
dots = np.sum(prediction * target, axis=1)
return np.rad2deg(np.arccos(np.clip(dots, -1.0, 1.0)))
def audit_h5(subject: str) -> dict:
path = ROOT / "data" / "processed" / f"{subject}_v16.h5"
result = {
"subject": subject,
"path": str(path),
"sha256": sha256(path),
}
with h5py.File(path, "r") as handle:
keys = sorted(handle.keys())
result["datasets"] = keys
result["missing_identity_fields"] = [name for name in IDENTITY_FIELDS if name not in handle]
result["missing_model_fields"] = [name for name in MODEL_FIELDS if name not in handle]
counts = {name: int(handle[name].shape[0]) for name in MODEL_FIELDS if name in handle}
result["row_counts"] = counts
result["row_counts_equal"] = len(set(counts.values())) == 1
result["teacher_logit_shapes_valid"] = all(
name in handle and handle[name].ndim == 2 and handle[name].shape[1] == 90
for name in ("teacher_pitch_logits", "teacher_yaw_logits")
)
finite = {}
for name in MODEL_FIELDS:
if name in handle:
finite[name] = bool(np.isfinite(handle[name][:]).all())
result["all_numeric_values_finite"] = bool(finite) and all(finite.values())
result["finite_by_dataset"] = finite
if not result["missing_model_fields"] and result["teacher_logit_shapes_valid"]:
pitch = expectation_deg(handle["teacher_pitch_logits"][:])
yaw = expectation_deg(handle["teacher_yaw_logits"][:])
target_deg = np.rad2deg(handle["left_gaze"][:].astype(np.float64))
teacher_vec = gaze_vectors(pitch, yaw)
target_vec = gaze_vectors(target_deg[:, 0], target_deg[:, 1])
error_3d = angular_error_deg(teacher_vec, target_vec)
axis_error = np.abs(np.column_stack((pitch, yaw)) - target_deg).mean(axis=1)
result["samples"] = int(len(error_3d))
result["teacher_axis_mae_deg"] = float(axis_error.mean())
result["teacher_3d_angular_error_deg"] = float(error_3d.mean())
result["teacher_3d_error_median_deg"] = float(np.median(error_3d))
result["teacher_3d_error_p90_deg"] = float(np.quantile(error_3d, 0.90))
result["cache_integrity_pass"] = bool(
not result["missing_identity_fields"]
and not result["missing_model_fields"]
and result["row_counts_equal"]
and result["teacher_logit_shapes_valid"]
and result["all_numeric_values_finite"]
)
return result
def main() -> None:
loader = json.loads(LOADER_AUDIT.read_text(encoding="utf-8"))
loader_pass = bool(
loader["historical_loader_loaded_key_count"] == loader["checkpoint_tensor_keys"]
and loader["historical_loader_unexpected_key_count"] == 0
and set(loader["historical_loader_missing_keys"]) <= {"idx_tensor"}
)
caches = [audit_h5(subject) for subject in SUBJECTS]
cache_pass = all(item["cache_integrity_pass"] for item in caches)
teacher_errors = [item.get("teacher_3d_angular_error_deg", math.nan) for item in caches]
report = {
"audit_type": "read-only clean-KD execution gate",
"legacy_files_modified": False,
"gate_a_loader": {
"pass": loader_pass,
"loaded_checkpoint_tensors": loader["historical_loader_loaded_key_count"],
"checkpoint_tensors": loader["checkpoint_tensor_keys"],
"unexpected_checkpoint_tensors": loader["historical_loader_unexpected_key_count"],
"reason": "Recovered historical loader does not strictly load the trained backbone."
if not loader_pass
else "Strict loading requirements satisfied.",
},
"gate_b_cache_integrity": {
"pass": cache_pass,
"required_identity_fields": list(IDENTITY_FIELDS),
"subjects": caches,
"reason": "Legacy caches have no row-level source identity/provenance fields."
if not cache_pass
else "Row-level provenance and numeric integrity requirements satisfied.",
},
"gate_c_teacher_quality": {
"pass": None,
"status": "DESCRIPTIVE_ONLY",
"mean_of_subjects_teacher_3d_angular_error_deg": float(np.nanmean(teacher_errors)),
"reason": "A matched clean control is required to apply the preregistered relative-quality gate.",
},
"decision": "STOP_BEFORE_TRAINING" if not (loader_pass and cache_pass) else "READY_FOR_TEACHER_QUALITY_GATE",
"next_action": "Create a new strict-loader, manifest-backed cache; do not repair or overwrite legacy H5 files.",
}
OUTPUT.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()