hyperbolix 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (41) hide show
  1. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/PKG-INFO +2 -2
  2. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/README.md +1 -1
  3. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/__init__.py +1 -2
  4. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hyperboloid_conv.py +70 -232
  5. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hyperboloid_linear.py +128 -70
  6. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hyperboloid_regression.py +3 -3
  7. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/poincare_conv.py +26 -19
  8. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/poincare_linear.py +37 -18
  9. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/pyproject.toml +1 -1
  10. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/__init__.py +0 -0
  11. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/distributions/__init__.py +0 -0
  12. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/distributions/_common.py +0 -0
  13. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/distributions/_wrapped_normal_base.py +0 -0
  14. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/distributions/uniform_poincare.py +0 -0
  15. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/distributions/wrapped_normal_hyperboloid.py +0 -0
  16. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/distributions/wrapped_normal_poincare.py +0 -0
  17. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/manifolds/__init__.py +0 -0
  18. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/manifolds/_base.py +0 -0
  19. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/manifolds/euclidean.py +0 -0
  20. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/manifolds/hyperboloid.py +0 -0
  21. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/manifolds/isometry_mappings.py +0 -0
  22. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/manifolds/poincare.py +0 -0
  23. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/manifolds/protocol.py +0 -0
  24. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/_helpers.py +0 -0
  25. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hybrid_regularization.py +0 -0
  26. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hyperboloid_activations.py +0 -0
  27. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hyperboloid_attention.py +0 -0
  28. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hyperboloid_core.py +0 -0
  29. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hyperboloid_positional.py +0 -0
  30. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/hyperboloid_regularization.py +0 -0
  31. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/poincare_activations.py +0 -0
  32. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/poincare_regression.py +0 -0
  33. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/nn_layers/poincare_rl.py +0 -0
  34. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/optim/__init__.py +0 -0
  35. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/optim/_riemannian_base.py +0 -0
  36. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/optim/manifold_metadata.py +0 -0
  37. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/optim/riemannian_adam.py +0 -0
  38. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/optim/riemannian_sgd.py +0 -0
  39. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/utils/__init__.py +0 -0
  40. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/utils/helpers.py +0 -0
  41. {hyperbolix-0.4.0 → hyperbolix-0.4.2}/hyperbolix/utils/math_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: hyperbolix
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Hyperbolic Deep Learning in JAX
5
5
  Keywords: hyperbolic,deep-learning,jax,flax,machine-learning,riemannian,poincare,hyperboloid,neural-networks,geometric-deep-learning
6
6
  Author: Timo Klein, Thomas Lang
@@ -42,7 +42,7 @@ Pure JAX implementation of hyperbolic deep learning with manifold operations, ne
42
42
  ## Features
43
43
 
44
44
  - 🌐 **3 Manifolds**: Euclidean, Poincaré Ball, Hyperboloid
45
- - 🧠 **13+ Neural Network Layers**: Linear, convolutional (2D/3D), regression
45
+ - 🧠 **13+ Neural Network Layers**: Linear, convolutional, regression
46
46
  - ⚡ **4 Hyperbolic Activations**: ReLU, Leaky ReLU, Tanh, Swish
47
47
  - 📈 **Riemannian Optimizers**: RAdam and RSGD with automatic manifold detection
48
48
  - 🚀 **Pure JAX/Flax NNX**: vmap-native API, JIT-compatible (10-100x speedup)
@@ -12,7 +12,7 @@ Pure JAX implementation of hyperbolic deep learning with manifold operations, ne
12
12
  ## Features
13
13
 
14
14
  - 🌐 **3 Manifolds**: Euclidean, Poincaré Ball, Hyperboloid
15
- - 🧠 **13+ Neural Network Layers**: Linear, convolutional (2D/3D), regression
15
+ - 🧠 **13+ Neural Network Layers**: Linear, convolutional, regression
16
16
  - ⚡ **4 Hyperbolic Activations**: ReLU, Leaky ReLU, Tanh, Swish
17
17
  - 📈 **Riemannian Optimizers**: RAdam and RSGD with automatic manifold detection
18
18
  - 🚀 **Pure JAX/Flax NNX**: vmap-native API, JIT-compatible (10-100x speedup)
@@ -19,7 +19,7 @@ from .hyperboloid_attention import (
19
19
  HyperbolicSoftmaxAttention,
20
20
  focus_transform,
21
21
  )
22
- from .hyperboloid_conv import FGGConv2D, HypConv2DHyperboloid, HypConv3DHyperboloid, LorentzConv2D
22
+ from .hyperboloid_conv import FGGConv2D, HypConv2DHyperboloid, LorentzConv2D
23
23
  from .hyperboloid_core import build_spacelike_V, hrc, htc, lorentz_midpoint, lorentz_residual, spatial_to_hyperboloid
24
24
  from .hyperboloid_linear import FGGLinear, HTCLinear, HypLinearHyperboloidFHCNN
25
25
  from .hyperboloid_positional import HyperbolicRoPE, HypformerPositionalEncoding, hope
@@ -43,7 +43,6 @@ __all__ = [
43
43
  "HTCLinear",
44
44
  "HypConv2DHyperboloid",
45
45
  "HypConv2DPoincare",
46
- "HypConv3DHyperboloid",
47
46
  "HypLinearHyperboloidFHCNN",
48
47
  "HypLinearPoincare",
49
48
  "HypLinearPoincarePP",
@@ -2,8 +2,8 @@
2
2
 
3
3
  Dimension key:
4
4
  B: batch size
5
- H: output height W: output width Z: output depth (3D conv)
6
- C: channels (in/out) K: kernel elements (kh*kw or kd*kh*kw)
5
+ H: output height W: output width
6
+ C: channels (in/out) K: kernel elements (kh*kw)
7
7
  A: ambient dimension (in_channels or hcat output dim)
8
8
  """
9
9
 
@@ -18,7 +18,7 @@ from hyperbolix.manifolds.hyperboloid import Hyperboloid
18
18
 
19
19
  from ._helpers import validate_hyperboloid_manifold
20
20
  from .hyperboloid_core import hrc
21
- from .hyperboloid_linear import FGGLinear, HypLinearHyperboloidFHCNN
21
+ from .hyperboloid_linear import _fgg_linear_forward, _fhcnn_forward, _get_effective_kernel
22
22
 
23
23
 
24
24
  class LorentzConv2D(nnx.Module):
@@ -230,17 +230,13 @@ class HypConv2DHyperboloid(nnx.Module):
230
230
  d = in_channels - 1 # Input manifold dimension
231
231
  hcat_out_ambient_dim = d * N + 1 # HCat output ambient dimension
232
232
 
233
- # Create the linear transformation layer
234
- # Input: hcat_out_ambient_dim, Output: out_channels
235
- self.linear = HypLinearHyperboloidFHCNN(
236
- manifold_module=self.manifold,
237
- in_dim=hcat_out_ambient_dim,
238
- out_dim=out_channels,
239
- rngs=rngs,
240
- input_space="manifold", # HCat output is always on manifold
241
- learnable_scale=False,
242
- normalize=False,
233
+ # Trainable parameters — owned directly for flat parameter paths
234
+ bound = 0.02
235
+ self.kernel = nnx.Param(
236
+ jax.random.uniform(rngs.params(), (out_channels, hcat_out_ambient_dim), minval=-bound, maxval=bound)
243
237
  )
238
+ self.bias = nnx.Param(jnp.zeros((1, out_channels)))
239
+ self.scale = 2.3 # not learnable (matches FHCNN default)
244
240
 
245
241
  def _extract_patches(
246
242
  self,
@@ -322,7 +318,18 @@ class HypConv2DHyperboloid(nnx.Module):
322
318
  hcat_out_NA = jax.vmap(self.manifold.hcat, in_axes=(0, None))(patches_flat_NKC, c) # (B*H*W, hcat_dim)
323
319
 
324
320
  # Linear: (hcat_dim,) -> (out_channels,)
325
- linear_out_NC = self.linear(hcat_out_NA, c) # (B*H*W, out_channels)
321
+ linear_out_NC = _fhcnn_forward(
322
+ hcat_out_NA,
323
+ self.kernel[...],
324
+ self.bias[...],
325
+ self.manifold,
326
+ c,
327
+ "manifold",
328
+ None,
329
+ False,
330
+ self.scale,
331
+ 1e-5,
332
+ ) # (B*H*W, out_channels)
326
333
 
327
334
  # Reshape back to spatial
328
335
  output_BHWC = linear_out_NC.reshape(batch, out_h, out_w, self.out_channels)
@@ -330,210 +337,6 @@ class HypConv2DHyperboloid(nnx.Module):
330
337
  return output_BHWC
331
338
 
332
339
 
333
- class HypConv3DHyperboloid(nnx.Module):
334
- """
335
- Hyperbolic 3D Convolutional Layer for Hyperboloid model.
336
-
337
- This layer implements fully hyperbolic 3D convolution, extending the 2D approach
338
- from "Fully Hyperbolic Convolutional Neural Networks for Computer Vision" to
339
- volumetric data.
340
-
341
- Computation steps:
342
- 1) Extract receptive field (kernel_size x kernel_size x kernel_size) of hyperbolic points
343
- 2) Apply HCat (Lorentz direct concatenation) to combine receptive field points
344
- 3) Pass through hyperbolic linear layer (LFC)
345
-
346
- Parameters
347
- ----------
348
- manifold_module : object
349
- Class-based Hyperboloid manifold instance
350
- in_channels : int
351
- Number of input channels
352
- out_channels : int
353
- Number of output channels
354
- kernel_size : int or tuple[int, int, int]
355
- Size of the convolutional kernel (depth, height, width)
356
- rngs : nnx.Rngs
357
- Random number generators for parameter initialization
358
- stride : int or tuple[int, int, int]
359
- Stride of the convolution (default: 1)
360
- padding : str
361
- Padding mode, either 'SAME' or 'VALID' (default: 'SAME')
362
- input_space : str
363
- Type of the input tensor, either 'tangent' or 'manifold' (default: 'manifold').
364
- Note: This is a static configuration - changing it after initialization requires recompilation.
365
-
366
- Notes
367
- -----
368
- JIT Compatibility:
369
- This layer is designed to work with nnx.jit. Configuration parameters (padding, input_space)
370
- are treated as static and will be baked into the compiled function.
371
-
372
- References
373
- ----------
374
- Ahmad Bdeir, Kristian Schwethelm, and Niels Landwehr. "Fully hyperbolic convolutional neural networks for computer vision."
375
- arXiv preprint arXiv:2303.15919 (2023).
376
- """
377
-
378
- def __init__(
379
- self,
380
- manifold_module: Hyperboloid,
381
- in_channels: int,
382
- out_channels: int,
383
- kernel_size: int | tuple[int, int, int],
384
- *,
385
- rngs: nnx.Rngs,
386
- stride: int | tuple[int, int, int] = 1,
387
- padding: str = "SAME",
388
- input_space: str = "manifold",
389
- ):
390
- if padding not in ["SAME", "VALID"]:
391
- raise ValueError(f"padding must be either 'SAME' or 'VALID', got '{padding}'")
392
- if input_space not in ["tangent", "manifold"]:
393
- raise ValueError(f"input_space must be either 'tangent' or 'manifold', got '{input_space}'")
394
-
395
- # Static configuration
396
- validate_hyperboloid_manifold(manifold_module, required_methods=("expmap_0", "hcat"))
397
- self.manifold = manifold_module
398
- self.in_channels = in_channels
399
- self.out_channels = out_channels
400
- self.input_space = input_space
401
- self.padding = padding
402
-
403
- # Handle kernel_size as int or tuple
404
- if isinstance(kernel_size, int):
405
- self.kernel_size = (kernel_size, kernel_size, kernel_size)
406
- else:
407
- self.kernel_size = kernel_size
408
-
409
- # Handle stride as int or tuple
410
- if isinstance(stride, int):
411
- self.stride = (stride, stride, stride)
412
- else:
413
- self.stride = stride
414
-
415
- # Compute dimensions for the linear layer
416
- # Receptive field: kernel_d x kernel_h x kernel_w voxels, each is a point in in_channels-dim ambient space
417
- # HCat input: N = kernel_d x kernel_h x kernel_w points, each in in_channels ambient dimensions
418
- # HCat output: ambient dim = (in_channels - 1) x N + 1
419
- # = (in_channels - 1) x (kernel_d x kernel_h x kernel_w) + 1
420
- kernel_d, kernel_h, kernel_w = self.kernel_size
421
- N = kernel_d * kernel_h * kernel_w # Number of points in receptive field
422
- d = in_channels - 1 # Input manifold dimension
423
- hcat_out_ambient_dim = d * N + 1 # HCat output ambient dimension
424
-
425
- # Create the linear transformation layer
426
- # Input: hcat_out_ambient_dim, Output: out_channels
427
- self.linear = HypLinearHyperboloidFHCNN(
428
- manifold_module=self.manifold,
429
- in_dim=hcat_out_ambient_dim,
430
- out_dim=out_channels,
431
- rngs=rngs,
432
- input_space="manifold", # HCat output is always on manifold
433
- learnable_scale=False,
434
- normalize=False,
435
- )
436
-
437
- def _extract_patches(
438
- self,
439
- x: Float[Array, "batch depth height width in_channels"],
440
- ) -> Float[Array, "batch out_depth out_height out_width kernel_d kernel_h kernel_w in_channels"]:
441
- """Extract patches (receptive fields) from the 3D input using optimized JAX primitives."""
442
- batch, depth, height, width, in_channels = x.shape
443
- kernel_d, kernel_h, kernel_w = self.kernel_size
444
- stride_d, stride_h, stride_w = self.stride
445
-
446
- # 1. Handle Padding (Manually, to ensure manifold validity)
447
- if self.padding == "SAME":
448
- out_depth = (depth + stride_d - 1) // stride_d
449
- out_height = (height + stride_h - 1) // stride_h
450
- out_width = (width + stride_w - 1) // stride_w
451
-
452
- pad_d = max((out_depth - 1) * stride_d + kernel_d - depth, 0)
453
- pad_h = max((out_height - 1) * stride_h + kernel_h - height, 0)
454
- pad_w = max((out_width - 1) * stride_w + kernel_w - width, 0)
455
-
456
- pad_front = pad_d // 2
457
- pad_back = pad_d - pad_front
458
- pad_top = pad_h // 2
459
- pad_bottom = pad_h - pad_top
460
- pad_left = pad_w // 2
461
- pad_right = pad_w - pad_left
462
-
463
- x = jnp.pad(
464
- x,
465
- ((0, 0), (pad_front, pad_back), (pad_top, pad_bottom), (pad_left, pad_right), (0, 0)),
466
- mode="edge",
467
- )
468
-
469
- # 2. Extract Patches — output: (B, Z, H, W, C*kd*kh*kw)
470
- patches_flat_BZHW_CKdKhKw = jax.lax.conv_general_dilated_patches(
471
- lhs=x,
472
- filter_shape=(kernel_d, kernel_h, kernel_w),
473
- window_strides=(stride_d, stride_h, stride_w),
474
- padding="VALID",
475
- dimension_numbers=("NDHWC", "OIDHW", "NDHWC"),
476
- )
477
-
478
- # 3. Reshape to separate kernel dims and channels, then transpose
479
- out_d, out_h, out_w = (
480
- patches_flat_BZHW_CKdKhKw.shape[1],
481
- patches_flat_BZHW_CKdKhKw.shape[2],
482
- patches_flat_BZHW_CKdKhKw.shape[3],
483
- )
484
- # Flattened dim order from rhs_spec="OIDHW": (C, kd, kh, kw)
485
- patches_BZHWCkdkhkw = patches_flat_BZHW_CKdKhKw.reshape(
486
- batch, out_d, out_h, out_w, in_channels, kernel_d, kernel_h, kernel_w
487
- )
488
- patches_BZHWkdkhkwC = patches_BZHWCkdkhkw.transpose(0, 1, 2, 3, 5, 6, 7, 4) # move C last
489
-
490
- return patches_BZHWkdkhkwC
491
-
492
- def __call__(
493
- self,
494
- x: Float[Array, "batch depth height width in_channels"],
495
- c: float = 1.0,
496
- ) -> Float[Array, "batch out_depth out_height out_width out_channels"]:
497
- """
498
- Forward pass through the hyperbolic 3D convolutional layer.
499
-
500
- Parameters
501
- ----------
502
- x : Array of shape (batch, depth, height, width, in_channels)
503
- Input feature map where each voxel is a point on the Hyperboloid manifold
504
- c : float
505
- Manifold curvature (default: 1.0)
506
-
507
- Returns
508
- -------
509
- out : Array of shape (batch, out_depth, out_height, out_width, out_channels)
510
- Output feature map on the Hyperboloid manifold
511
- """
512
- # Map to manifold if needed (static branch - JIT friendly)
513
- if self.input_space == "tangent":
514
- x_flat_NC = x.reshape(-1, x.shape[-1]) # (B*Z*H*W, C)
515
- x_mapped_NC = jax.vmap(self.manifold.expmap_0, in_axes=(0, None))(x_flat_NC, c)
516
- x = x_mapped_NC.reshape(x.shape) # (B, Z, H, W, C)
517
-
518
- # Extract patches: (B, Z, H, W, kd, kh, kw, C)
519
- patches_BZHWkdkhkwC = self._extract_patches(x)
520
- batch, out_d, out_h, out_w, kd, kh, kw, in_c = patches_BZHWkdkhkwC.shape
521
-
522
- # Flatten batch+spatial for parallel processing: (B*Z*H*W, K, C)
523
- patches_flat_NKC = patches_BZHWkdkhkwC.reshape(-1, kd * kh * kw, in_c)
524
-
525
- # HCat: (K, C) -> (hcat_dim,) per patch
526
- hcat_out_NA = jax.vmap(self.manifold.hcat, in_axes=(0, None))(patches_flat_NKC, c) # (B*Z*H*W, hcat_dim)
527
-
528
- # Linear: (hcat_dim,) -> (out_channels,)
529
- linear_out_NC = self.linear(hcat_out_NA, c) # (B*Z*H*W, out_channels)
530
-
531
- # Reshape back to spatial
532
- output_BZHWC = linear_out_NC.reshape(batch, out_d, out_h, out_w, self.out_channels)
533
-
534
- return output_BZHWC
535
-
536
-
537
340
  class FGGConv2D(nnx.Module):
538
341
  """Fast and Geometrically Grounded Lorentz 2D convolutional layer.
539
342
 
@@ -595,7 +398,7 @@ class FGGConv2D(nnx.Module):
595
398
  padding: str = "SAME",
596
399
  pad_mode: str = "origin",
597
400
  activation: Callable | None = None,
598
- reset_params: str = "kaiming",
401
+ reset_params: str = "lorentz_kaiming",
599
402
  use_weight_norm: bool = False,
600
403
  init_bias: float = 0.5,
601
404
  eps: float = 1e-7,
@@ -627,17 +430,45 @@ class FGGConv2D(nnx.Module):
627
430
  kh, kw = self.kernel_size
628
431
  hcat_out_ambient = (in_channels - 1) * kh * kw + 1
629
432
 
630
- # FGGLinear for channel transformation
631
- self.fgg_linear = FGGLinear(
632
- hcat_out_ambient,
633
- out_channels,
634
- rngs=rngs,
635
- activation=activation,
636
- reset_params=reset_params,
637
- use_weight_norm=use_weight_norm,
638
- init_bias=init_bias,
639
- eps=eps,
640
- )
433
+ # Trainable parameters — owned directly for flat parameter paths
434
+ if reset_params not in ("eye", "xavier", "kaiming", "lorentz_kaiming", "mlr"):
435
+ raise ValueError(
436
+ f"reset_params must be 'eye', 'xavier', 'kaiming', 'lorentz_kaiming', or 'mlr', got '{reset_params}'"
437
+ )
438
+
439
+ in_spatial = hcat_out_ambient - 1 # I
440
+ out_spatial = out_channels - 1 # O
441
+
442
+ self.activation = activation
443
+ self.use_weight_norm = use_weight_norm
444
+
445
+ # Initialize Euclidean weight U: (I, O)
446
+ # Reference computes std from ambient dimensions (hcat_out_ambient, out_channels)
447
+ key = rngs.params()
448
+ if reset_params == "eye":
449
+ U_init = 0.5 * jnp.eye(in_spatial, out_spatial)
450
+ elif reset_params == "xavier":
451
+ std = jnp.sqrt(1.0 / (hcat_out_ambient + out_channels))
452
+ U_init = jax.random.normal(key, (in_spatial, out_spatial)) * std
453
+ elif reset_params == "kaiming":
454
+ std = jnp.sqrt(2.0 / hcat_out_ambient)
455
+ U_init = jax.random.normal(key, (in_spatial, out_spatial)) * std
456
+ elif reset_params == "lorentz_kaiming":
457
+ std = jnp.sqrt(1.0 / hcat_out_ambient)
458
+ U_init = jax.random.normal(key, (in_spatial, out_spatial)) * std
459
+ else: # mlr
460
+ std = jnp.sqrt(5.0 / hcat_out_ambient)
461
+ U_init = jax.random.normal(key, (in_spatial, out_spatial)) * std
462
+
463
+ # Weight normalization: decompose kernel = softplus(kernel_scale) * kernel_dir / ||kernel_dir||
464
+ if use_weight_norm:
465
+ self.kernel_dir = nnx.Param(U_init) # (I, O) direction
466
+ g_init_val = jnp.sqrt(1.0 / (hcat_out_ambient + out_channels))
467
+ self.kernel_scale = nnx.Param(jnp.full((out_spatial,), g_init_val)) # (O,)
468
+ else:
469
+ self.kernel = nnx.Param(U_init) # (I, O)
470
+
471
+ self.bias = nnx.Param(jnp.full((out_spatial,), init_bias)) # (O,)
641
472
 
642
473
  def _extract_patches(
643
474
  self,
@@ -717,8 +548,15 @@ class FGGConv2D(nnx.Module):
717
548
  # HCat: (K, C) -> (hcat_dim,) per patch
718
549
  hcat_out_NA = jax.vmap(self.manifold.hcat, in_axes=(0, None))(patches_flat_NKC, c)
719
550
 
720
- # FGGLinear: (hcat_dim,) -> (out_channels,)
721
- linear_out_NC = self.fgg_linear(hcat_out_NA, c)
551
+ # FGG forward: (hcat_dim,) -> (out_channels,)
552
+ U_IO = _get_effective_kernel(
553
+ getattr(self, "kernel", None),
554
+ getattr(self, "kernel_dir", None),
555
+ getattr(self, "kernel_scale", None),
556
+ self.use_weight_norm,
557
+ self.eps,
558
+ )
559
+ linear_out_NC = _fgg_linear_forward(hcat_out_NA, U_IO, self.bias[...], c, self.activation, self.eps)
722
560
 
723
561
  # Reshape back to spatial
724
562
  return linear_out_NC.reshape(batch, out_h, out_w, self.out_channels)
@@ -25,6 +25,107 @@ from ._helpers import validate_hyperboloid_manifold
25
25
  from .hyperboloid_core import build_spacelike_V, htc
26
26
 
27
27
 
28
+ def _fhcnn_forward(
29
+ x_BI: Float[Array, "batch in_dim"],
30
+ kernel_OI: Array,
31
+ bias_1O: Array,
32
+ manifold: Manifold,
33
+ c: float,
34
+ input_space: str,
35
+ activation: Callable[[Array], Array] | None,
36
+ normalize: bool,
37
+ scale_val: float | Array,
38
+ eps: float,
39
+ ) -> Float[Array, "batch out_dim"]:
40
+ """Pure-function FHCNN forward pass.
41
+
42
+ Used by both HypLinearHyperboloidFHCNN and HypConv2DHyperboloid.
43
+ """
44
+ # Map to manifold if needed (static branch - JIT friendly)
45
+ if input_space == "tangent":
46
+ x_BI = jax.vmap(manifold.expmap_0, in_axes=(0, None), out_axes=0)(x_BI, c)
47
+
48
+ # Apply activation if provided (static branch - JIT friendly)
49
+ if activation is not None:
50
+ x_BI = activation(x_BI)
51
+
52
+ # Linear transformation: (B, in_dim) -> (B, out_dim)
53
+ x_BO = jnp.einsum("bi,oi->bo", x_BI, kernel_OI) + bias_1O
54
+
55
+ # Split into time and space: x0 is first coord, x_rem is spatial
56
+ x0_B1 = x_BO[:, 0:1] # (B, 1) -- time coordinate
57
+ x_rem_BD = x_BO[:, 1:] # (B, D) where D = out_dim - 1
58
+
59
+ # Static branch - JIT friendly
60
+ if normalize:
61
+ x_rem_norm_B1 = jnp.linalg.norm(x_rem_BD, ord=2, axis=-1, keepdims=True) # (B, 1)
62
+
63
+ # Learnable sigmoid scaling
64
+ scale_B1 = jnp.exp(scale_val) * jax.nn.sigmoid(x0_B1) # (B, 1)
65
+
66
+ res0_B1 = jnp.sqrt(scale_B1**2 + 1 / c + eps) # (B, 1)
67
+ res_rem_BD = scale_B1 * x_rem_BD / x_rem_norm_B1 # (B, D)
68
+
69
+ res_BA = jnp.concatenate([res0_B1, res_rem_BD], axis=-1) # (B, A)
70
+
71
+ # Cast near-zero-norm vectors to origin
72
+ origin_time_B1 = jnp.sqrt(1 / c) * jnp.ones_like(res0_B1)
73
+ origin_BA = jnp.concatenate([origin_time_B1, jnp.zeros_like(res_rem_BD)], axis=-1)
74
+
75
+ mask_B1 = x_rem_norm_B1 <= 1e-5
76
+ res_BA = jnp.where(mask_B1, origin_BA, res_BA)
77
+ else:
78
+ # Reconstruct time from space: x0 = sqrt(||x_rem||^2 + 1/c)
79
+ res0_B1 = jnp.sqrt(jnp.sum(x_rem_BD**2, axis=-1, keepdims=True) + 1 / c) # (B, 1)
80
+ res_BA = jnp.concatenate([res0_B1, x_rem_BD], axis=-1) # (B, A)
81
+
82
+ return res_BA
83
+
84
+
85
+ def _get_effective_kernel(
86
+ kernel: Array | None,
87
+ kernel_dir: Array | None,
88
+ kernel_scale: Array | None,
89
+ use_weight_norm: bool,
90
+ eps: float,
91
+ ) -> Array:
92
+ """Compute effective weight matrix, handling weight normalization."""
93
+ if use_weight_norm:
94
+ assert kernel_scale is not None and kernel_dir is not None
95
+ g_pos_O = jax.nn.softplus(kernel_scale) # (O,) force positive magnitudes
96
+ v_norm_O = jnp.sqrt(jnp.sum(kernel_dir**2, axis=0) + eps) # (O,)
97
+ return g_pos_O[None, :] * kernel_dir / v_norm_O[None, :] # (I, O)
98
+ assert kernel is not None
99
+ return kernel
100
+
101
+
102
+ def _fgg_linear_forward(
103
+ x_BAi: Float[Array, "batch in_features"],
104
+ U_IO: Array,
105
+ bias_O: Array,
106
+ c: float,
107
+ activation: Callable[[jax.Array], jax.Array] | None,
108
+ eps: float,
109
+ ) -> Float[Array, "batch out_features"]:
110
+ """Pure-function FGG forward: build_spacelike_V -> matmul -> activation -> time reconstruct."""
111
+ # Build V_mink from (kernel, bias) -- Minkowski metric absorbed
112
+ V_AiO = build_spacelike_V(U_IO, bias_O, c, eps) # (Ai, O)
113
+ # Cast V to match input dtype (avoids float32/float64 scatter warnings)
114
+ V_AiO = V_AiO.astype(x_BAi.dtype)
115
+
116
+ # Minkowski inner products via matmul (metric in V)
117
+ z_BO = x_BAi @ V_AiO # (B, O)
118
+
119
+ # Apply Euclidean activation (Lorentzian wrapping implicit via cancellation)
120
+ if activation is not None:
121
+ z_BO = activation(z_BO)
122
+
123
+ # Reconstruct hyperboloid point: spatial = z, time from constraint
124
+ y_0_B1 = jnp.sqrt(jnp.sum(z_BO**2, axis=-1, keepdims=True) + 1.0 / c) # (B, 1)
125
+
126
+ return jnp.concatenate([y_0_B1, z_BO], axis=-1) # (B, Ao)
127
+
128
+
28
129
  class HypLinearHyperboloidFHCNN(nnx.Module):
29
130
  """
30
131
  Fully Hyperbolic Convolutional Neural Networks fully connected layer (Hyperboloid model).
@@ -147,46 +248,19 @@ class HypLinearHyperboloidFHCNN(nnx.Module):
147
248
  res : Array of shape (batch, out_dim)
148
249
  Output on the Hyperboloid manifold
149
250
  """
150
- # Map to manifold if needed (static branch - JIT friendly)
151
- if self.input_space == "tangent":
152
- x = jax.vmap(self.manifold.expmap_0, in_axes=(0, None), out_axes=0)(x, c)
153
-
154
- # Apply activation if provided (static branch - JIT friendly)
155
- if self.activation is not None:
156
- x = self.activation(x)
157
-
158
- # Linear transformation: (B, in_dim) → (B, out_dim)
159
- x_BO = jnp.einsum("bi,oi->bo", x, self.kernel) + self.bias
160
-
161
- # Split into time and space: x0 is first coord, x_rem is spatial
162
- x0_B1 = x_BO[:, 0:1] # (B, 1) — time coordinate
163
- x_rem_BD = x_BO[:, 1:] # (B, D) where D = out_dim - 1
164
-
165
- # Static branch - JIT friendly
166
- if self.normalize:
167
- x_rem_norm_B1 = jnp.linalg.norm(x_rem_BD, ord=2, axis=-1, keepdims=True) # (B, 1)
168
-
169
- # Learnable sigmoid scaling
170
- scale_val = self.scale[...] if isinstance(self.scale, nnx.Param) else self.scale
171
- scale_B1 = jnp.exp(scale_val) * jax.nn.sigmoid(x0_B1) # (B, 1)
172
-
173
- res0_B1 = jnp.sqrt(scale_B1**2 + 1 / c + self.eps) # (B, 1)
174
- res_rem_BD = scale_B1 * x_rem_BD / x_rem_norm_B1 # (B, D)
175
-
176
- res_BA = jnp.concatenate([res0_B1, res_rem_BD], axis=-1) # (B, A)
177
-
178
- # Cast near-zero-norm vectors to origin
179
- origin_time_B1 = jnp.sqrt(1 / c) * jnp.ones_like(res0_B1)
180
- origin_BA = jnp.concatenate([origin_time_B1, jnp.zeros_like(res_rem_BD)], axis=-1)
181
-
182
- mask_B1 = x_rem_norm_B1 <= 1e-5
183
- res_BA = jnp.where(mask_B1, origin_BA, res_BA)
184
- else:
185
- # Reconstruct time from space: x₀ = sqrt(||x_rem||² + 1/c)
186
- res0_B1 = jnp.sqrt(jnp.sum(x_rem_BD**2, axis=-1, keepdims=True) + 1 / c) # (B, 1)
187
- res_BA = jnp.concatenate([res0_B1, x_rem_BD], axis=-1) # (B, A)
188
-
189
- return res_BA
251
+ scale_val = self.scale[...] if isinstance(self.scale, nnx.Param) else self.scale
252
+ return _fhcnn_forward(
253
+ x,
254
+ self.kernel[...],
255
+ self.bias[...],
256
+ self.manifold,
257
+ c,
258
+ self.input_space,
259
+ self.activation,
260
+ self.normalize,
261
+ scale_val,
262
+ self.eps,
263
+ )
190
264
 
191
265
 
192
266
  class HTCLinear(nnx.Module):
@@ -392,27 +466,28 @@ class FGGLinear(nnx.Module):
392
466
  self.eps = eps
393
467
 
394
468
  # Initialize Euclidean weight U: (I, O)
469
+ # Reference computes std from ambient dimensions (in_features, out_features)
395
470
  key = rngs.params()
396
471
  if reset_params == "eye":
397
472
  U_init = 0.5 * jnp.eye(in_spatial, out_spatial)
398
473
  elif reset_params == "xavier":
399
- std = jnp.sqrt(1.0 / (in_spatial + out_spatial))
474
+ std = jnp.sqrt(1.0 / (in_features + out_features))
400
475
  U_init = jax.random.normal(key, (in_spatial, out_spatial)) * std
401
476
  elif reset_params == "kaiming":
402
- std = jnp.sqrt(2.0 / in_spatial)
477
+ std = jnp.sqrt(2.0 / in_features)
403
478
  U_init = jax.random.normal(key, (in_spatial, out_spatial)) * std
404
479
  elif reset_params == "lorentz_kaiming":
405
- std = jnp.sqrt(1.0 / in_spatial)
480
+ std = jnp.sqrt(1.0 / in_features)
406
481
  U_init = jax.random.normal(key, (in_spatial, out_spatial)) * std
407
482
  else: # mlr
408
- std = jnp.sqrt(5.0 / in_spatial)
483
+ std = jnp.sqrt(5.0 / in_features)
409
484
  U_init = jax.random.normal(key, (in_spatial, out_spatial)) * std
410
485
 
411
486
  # Weight normalization: decompose kernel = softplus(kernel_scale) * kernel_dir / ||kernel_dir||
412
487
  if use_weight_norm:
413
488
  # Reference: kernel_dir from reset_params (normalized in forward), kernel_scale fixed magnitude
414
489
  self.kernel_dir = nnx.Param(U_init) # (I, O) direction
415
- g_init_val = jnp.sqrt(1.0 / (in_spatial + out_spatial))
490
+ g_init_val = jnp.sqrt(1.0 / (in_features + out_features))
416
491
  self.kernel_scale = nnx.Param(jnp.full((out_spatial,), g_init_val)) # (O,)
417
492
  else:
418
493
  self.kernel = nnx.Param(U_init) # (I, O)
@@ -422,13 +497,13 @@ class FGGLinear(nnx.Module):
422
497
 
423
498
  def _get_kernel(self) -> jax.Array:
424
499
  """Return the effective weight matrix, handling weight normalization."""
425
- if self.use_weight_norm:
426
- v_IO = self.kernel_dir[...] # (I, O)
427
- g_O = self.kernel_scale[...] # (O,)
428
- g_pos_O = jax.nn.softplus(g_O) # (O,) force positive magnitudes
429
- v_norm_O = jnp.sqrt(jnp.sum(v_IO**2, axis=0) + self.eps) # (O,)
430
- return g_pos_O[None, :] * v_IO / v_norm_O[None, :] # (I, O)
431
- return self.kernel[...] # (I, O)
500
+ return _get_effective_kernel(
501
+ self.kernel[...] if not self.use_weight_norm else None,
502
+ self.kernel_dir[...] if self.use_weight_norm else None,
503
+ self.kernel_scale[...] if self.use_weight_norm else None,
504
+ self.use_weight_norm,
505
+ self.eps,
506
+ )
432
507
 
433
508
  def __call__(
434
509
  self,
@@ -451,22 +526,5 @@ class FGGLinear(nnx.Module):
451
526
  Output points on the hyperboloid with curvature ``c``.
452
527
  Ao = out_features (ambient dimension).
453
528
  """
454
- # 1. Get effective kernel (handle weight norm)
455
529
  U_IO = self._get_kernel() # (I, O)
456
-
457
- # 2. Build V_mink from (kernel, bias) — Minkowski metric absorbed
458
- V_AiO = build_spacelike_V(U_IO, self.bias[...], c, self.eps) # (Ai, O)
459
- # Cast V to match input dtype (avoids float32/float64 scatter warnings)
460
- V_AiO = V_AiO.astype(x_BAi.dtype)
461
-
462
- # 3. Minkowski inner products via matmul (metric in V)
463
- z_BO = x_BAi @ V_AiO # (B, O)
464
-
465
- # 4. Apply Euclidean activation (Lorentzian wrapping implicit via cancellation)
466
- if self.activation is not None:
467
- z_BO = self.activation(z_BO)
468
-
469
- # 5. Reconstruct hyperboloid point: spatial = z, time from constraint
470
- y_0_B1 = jnp.sqrt(jnp.sum(z_BO**2, axis=-1, keepdims=True) + 1.0 / c) # (B, 1)
471
-
472
- return jnp.concatenate([y_0_B1, z_BO], axis=-1) # (B, Ao)
530
+ return _fgg_linear_forward(x_BAi, U_IO, self.bias[...], c, self.activation, self.eps)
@@ -174,13 +174,13 @@ class FGGLorentzMLR(nnx.Module):
174
174
  self.eps = eps
175
175
 
176
176
  # Hyperplane normals (spatial) and bias offsets
177
+ # Reference computes std from ambient dimension (in_features)
177
178
  key = rngs.params()
178
179
  if reset_params == "mlr":
179
- # Reference: N(0, sqrt(5/I)) for spatial weights
180
- std = jnp.sqrt(5.0 / in_spatial)
180
+ std = jnp.sqrt(5.0 / in_features)
181
181
  self.kernel = nnx.Param(jax.random.normal(key, (in_spatial, num_classes)) * std)
182
182
  else: # default
183
- stdv = 1.0 / jnp.sqrt(jnp.array(in_spatial, dtype=jnp.float32))
183
+ stdv = 1.0 / jnp.sqrt(jnp.array(in_features, dtype=jnp.float32))
184
184
  self.kernel = nnx.Param(jax.random.uniform(key, (in_spatial, num_classes), minval=-stdv, maxval=stdv))
185
185
  self.bias = nnx.Param(jnp.full((num_classes,), init_bias))
186
186
 
@@ -25,7 +25,7 @@ from jaxtyping import Array, Float
25
25
  from hyperbolix.manifolds.poincare import Poincare
26
26
 
27
27
  from ._helpers import validate_poincare_manifold
28
- from .poincare_linear import HypLinearPoincarePP
28
+ from .poincare_linear import _poincare_pp_forward
29
29
 
30
30
 
31
31
  class HypConv2DPoincare(nnx.Module):
@@ -150,25 +150,23 @@ class HypConv2DPoincare(nnx.Module):
150
150
  concat_dim = K2 * in_channels
151
151
  beta_n = jax.scipy.special.beta(concat_dim / 2.0, 0.5)
152
152
  beta_ni = jax.scipy.special.beta(in_channels / 2.0, 0.5)
153
- self.beta_scale = beta_n / beta_ni
154
-
155
- # HNN++ linear: in_dim = K^2 * C_in, out_dim = C_out
156
- self.linear = HypLinearPoincarePP(
157
- manifold_module=self.manifold,
158
- in_dim=concat_dim,
159
- out_dim=out_channels,
160
- rngs=rngs,
161
- input_space="manifold", # input is on manifold after expmap_0
162
- clamping_factor=clamping_factor,
163
- smoothing_factor=smoothing_factor,
164
- )
153
+ self.beta_scale = float(beta_n / beta_ni)
154
+
155
+ self.clamping_factor = clamping_factor
156
+ self.smoothing_factor = smoothing_factor
165
157
 
166
- # Override weight init with identity initialization from reference
167
- # (van Spengler et al. 2023): W = 1/2 * I(C_out, K^2*C_in)
168
- # The 1/2 factor compensates for the factor of 2 in the HNN++ distance formula.
158
+ # Trainable parameters — owned directly for flat parameter paths
159
+ # Weight initialization: identity from reference (van Spengler et al. 2023)
160
+ # or scaled normal std = 1/sqrt(fan_in)
169
161
  if id_init:
170
- eye = jnp.eye(out_channels, concat_dim) # (C_out, K^2*C_in)
171
- self.linear.kernel[...] = 0.5 * eye
162
+ # W = 1/2 * I(C_out, K^2*C_in)
163
+ # The 1/2 factor compensates for the factor of 2 in the HNN++ distance formula.
164
+ kernel_init = 0.5 * jnp.eye(out_channels, concat_dim)
165
+ else:
166
+ std = 1.0 / jnp.sqrt(concat_dim)
167
+ kernel_init = jax.random.normal(rngs.params(), (out_channels, concat_dim)) * std
168
+ self.kernel = nnx.Param(kernel_init)
169
+ self.bias = nnx.Param(jnp.zeros((out_channels, 1)))
172
170
 
173
171
  def __call__(
174
172
  self,
@@ -228,7 +226,16 @@ class HypConv2DPoincare(nnx.Module):
228
226
  ) # (N, K²·C_in) on Poincaré ball
229
227
 
230
228
  # Step 5: HNN++ FC — (N, K²·C_in) on manifold → (N, C_out) on manifold
231
- fc_out_NC = self.linear(manifold_pts_NKC, c)
229
+ fc_out_NC = _poincare_pp_forward(
230
+ manifold_pts_NKC,
231
+ self.kernel[...],
232
+ self.bias[...],
233
+ self.manifold,
234
+ c,
235
+ "manifold",
236
+ self.clamping_factor,
237
+ self.smoothing_factor,
238
+ )
232
239
 
233
240
  # Step 6: Map back to tangent space
234
241
  tangent_out_NC = jax.vmap(self.manifold.logmap_0, in_axes=(0, None))(fc_out_NC, c) # (N, C_out)
@@ -128,6 +128,40 @@ class HypLinearPoincare(nnx.Module):
128
128
  return res_BO
129
129
 
130
130
 
131
+ def _poincare_pp_forward(
132
+ x_BI: Float[Array, "batch in_dim"],
133
+ kernel_OI: Array,
134
+ bias_O1: Array,
135
+ manifold: Poincare,
136
+ c: float,
137
+ input_space: str,
138
+ clamping_factor: float,
139
+ smoothing_factor: float,
140
+ ) -> Float[Array, "batch out_dim"]:
141
+ """Pure-function HNN++ forward pass.
142
+
143
+ Used by both HypLinearPoincarePP and HypConv2DPoincare.
144
+ """
145
+ # Map to manifold if needed (static branch - JIT friendly)
146
+ if input_space == "tangent":
147
+ x_BI = jax.vmap(manifold.expmap_0, in_axes=(0, None), out_axes=0)(x_BI, c)
148
+
149
+ # Compute multinomial linear regression
150
+ v = manifold.compute_mlr_pp(x_BI, kernel_OI, bias_O1, c, clamping_factor, smoothing_factor)
151
+
152
+ # Generalized linear transformation
153
+ sqrt_c = jnp.sqrt(c)
154
+ w_BO = sinh(sqrt_c * v) / sqrt_c
155
+ w2_B1 = jnp.sum(w_BO**2, axis=-1, keepdims=True)
156
+ denom_B1 = 1 + jnp.sqrt(1 + c * w2_B1)
157
+ res_BO = w_BO / denom_B1 # (B, 1) broadcasts over (B, O)
158
+
159
+ # Project results to the manifold
160
+ res_BO = jax.vmap(manifold.proj, in_axes=(0, None), out_axes=0)(res_BO, c)
161
+
162
+ return res_BO
163
+
164
+
131
165
  class HypLinearPoincarePP(nnx.Module):
132
166
  """
133
167
  Hyperbolic Neural Networks ++ fully connected layer (Poincaré ball model).
@@ -220,28 +254,13 @@ class HypLinearPoincarePP(nnx.Module):
220
254
  res : Array of shape (batch, out_dim)
221
255
  Output on the Poincaré ball manifold
222
256
  """
223
- # Map to manifold if needed (static branch - JIT friendly)
224
- if self.input_space == "tangent":
225
- x = jax.vmap(self.manifold.expmap_0, in_axes=(0, None), out_axes=0)(x, c)
226
-
227
- # Compute multinomial linear regression
228
- v = self.manifold.compute_mlr_pp(
257
+ return _poincare_pp_forward(
229
258
  x,
230
259
  self.kernel[...],
231
260
  self.bias[...],
261
+ self.manifold,
232
262
  c,
263
+ self.input_space,
233
264
  self.clamping_factor,
234
265
  self.smoothing_factor,
235
266
  )
236
-
237
- # Generalized linear transformation
238
- sqrt_c = jnp.sqrt(c)
239
- w_BO = sinh(sqrt_c * v) / sqrt_c
240
- w2_B1 = jnp.sum(w_BO**2, axis=-1, keepdims=True)
241
- denom_B1 = 1 + jnp.sqrt(1 + c * w2_B1)
242
- res_BO = w_BO / denom_B1 # (B, 1) broadcasts over (B, O)
243
-
244
- # Project results to the manifold
245
- res_BO = jax.vmap(self.manifold.proj, in_axes=(0, None), out_axes=0)(res_BO, c)
246
-
247
- return res_BO
@@ -6,7 +6,7 @@ build-backend = "uv_build"
6
6
 
7
7
  [project]
8
8
  name = "hyperbolix"
9
- version = "0.4.0"
9
+ version = "0.4.2"
10
10
  description = "Hyperbolic Deep Learning in JAX"
11
11
  authors = [
12
12
  { name="Timo Klein", email="timo.klein@univie.ac.at" },