708fbc70ce
- Introduced `bridge_attention_ceiling_check.py` for variance decomposition analysis on bridge attention configurations. - Added `bridge_attention_readout.py` to perform per-tower gate and contribution readouts, including AUC sanity checks. - Created multiple JSON configuration files for backbone replication experiments, including anonymous CV variants and basic backbones. - Implemented sensitivity experiments to evaluate the impact of axial length inclusion and EfficientNetV2-M performance at higher resolutions. - Added a memory probe script to assess GPU memory usage during training with EfficientNetV2-M.
407 lines
15 KiB
Python
407 lines
15 KiB
Python
"""prediction_store — per-epoch logit and embedding recording across folds and phases.
|
|
|
|
PredictionStore — records logits for every head, fold, phase, and epoch.
|
|
FeatureStore — records embeddings (opt-in); same structure but per-head
|
|
tensors since embedding dims vary across heads.
|
|
|
|
HDF5 layout — PredictionStore
|
|
------------------------------
|
|
/{phase}/logits float32 (n_folds, n_epochs, n_samples, n_heads, n_classes)
|
|
/{phase}/head_names str (n_heads,)
|
|
/{phase}/y_true int64 | float64 (n_samples,) — float64 for regression targets, int64 otherwise
|
|
/{phase}/entity_id_{k} int64|str (n_samples,) — one dataset per id component
|
|
/{phase}/split str (n_folds, n_samples)
|
|
/{phase}/loss float32 (n_folds, n_epochs)
|
|
|
|
HDF5 layout — FeatureStore
|
|
---------------------------
|
|
/{phase}/{head_name} float32 (n_folds, n_epochs, n_samples, embedding_dim)
|
|
/{phase}/y_true int64 | float64 (n_samples,) — float64 for regression targets, int64 otherwise
|
|
/{phase}/entity_id_{k} int64|str (n_samples,)
|
|
/{phase}/split str (n_folds, n_samples)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Sequence
|
|
|
|
import numpy as np
|
|
|
|
try:
|
|
import h5py
|
|
except ImportError as e:
|
|
raise ImportError("PredictionStore requires h5py: pip install h5py") from e
|
|
|
|
_STR_DT = h5py.string_dtype()
|
|
|
|
|
|
def _coerce_y_true(y_true) -> np.ndarray:
|
|
"""Coerce y_true to int64 for integer-typed input, float64 otherwise.
|
|
|
|
Forcing int64 unconditionally would silently round regression targets
|
|
(e.g. VF_MD), so we honour float input by storing as float64.
|
|
"""
|
|
arr = np.asarray(y_true)
|
|
if np.issubdtype(arr.dtype, np.floating):
|
|
return arr.astype(np.float64)
|
|
return arr.astype(np.int64)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal phase buffer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _PhaseBuffer:
|
|
def __init__(
|
|
self,
|
|
entity_ids: list[tuple],
|
|
y_true: np.ndarray,
|
|
head_names: list[str],
|
|
n_epochs: int,
|
|
n_folds: int,
|
|
n_classes: int,
|
|
):
|
|
n_s = len(entity_ids)
|
|
n_h = len(head_names)
|
|
self.entity_ids = list(entity_ids)
|
|
self.y_true = _coerce_y_true(y_true)
|
|
self.head_names = list(head_names)
|
|
self.n_epochs = n_epochs
|
|
self.logits = np.full((n_folds, n_epochs, n_s, n_h, n_classes), np.nan, dtype=np.float32)
|
|
self.split = np.full((n_folds, n_s), "", dtype=object)
|
|
self.loss = np.full((n_folds, n_epochs), np.nan, dtype=np.float32)
|
|
self._sid = {str(eid): i for i, eid in enumerate(entity_ids)}
|
|
self._hid = {h: i for i, h in enumerate(head_names)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal feature buffer (per-head, variable embedding_dim)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FeaturePhaseBuffer:
|
|
def __init__(
|
|
self,
|
|
entity_ids: list[tuple],
|
|
y_true: np.ndarray,
|
|
n_folds: int,
|
|
):
|
|
self.entity_ids = list(entity_ids)
|
|
self.y_true = _coerce_y_true(y_true)
|
|
self.split = np.full((n_folds, len(entity_ids)), "", dtype=object)
|
|
self._sid = {str(eid): i for i, eid in enumerate(entity_ids)}
|
|
# head_name → (buffer array, n_epochs)
|
|
self._heads: dict[str, tuple[np.ndarray, int]] = {}
|
|
|
|
def register_head(self, head: str, n_epochs: int, embedding_dim: int, n_folds: int) -> None:
|
|
n_s = len(self.entity_ids)
|
|
self._heads[head] = (
|
|
np.full((n_folds, n_epochs, n_s, embedding_dim), np.nan, dtype=np.float32),
|
|
n_epochs,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _write_entity_ids(grp: h5py.Group, entity_ids: list[tuple]) -> None:
|
|
if not entity_ids:
|
|
return
|
|
n_components = max(len(eid) for eid in entity_ids)
|
|
for k in range(n_components):
|
|
vals = [eid[k] if k < len(eid) else "" for eid in entity_ids]
|
|
if all(isinstance(v, (int, np.integer)) for v in vals):
|
|
grp.create_dataset(f"entity_id_{k}", data=np.array(vals, dtype=np.int64))
|
|
else:
|
|
grp.create_dataset(f"entity_id_{k}", data=np.array(vals, dtype=object), dtype=_STR_DT)
|
|
|
|
|
|
def _read_entity_ids(grp: h5py.Group, n_samples: int) -> list[tuple]:
|
|
k, components = 0, []
|
|
while f"entity_id_{k}" in grp:
|
|
arr = grp[f"entity_id_{k}"][:]
|
|
if arr.dtype.kind in ("S", "O", "U"):
|
|
arr = np.array([v.decode() if isinstance(v, bytes) else str(v) for v in arr])
|
|
components.append(arr)
|
|
k += 1
|
|
if not components:
|
|
return [() for _ in range(n_samples)]
|
|
return [tuple(c[i] for c in components) for i in range(n_samples)]
|
|
|
|
|
|
def _decode_str_array(arr: np.ndarray) -> list[str]:
|
|
return [v.decode() if isinstance(v, bytes) else str(v) for v in arr.flat]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PredictionStore
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class PredictionStore:
|
|
"""Records per-epoch logits across all folds and phases, saves to HDF5.
|
|
|
|
The store is generic — it knows nothing about what heads or phases exist.
|
|
The orchestrator registers phases and records whatever heads it builds.
|
|
"""
|
|
|
|
def __init__(self, n_folds: int, n_classes: int) -> None:
|
|
self.n_folds = n_folds
|
|
self.n_classes = n_classes
|
|
self._phases: dict[str, _PhaseBuffer] = {}
|
|
|
|
def register_phase(
|
|
self,
|
|
phase: str,
|
|
entity_ids: list[tuple],
|
|
y_true: Sequence[int],
|
|
head_names: list[str],
|
|
n_epochs: int,
|
|
) -> None:
|
|
"""Register a training phase before recording begins."""
|
|
self._phases[phase] = _PhaseBuffer(
|
|
entity_ids=list(entity_ids),
|
|
y_true=_coerce_y_true(y_true),
|
|
head_names=list(head_names),
|
|
n_epochs=n_epochs,
|
|
n_folds=self.n_folds,
|
|
n_classes=self.n_classes,
|
|
)
|
|
|
|
def record(
|
|
self,
|
|
phase: str,
|
|
fold: int,
|
|
epoch: int,
|
|
entity_ids: Sequence[tuple],
|
|
head: str,
|
|
logits: np.ndarray,
|
|
) -> None:
|
|
"""Record a batch of logits for one head at one epoch."""
|
|
buf = self._phases[phase]
|
|
hidx = buf._hid.get(head)
|
|
if hidx is None:
|
|
return
|
|
for i, eid in enumerate(entity_ids):
|
|
sidx = buf._sid.get(str(eid))
|
|
if sidx is not None:
|
|
buf.logits[fold, epoch, sidx, hidx, :] = logits[i]
|
|
|
|
def record_loss(self, phase: str, fold: int, epoch: int, loss: float) -> None:
|
|
self._phases[phase].loss[fold, epoch] = float(loss)
|
|
|
|
def set_split(
|
|
self,
|
|
phase: str,
|
|
fold: int,
|
|
entity_ids: Sequence[tuple],
|
|
label: str,
|
|
) -> None:
|
|
"""Mark samples as 'train', 'val', or 'test' for a fold."""
|
|
buf = self._phases[phase]
|
|
for eid in entity_ids:
|
|
sidx = buf._sid.get(str(eid))
|
|
if sidx is not None:
|
|
buf.split[fold, sidx] = label
|
|
|
|
# ------------------------------------------------------------------
|
|
# Persistence
|
|
# ------------------------------------------------------------------
|
|
|
|
def save(self, path: str | Path) -> None:
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with h5py.File(path, "w") as f:
|
|
for phase, buf in self._phases.items():
|
|
grp = f.create_group(phase)
|
|
grp.create_dataset("logits", data=buf.logits, compression="gzip", compression_opts=4)
|
|
grp.create_dataset("y_true", data=buf.y_true)
|
|
grp.create_dataset("loss", data=buf.loss)
|
|
grp.create_dataset("head_names", data=np.array(buf.head_names, dtype=object), dtype=_STR_DT)
|
|
grp.create_dataset("split", data=buf.split, dtype=_STR_DT)
|
|
_write_entity_ids(grp, buf.entity_ids)
|
|
|
|
@classmethod
|
|
def load(cls, path: str | Path) -> "PredictionStore":
|
|
"""Load all phases into memory."""
|
|
with h5py.File(path, "r") as f:
|
|
first = next(iter(f.values()))
|
|
n_folds, _, _, _, n_classes = first["logits"].shape
|
|
store = cls(n_folds=n_folds, n_classes=n_classes)
|
|
for phase in f:
|
|
grp = f[phase]
|
|
logits = grp["logits"][:]
|
|
n_folds_, n_epochs, n_samples, n_heads, _ = logits.shape
|
|
head_names = _decode_str_array(grp["head_names"][:])
|
|
entity_ids = _read_entity_ids(grp, n_samples)
|
|
buf = _PhaseBuffer(
|
|
entity_ids=entity_ids,
|
|
y_true=grp["y_true"][:],
|
|
head_names=head_names,
|
|
n_epochs=n_epochs,
|
|
n_folds=n_folds_,
|
|
n_classes=n_classes,
|
|
)
|
|
buf.logits = logits
|
|
buf.loss = grp["loss"][:]
|
|
split_raw = grp["split"][:]
|
|
buf.split = np.array(
|
|
[[v.decode() if isinstance(v, bytes) else str(v) for v in row]
|
|
for row in split_raw],
|
|
dtype=object,
|
|
)
|
|
store._phases[phase] = buf
|
|
return store
|
|
|
|
# ------------------------------------------------------------------
|
|
# Query helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def phases(self) -> list[str]:
|
|
return list(self._phases.keys())
|
|
|
|
def head_names(self, phase: str) -> list[str]:
|
|
return self._phases[phase].head_names
|
|
|
|
def entity_ids(self, phase: str) -> list[tuple]:
|
|
return self._phases[phase].entity_ids
|
|
|
|
def get_logits(
|
|
self,
|
|
phase: str,
|
|
head: str,
|
|
fold: int | None = None,
|
|
epoch: int | None = None,
|
|
) -> np.ndarray:
|
|
"""Slice logits for one head. Unspecified dims return the full axis.
|
|
|
|
Returns shape (folds, epochs, samples, classes) by default,
|
|
with leading dims dropped for each specified index.
|
|
"""
|
|
buf = self._phases[phase]
|
|
hidx = buf._hid[head]
|
|
data = buf.logits[:, :, :, hidx, :] # (folds, epochs, samples, classes)
|
|
if fold is not None: data = data[fold] # (epochs, samples, classes)
|
|
if epoch is not None: data = data[..., epoch, :, :] if fold is None else data[epoch]
|
|
return data
|
|
|
|
def get_split(self, phase: str, fold: int) -> dict[str, list[tuple]]:
|
|
"""Return {'train': [...], 'val': [...], 'test': [...]} entity_id lists."""
|
|
buf = self._phases[phase]
|
|
labels = buf.split[fold]
|
|
out: dict[str, list[tuple]] = {}
|
|
for eid, lbl in zip(buf.entity_ids, labels):
|
|
out.setdefault(lbl, []).append(eid)
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FeatureStore
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FeatureStore:
|
|
"""Records per-epoch embeddings (variable dim per head), saves to HDF5.
|
|
|
|
Opt-in companion to PredictionStore. Typically written only on checkpoint
|
|
runs where you want to do dimensionality reduction or cluster analysis.
|
|
"""
|
|
|
|
def __init__(self, n_folds: int) -> None:
|
|
self.n_folds = n_folds
|
|
self._phases: dict[str, _FeaturePhaseBuffer] = {}
|
|
|
|
def register_phase(
|
|
self,
|
|
phase: str,
|
|
entity_ids: list[tuple],
|
|
y_true: Sequence[int],
|
|
) -> None:
|
|
self._phases[phase] = _FeaturePhaseBuffer(
|
|
entity_ids=list(entity_ids),
|
|
y_true=_coerce_y_true(y_true),
|
|
n_folds=self.n_folds,
|
|
)
|
|
|
|
def register_head(
|
|
self,
|
|
phase: str,
|
|
head: str,
|
|
n_epochs: int,
|
|
embedding_dim: int,
|
|
) -> None:
|
|
self._phases[phase].register_head(head, n_epochs, embedding_dim, self.n_folds)
|
|
|
|
def record(
|
|
self,
|
|
phase: str,
|
|
fold: int,
|
|
epoch: int,
|
|
entity_ids: Sequence[tuple],
|
|
head: str,
|
|
embeddings: np.ndarray,
|
|
) -> None:
|
|
buf = self._phases[phase]
|
|
arr, _= buf._heads[head]
|
|
for i, eid in enumerate(entity_ids):
|
|
sidx = buf._sid.get(str(eid))
|
|
if sidx is not None:
|
|
arr[fold, epoch, sidx, :] = embeddings[i]
|
|
|
|
def set_split(
|
|
self,
|
|
phase: str,
|
|
fold: int,
|
|
entity_ids: Sequence[tuple],
|
|
label: str,
|
|
) -> None:
|
|
buf = self._phases[phase]
|
|
for eid in entity_ids:
|
|
sidx = buf._sid.get(str(eid))
|
|
if sidx is not None:
|
|
buf.split[fold, sidx] = label
|
|
|
|
def save(self, path: str | Path) -> None:
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with h5py.File(path, "w") as f:
|
|
for phase, buf in self._phases.items():
|
|
grp = f.create_group(phase)
|
|
grp.create_dataset("y_true", data=buf.y_true)
|
|
grp.create_dataset("split", data=buf.split, dtype=_STR_DT)
|
|
_write_entity_ids(grp, buf.entity_ids)
|
|
for head, (arr, _) in buf._heads.items():
|
|
grp.create_dataset(head, data=arr, compression="gzip", compression_opts=4)
|
|
|
|
@classmethod
|
|
def load(cls, path: str | Path) -> "FeatureStore":
|
|
with h5py.File(path, "r") as f:
|
|
n_folds = next(
|
|
arr.shape[0]
|
|
for grp in f.values()
|
|
for k, arr in grp.items()
|
|
if k not in ("y_true", "split") and not k.startswith("entity_id_")
|
|
)
|
|
store = cls(n_folds=n_folds)
|
|
_meta = {"y_true", "split"}
|
|
for phase in f:
|
|
grp = f[phase]
|
|
n_samples = grp["y_true"].shape[0]
|
|
entity_ids = _read_entity_ids(grp, n_samples)
|
|
buf = _FeaturePhaseBuffer(
|
|
entity_ids=entity_ids,
|
|
y_true=grp["y_true"][:],
|
|
n_folds=n_folds,
|
|
)
|
|
buf.split = np.array(
|
|
[[v.decode() if isinstance(v, bytes) else str(v) for v in row]
|
|
for row in grp["split"][:]],
|
|
dtype=object,
|
|
)
|
|
for key in grp:
|
|
if key in _meta or key.startswith("entity_id_"):
|
|
continue
|
|
arr = grp[key][:]
|
|
buf._heads[key] = (arr, arr.shape[1])
|
|
store._phases[phase] = buf
|
|
return store
|