| """ |
| gnaninet.py — Inference-only model loader for HuggingFace. |
| |
| This is a self-contained, minimal inference class. No training code, |
| no gradient computation, no Clifford algebra — just forward pass and softmax. |
| """ |
|
|
| import json |
| import math |
| import numpy as np |
| from pathlib import Path |
|
|
|
|
| def _softmax(logits): |
| m = np.max(logits) |
| e = np.exp(logits - m) |
| return e / e.sum() |
|
|
|
|
| class GnaniNet: |
| """ |
| GnaniNet inference-only model. |
| |
| A lightweight fully-connected classifier with ReLU activations |
| and softmax output. Pure NumPy — no framework dependencies. |
| """ |
|
|
| def __init__(self, input_dim, hidden_dims, output_dim, activation='relu'): |
| self.input_dim = input_dim |
| self.hidden_dims = list(hidden_dims) |
| self.output_dim = output_dim |
| self.activation = activation |
|
|
| 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 = [] |
| self.bs = [] |
| for fan_in, fan_out in self.layer_dims: |
| self.Ws.append(np.zeros((fan_out, fan_in))) |
| self.bs.append(np.zeros(fan_out)) |
|
|
| @classmethod |
| def from_pretrained(cls, path): |
| """Load model from a directory containing weights.txt and meta.json.""" |
| path = Path(path) |
| with open(path / 'meta.json') as f: |
| meta = json.load(f) |
|
|
| model = cls( |
| input_dim=meta['input_dim'], |
| hidden_dims=meta['hidden_dims'], |
| output_dim=meta['output_dim'], |
| activation=meta.get('activation', 'relu'), |
| ) |
|
|
| with open(path / 'weights.txt') as f: |
| params = np.array([float(x) for x in f.read().split()]) |
|
|
| idx = 0 |
| for i, (fan_in, fan_out) in enumerate(model.layer_dims): |
| n_W = fan_out * fan_in |
| model.Ws[i] = params[idx:idx + n_W].reshape(fan_out, fan_in) |
| idx += n_W |
| model.bs[i] = params[idx:idx + fan_out].copy() |
| idx += fan_out |
|
|
| return model |
|
|
| def forward(self, x): |
| """Forward pass. Returns pre-softmax logits.""" |
| h = np.asarray(x, dtype=np.float64) |
| for i, (W, b) in enumerate(zip(self.Ws, self.bs)): |
| h = W @ h + b |
| if i < self.n_layers - 1: |
| if self.activation == 'relu': |
| h = np.maximum(0.0, h) |
| else: |
| h = np.tanh(h) |
| return h |
|
|
| def predict_proba(self, x): |
| """Return class probabilities.""" |
| return _softmax(self.forward(x)) |
|
|
| def predict(self, features, class_names=None): |
| """ |
| High-level predict from human-readable transaction features. |
| |
| Parameters |
| ---------- |
| features : list or dict |
| If list: [amount_ratio, hour, day_of_week, location_delta, |
| velocity_1h, velocity_24h, merchant_risk, |
| international, card_present, device_match, |
| account_age_days, prev_fraud_score] |
| If dict: keys matching the feature names above |
| class_names : list, optional |
| Names for output classes (default: class_0, class_1, ...) |
| |
| Returns |
| ------- |
| dict with class name → probability |
| """ |
| if isinstance(features, dict): |
| raw = features |
| else: |
| keys = [ |
| 'amount_ratio', 'hour', 'day_of_week', 'location_delta', |
| 'velocity_1h', 'velocity_24h', 'merchant_risk', |
| 'international', 'card_present', 'device_match', |
| 'account_age_days', 'prev_fraud_score', |
| ] |
| raw = dict(zip(keys, features)) |
|
|
| x = self._normalize(raw) |
| proba = self.predict_proba(x) |
|
|
| if class_names is None: |
| class_names = [f'class_{i}' for i in range(self.output_dim)] |
|
|
| return {name: round(float(proba[i]), 4) for i, name in enumerate(class_names)} |
|
|
| @staticmethod |
| def _normalize(raw): |
| """Convert human-readable features to 14-dim normalized vector.""" |
| h_rad = 2 * math.pi * raw['hour'] / 24 |
| d_rad = 2 * math.pi * raw['day_of_week'] / 7 |
| return np.array([ |
| float(raw['amount_ratio']), |
| math.sin(h_rad), math.cos(h_rad), |
| math.sin(d_rad), math.cos(d_rad), |
| float(raw['location_delta']), |
| min(raw['velocity_1h'] / 10.0, 1.0), |
| min(raw['velocity_24h'] / 30.0, 1.0), |
| float(raw['merchant_risk']), |
| 1.0 if raw['international'] else 0.0, |
| 1.0 if raw['card_present'] else 0.0, |
| 1.0 if raw['device_match'] else 0.0, |
| min(raw['account_age_days'] / 3650.0, 1.0), |
| float(raw['prev_fraud_score']), |
| ], dtype=np.float64) |
|
|
| 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'GnaniNet({arch}, {self.param_count():,} params)' |
|
|