| """ |
| inference.py β Self-contained model loader for KestrelNet/GoshawkNet benchmarks. |
| |
| Pure NumPy. No framework dependencies. Supports both standard FC (KestrelNet) |
| and multivector product (GoshawkNet) architectures. |
| |
| Usage: |
| from inference import load_model |
| model = load_model("ecg-heartbeat") |
| proba = model.predict_proba(x) |
| """ |
|
|
| import numpy as np |
| from pathlib import Path |
|
|
| ROOT = Path(__file__).resolve().parent |
|
|
| |
|
|
| CONFIGS = { |
| "ecg-heartbeat": { |
| "input_dim": 187, |
| "hidden_dims": [16, 8], |
| "output_dim": 5, |
| "algebra": (0, 2), |
| "class_names": ["Normal", "Supraventricular", "Ventricular", "Fusion", "Unknown"], |
| }, |
| "eeg-emotions": { |
| "input_dim": 2548, |
| "hidden_dims": [16, 8], |
| "output_dim": 3, |
| "algebra": (0, 2), |
| "class_names": ["Negative", "Neutral", "Positive"], |
| }, |
| "eye-state": { |
| "input_dim": 14, |
| "hidden_dims": [16, 8], |
| "output_dim": 2, |
| "algebra": (0, 2), |
| "class_names": ["Eyes Open", "Eyes Closed"], |
| }, |
| "seizure-prediction": { |
| "input_dim": 178, |
| "hidden_dims": [16, 8], |
| "output_dim": 2, |
| "algebra": (0, 2), |
| "class_names": ["Non-seizure", "Seizure"], |
| }, |
| "har-smartphones": { |
| "input_dim": 228, |
| "hidden_dims": [16, 8], |
| "output_dim": 6, |
| "algebra": (0, 2), |
| "class_names": ["Walking", "Walking Upstairs", "Walking Downstairs", |
| "Sitting", "Standing", "Laying"], |
| }, |
| } |
|
|
|
|
| |
|
|
| class _CliffordAlgebra: |
| """Minimal Cl(p,q) for inference. Precomputes Cayley tensor.""" |
|
|
| def __init__(self, p, q): |
| self.p, self.q = p, q |
| self.n = p + q |
| self.dim = 1 << self.n |
|
|
| self.cayley = np.zeros((self.dim, self.dim, self.dim), dtype=np.float64) |
| for i in range(self.dim): |
| for j in range(self.dim): |
| sign, k = self._blade_product(i, j) |
| self.cayley[k, i, j] = sign |
|
|
| self.cayley_flat = self.cayley.reshape(self.dim * self.dim, self.dim) |
|
|
| def _blade_product(self, a, b): |
| n_swaps = 0 |
| temp = a >> 1 |
| while temp: |
| n_swaps += bin(temp & b).count('1') |
| temp >>= 1 |
| sign = -1 if n_swaps % 2 else 1 |
| common = a & b |
| for i in range(self.n): |
| if (common >> i) & 1 and i >= self.p: |
| sign = -sign |
| return sign, a ^ b |
|
|
|
|
| |
|
|
| def _softmax(logits): |
| m = np.max(logits) |
| e = np.exp(logits - m) |
| return e / e.sum() |
|
|
|
|
| |
|
|
| class GoshawkNet: |
| """Multivector product neural network β inference only.""" |
|
|
| def __init__(self, input_dim, hidden_dims, output_dim, p=0, q=2): |
| self.input_dim = input_dim |
| self.hidden_dims = list(hidden_dims) |
| self.output_dim = output_dim |
|
|
| self.algebra = _CliffordAlgebra(p, q) |
| self.D = self.algebra.dim |
|
|
| dims = [input_dim] + list(hidden_dims) + [output_dim] |
| self.layer_dims = list(zip(dims[:-1], dims[1:])) |
| self.n_layers = len(self.layer_dims) |
|
|
| self.Ws = [np.zeros((fo, fi, self.D)) for fi, fo in self.layer_dims] |
| self.bs = [np.zeros((fo, self.D)) for _, fo in self.layer_dims] |
|
|
| def set_params(self, v): |
| idx = 0 |
| for l, (fi, fo) in enumerate(self.layer_dims): |
| n_W = fo * fi * self.D |
| self.Ws[l] = v[idx:idx + n_W].reshape(fo, fi, self.D) |
| idx += n_W |
| n_b = fo * self.D |
| self.bs[l] = v[idx:idx + n_b].reshape(fo, self.D) |
| idx += n_b |
|
|
| def predict_proba(self, x): |
| x = np.asarray(x, dtype=np.float64) |
| D = self.D |
| cf = self.algebra.cayley_flat |
|
|
| |
| h = np.zeros((self.input_dim, D)) |
| h[:, 0] = x |
|
|
| for l in range(self.n_layers): |
| W, b = self.Ws[l], self.bs[l] |
| fo, fi = W.shape[0], W.shape[1] |
|
|
| Rh = (h @ cf.T).reshape(fi, D, D) |
| Rh_mat = Rh.transpose(0, 2, 1).reshape(fi * D, D) |
| W_mat = W.reshape(fo, fi * D) |
| z = W_mat @ Rh_mat + b |
|
|
| if l < self.n_layers - 1: |
| h = np.maximum(0.0, z) |
| else: |
| h = z |
|
|
| return _softmax(h[:, 0]) |
|
|
| def predict(self, x): |
| return int(np.argmax(self.predict_proba(x))) |
|
|
| def param_count(self): |
| return sum(W.size + b.size for W, b in zip(self.Ws, self.bs)) |
|
|
| def __repr__(self): |
| dims = [self.input_dim] + self.hidden_dims + [self.output_dim] |
| arch = ' > '.join(str(d) for d in dims) |
| return f'GoshawkNet({arch}, Cl({self.algebra.p},{self.algebra.q}), {self.param_count():,} params)' |
|
|
|
|
| |
|
|
| def load_model(name): |
| """ |
| Load a benchmark model by name. |
| |
| Parameters |
| ---------- |
| name : str |
| One of: 'ecg-heartbeat', 'eeg-emotions', 'eye-state', |
| 'seizure-prediction', 'har-smartphones' |
| |
| Returns |
| ------- |
| model : GoshawkNet with loaded weights |
| """ |
| if name not in CONFIGS: |
| available = ', '.join(sorted(CONFIGS.keys())) |
| raise ValueError(f"Unknown model '{name}'. Available: {available}") |
|
|
| cfg = CONFIGS[name] |
| p, q = cfg["algebra"] |
|
|
| model = GoshawkNet( |
| input_dim=cfg["input_dim"], |
| hidden_dims=cfg["hidden_dims"], |
| output_dim=cfg["output_dim"], |
| p=p, q=q, |
| ) |
|
|
| weights_path = ROOT / name / "weights.txt" |
| with open(weights_path) as f: |
| params = np.array([float(x) for x in f.read().split()]) |
| model.set_params(params) |
|
|
| model.class_names = cfg["class_names"] |
| return model |
|
|
|
|
| def list_models(): |
| """List available benchmark models with their configs.""" |
| for name, cfg in CONFIGS.items(): |
| p, q = cfg["algebra"] |
| model = GoshawkNet(cfg["input_dim"], cfg["hidden_dims"], |
| cfg["output_dim"], p=p, q=q) |
| print(f" {name:<25} {model} classes={cfg['output_dim']}") |
|
|
|
|
| if __name__ == "__main__": |
| print("Available models:\n") |
| list_models() |
|
|
| print("\n\nQuick test β loading all models:\n") |
| for name in CONFIGS: |
| model = load_model(name) |
| x = np.random.randn(model.input_dim) |
| proba = model.predict_proba(x) |
| top = model.class_names[np.argmax(proba)] |
| print(f" {name:<25} {top:<20} (prob={proba.max():.3f}, params={model.param_count():,})") |
|
|