obvaekernel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """Public package interface for obvaekernel."""
2
+
3
+ from ._version import __version__
4
+ from .core import OBVAEK, OBVAEKernel
5
+
6
+ __all__ = ["OBVAEKernel", "OBVAEK", "__version__"]
7
+
@@ -0,0 +1,4 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.1.0"
4
+
obvaekernel/core.py ADDED
@@ -0,0 +1,621 @@
1
+ """Portable NumPy-only OBVAE kernel module.
2
+
3
+ This module implements an overcomplete beta-VAE encoder that can be used as a
4
+ feature map for kernel methods. Training and inference are fully NumPy-based,
5
+ with optional serialization for cross-runtime portability.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Callable, Dict, Optional, Tuple, Union
13
+
14
+ import numpy as np
15
+
16
+
17
+ ArrayLike = Union[np.ndarray, list, tuple]
18
+
19
+
20
+ class _NumPyVAEEngine:
21
+ """Internal pure NumPy VAE engine with manual backpropagation."""
22
+
23
+ def __init__(
24
+ self,
25
+ input_dim: int,
26
+ hidden_dim: int,
27
+ latent_dim: int,
28
+ rng: np.random.Generator,
29
+ dtype: np.dtype,
30
+ logvar_clip: Tuple[float, float] = (-30.0, 20.0),
31
+ ) -> None:
32
+ self.input_dim = int(input_dim)
33
+ self.hidden_dim = int(hidden_dim)
34
+ self.latent_dim = int(latent_dim)
35
+ self.rng = rng
36
+ self.dtype = np.dtype(dtype)
37
+ self.logvar_clip = logvar_clip
38
+
39
+ self.W_enc = self._init_weights(self.input_dim, self.hidden_dim)
40
+ self.b_enc = np.zeros(self.hidden_dim, dtype=self.dtype)
41
+
42
+ self.W_mu = self._init_weights(self.hidden_dim, self.latent_dim)
43
+ self.b_mu = np.zeros(self.latent_dim, dtype=self.dtype)
44
+
45
+ self.W_logvar = self._init_weights(self.hidden_dim, self.latent_dim)
46
+ self.b_logvar = np.zeros(self.latent_dim, dtype=self.dtype)
47
+
48
+ self.W_dec1 = self._init_weights(self.latent_dim, self.hidden_dim)
49
+ self.b_dec1 = np.zeros(self.hidden_dim, dtype=self.dtype)
50
+
51
+ self.W_dec2 = self._init_weights(self.hidden_dim, self.input_dim)
52
+ self.b_dec2 = np.zeros(self.input_dim, dtype=self.dtype)
53
+
54
+ self._cache: Dict[str, np.ndarray] = {}
55
+
56
+ def _init_weights(self, fan_in: int, fan_out: int) -> np.ndarray:
57
+ limit = np.sqrt(6.0 / float(fan_in + fan_out))
58
+ return self.rng.uniform(
59
+ low=-limit, high=limit, size=(fan_out, fan_in)
60
+ ).astype(self.dtype, copy=False)
61
+
62
+ @staticmethod
63
+ def _relu(x: np.ndarray) -> np.ndarray:
64
+ return np.maximum(x, 0.0)
65
+
66
+ @staticmethod
67
+ def _relu_derivative(x: np.ndarray) -> np.ndarray:
68
+ return (x > 0.0).astype(x.dtype, copy=False)
69
+
70
+ def encode(self, x: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
71
+ z_enc = x @ self.W_enc.T + self.b_enc
72
+ h_enc = self._relu(z_enc)
73
+
74
+ mu = h_enc @ self.W_mu.T + self.b_mu
75
+ logvar = h_enc @ self.W_logvar.T + self.b_logvar
76
+ logvar = np.clip(logvar, self.logvar_clip[0], self.logvar_clip[1])
77
+ return z_enc, h_enc, mu, logvar
78
+
79
+ def decode(self, z: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
80
+ z_dec1 = z @ self.W_dec1.T + self.b_dec1
81
+ h_dec = self._relu(z_dec1)
82
+ x_recon = h_dec @ self.W_dec2.T + self.b_dec2
83
+ return z_dec1, h_dec, x_recon
84
+
85
+ def forward(self, x: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
86
+ z_enc, h_enc, mu, logvar = self.encode(x)
87
+ std = np.exp(0.5 * logvar).astype(self.dtype, copy=False)
88
+ eps = self.rng.standard_normal(mu.shape).astype(self.dtype, copy=False)
89
+ z = mu + std * eps
90
+ z_dec1, h_dec, x_recon = self.decode(z)
91
+
92
+ self._cache = {
93
+ "x": x,
94
+ "z_enc": z_enc,
95
+ "h_enc": h_enc,
96
+ "mu": mu,
97
+ "logvar": logvar,
98
+ "std": std,
99
+ "eps": eps,
100
+ "z": z,
101
+ "z_dec1": z_dec1,
102
+ "h_dec": h_dec,
103
+ "x_recon": x_recon,
104
+ }
105
+ return x_recon, mu, logvar
106
+
107
+ def encode_mean(self, x: np.ndarray) -> np.ndarray:
108
+ _, h_enc, mu, _ = self.encode(x)
109
+ # h_enc is computed intentionally to match transform path numerics.
110
+ _ = h_enc
111
+ return mu
112
+
113
+ @staticmethod
114
+ def compute_loss(
115
+ x_recon: np.ndarray,
116
+ x: np.ndarray,
117
+ mu: np.ndarray,
118
+ logvar: np.ndarray,
119
+ beta: float,
120
+ ) -> Tuple[float, float, float]:
121
+ mse_loss = float(np.sum((x_recon - x) ** 2))
122
+ kld_loss = float(-0.5 * np.sum(1.0 + logvar - mu**2 - np.exp(logvar)))
123
+ loss = mse_loss + float(beta) * kld_loss
124
+ return loss, mse_loss, kld_loss
125
+
126
+ def backward(
127
+ self,
128
+ x: np.ndarray,
129
+ x_recon: np.ndarray,
130
+ mu: np.ndarray,
131
+ logvar: np.ndarray,
132
+ beta: float,
133
+ ) -> Dict[str, np.ndarray]:
134
+ del x, x_recon, mu, logvar
135
+ cache = self._cache
136
+
137
+ x_batch = cache["x"]
138
+ z_enc = cache["z_enc"]
139
+ h_enc = cache["h_enc"]
140
+ mu_cached = cache["mu"]
141
+ logvar_cached = cache["logvar"]
142
+ std = cache["std"]
143
+ eps = cache["eps"]
144
+ z = cache["z"]
145
+ z_dec1 = cache["z_dec1"]
146
+ h_dec = cache["h_dec"]
147
+ x_recon_cached = cache["x_recon"]
148
+
149
+ dx_recon = 2.0 * (x_recon_cached - x_batch)
150
+ dW_dec2 = dx_recon.T @ h_dec
151
+ db_dec2 = np.sum(dx_recon, axis=0)
152
+
153
+ dh_dec = dx_recon @ self.W_dec2
154
+ dz_dec1 = dh_dec * self._relu_derivative(z_dec1)
155
+ dW_dec1 = dz_dec1.T @ z
156
+ db_dec1 = np.sum(dz_dec1, axis=0)
157
+
158
+ dz = dz_dec1 @ self.W_dec1
159
+ dmu_kl = mu_cached
160
+ dlogvar_kl = -0.5 * (1.0 - np.exp(logvar_cached))
161
+
162
+ dmu = dz + float(beta) * dmu_kl
163
+ dlogvar = dz * (0.5 * std * eps) + float(beta) * dlogvar_kl
164
+
165
+ dW_mu = dmu.T @ h_enc
166
+ db_mu = np.sum(dmu, axis=0)
167
+
168
+ dW_logvar = dlogvar.T @ h_enc
169
+ db_logvar = np.sum(dlogvar, axis=0)
170
+
171
+ dh_enc = dmu @ self.W_mu + dlogvar @ self.W_logvar
172
+ dz_enc = dh_enc * self._relu_derivative(z_enc)
173
+ dW_enc = dz_enc.T @ x_batch
174
+ db_enc = np.sum(dz_enc, axis=0)
175
+
176
+ return {
177
+ "W_enc": dW_enc.astype(self.dtype, copy=False),
178
+ "b_enc": db_enc.astype(self.dtype, copy=False),
179
+ "W_mu": dW_mu.astype(self.dtype, copy=False),
180
+ "b_mu": db_mu.astype(self.dtype, copy=False),
181
+ "W_logvar": dW_logvar.astype(self.dtype, copy=False),
182
+ "b_logvar": db_logvar.astype(self.dtype, copy=False),
183
+ "W_dec1": dW_dec1.astype(self.dtype, copy=False),
184
+ "b_dec1": db_dec1.astype(self.dtype, copy=False),
185
+ "W_dec2": dW_dec2.astype(self.dtype, copy=False),
186
+ "b_dec2": db_dec2.astype(self.dtype, copy=False),
187
+ }
188
+
189
+
190
+ class AdamOptimizer:
191
+ """Pure NumPy Adam optimizer."""
192
+
193
+ def __init__(
194
+ self,
195
+ params: Dict[str, np.ndarray],
196
+ lr: float = 0.001,
197
+ betas: Tuple[float, float] = (0.9, 0.999),
198
+ eps: float = 1e-8,
199
+ ) -> None:
200
+ self.params = params
201
+ self.lr = float(lr)
202
+ self.beta1 = float(betas[0])
203
+ self.beta2 = float(betas[1])
204
+ self.eps = float(eps)
205
+ self.t = 0
206
+ self.m = {name: np.zeros_like(value) for name, value in params.items()}
207
+ self.v = {name: np.zeros_like(value) for name, value in params.items()}
208
+
209
+ def step(self, grads: Dict[str, np.ndarray]) -> None:
210
+ self.t += 1
211
+ for name, param in self.params.items():
212
+ grad = grads[name]
213
+ self.m[name] = self.beta1 * self.m[name] + (1.0 - self.beta1) * grad
214
+ self.v[name] = self.beta2 * self.v[name] + (1.0 - self.beta2) * (grad**2)
215
+
216
+ m_hat = self.m[name] / (1.0 - self.beta1**self.t)
217
+ v_hat = self.v[name] / (1.0 - self.beta2**self.t)
218
+ param -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
219
+
220
+
221
+ class OBVAEKernel:
222
+ """Overcomplete Beta-VAE kernel transformer with NumPy-only core."""
223
+
224
+ SERIAL_VERSION = 1
225
+
226
+ def __init__(
227
+ self,
228
+ latent_dim: Union[str, int] = "auto",
229
+ expansion_factor: int = 10,
230
+ hidden_dim: int = 64,
231
+ beta: float = 2.0,
232
+ epochs: int = 300,
233
+ lr: float = 0.005,
234
+ batch_size: Optional[int] = None,
235
+ variance_threshold: float = 1e-4,
236
+ dtype: Union[str, np.dtype, type] = np.float32,
237
+ keep_decoder: bool = True,
238
+ verbose: bool = False,
239
+ random_state: Optional[int] = None,
240
+ ) -> None:
241
+ self.latent_dim = latent_dim
242
+ self.expansion_factor = expansion_factor
243
+ self.hidden_dim = hidden_dim
244
+ self.beta = beta
245
+ self.epochs = epochs
246
+ self.lr = lr
247
+ self.batch_size = batch_size
248
+ self.variance_threshold = variance_threshold
249
+ self.dtype = np.dtype(dtype)
250
+ self.keep_decoder = keep_decoder
251
+ self.verbose = verbose
252
+ self.random_state = random_state
253
+
254
+ self.W_h: Optional[np.ndarray] = None
255
+ self.b_h: Optional[np.ndarray] = None
256
+ self.W_mu: Optional[np.ndarray] = None
257
+ self.b_mu: Optional[np.ndarray] = None
258
+
259
+ self._full_W_mu: Optional[np.ndarray] = None
260
+ self._full_b_mu: Optional[np.ndarray] = None
261
+
262
+ self._W_dec1: Optional[np.ndarray] = None
263
+ self._b_dec1: Optional[np.ndarray] = None
264
+ self._W_dec2: Optional[np.ndarray] = None
265
+ self._b_dec2: Optional[np.ndarray] = None
266
+
267
+ self.n_features_in_: Optional[int] = None
268
+ self.training_dim_: Optional[int] = None
269
+ self.effective_dim_: Optional[int] = None
270
+ self.active_indices_: Optional[np.ndarray] = None
271
+ self.feature_variances_: Optional[np.ndarray] = None
272
+ self.is_fitted_ = False
273
+
274
+ def _validate_hyperparameters(self) -> None:
275
+ if self.latent_dim != "auto":
276
+ if not isinstance(self.latent_dim, (int, np.integer)):
277
+ raise ValueError("latent_dim must be 'auto' or a positive integer.")
278
+ if int(self.latent_dim) <= 0:
279
+ raise ValueError("latent_dim must be a positive integer.")
280
+
281
+ if not isinstance(self.expansion_factor, (int, np.integer)) or int(self.expansion_factor) <= 0:
282
+ raise ValueError("expansion_factor must be a positive integer.")
283
+ if not isinstance(self.hidden_dim, (int, np.integer)) or int(self.hidden_dim) <= 0:
284
+ raise ValueError("hidden_dim must be a positive integer.")
285
+ if not isinstance(self.epochs, (int, np.integer)) or int(self.epochs) <= 0:
286
+ raise ValueError("epochs must be a positive integer.")
287
+ if float(self.lr) <= 0.0:
288
+ raise ValueError("lr must be greater than 0.")
289
+ if float(self.variance_threshold) < 0.0:
290
+ raise ValueError("variance_threshold must be >= 0.")
291
+ if self.batch_size is not None and int(self.batch_size) <= 0:
292
+ raise ValueError("batch_size must be a positive integer or None.")
293
+
294
+ def _to_2d_array(self, x: ArrayLike, expected_features: Optional[int] = None) -> np.ndarray:
295
+ arr = np.asarray(x, dtype=self.dtype)
296
+ if arr.ndim != 2:
297
+ raise ValueError(f"Expected a 2D array, got {arr.ndim}D.")
298
+ if expected_features is not None and arr.shape[1] != expected_features:
299
+ raise ValueError(
300
+ f"Expected {expected_features} features, got {arr.shape[1]}."
301
+ )
302
+ return np.ascontiguousarray(arr, dtype=self.dtype)
303
+
304
+ def _to_single_sample(self, x: ArrayLike) -> np.ndarray:
305
+ arr = np.asarray(x, dtype=self.dtype)
306
+ if arr.ndim == 1:
307
+ arr = arr.reshape(1, -1)
308
+ elif arr.ndim == 2 and arr.shape[0] == 1:
309
+ pass
310
+ else:
311
+ raise ValueError("Input must be a single sample as shape (n_features,) or (1, n_features).")
312
+ return np.ascontiguousarray(arr, dtype=self.dtype)
313
+
314
+ def _require_fitted(self) -> None:
315
+ if not self.is_fitted_:
316
+ raise RuntimeError("Model must be fitted before calling this method.")
317
+
318
+ def _resolve_training_dim(self, input_dim: int) -> int:
319
+ if self.latent_dim == "auto":
320
+ return int(min(input_dim * int(self.expansion_factor), 500))
321
+ return int(self.latent_dim)
322
+
323
+ def _normalize_rows(self, phi: np.ndarray) -> np.ndarray:
324
+ eps = np.finfo(phi.dtype).eps
325
+ norms = np.linalg.norm(phi, axis=1, keepdims=True)
326
+ return phi / np.maximum(norms, eps)
327
+
328
+ def fit(self, X: ArrayLike, y: Optional[ArrayLike] = None) -> "OBVAEKernel":
329
+ del y
330
+ self._validate_hyperparameters()
331
+ X_arr = self._to_2d_array(X)
332
+ n_samples, input_dim = X_arr.shape
333
+
334
+ training_dim = self._resolve_training_dim(input_dim)
335
+ if training_dim <= 0:
336
+ raise ValueError("Resolved training latent dimension must be positive.")
337
+
338
+ rng = np.random.default_rng(self.random_state)
339
+ engine = _NumPyVAEEngine(
340
+ input_dim=input_dim,
341
+ hidden_dim=int(self.hidden_dim),
342
+ latent_dim=training_dim,
343
+ rng=rng,
344
+ dtype=self.dtype,
345
+ logvar_clip=(-30.0, 20.0),
346
+ )
347
+
348
+ params = {
349
+ "W_enc": engine.W_enc,
350
+ "b_enc": engine.b_enc,
351
+ "W_mu": engine.W_mu,
352
+ "b_mu": engine.b_mu,
353
+ "W_logvar": engine.W_logvar,
354
+ "b_logvar": engine.b_logvar,
355
+ "W_dec1": engine.W_dec1,
356
+ "b_dec1": engine.b_dec1,
357
+ "W_dec2": engine.W_dec2,
358
+ "b_dec2": engine.b_dec2,
359
+ }
360
+ optimizer = AdamOptimizer(params=params, lr=float(self.lr))
361
+
362
+ if self.batch_size is None:
363
+ batch_size = n_samples
364
+ else:
365
+ batch_size = min(int(self.batch_size), n_samples)
366
+ n_batches = (n_samples + batch_size - 1) // batch_size
367
+
368
+ for epoch in range(int(self.epochs)):
369
+ indices = rng.permutation(n_samples)
370
+ X_shuffled = X_arr[indices]
371
+ epoch_loss = 0.0
372
+
373
+ for batch_idx in range(n_batches):
374
+ start = batch_idx * batch_size
375
+ end = min((batch_idx + 1) * batch_size, n_samples)
376
+ X_batch = X_shuffled[start:end]
377
+
378
+ x_recon, mu, logvar = engine.forward(X_batch)
379
+ loss, _, _ = engine.compute_loss(x_recon, X_batch, mu, logvar, beta=float(self.beta))
380
+ grads = engine.backward(X_batch, x_recon, mu, logvar, beta=float(self.beta))
381
+ optimizer.step(grads)
382
+ epoch_loss += loss
383
+
384
+ if self.verbose and ((epoch + 1) % 50 == 0 or epoch == 0 or (epoch + 1) == int(self.epochs)):
385
+ print(f"Epoch [{epoch + 1}/{int(self.epochs)}], Loss: {epoch_loss / n_samples:.6f}")
386
+
387
+ final_mu = engine.encode_mean(X_arr)
388
+ feature_variances = np.var(final_mu, axis=0).astype(self.dtype, copy=False)
389
+ active_indices = np.flatnonzero(feature_variances > float(self.variance_threshold)).astype(np.int64)
390
+ if active_indices.size == 0:
391
+ active_indices = np.array([int(np.argmax(feature_variances))], dtype=np.int64)
392
+
393
+ self.W_h = engine.W_enc.copy()
394
+ self.b_h = engine.b_enc.copy()
395
+ self.W_mu = engine.W_mu[active_indices, :].copy()
396
+ self.b_mu = engine.b_mu[active_indices].copy()
397
+ self._full_W_mu = engine.W_mu.copy()
398
+ self._full_b_mu = engine.b_mu.copy()
399
+
400
+ if self.keep_decoder:
401
+ self._W_dec1 = engine.W_dec1.copy()
402
+ self._b_dec1 = engine.b_dec1.copy()
403
+ self._W_dec2 = engine.W_dec2.copy()
404
+ self._b_dec2 = engine.b_dec2.copy()
405
+ else:
406
+ self._W_dec1 = None
407
+ self._b_dec1 = None
408
+ self._W_dec2 = None
409
+ self._b_dec2 = None
410
+
411
+ self.n_features_in_ = input_dim
412
+ self.training_dim_ = training_dim
413
+ self.active_indices_ = active_indices
414
+ self.feature_variances_ = feature_variances.copy()
415
+ self.effective_dim_ = int(active_indices.size)
416
+ self.is_fitted_ = True
417
+
418
+ if self.verbose:
419
+ print(
420
+ f"OBVAEKernel: Inflated to {training_dim}D, "
421
+ f"pruned to {self.effective_dim_} active features."
422
+ )
423
+
424
+ return self
425
+
426
+ def transform(self, X: ArrayLike) -> np.ndarray:
427
+ self._require_fitted()
428
+ X_arr = self._to_2d_array(X, expected_features=int(self.n_features_in_))
429
+ z_hidden = X_arr @ self.W_h.T + self.b_h
430
+ h = np.maximum(z_hidden, 0.0)
431
+ mu = h @ self.W_mu.T + self.b_mu
432
+ if not np.isfinite(mu).all():
433
+ raise ValueError("Non-finite values encountered in transform output.")
434
+ return mu.astype(self.dtype, copy=False)
435
+
436
+ def fit_transform(self, X: ArrayLike, y: Optional[ArrayLike] = None) -> np.ndarray:
437
+ return self.fit(X, y).transform(X)
438
+
439
+ def inverse_transform(self, Z: ArrayLike) -> np.ndarray:
440
+ self._require_fitted()
441
+ if self._W_dec1 is None or self._b_dec1 is None or self._W_dec2 is None or self._b_dec2 is None:
442
+ raise RuntimeError("Decoder weights are unavailable (keep_decoder=False during fit).")
443
+
444
+ Z_arr = self._to_2d_array(Z)
445
+ if Z_arr.shape[1] == int(self.effective_dim_):
446
+ z_full = np.zeros((Z_arr.shape[0], int(self.training_dim_)), dtype=self.dtype)
447
+ z_full[:, self.active_indices_] = Z_arr
448
+ elif Z_arr.shape[1] == int(self.training_dim_):
449
+ z_full = Z_arr
450
+ else:
451
+ raise ValueError(
452
+ f"Expected latent width {self.effective_dim_} (pruned) "
453
+ f"or {self.training_dim_} (full), got {Z_arr.shape[1]}."
454
+ )
455
+
456
+ z_dec1 = z_full @ self._W_dec1.T + self._b_dec1
457
+ h_dec = np.maximum(z_dec1, 0.0)
458
+ x_recon = h_dec @ self._W_dec2.T + self._b_dec2
459
+ if not np.isfinite(x_recon).all():
460
+ raise ValueError("Non-finite values encountered in inverse_transform output.")
461
+ return x_recon.astype(self.dtype, copy=False)
462
+
463
+ def kernel_matrix(
464
+ self,
465
+ X: ArrayLike,
466
+ Y: Optional[ArrayLike] = None,
467
+ normalize: bool = True,
468
+ center: bool = False,
469
+ ) -> np.ndarray:
470
+ self._require_fitted()
471
+ phi_x = self.transform(X)
472
+ phi_y = phi_x if Y is None else self.transform(Y)
473
+
474
+ if normalize:
475
+ phi_x = self._normalize_rows(phi_x)
476
+ phi_y = self._normalize_rows(phi_y)
477
+
478
+ K = phi_x @ phi_y.T
479
+
480
+ if center:
481
+ row_means = np.mean(K, axis=1, keepdims=True)
482
+ col_means = np.mean(K, axis=0, keepdims=True)
483
+ global_mean = np.mean(K)
484
+ K = K - row_means - col_means + global_mean
485
+
486
+ if Y is None:
487
+ K = 0.5 * (K + K.T)
488
+
489
+ if not np.isfinite(K).all():
490
+ raise ValueError("Non-finite values encountered in kernel matrix.")
491
+
492
+ return K.astype(self.dtype, copy=False)
493
+
494
+ def pairwise_scalar(self, x: ArrayLike, y: ArrayLike, normalize: bool = True) -> float:
495
+ self._require_fitted()
496
+ x_arr = self._to_single_sample(x)
497
+ y_arr = self._to_single_sample(y)
498
+ phi_x = self.transform(x_arr)[0]
499
+ phi_y = self.transform(y_arr)[0]
500
+
501
+ if normalize:
502
+ eps = np.finfo(phi_x.dtype).eps
503
+ phi_x = phi_x / max(float(np.linalg.norm(phi_x)), float(eps))
504
+ phi_y = phi_y / max(float(np.linalg.norm(phi_y)), float(eps))
505
+
506
+ value = float(np.dot(phi_x, phi_y))
507
+ if not np.isfinite(value):
508
+ raise ValueError("Non-finite value encountered in pairwise kernel output.")
509
+ return value
510
+
511
+ def as_pairwise_callable(self, normalize: bool = True) -> Callable[[ArrayLike, ArrayLike], float]:
512
+ def _kernel(a: ArrayLike, b: ArrayLike) -> float:
513
+ return self.pairwise_scalar(a, b, normalize=normalize)
514
+
515
+ return _kernel
516
+
517
+ def get_feature_variances(self) -> np.ndarray:
518
+ self._require_fitted()
519
+ return self.feature_variances_.copy()
520
+
521
+ def _config_dict(self) -> Dict[str, object]:
522
+ return {
523
+ "latent_dim": self.latent_dim,
524
+ "expansion_factor": int(self.expansion_factor),
525
+ "hidden_dim": int(self.hidden_dim),
526
+ "beta": float(self.beta),
527
+ "epochs": int(self.epochs),
528
+ "lr": float(self.lr),
529
+ "batch_size": None if self.batch_size is None else int(self.batch_size),
530
+ "variance_threshold": float(self.variance_threshold),
531
+ "dtype": self.dtype.name,
532
+ "keep_decoder": bool(self.keep_decoder),
533
+ "verbose": bool(self.verbose),
534
+ "random_state": self.random_state,
535
+ }
536
+
537
+ def save(self, path_prefix: str) -> None:
538
+ self._require_fitted()
539
+ prefix = Path(path_prefix)
540
+ npz_path = Path(f"{prefix}.npz")
541
+ json_path = Path(f"{prefix}.json")
542
+
543
+ arrays: Dict[str, np.ndarray] = {
544
+ "W_h": self.W_h,
545
+ "b_h": self.b_h,
546
+ "W_mu": self.W_mu,
547
+ "b_mu": self.b_mu,
548
+ "full_W_mu": self._full_W_mu,
549
+ "full_b_mu": self._full_b_mu,
550
+ "active_indices": self.active_indices_.astype(np.int64, copy=False),
551
+ "feature_variances": self.feature_variances_,
552
+ }
553
+
554
+ has_decoder = (
555
+ self._W_dec1 is not None
556
+ and self._b_dec1 is not None
557
+ and self._W_dec2 is not None
558
+ and self._b_dec2 is not None
559
+ )
560
+ if has_decoder:
561
+ arrays["W_dec1"] = self._W_dec1
562
+ arrays["b_dec1"] = self._b_dec1
563
+ arrays["W_dec2"] = self._W_dec2
564
+ arrays["b_dec2"] = self._b_dec2
565
+
566
+ np.savez(npz_path, **arrays)
567
+
568
+ metadata = {
569
+ "version": self.SERIAL_VERSION,
570
+ "class_name": "OBVAEKernel",
571
+ "config": self._config_dict(),
572
+ "fitted": {
573
+ "n_features_in_": int(self.n_features_in_),
574
+ "training_dim_": int(self.training_dim_),
575
+ "effective_dim_": int(self.effective_dim_),
576
+ "has_decoder": bool(has_decoder),
577
+ },
578
+ }
579
+ json_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
580
+
581
+ @classmethod
582
+ def load(cls, path_prefix: str) -> "OBVAEKernel":
583
+ prefix = Path(path_prefix)
584
+ npz_path = Path(f"{prefix}.npz")
585
+ json_path = Path(f"{prefix}.json")
586
+
587
+ metadata = json.loads(json_path.read_text(encoding="utf-8"))
588
+ config = metadata["config"]
589
+ model = cls(**config)
590
+
591
+ with np.load(npz_path, allow_pickle=False) as data:
592
+ model.W_h = np.asarray(data["W_h"], dtype=model.dtype)
593
+ model.b_h = np.asarray(data["b_h"], dtype=model.dtype)
594
+ model.W_mu = np.asarray(data["W_mu"], dtype=model.dtype)
595
+ model.b_mu = np.asarray(data["b_mu"], dtype=model.dtype)
596
+ model._full_W_mu = np.asarray(data["full_W_mu"], dtype=model.dtype)
597
+ model._full_b_mu = np.asarray(data["full_b_mu"], dtype=model.dtype)
598
+ model.active_indices_ = np.asarray(data["active_indices"], dtype=np.int64)
599
+ model.feature_variances_ = np.asarray(data["feature_variances"], dtype=model.dtype)
600
+
601
+ has_decoder = bool(metadata["fitted"]["has_decoder"])
602
+ if has_decoder:
603
+ model._W_dec1 = np.asarray(data["W_dec1"], dtype=model.dtype)
604
+ model._b_dec1 = np.asarray(data["b_dec1"], dtype=model.dtype)
605
+ model._W_dec2 = np.asarray(data["W_dec2"], dtype=model.dtype)
606
+ model._b_dec2 = np.asarray(data["b_dec2"], dtype=model.dtype)
607
+ else:
608
+ model._W_dec1 = None
609
+ model._b_dec1 = None
610
+ model._W_dec2 = None
611
+ model._b_dec2 = None
612
+
613
+ model.n_features_in_ = int(metadata["fitted"]["n_features_in_"])
614
+ model.training_dim_ = int(metadata["fitted"]["training_dim_"])
615
+ model.effective_dim_ = int(metadata["fitted"]["effective_dim_"])
616
+ model.is_fitted_ = True
617
+ return model
618
+
619
+
620
+ OBVAEK = OBVAEKernel
621
+
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: obvaekernel
3
+ Version: 0.1.0
4
+ Summary: Portable NumPy-first OBVAE kernel features for PCA/kernel workflows.
5
+ Project-URL: Homepage, https://github.com/sweet00000/OvercompleteBetaVariationalAutoEncoderKernel
6
+ Project-URL: Repository, https://github.com/sweet00000/OvercompleteBetaVariationalAutoEncoderKernel
7
+ Author: Sweet
8
+ License: BSD 3-Clause License
9
+
10
+ Copyright (c) 2026, Sweet
11
+ All rights reserved.
12
+
13
+ Redistribution and use in source and binary forms, with or without
14
+ modification, are permitted provided that the following conditions are met:
15
+
16
+ 1. Redistributions of source code must retain the above copyright notice, this
17
+ list of conditions and the following disclaimer.
18
+
19
+ 2. Redistributions in binary form must reproduce the above copyright notice,
20
+ this list of conditions and the following disclaimer in the documentation
21
+ and/or other materials provided with the distribution.
22
+
23
+ 3. Neither the name of the copyright holder nor the names of its
24
+ contributors may be used to endorse or promote products derived from
25
+ this software without specific prior written permission.
26
+
27
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
28
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
29
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
30
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
31
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
32
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
33
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
34
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
35
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
36
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
37
+
38
+ License-File: LICENSE
39
+ Classifier: Development Status :: 3 - Alpha
40
+ Classifier: Intended Audience :: Science/Research
41
+ Classifier: License :: OSI Approved :: BSD License
42
+ Classifier: Programming Language :: Python :: 3
43
+ Classifier: Programming Language :: Python :: 3.10
44
+ Classifier: Programming Language :: Python :: 3.11
45
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
46
+ Requires-Python: >=3.10
47
+ Requires-Dist: numpy>=1.23
48
+ Provides-Extra: benchmark
49
+ Requires-Dist: matplotlib>=3.7; extra == 'benchmark'
50
+ Requires-Dist: pandas>=1.5; extra == 'benchmark'
51
+ Requires-Dist: requests>=2.31; extra == 'benchmark'
52
+ Requires-Dist: scikit-learn>=1.2; extra == 'benchmark'
53
+ Requires-Dist: scipy>=1.9; extra == 'benchmark'
54
+ Provides-Extra: dev
55
+ Requires-Dist: build>=1.2; extra == 'dev'
56
+ Requires-Dist: ruff>=0.6; extra == 'dev'
57
+ Requires-Dist: twine>=5; extra == 'dev'
58
+ Description-Content-Type: text/markdown
59
+
60
+ # obvaekernel
61
+
62
+ `obvaekernel` is a NumPy-first implementation of an overcomplete beta-VAE kernel feature map designed for PCA and kernel-method workflows.
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ pip install .
68
+ ```
69
+
70
+ Benchmark/data extras:
71
+
72
+ ```bash
73
+ pip install .[benchmark]
74
+ ```
75
+
76
+ Development/build extras:
77
+
78
+ ```bash
79
+ pip install .[dev]
80
+ ```
81
+
82
+ ## Quick Usage
83
+
84
+ ```python
85
+ import numpy as np
86
+ from obvaekernel import OBVAEKernel
87
+
88
+ X = np.random.randn(256, 12).astype("float32")
89
+ model = OBVAEKernel(latent_dim="auto", epochs=25, random_state=42)
90
+ Z = model.fit_transform(X)
91
+ K = model.kernel_matrix(X)
92
+ ```
93
+
94
+ ## Benchmark Scripts
95
+
96
+ Run the root benchmark harness:
97
+
98
+ ```bash
99
+ python main.py --mode quick --no-plots
100
+ ```
101
+
102
+ Run the examples copy:
103
+
104
+ ```bash
105
+ python examples/benchmark_main.py --mode full --include-weather
106
+ ```
107
+
108
+ ## Build and Publish Checks
109
+
110
+ Build wheel + sdist:
111
+
112
+ ```bash
113
+ python -m build
114
+ ```
115
+
116
+ Validate distribution metadata:
117
+
118
+ ```bash
119
+ python -m twine check dist/*
120
+ ```
121
+
@@ -0,0 +1,7 @@
1
+ obvaekernel/__init__.py,sha256=gsOb06tzViAw-R3TjeyV8V0dnPUaWHL74omkhy1VLXc,174
2
+ obvaekernel/_version.py,sha256=m2jSerkTHRf7k7u-zWcItoCAXxz6nYPl6D83y7ho0M4,47
3
+ obvaekernel/core.py,sha256=gQ6-bH1xAPjl_-8DLh50iCxW9C8Ct2Gj9SYenfYYgrI,23616
4
+ obvaekernel-0.1.0.dist-info/METADATA,sha256=DdqEjhSANKE4Yp16hNvPIF9laIBX0QJ0f7-QG8fU3ys,3939
5
+ obvaekernel-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ obvaekernel-0.1.0.dist-info/licenses/LICENSE,sha256=Sthn0-FlQjNpBd4wfp6leeGwi7W1X6PEe-B-YfO6z9c,1514
7
+ obvaekernel-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,30 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Sweet
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30
+