HAKO upload: hako/core/plateau.py
Browse files- hako/core/plateau.py +112 -0
hako/core/plateau.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""2D plateau: kernel density of prototypes under the mapping Pi: R^N -> R^2.
|
| 2 |
+
|
| 3 |
+
Theorem T-PLATEAU (existence of a compact non-empty plateau; Morse analytics).
|
| 4 |
+
Let Pi: R^N -> R^2 be continuous (here: PCA whitening followed by a trainable
|
| 5 |
+
linear refinement; PCA projection is linear, hence analytic) and let
|
| 6 |
+
rho(x, y) = sum_{k=1..K} exp( -|| Pi(mu_k) - (x, y) ||^2 / (2 sigma^2) ).
|
| 7 |
+
(1) rho is real-analytic on R^2 (finite sum of Gaussian bumps, each analytic).
|
| 8 |
+
(2) For generic sigma > 0, rho is a Morse function: its critical points are
|
| 9 |
+
non-degenerate (degeneracy requires an algebraic coincidence of the K
|
| 10 |
+
bump centers, a measure-zero set in sigma; Sard's theorem argument).
|
| 11 |
+
(3) rho(x,y) -> 0 as ||(x,y)|| -> 0-distance-to-infinity, and
|
| 12 |
+
max rho >= rho(Pi(mu_1)) >= 1 (the bump centered at Pi(mu_1) contributes
|
| 13 |
+
>= 1 there). Hence for any delta with 0 < delta < max rho the superlevel
|
| 14 |
+
set P_delta = {(x,y) : rho >= delta} is non-empty (contains a global
|
| 15 |
+
maximizer, which exists because rho is continuous and vanishes at
|
| 16 |
+
infinity => attains max on a compact set) and compact (closed superlevel
|
| 17 |
+
set of a coercive function). QED.
|
| 18 |
+
|
| 19 |
+
The orchestrator monitors: plateau mass M = rho(argmax), gradient norm at the
|
| 20 |
+
current projected point, and saturation = 1 - entropy of the normalized
|
| 21 |
+
bump contributions (low entropy = one dominant prototype = saturation signal
|
| 22 |
+
feeding the dimensional-growth trigger, per PHASE1 inertia rule).
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from typing import Tuple
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import torch
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Plateau2D:
|
| 33 |
+
def __init__(self, sigma: float = 0.55, n_components: int = 2) -> None:
|
| 34 |
+
self.sigma = float(sigma)
|
| 35 |
+
self.mean = None
|
| 36 |
+
self.comp = None # (N, 2) PCA components
|
| 37 |
+
self.refined = np.zeros((2, 2), dtype=np.float32) # trainable refine I
|
| 38 |
+
|
| 39 |
+
@staticmethod
|
| 40 |
+
def _np(x):
|
| 41 |
+
"""Torch/numpy agnostic conversion (detached; plateau coords are
|
| 42 |
+
monitoring/control signals, not gradient carriers)."""
|
| 43 |
+
if isinstance(x, torch.Tensor):
|
| 44 |
+
return x.detach().cpu().numpy().astype(np.float32)
|
| 45 |
+
return np.asarray(x, dtype=np.float32)
|
| 46 |
+
|
| 47 |
+
# ------------------------------------------------------------- fitting
|
| 48 |
+
def fit_pca(self, Z: np.ndarray) -> None:
|
| 49 |
+
X = np.asarray(Z, dtype=np.float32)
|
| 50 |
+
self.mean = X.mean(axis=0)
|
| 51 |
+
Xc = X - self.mean
|
| 52 |
+
# economical SVD on the covariance (N x 2 subspace)
|
| 53 |
+
C = Xc.T @ Xc / max(1, len(X))
|
| 54 |
+
w, V = np.linalg.eigh(C)
|
| 55 |
+
order = np.argsort(w)[::-1][:2]
|
| 56 |
+
self.comp = V[:, order].astype(np.float32) # (N, 2)
|
| 57 |
+
|
| 58 |
+
def project(self, Z: np.ndarray) -> np.ndarray:
|
| 59 |
+
"""Pi(z) = refined PCA coords (zeros until fit_pca has run --
|
| 60 |
+
plateau coords are monitoring signals, never gradient carriers)."""
|
| 61 |
+
Xc = self._np(Z)
|
| 62 |
+
if self.mean is None or self.comp is None:
|
| 63 |
+
return np.zeros((len(Xc), 2), dtype=np.float32)
|
| 64 |
+
base = (Xc - self.mean) @ self.comp
|
| 65 |
+
return (base @ self.refined.T).astype(np.float32)
|
| 66 |
+
|
| 67 |
+
def refine_gradient(self, coords: np.ndarray, proto_coords: np.ndarray,
|
| 68 |
+
lr: float = 1e-3) -> float:
|
| 69 |
+
"""One gradient step aligning Pi(z) mass toward prototype plateau.
|
| 70 |
+
|
| 71 |
+
Uses the analytic gradient of the local density at the sample points;
|
| 72 |
+
returns the mean gradient norm (monitoring signal for the retrain
|
| 73 |
+
rule: if ||grad rho|| > delta, Pi is refined for one epoch).
|
| 74 |
+
"""
|
| 75 |
+
if self.refined is None:
|
| 76 |
+
return 0.0
|
| 77 |
+
base = (np.asarray(coords, dtype=np.float32) @ self.refined.T)
|
| 78 |
+
diff = base[:, None, :] - proto_coords[None, :, :]
|
| 79 |
+
d2 = (diff ** 2).sum(-1)
|
| 80 |
+
w = np.exp(-d2 / (2 * self.sigma ** 2))
|
| 81 |
+
grad_pts = -(diff * w[..., None]).sum(1) / (self.sigma ** 2)
|
| 82 |
+
g = grad_pts.mean(0) # (2,)
|
| 83 |
+
# chain rule back to refined (2x2): dL/dR = outer(g, base_mean)
|
| 84 |
+
self.refined -= lr * np.outer(g, coords.mean(0)).astype(np.float32)
|
| 85 |
+
return float(np.linalg.norm(g))
|
| 86 |
+
|
| 87 |
+
# ------------------------------------------------------------ monitoring
|
| 88 |
+
def density(self, pts: np.ndarray, proto_coords: np.ndarray) -> np.ndarray:
|
| 89 |
+
pts = self._np(pts)
|
| 90 |
+
proto_coords = self._np(proto_coords)
|
| 91 |
+
diff = pts[:, None, :] - proto_coords[None, :, :]
|
| 92 |
+
d2 = (diff ** 2).sum(-1)
|
| 93 |
+
return np.exp(-d2 / (2 * self.sigma ** 2)).sum(1)
|
| 94 |
+
|
| 95 |
+
def saturation(self, proto_coords: np.ndarray) -> float:
|
| 96 |
+
"""1 - normalized entropy of bump mass at the density argmax."""
|
| 97 |
+
pts = proto_coords
|
| 98 |
+
dens = self.density(pts, proto_coords)
|
| 99 |
+
p = dens / max(dens.sum(), 1e-12)
|
| 100 |
+
H = -(p * np.log(p + 1e-12)).sum()
|
| 101 |
+
Hn = H / np.log(len(p))
|
| 102 |
+
return float(1.0 - Hn)
|
| 103 |
+
|
| 104 |
+
def inertia(self, proto_coords: np.ndarray) -> float:
|
| 105 |
+
"""Mean squared spread of prototypes on the plateau (PHASE1 trigger)."""
|
| 106 |
+
c = proto_coords.mean(0, keepdims=True)
|
| 107 |
+
return float(((proto_coords - c) ** 2).sum(1).mean())
|
| 108 |
+
|
| 109 |
+
def coords_of(self, Z: np.ndarray, protos: np.ndarray
|
| 110 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 111 |
+
pc = self.project(protos)
|
| 112 |
+
return self.project(Z), pc
|