Source code for physioex.explain.prototypes.posthoc.nmf
"""Post-hoc prototype discovery via Non-negative Matrix Factorization.
Inspired by Tan, Zhou & Chen, "Post-hoc Part-Prototype Networks", ICML 2024
(arXiv:2406.03421).
Given pre-extracted epoch embeddings Z and labels Y from a frozen encoder h(x),
discovers k prototypes per class by applying NMF on class-conditional embeddings.
Each class c is described by k basis vectors (prototypes) such that the
embeddings of class c can be approximately reconstructed as non-negative
linear combinations of these prototypes.
Since NMF requires non-negative inputs and neural embeddings may contain
negative values, we apply a shift: Z_pos = Z - Z.min(axis=0).
"""
from __future__ import annotations
import numpy as np
from sklearn.decomposition import NMF
[docs]
def discover_prototypes_nmf(
Z: np.ndarray,
Y: np.ndarray,
k_per_class: int = 10,
n_classes: int = 5,
random_state: int = 42,
max_iter: int = 500,
) -> tuple[np.ndarray, np.ndarray]:
"""Discover prototypes via per-class NMF decomposition.
For each class c, collects all embeddings F_c = Z[Y == c] and runs
NMF: F_c ≈ E_c @ P_c, where P_c has k rows (the prototypes).
Args:
Z: (N, d_model) epoch embeddings (float32).
Y: (N,) integer class labels.
k_per_class: Number of prototypes to discover per class.
n_classes: Total number of classes.
random_state: Seed for NMF.
max_iter: Maximum NMF iterations.
Returns:
Tuple of:
prototypes: (k_per_class * n_classes, d_model)
proto_labels: (k_per_class * n_classes,) class label per prototype
"""
# Shift to non-negative (per-feature minimum)
z_min = Z.min(axis=0, keepdims=True)
Z_pos = Z - z_min # all >= 0
prototypes_list = []
labels_list = []
for c in range(n_classes):
mask = Y == c
F_c = Z_pos[mask]
if len(F_c) < k_per_class:
# Not enough samples — use all as prototypes
k = len(F_c)
else:
k = k_per_class
if len(F_c) == 0:
continue
nmf = NMF(
n_components=k,
init="nndsvda",
random_state=random_state,
max_iter=max_iter,
)
nmf.fit(F_c)
P_c = nmf.components_ # (k, d_model) in shifted space
# Shift back to original space
P_c = P_c + z_min
prototypes_list.append(P_c)
labels_list.append(np.full(k, c, dtype=np.int64))
prototypes = np.concatenate(prototypes_list, axis=0)
proto_labels = np.concatenate(labels_list, axis=0)
return prototypes, proto_labels