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.
- neembed_geoopt-0.1.0/LICENSE +21 -0
- neembed_geoopt-0.1.0/PKG-INFO +163 -0
- neembed_geoopt-0.1.0/README.md +132 -0
- neembed_geoopt-0.1.0/pyproject.toml +62 -0
- neembed_geoopt-0.1.0/setup.cfg +4 -0
- neembed_geoopt-0.1.0/src/neembed/__init__.py +11 -0
- neembed_geoopt-0.1.0/src/neembed/losses.py +60 -0
- neembed_geoopt-0.1.0/src/neembed/manifolds.py +15 -0
- neembed_geoopt-0.1.0/src/neembed/model.py +182 -0
- neembed_geoopt-0.1.0/src/neembed/trainer.py +79 -0
- neembed_geoopt-0.1.0/src/neembed_geoopt.egg-info/PKG-INFO +163 -0
- neembed_geoopt-0.1.0/src/neembed_geoopt.egg-info/SOURCES.txt +23 -0
- neembed_geoopt-0.1.0/src/neembed_geoopt.egg-info/dependency_links.txt +1 -0
- neembed_geoopt-0.1.0/src/neembed_geoopt.egg-info/requires.txt +6 -0
- neembed_geoopt-0.1.0/src/neembed_geoopt.egg-info/top_level.txt +1 -0
- neembed_geoopt-0.1.0/tests/test_baseline_experiment.py +152 -0
- neembed_geoopt-0.1.0/tests/test_example.py +83 -0
- neembed_geoopt-0.1.0/tests/test_import.py +16 -0
- neembed_geoopt-0.1.0/tests/test_losses.py +93 -0
- neembed_geoopt-0.1.0/tests/test_manifolds.py +34 -0
- neembed_geoopt-0.1.0/tests/test_model.py +137 -0
- neembed_geoopt-0.1.0/tests/test_release_metadata.py +137 -0
- neembed_geoopt-0.1.0/tests/test_save_load.py +161 -0
- neembed_geoopt-0.1.0/tests/test_stability.py +175 -0
- neembed_geoopt-0.1.0/tests/test_training.py +76 -0
|
@@ -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,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
|