neembed-geoopt 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 taishi-yamasaki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: neembed-geoopt
3
+ Version: 0.1.0
4
+ Summary: Fine-tune pretrained sentence embedding models in non-Euclidean spaces with Geoopt.
5
+ Author: taishi-yamasaki
6
+ Maintainer: taishi-yamasaki
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/t-yamsaki/neembed
9
+ Project-URL: Documentation, https://neembed.readthedocs.io/en/latest/
10
+ Project-URL: Repository, https://github.com/t-yamsaki/neembed
11
+ Project-URL: Issues, https://github.com/t-yamsaki/neembed/issues
12
+ Keywords: sentence-embeddings,hyperbolic-embeddings,non-euclidean,poincare,geoopt,sentence-transformers
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: torch
26
+ Requires-Dist: sentence-transformers
27
+ Requires-Dist: geoopt
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # neembed
33
+
34
+ **Fine-tune pretrained sentence embedding models in non-Euclidean spaces with Geoopt.**
35
+
36
+ [Documentation](https://neembed.readthedocs.io/en/latest/) · [日本語](docs/README_ja.md)
37
+
38
+ > **Status:** v0.1 is implemented and being prepared for its first public PyPI release. The API is intentionally small and may still evolve before a stable 1.0 release.
39
+
40
+ `neembed` is a lightweight integration layer between pretrained Sentence Transformer models and manifold-valued representations. It keeps the pretrained encoder intact, optionally projects its Euclidean output, and delegates Poincaré-ball geometry to Geoopt.
41
+
42
+ ```text
43
+ Pretrained Sentence Encoder
44
+
45
+ Euclidean sentence embedding
46
+
47
+ Projection head (optional)
48
+
49
+ Tangent-space representation
50
+
51
+ Geoopt manifold map
52
+
53
+ Non-Euclidean embedding
54
+ ```
55
+
56
+ ## Why non-Euclidean embeddings?
57
+
58
+ Hierarchical and tree-like relations can be awkward to represent in a flat Euclidean space. Hyperbolic spaces are a natural fit for rapidly expanding structures such as:
59
+
60
+ - taxonomies and concept hierarchies
61
+ - knowledge graphs
62
+ - hierarchical labels
63
+ - tree-like semantic relations
64
+
65
+ v0.1 deliberately focuses on one geometry: the Poincaré ball.
66
+
67
+ ## v0.1
68
+
69
+ The first release includes:
70
+
71
+ - Sentence Transformers as the pretrained encoder backend
72
+ - Poincaré-ball embeddings through Geoopt
73
+ - optional lower-dimensional tangent-space projection
74
+ - geodesic distance
75
+ - manifold-aware multiple-negatives ranking / InfoNCE-style loss
76
+ - ordinary `AdamW` fine-tuning
77
+ - `encode()` and `distance()` inference helpers
78
+ - `save_pretrained()` / `from_pretrained()` local persistence
79
+ - numerical-stability tests, a runnable example, and a Euclidean baseline experiment
80
+
81
+ Detailed behavior, assumptions, and API signatures live in the [documentation](https://neembed.readthedocs.io/en/latest/).
82
+
83
+ ## Installation
84
+
85
+ After `v0.1.0` is published to PyPI:
86
+
87
+ ```bash
88
+ pip install neembed-geoopt
89
+ ```
90
+
91
+ The PyPI distribution is named `neembed-geoopt`; the Python import package remains `neembed`.
92
+
93
+ For development:
94
+
95
+ ```bash
96
+ git clone https://github.com/t-yamsaki/neembed.git
97
+ cd neembed
98
+ pip install -e ".[dev]"
99
+ ```
100
+
101
+ ## Quick start
102
+
103
+ ```python
104
+ from neembed import (
105
+ ManifoldSentenceTransformer,
106
+ ManifoldMultipleNegativesRankingLoss,
107
+ ManifoldTrainer,
108
+ )
109
+
110
+ model = ManifoldSentenceTransformer(
111
+ "sentence-transformers/all-MiniLM-L6-v2",
112
+ manifold="poincare",
113
+ embedding_dim=64,
114
+ curvature=1.0,
115
+ )
116
+
117
+ loss = ManifoldMultipleNegativesRankingLoss(
118
+ model=model,
119
+ temperature=0.1,
120
+ )
121
+
122
+ trainer = ManifoldTrainer(model=model, loss=loss)
123
+
124
+ train_batches = [
125
+ (["Shiba Inu", "Siamese cat"], ["dog", "cat"]),
126
+ (["dog", "cat"], ["mammal", "feline"]),
127
+ ]
128
+
129
+ trainer.fit(train_batches, epochs=1)
130
+
131
+ embeddings = model.encode(["Shiba Inu", "dog", "mammal"])
132
+ distance = model.distance(embeddings[0], embeddings[1])
133
+ print(float(distance))
134
+ ```
135
+
136
+ Each anchor is paired with the positive at the same batch index. Because off-diagonal candidates become in-batch negatives, avoid duplicate positives within one batch. See the [Training guide](https://neembed.readthedocs.io/en/latest/user_guide/training.html) for the objective and batching details.
137
+
138
+ ## Documentation
139
+
140
+ The full guide is hosted on Read the Docs:
141
+
142
+ - [Installation](https://neembed.readthedocs.io/en/latest/getting_started/installation.html)
143
+ - [Quick Start](https://neembed.readthedocs.io/en/latest/getting_started/quickstart.html)
144
+ - [Architecture](https://neembed.readthedocs.io/en/latest/user_guide/architecture.html)
145
+ - [Training](https://neembed.readthedocs.io/en/latest/user_guide/training.html)
146
+ - [Inference](https://neembed.readthedocs.io/en/latest/user_guide/inference.html)
147
+ - [Saving and Loading](https://neembed.readthedocs.io/en/latest/user_guide/saving_loading.html)
148
+ - [API Reference](https://neembed.readthedocs.io/en/latest/#api-reference)
149
+
150
+ ## Examples and validation
151
+
152
+ Run the end-to-end example:
153
+
154
+ ```bash
155
+ python examples/train_poincare.py
156
+ ```
157
+
158
+ - [examples/train_poincare.py](examples/train_poincare.py) contains the workflow.
159
+ - [experiments/README.md](experiments/README.md) documents the fixed Euclidean-vs-Poincaré comparison experiment and its interpretation limits.
160
+
161
+ ## License
162
+
163
+ `neembed` is released under the [MIT License](LICENSE). Third-party dependencies retain their own licenses.
@@ -0,0 +1,132 @@
1
+ # neembed
2
+
3
+ **Fine-tune pretrained sentence embedding models in non-Euclidean spaces with Geoopt.**
4
+
5
+ [Documentation](https://neembed.readthedocs.io/en/latest/) · [日本語](docs/README_ja.md)
6
+
7
+ > **Status:** v0.1 is implemented and being prepared for its first public PyPI release. The API is intentionally small and may still evolve before a stable 1.0 release.
8
+
9
+ `neembed` is a lightweight integration layer between pretrained Sentence Transformer models and manifold-valued representations. It keeps the pretrained encoder intact, optionally projects its Euclidean output, and delegates Poincaré-ball geometry to Geoopt.
10
+
11
+ ```text
12
+ Pretrained Sentence Encoder
13
+
14
+ Euclidean sentence embedding
15
+
16
+ Projection head (optional)
17
+
18
+ Tangent-space representation
19
+
20
+ Geoopt manifold map
21
+
22
+ Non-Euclidean embedding
23
+ ```
24
+
25
+ ## Why non-Euclidean embeddings?
26
+
27
+ Hierarchical and tree-like relations can be awkward to represent in a flat Euclidean space. Hyperbolic spaces are a natural fit for rapidly expanding structures such as:
28
+
29
+ - taxonomies and concept hierarchies
30
+ - knowledge graphs
31
+ - hierarchical labels
32
+ - tree-like semantic relations
33
+
34
+ v0.1 deliberately focuses on one geometry: the Poincaré ball.
35
+
36
+ ## v0.1
37
+
38
+ The first release includes:
39
+
40
+ - Sentence Transformers as the pretrained encoder backend
41
+ - Poincaré-ball embeddings through Geoopt
42
+ - optional lower-dimensional tangent-space projection
43
+ - geodesic distance
44
+ - manifold-aware multiple-negatives ranking / InfoNCE-style loss
45
+ - ordinary `AdamW` fine-tuning
46
+ - `encode()` and `distance()` inference helpers
47
+ - `save_pretrained()` / `from_pretrained()` local persistence
48
+ - numerical-stability tests, a runnable example, and a Euclidean baseline experiment
49
+
50
+ Detailed behavior, assumptions, and API signatures live in the [documentation](https://neembed.readthedocs.io/en/latest/).
51
+
52
+ ## Installation
53
+
54
+ After `v0.1.0` is published to PyPI:
55
+
56
+ ```bash
57
+ pip install neembed-geoopt
58
+ ```
59
+
60
+ The PyPI distribution is named `neembed-geoopt`; the Python import package remains `neembed`.
61
+
62
+ For development:
63
+
64
+ ```bash
65
+ git clone https://github.com/t-yamsaki/neembed.git
66
+ cd neembed
67
+ pip install -e ".[dev]"
68
+ ```
69
+
70
+ ## Quick start
71
+
72
+ ```python
73
+ from neembed import (
74
+ ManifoldSentenceTransformer,
75
+ ManifoldMultipleNegativesRankingLoss,
76
+ ManifoldTrainer,
77
+ )
78
+
79
+ model = ManifoldSentenceTransformer(
80
+ "sentence-transformers/all-MiniLM-L6-v2",
81
+ manifold="poincare",
82
+ embedding_dim=64,
83
+ curvature=1.0,
84
+ )
85
+
86
+ loss = ManifoldMultipleNegativesRankingLoss(
87
+ model=model,
88
+ temperature=0.1,
89
+ )
90
+
91
+ trainer = ManifoldTrainer(model=model, loss=loss)
92
+
93
+ train_batches = [
94
+ (["Shiba Inu", "Siamese cat"], ["dog", "cat"]),
95
+ (["dog", "cat"], ["mammal", "feline"]),
96
+ ]
97
+
98
+ trainer.fit(train_batches, epochs=1)
99
+
100
+ embeddings = model.encode(["Shiba Inu", "dog", "mammal"])
101
+ distance = model.distance(embeddings[0], embeddings[1])
102
+ print(float(distance))
103
+ ```
104
+
105
+ Each anchor is paired with the positive at the same batch index. Because off-diagonal candidates become in-batch negatives, avoid duplicate positives within one batch. See the [Training guide](https://neembed.readthedocs.io/en/latest/user_guide/training.html) for the objective and batching details.
106
+
107
+ ## Documentation
108
+
109
+ The full guide is hosted on Read the Docs:
110
+
111
+ - [Installation](https://neembed.readthedocs.io/en/latest/getting_started/installation.html)
112
+ - [Quick Start](https://neembed.readthedocs.io/en/latest/getting_started/quickstart.html)
113
+ - [Architecture](https://neembed.readthedocs.io/en/latest/user_guide/architecture.html)
114
+ - [Training](https://neembed.readthedocs.io/en/latest/user_guide/training.html)
115
+ - [Inference](https://neembed.readthedocs.io/en/latest/user_guide/inference.html)
116
+ - [Saving and Loading](https://neembed.readthedocs.io/en/latest/user_guide/saving_loading.html)
117
+ - [API Reference](https://neembed.readthedocs.io/en/latest/#api-reference)
118
+
119
+ ## Examples and validation
120
+
121
+ Run the end-to-end example:
122
+
123
+ ```bash
124
+ python examples/train_poincare.py
125
+ ```
126
+
127
+ - [examples/train_poincare.py](examples/train_poincare.py) contains the workflow.
128
+ - [experiments/README.md](experiments/README.md) documents the fixed Euclidean-vs-Poincaré comparison experiment and its interpretation limits.
129
+
130
+ ## License
131
+
132
+ `neembed` is released under the [MIT License](LICENSE). Third-party dependencies retain their own licenses.
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "neembed-geoopt"
7
+ version = "0.1.0"
8
+ description = "Fine-tune pretrained sentence embedding models in non-Euclidean spaces with Geoopt."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "taishi-yamasaki" },
15
+ ]
16
+ maintainers = [
17
+ { name = "taishi-yamasaki" },
18
+ ]
19
+ keywords = [
20
+ "sentence-embeddings",
21
+ "hyperbolic-embeddings",
22
+ "non-euclidean",
23
+ "poincare",
24
+ "geoopt",
25
+ "sentence-transformers",
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 3 - Alpha",
29
+ "Intended Audience :: Developers",
30
+ "Intended Audience :: Science/Research",
31
+ "Operating System :: OS Independent",
32
+ "Programming Language :: Python :: 3",
33
+ "Programming Language :: Python :: 3.10",
34
+ "Programming Language :: Python :: 3.11",
35
+ "Programming Language :: Python :: 3.12",
36
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
37
+ ]
38
+ dependencies = [
39
+ "torch",
40
+ "sentence-transformers",
41
+ "geoopt",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/t-yamsaki/neembed"
46
+ Documentation = "https://neembed.readthedocs.io/en/latest/"
47
+ Repository = "https://github.com/t-yamsaki/neembed"
48
+ Issues = "https://github.com/t-yamsaki/neembed/issues"
49
+
50
+ [project.optional-dependencies]
51
+ dev = [
52
+ "pytest>=8",
53
+ ]
54
+
55
+ [tool.setuptools.packages.find]
56
+ where = ["src"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ markers = [
61
+ "real_stack: loads the real Sentence Transformer and runs slow integration gates",
62
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ """neembed package."""
2
+
3
+ from neembed.losses import ManifoldMultipleNegativesRankingLoss
4
+ from neembed.model import ManifoldSentenceTransformer
5
+ from neembed.trainer import ManifoldTrainer
6
+
7
+ __all__ = [
8
+ "ManifoldMultipleNegativesRankingLoss",
9
+ "ManifoldSentenceTransformer",
10
+ "ManifoldTrainer",
11
+ ]
@@ -0,0 +1,60 @@
1
+ """Losses for manifold-valued sentence embeddings."""
2
+
3
+ from collections.abc import Sequence
4
+ import math
5
+
6
+ import torch
7
+ import torch.nn.functional as F
8
+ from torch import nn
9
+
10
+ from neembed.model import ManifoldSentenceTransformer
11
+
12
+
13
+ class ManifoldMultipleNegativesRankingLoss(nn.Module):
14
+ """In-batch ranking loss based on manifold geodesic distance.
15
+
16
+ Each anchor is paired with the positive at the same batch index. All
17
+ off-diagonal positive candidates are treated as negatives, so duplicate
18
+ positives within one batch should be avoided.
19
+
20
+ Args:
21
+ model: Manifold sentence model used to encode anchors and positives.
22
+ temperature: Positive, finite temperature used to scale distance logits.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ model: ManifoldSentenceTransformer,
28
+ temperature: float = 0.1,
29
+ ) -> None:
30
+ super().__init__()
31
+ if temperature <= 0 or not math.isfinite(temperature):
32
+ raise ValueError("temperature must be positive and finite")
33
+
34
+ self.model = model
35
+ self.temperature = float(temperature)
36
+
37
+ def forward(
38
+ self,
39
+ anchors: Sequence[str],
40
+ positives: Sequence[str],
41
+ ) -> torch.Tensor:
42
+ """Return the contrastive loss for aligned anchor-positive pairs.
43
+
44
+ Args:
45
+ anchors: Batch of anchor texts.
46
+ positives: Batch of positive texts aligned by index with ``anchors``.
47
+
48
+ Returns:
49
+ A scalar cross-entropy loss built from pairwise geodesic distances.
50
+ """
51
+ anchor_embeddings = self.model(anchors)
52
+ positive_embeddings = self.model(positives)
53
+
54
+ distances = self.model.manifold.dist(
55
+ anchor_embeddings[:, None, :],
56
+ positive_embeddings[None, :, :],
57
+ )
58
+ logits = -distances / self.temperature
59
+ targets = torch.arange(logits.shape[0], device=logits.device)
60
+ return F.cross_entropy(logits, targets)
@@ -0,0 +1,15 @@
1
+ """Manifold integration points for neembed."""
2
+
3
+ import math
4
+
5
+ import geoopt
6
+
7
+
8
+ def get_manifold(name: str, curvature: float = 1.0) -> geoopt.PoincareBall:
9
+ """Return the Geoopt manifold used by the minimal neembed v0.1 API."""
10
+ if name != "poincare":
11
+ raise ValueError(f"Unsupported manifold: {name}")
12
+ if curvature <= 0 or not math.isfinite(curvature):
13
+ raise ValueError("curvature must be positive and finite")
14
+
15
+ return geoopt.PoincareBall(c=curvature)
@@ -0,0 +1,182 @@
1
+ """Sentence Transformer integration for manifold-valued embeddings."""
2
+
3
+ from collections.abc import Sequence
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import torch
9
+ from sentence_transformers import SentenceTransformer
10
+ from torch import nn
11
+
12
+ from neembed.manifolds import get_manifold
13
+
14
+
15
+ class ManifoldSentenceTransformer(nn.Module):
16
+ """Map pretrained sentence embeddings onto a configured manifold.
17
+
18
+ Args:
19
+ model_name_or_path: Sentence Transformer model name or local model path.
20
+ manifold: Manifold backend name. v0.1 supports only ``"poincare"``.
21
+ embedding_dim: Optional output dimension for a learned linear projection.
22
+ If omitted, the encoder embedding dimension is preserved.
23
+ curvature: Positive, finite Poincaré-ball curvature parameter.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ model_name_or_path: str,
29
+ *,
30
+ manifold: str = "poincare",
31
+ embedding_dim: int | None = None,
32
+ curvature: float = 1.0,
33
+ ) -> None:
34
+ super().__init__()
35
+ self.encoder = SentenceTransformer(model_name_or_path)
36
+
37
+ encoder_dim = self.encoder.get_embedding_dimension()
38
+ if encoder_dim is None:
39
+ raise ValueError("Sentence Transformer embedding dimension is unknown")
40
+
41
+ self._projection_dim = embedding_dim
42
+ self.projection: nn.Module
43
+ if embedding_dim is None:
44
+ self.projection = nn.Identity()
45
+ self.embedding_dim = encoder_dim
46
+ else:
47
+ self.projection = nn.Linear(encoder_dim, embedding_dim)
48
+ self.embedding_dim = embedding_dim
49
+ self.projection.to(self.encoder.device)
50
+
51
+ self.manifold_name = manifold
52
+ self.curvature = float(curvature)
53
+ self.manifold = get_manifold(self.manifold_name, self.curvature)
54
+ self.manifold.to(self.encoder.device)
55
+
56
+ def forward(self, sentences: Sequence[str]) -> torch.Tensor:
57
+ """Encode a batch and map the embeddings from the origin tangent space.
58
+
59
+ Args:
60
+ sentences: Batch of input texts.
61
+
62
+ Returns:
63
+ A tensor of manifold-valued embeddings with shape
64
+ ``(batch_size, embedding_dim)``.
65
+ """
66
+ features = self.encoder.preprocess(list(sentences))
67
+ features = {
68
+ key: value.to(self.encoder.device) if torch.is_tensor(value) else value
69
+ for key, value in features.items()
70
+ }
71
+ encoder_output: dict[str, Any] = self.encoder(features)
72
+ tangent = self.projection(encoder_output["sentence_embedding"])
73
+ return self.manifold.expmap0(tangent)
74
+
75
+ def encode(
76
+ self,
77
+ sentences: str | Sequence[str],
78
+ *,
79
+ convert_to_tensor: bool = False,
80
+ ) -> Any:
81
+ """Encode text as manifold-valued embeddings for inference.
82
+
83
+ Args:
84
+ sentences: A single text or a sequence of texts.
85
+ convert_to_tensor: Return a ``torch.Tensor`` instead of a NumPy array.
86
+
87
+ Returns:
88
+ A single embedding with shape ``(embedding_dim,)`` for string input,
89
+ or a batch with shape ``(batch_size, embedding_dim)`` for sequence
90
+ input. NumPy arrays are returned by default; tensors are returned when
91
+ ``convert_to_tensor=True``.
92
+
93
+ Notes:
94
+ Encoding switches the model to evaluation mode and runs under
95
+ ``torch.inference_mode()``, so returned embeddings do not track
96
+ gradients.
97
+ """
98
+ single_input = isinstance(sentences, str)
99
+ batch = [sentences] if single_input else list(sentences)
100
+
101
+ self.eval()
102
+ with torch.inference_mode():
103
+ embeddings = self(batch)
104
+
105
+ if single_input:
106
+ embeddings = embeddings[0]
107
+ if convert_to_tensor:
108
+ return embeddings
109
+ return embeddings.cpu().numpy()
110
+
111
+ def distance(self, a: Any, b: Any) -> torch.Tensor:
112
+ """Return the geodesic distance between two manifold embeddings.
113
+
114
+ Args:
115
+ a: First manifold embedding or array-like value.
116
+ b: Second manifold embedding or array-like value.
117
+
118
+ Returns:
119
+ A tensor containing the manifold geodesic distance.
120
+
121
+ Notes:
122
+ This is an inference helper. Inputs are moved to the model device and
123
+ dtype, and the distance is computed under ``torch.no_grad()``.
124
+ """
125
+ reference = next(self.parameters())
126
+ a_tensor = torch.as_tensor(a, device=reference.device, dtype=reference.dtype)
127
+ b_tensor = torch.as_tensor(b, device=reference.device, dtype=reference.dtype)
128
+
129
+ with torch.no_grad():
130
+ return self.manifold.dist(a_tensor, b_tensor)
131
+
132
+ def save_pretrained(self, output_path: str | Path) -> None:
133
+ """Save the encoder, projection weights, and neembed configuration.
134
+
135
+ Args:
136
+ output_path: Directory in which to save the model.
137
+ """
138
+ output_path = Path(output_path)
139
+ output_path.mkdir(parents=True, exist_ok=True)
140
+
141
+ self.encoder.save_pretrained(str(output_path / "encoder"))
142
+ config = {
143
+ "embedding_dim": self._projection_dim,
144
+ "manifold": self.manifold_name,
145
+ "curvature": self.curvature,
146
+ }
147
+ (output_path / "neembed_config.json").write_text(
148
+ json.dumps(config, indent=2) + "\n",
149
+ encoding="utf-8",
150
+ )
151
+ torch.save(self.projection.state_dict(), output_path / "projection.pt")
152
+
153
+ @classmethod
154
+ def from_pretrained(
155
+ cls,
156
+ model_path: str | Path,
157
+ ) -> "ManifoldSentenceTransformer":
158
+ """Load a model previously saved with :meth:`save_pretrained`.
159
+
160
+ Args:
161
+ model_path: Directory containing a saved neembed model.
162
+
163
+ Returns:
164
+ The reconstructed manifold sentence model.
165
+ """
166
+ model_path = Path(model_path)
167
+ config = json.loads(
168
+ (model_path / "neembed_config.json").read_text(encoding="utf-8")
169
+ )
170
+ model = cls(
171
+ str(model_path / "encoder"),
172
+ manifold=config["manifold"],
173
+ embedding_dim=config["embedding_dim"],
174
+ curvature=config["curvature"],
175
+ )
176
+ projection_state = torch.load(
177
+ model_path / "projection.pt",
178
+ map_location="cpu",
179
+ weights_only=True,
180
+ )
181
+ model.projection.load_state_dict(projection_state)
182
+ return model