mantissa-embed 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 Tekin Ertekin
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,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: mantissa-embed
3
+ Version: 0.1.0
4
+ Summary: CNN metric learning on the mantissa C engine: image embeddings for similarity, verification and retrieval
5
+ Author: Tekin Ertekin
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tekinertekin/mantissa-embed
8
+ Project-URL: Base, https://github.com/tekinertekin/mantissa-cnn
9
+ Project-URL: Engine, https://github.com/tekinertekin/mantissa
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: numpy>=1.20
14
+ Requires-Dist: mantissa-cnn>=0.2.2
15
+ Provides-Extra: viz
16
+ Requires-Dist: matplotlib>=3.5; extra == "viz"
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # mantissa-embed
22
+
23
+ ![License](https://img.shields.io/badge/license-MIT-blue.svg)
24
+ ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB.svg)
25
+ [![Base](https://img.shields.io/badge/base-mantissa--cnn-4B8BBE.svg)](https://github.com/tekinertekin/mantissa-cnn)
26
+ [![Engine](https://img.shields.io/badge/engine-mantissa-00599C.svg)](https://github.com/tekinertekin/mantissa)
27
+
28
+ **Learning what "similar" means, with a C engine.** A classifier answers *which
29
+ class*; an embedder answers *how alike* — it maps each image to a point in a
30
+ vector space where same-thing images land close together and different-thing
31
+ images land far apart. That single geometry powers similarity search,
32
+ verification ("are these two the same?") and retrieval ("show me the nearest
33
+ matches"), for classes the model was never trained to name.
34
+
35
+ `mantissa-embed` trains that embedding with **metric learning** — the
36
+ contrastive and triplet losses — on top of a
37
+ [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn) trunk: its
38
+ Conv2D / MaxPool2D / Flatten / Dense layers, its
39
+ [mantissa](https://github.com/tekinertekin/mantissa) C-engine and pure-numpy
40
+ backends, and its dataset loaders are reused, not reimplemented. This package
41
+ adds only what metric learning needs on top of a classifier: the two losses,
42
+ an `Embedder` that trains a trunk with them, and the four things embeddings buy
43
+ you — `fit` / `embed` / `retrieve` / `verify`.
44
+
45
+ ## The mantissa family
46
+
47
+ Part of the **mantissa** family: a low-precision engine written in C, with
48
+ small Python packages built on top. Each package sits under the one it depends
49
+ on — ⭐ marks where you are, and every other name links to its repo.
50
+
51
+ - [mantissa](https://github.com/tekinertekin/mantissa) — low-precision neural-network engine in C (the core)
52
+ - [mantissa-perceptron](https://github.com/tekinertekin/mantissa-perceptron) — perceptron & ADALINE, the linear classics
53
+ - [mantissa-nn](https://github.com/tekinertekin/mantissa-nn) — shared neural-net primitives (layers, engine binding)
54
+ - [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn) — convolutional networks for images
55
+ - [mantissa-auto-encoder](https://github.com/tekinertekin/mantissa-auto-encoder) — autoencoders for denoising & super-resolution
56
+ - [mantissa-interpret](https://github.com/tekinertekin/mantissa-interpret) — CNN interpretability (occlusion, saliency, Grad-CAM)
57
+ - ⭐ **mantissa-embed** — CNN metric learning (image embeddings for similarity & retrieval) *(you are here)*
58
+ - [mantissa-mlp](https://github.com/tekinertekin/mantissa-mlp) — multilayer perceptrons, fully-connected nets
59
+
60
+
61
+ ## New to metric learning?
62
+
63
+ A classifier learns a fixed set of labels and a decision boundary between them.
64
+ Metric learning learns something more basic and more reusable: a **distance**.
65
+ It trains the network so that the Euclidean distance between two images'
66
+ embeddings *is* a measure of how similar they are — and it does that using only
67
+ the relation between examples ("these two are the same kind", "these two are
68
+ not"), never a class *name*. Two consequences follow:
69
+
70
+ - **It generalizes to classes it never trained on.** Because the loss only ever
71
+ says "closer" or "farther", a model trained on some identities produces useful
72
+ distances for identities it has never seen — the basis of face verification,
73
+ where you cannot retrain for every new person.
74
+ - **One embedding serves many tasks.** Compute it once per image, then:
75
+ *verify* by thresholding a distance, *retrieve* by nearest-neighbour search,
76
+ *cluster* by feeding the vectors to any clustering method.
77
+
78
+ Two classic losses do the training, and this package implements both:
79
+
80
+ - **Contrastive loss** works on **pairs**. A same-class pair pays its squared
81
+ distance (pulled together); a different-class pair pays a hinge that is zero
82
+ once the pair is at least a `margin` apart (pushed apart, but only until far
83
+ enough) — Hadsell, Chopra & LeCun (2006), "Dimensionality Reduction by
84
+ Learning an Invariant Mapping", *CVPR*.
85
+ - **Triplet loss** works on **triples** of (anchor, positive, negative). It asks
86
+ only that the negative be farther from the anchor than the positive, by at
87
+ least a `margin` — a *relative* constraint, which is often easier to satisfy
88
+ and to scale than pinning absolute distances — Schroff, Kalenichenko &
89
+ Philbin (2015), "FaceNet: A Unified Embedding for Face Recognition and
90
+ Clustering", *CVPR*.
91
+
92
+ **A Siamese/triplet network is just one network run more than once.** The two
93
+ legs of a pair (or three of a triplet) share the *same* weights, so there is no
94
+ second network to manage: stack the legs into one batch, run a single forward
95
+ pass, compute the loss and its per-row gradient on the resulting embeddings,
96
+ and backpropagate that gradient through the one trunk. That is exactly what
97
+ `Embedder.fit` does — the same custom forward/loss/backward loop the rest of the
98
+ family uses, with a metric loss where the classifier's softmax would be.
99
+
100
+ ## Install
101
+
102
+ ```sh
103
+ pip install mantissa-embed
104
+ ```
105
+
106
+ Pulls in `mantissa-cnn >= 0.2.2` (and transitively `mantissa-nn` + the
107
+ `mantissa-core` engine). For the demo's plots, `pip install mantissa-embed[viz]`.
108
+
109
+ From checkouts (works today, no PyPI needed): clone this repo, `cnn`,
110
+ `mantissa-nn` and [mantissa](https://github.com/tekinertekin/mantissa) side by
111
+ side, build the engine (`make dist` there), then here:
112
+
113
+ ```sh
114
+ pip install -e ../mantissa -e ../mantissa-nn -e ../cnn && pip install -e ".[viz]"
115
+ ```
116
+
117
+ mantissa-cnn finds the sibling engine checkout automatically, and its dataset
118
+ loaders find a `data/` directory via `MANTISSA_CNN_DATA` (the demo points this
119
+ at the sibling `cnn/data/` for you).
120
+
121
+ ## Quickstart
122
+
123
+ ```sh
124
+ # datasets are mantissa-cnn's; nothing downloads implicitly — fetch once:
125
+ python -m mantissa_cnn.datasets download mnist
126
+ ```
127
+
128
+ ```python
129
+ from mantissa_cnn import datasets
130
+ from mantissa_embed import Embedder, models
131
+
132
+ Xtr, ytr, Xte, yte = datasets.subset("mnist", 6000, 2000, seed=0)
133
+
134
+ emb = Embedder(models.small_cnn_embedder(embed_dim=16), loss="triplet") # or "contrastive"
135
+ emb.fit(Xtr, ytr, epochs=8, batch_size=64, lr=0.05, verbose=True)
136
+
137
+ Z = emb.embed(Xte) # (2000, 16) embedding vectors
138
+ idx, dist = emb.retrieve(Xte[0], Z, k=5) # 5 nearest test images to query 0
139
+ same = emb.verify(Xte[0], Xte[1], threshold=1.0) # same identity? (bool)
140
+ ```
141
+
142
+ Or compose your own trunk from mantissa-cnn's layers — any Conv/Pool/Flatten
143
+ stack ending in `Dense(embed_dim, act="identity")`:
144
+
145
+ ```python
146
+ from mantissa_cnn import Conv2D, MaxPool2D, Flatten, Dense
147
+ from mantissa_embed import Embedder
148
+
149
+ emb = Embedder(
150
+ [Conv2D(16, 3, pad=1), MaxPool2D(2), Flatten(), Dense(32)], # 32-D embedding
151
+ loss="contrastive", margin=1.0, seed=0)
152
+ ```
153
+
154
+ ## The API
155
+
156
+ `Embedder(layers, loss="triplet"|"contrastive", margin=None, seed=0, backend="mantissa")`,
157
+ then:
158
+
159
+ | method | does | returns |
160
+ |---|---|---|
161
+ | `fit(X, y, epochs, batch_size, lr, verbose)` | trains the trunk by metric learning; `y` only decides same/different | `self` (`history_["loss"]` per epoch) |
162
+ | `embed(X)` | one forward pass, chunked | `(n, embed_dim)` embeddings |
163
+ | `retrieve(query, gallery, k)` | k nearest gallery items in embedding space (numpy `argpartition`, no sklearn) | `(indices, distances)`, nearest-first |
164
+ | `verify(a, b, threshold=None)` | embedding distance between `a` and `b`; with `threshold`, a boolean same/different | distance, or bool |
165
+
166
+ `retrieve` and `verify` accept raw images **or** a pre-computed `(., embed_dim)`
167
+ matrix, so a fixed gallery is embedded once and reused across queries.
168
+
169
+ Deliberately minimal, like the rest of the family: NCHW float32 images, plain
170
+ SGD, the two classic losses, plain Euclidean distances. No autograd graph, no
171
+ optimizer zoo, no ANN index. The trunk's convolutions run in C on zero-copy
172
+ float32 buffers; the losses are memory-bound reductions over embedding vectors
173
+ and honestly stay in numpy (`mantissa_embed.losses`, importable and gradient-
174
+ checked on their own). Layers allocate scratch once per batch shape and reuse
175
+ it.
176
+
177
+ ## Results
178
+
179
+ A `small_cnn_embedder` (52,528 params, 16-D embedding) trained on a 6k-image
180
+ MNIST subset with the triplet loss and the mantissa C engine (8 epochs, triplet
181
+ loss **0.090 → 0.016**), then embedding the held-out 2k test set. Reproduce with
182
+ `python examples/embeddings_demo.py`.
183
+
184
+ The embedding separates the digits with no label ever entering the loss — colour
185
+ is the true digit, shown only for the plot:
186
+
187
+ ![MNIST test embeddings, 2-D PCA, coloured by true digit — visible per-digit clusters](https://raw.githubusercontent.com/tekinertekin/mantissa-embed/main/assets/embedding_manifold.png)
188
+
189
+ And nearest-neighbour retrieval returns same-digit images (green border = a
190
+ correct match, i.e. the neighbour's true digit equals the query's):
191
+
192
+ ![retrieval gallery: five query digits, each with its five nearest neighbours in embedding space](https://raw.githubusercontent.com/tekinertekin/mantissa-embed/main/assets/retrieval_gallery.png)
193
+
194
+ ## License
195
+
196
+ MIT — © Tekin Ertekin. Base package:
197
+ [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn); engine:
198
+ [mantissa](https://github.com/tekinertekin/mantissa) — same author, MIT.
@@ -0,0 +1,178 @@
1
+ # mantissa-embed
2
+
3
+ ![License](https://img.shields.io/badge/license-MIT-blue.svg)
4
+ ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB.svg)
5
+ [![Base](https://img.shields.io/badge/base-mantissa--cnn-4B8BBE.svg)](https://github.com/tekinertekin/mantissa-cnn)
6
+ [![Engine](https://img.shields.io/badge/engine-mantissa-00599C.svg)](https://github.com/tekinertekin/mantissa)
7
+
8
+ **Learning what "similar" means, with a C engine.** A classifier answers *which
9
+ class*; an embedder answers *how alike* — it maps each image to a point in a
10
+ vector space where same-thing images land close together and different-thing
11
+ images land far apart. That single geometry powers similarity search,
12
+ verification ("are these two the same?") and retrieval ("show me the nearest
13
+ matches"), for classes the model was never trained to name.
14
+
15
+ `mantissa-embed` trains that embedding with **metric learning** — the
16
+ contrastive and triplet losses — on top of a
17
+ [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn) trunk: its
18
+ Conv2D / MaxPool2D / Flatten / Dense layers, its
19
+ [mantissa](https://github.com/tekinertekin/mantissa) C-engine and pure-numpy
20
+ backends, and its dataset loaders are reused, not reimplemented. This package
21
+ adds only what metric learning needs on top of a classifier: the two losses,
22
+ an `Embedder` that trains a trunk with them, and the four things embeddings buy
23
+ you — `fit` / `embed` / `retrieve` / `verify`.
24
+
25
+ ## The mantissa family
26
+
27
+ Part of the **mantissa** family: a low-precision engine written in C, with
28
+ small Python packages built on top. Each package sits under the one it depends
29
+ on — ⭐ marks where you are, and every other name links to its repo.
30
+
31
+ - [mantissa](https://github.com/tekinertekin/mantissa) — low-precision neural-network engine in C (the core)
32
+ - [mantissa-perceptron](https://github.com/tekinertekin/mantissa-perceptron) — perceptron & ADALINE, the linear classics
33
+ - [mantissa-nn](https://github.com/tekinertekin/mantissa-nn) — shared neural-net primitives (layers, engine binding)
34
+ - [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn) — convolutional networks for images
35
+ - [mantissa-auto-encoder](https://github.com/tekinertekin/mantissa-auto-encoder) — autoencoders for denoising & super-resolution
36
+ - [mantissa-interpret](https://github.com/tekinertekin/mantissa-interpret) — CNN interpretability (occlusion, saliency, Grad-CAM)
37
+ - ⭐ **mantissa-embed** — CNN metric learning (image embeddings for similarity & retrieval) *(you are here)*
38
+ - [mantissa-mlp](https://github.com/tekinertekin/mantissa-mlp) — multilayer perceptrons, fully-connected nets
39
+
40
+
41
+ ## New to metric learning?
42
+
43
+ A classifier learns a fixed set of labels and a decision boundary between them.
44
+ Metric learning learns something more basic and more reusable: a **distance**.
45
+ It trains the network so that the Euclidean distance between two images'
46
+ embeddings *is* a measure of how similar they are — and it does that using only
47
+ the relation between examples ("these two are the same kind", "these two are
48
+ not"), never a class *name*. Two consequences follow:
49
+
50
+ - **It generalizes to classes it never trained on.** Because the loss only ever
51
+ says "closer" or "farther", a model trained on some identities produces useful
52
+ distances for identities it has never seen — the basis of face verification,
53
+ where you cannot retrain for every new person.
54
+ - **One embedding serves many tasks.** Compute it once per image, then:
55
+ *verify* by thresholding a distance, *retrieve* by nearest-neighbour search,
56
+ *cluster* by feeding the vectors to any clustering method.
57
+
58
+ Two classic losses do the training, and this package implements both:
59
+
60
+ - **Contrastive loss** works on **pairs**. A same-class pair pays its squared
61
+ distance (pulled together); a different-class pair pays a hinge that is zero
62
+ once the pair is at least a `margin` apart (pushed apart, but only until far
63
+ enough) — Hadsell, Chopra & LeCun (2006), "Dimensionality Reduction by
64
+ Learning an Invariant Mapping", *CVPR*.
65
+ - **Triplet loss** works on **triples** of (anchor, positive, negative). It asks
66
+ only that the negative be farther from the anchor than the positive, by at
67
+ least a `margin` — a *relative* constraint, which is often easier to satisfy
68
+ and to scale than pinning absolute distances — Schroff, Kalenichenko &
69
+ Philbin (2015), "FaceNet: A Unified Embedding for Face Recognition and
70
+ Clustering", *CVPR*.
71
+
72
+ **A Siamese/triplet network is just one network run more than once.** The two
73
+ legs of a pair (or three of a triplet) share the *same* weights, so there is no
74
+ second network to manage: stack the legs into one batch, run a single forward
75
+ pass, compute the loss and its per-row gradient on the resulting embeddings,
76
+ and backpropagate that gradient through the one trunk. That is exactly what
77
+ `Embedder.fit` does — the same custom forward/loss/backward loop the rest of the
78
+ family uses, with a metric loss where the classifier's softmax would be.
79
+
80
+ ## Install
81
+
82
+ ```sh
83
+ pip install mantissa-embed
84
+ ```
85
+
86
+ Pulls in `mantissa-cnn >= 0.2.2` (and transitively `mantissa-nn` + the
87
+ `mantissa-core` engine). For the demo's plots, `pip install mantissa-embed[viz]`.
88
+
89
+ From checkouts (works today, no PyPI needed): clone this repo, `cnn`,
90
+ `mantissa-nn` and [mantissa](https://github.com/tekinertekin/mantissa) side by
91
+ side, build the engine (`make dist` there), then here:
92
+
93
+ ```sh
94
+ pip install -e ../mantissa -e ../mantissa-nn -e ../cnn && pip install -e ".[viz]"
95
+ ```
96
+
97
+ mantissa-cnn finds the sibling engine checkout automatically, and its dataset
98
+ loaders find a `data/` directory via `MANTISSA_CNN_DATA` (the demo points this
99
+ at the sibling `cnn/data/` for you).
100
+
101
+ ## Quickstart
102
+
103
+ ```sh
104
+ # datasets are mantissa-cnn's; nothing downloads implicitly — fetch once:
105
+ python -m mantissa_cnn.datasets download mnist
106
+ ```
107
+
108
+ ```python
109
+ from mantissa_cnn import datasets
110
+ from mantissa_embed import Embedder, models
111
+
112
+ Xtr, ytr, Xte, yte = datasets.subset("mnist", 6000, 2000, seed=0)
113
+
114
+ emb = Embedder(models.small_cnn_embedder(embed_dim=16), loss="triplet") # or "contrastive"
115
+ emb.fit(Xtr, ytr, epochs=8, batch_size=64, lr=0.05, verbose=True)
116
+
117
+ Z = emb.embed(Xte) # (2000, 16) embedding vectors
118
+ idx, dist = emb.retrieve(Xte[0], Z, k=5) # 5 nearest test images to query 0
119
+ same = emb.verify(Xte[0], Xte[1], threshold=1.0) # same identity? (bool)
120
+ ```
121
+
122
+ Or compose your own trunk from mantissa-cnn's layers — any Conv/Pool/Flatten
123
+ stack ending in `Dense(embed_dim, act="identity")`:
124
+
125
+ ```python
126
+ from mantissa_cnn import Conv2D, MaxPool2D, Flatten, Dense
127
+ from mantissa_embed import Embedder
128
+
129
+ emb = Embedder(
130
+ [Conv2D(16, 3, pad=1), MaxPool2D(2), Flatten(), Dense(32)], # 32-D embedding
131
+ loss="contrastive", margin=1.0, seed=0)
132
+ ```
133
+
134
+ ## The API
135
+
136
+ `Embedder(layers, loss="triplet"|"contrastive", margin=None, seed=0, backend="mantissa")`,
137
+ then:
138
+
139
+ | method | does | returns |
140
+ |---|---|---|
141
+ | `fit(X, y, epochs, batch_size, lr, verbose)` | trains the trunk by metric learning; `y` only decides same/different | `self` (`history_["loss"]` per epoch) |
142
+ | `embed(X)` | one forward pass, chunked | `(n, embed_dim)` embeddings |
143
+ | `retrieve(query, gallery, k)` | k nearest gallery items in embedding space (numpy `argpartition`, no sklearn) | `(indices, distances)`, nearest-first |
144
+ | `verify(a, b, threshold=None)` | embedding distance between `a` and `b`; with `threshold`, a boolean same/different | distance, or bool |
145
+
146
+ `retrieve` and `verify` accept raw images **or** a pre-computed `(., embed_dim)`
147
+ matrix, so a fixed gallery is embedded once and reused across queries.
148
+
149
+ Deliberately minimal, like the rest of the family: NCHW float32 images, plain
150
+ SGD, the two classic losses, plain Euclidean distances. No autograd graph, no
151
+ optimizer zoo, no ANN index. The trunk's convolutions run in C on zero-copy
152
+ float32 buffers; the losses are memory-bound reductions over embedding vectors
153
+ and honestly stay in numpy (`mantissa_embed.losses`, importable and gradient-
154
+ checked on their own). Layers allocate scratch once per batch shape and reuse
155
+ it.
156
+
157
+ ## Results
158
+
159
+ A `small_cnn_embedder` (52,528 params, 16-D embedding) trained on a 6k-image
160
+ MNIST subset with the triplet loss and the mantissa C engine (8 epochs, triplet
161
+ loss **0.090 → 0.016**), then embedding the held-out 2k test set. Reproduce with
162
+ `python examples/embeddings_demo.py`.
163
+
164
+ The embedding separates the digits with no label ever entering the loss — colour
165
+ is the true digit, shown only for the plot:
166
+
167
+ ![MNIST test embeddings, 2-D PCA, coloured by true digit — visible per-digit clusters](https://raw.githubusercontent.com/tekinertekin/mantissa-embed/main/assets/embedding_manifold.png)
168
+
169
+ And nearest-neighbour retrieval returns same-digit images (green border = a
170
+ correct match, i.e. the neighbour's true digit equals the query's):
171
+
172
+ ![retrieval gallery: five query digits, each with its five nearest neighbours in embedding space](https://raw.githubusercontent.com/tekinertekin/mantissa-embed/main/assets/retrieval_gallery.png)
173
+
174
+ ## License
175
+
176
+ MIT — © Tekin Ertekin. Base package:
177
+ [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn); engine:
178
+ [mantissa](https://github.com/tekinertekin/mantissa) — same author, MIT.
@@ -0,0 +1,31 @@
1
+ """mantissa-embed: CNN metric learning on the mantissa C engine, built on
2
+ mantissa-nn and mantissa-cnn.
3
+
4
+ >>> from mantissa_embed import Embedder, models
5
+ >>> from mantissa_cnn import datasets
6
+ >>> Xtr, ytr, Xte, yte = datasets.subset("mnist", 4000, 1000, seed=0)
7
+ >>> emb = Embedder(models.small_cnn_embedder(embed_dim=16), loss="triplet")
8
+ >>> emb.fit(Xtr, ytr, epochs=5, batch_size=64, lr=0.05)
9
+ >>> idx, dist = emb.retrieve(Xte[0], Xte[1:], k=5) # 5 nearest neighbours
10
+
11
+ The shared base (Dense/Flatten layers, both backends) comes from mantissa-nn;
12
+ the convolution and pooling layers and the image datasets come from
13
+ mantissa-cnn. This package adds the metric losses (contrastive, triplet), an
14
+ ``Embedder`` that trains a trunk with them, and a small-CNN trunk factory. The
15
+ public API is fit / embed / retrieve / verify — similarity, retrieval and
16
+ verification from learned image embeddings.
17
+ """
18
+ try:
19
+ import mantissa_cnn # noqa: F401 (Conv2D/MaxPool2D; pulls the mantissa-nn base)
20
+ except ImportError:
21
+ raise ImportError(
22
+ "mantissa-cnn is not installed — run: pip install mantissa-cnn"
23
+ ) from None
24
+
25
+ from .losses import contrastive_loss_grad, triplet_loss_grad
26
+ from .model import Embedder
27
+ from . import models
28
+
29
+ __version__ = "0.1.0"
30
+ __all__ = ["Embedder", "contrastive_loss_grad", "triplet_loss_grad",
31
+ "models", "__version__"]
@@ -0,0 +1,81 @@
1
+ """Metric-learning losses, computed in numpy: contrastive and triplet.
2
+
3
+ Both are memory-bound elementwise reductions over a batch of embeddings — one
4
+ subtract, one square-sum per row — so numpy is the right tool; the convolution
5
+ trunk that produced the embeddings still runs in the C engine (see
6
+ :mod:`mantissa_embed.model`). Each returns the scalar loss and the per-row
7
+ gradient(s) of that loss w.r.t. its embedding input(s), already scaled by
8
+ 1/batch so one learning rate works regardless of how many pairs/triplets a
9
+ batch forms.
10
+
11
+ Convention: distances are plain Euclidean. Contrastive uses the distance
12
+ itself; triplet uses the squared distance, following each paper.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+
18
+ __all__ = ["contrastive_loss_grad", "triplet_loss_grad"]
19
+
20
+ _EPS = np.float32(1e-8)
21
+
22
+
23
+ def contrastive_loss_grad(Za, Zb, same, margin: float = 1.0):
24
+ """Contrastive loss over a batch of pairs (Hadsell, Chopra & LeCun, 2006,
25
+ "Dimensionality Reduction by Learning an Invariant Mapping", CVPR).
26
+
27
+ For each pair with Euclidean distance ``d = ||Za - Zb||``:
28
+ similar pairs (``same == 1``) pay ``d**2``; dissimilar pairs pay
29
+ ``max(margin - d, 0)**2`` — pulled together, or pushed apart until at
30
+ least ``margin`` away. Returns ``(loss, dZa, dZb)``; ``dZb == -dZa`` since
31
+ the loss sees only the difference ``Za - Zb``.
32
+ """
33
+ Za = np.asarray(Za, dtype=np.float32)
34
+ Zb = np.asarray(Zb, dtype=np.float32)
35
+ same = np.asarray(same, dtype=np.float32).reshape(-1, 1)
36
+ n = Za.shape[0]
37
+
38
+ diff = Za - Zb # (n, d)
39
+ d = np.sqrt(np.sum(diff * diff, axis=1, keepdims=True) + _EPS) # (n, 1)
40
+ hinge = np.maximum(margin - d, 0.0) # dissimilar push term
41
+
42
+ per = same * (d * d) + (1.0 - same) * (hinge * hinge)
43
+ loss = float(np.sum(per)) / n
44
+
45
+ # d(d**2)/dZa = 2*diff ; d(hinge**2)/dZa = -2*hinge*diff/d (when hinge>0)
46
+ g = same * 2.0 * diff - (1.0 - same) * 2.0 * hinge * diff / d
47
+ dZa = (g / n).astype(np.float32)
48
+ return loss, dZa, -dZa
49
+
50
+
51
+ def triplet_loss_grad(Za, Zp, Zn, margin: float = 0.2):
52
+ """Triplet loss over a batch of (anchor, positive, negative) triples
53
+ (Schroff, Kalenichenko & Philbin, 2015, "FaceNet: A Unified Embedding for
54
+ Face Recognition and Clustering", CVPR).
55
+
56
+ With squared Euclidean distances ``d_ap = ||Za - Zp||**2`` and
57
+ ``d_an = ||Za - Zn||**2``, each triple pays
58
+ ``max(d_ap - d_an + margin, 0)`` — the anchor is pulled toward its
59
+ positive and pushed off its negative until the negative is at least
60
+ ``margin`` farther than the positive. Non-violating triples contribute no
61
+ loss and no gradient. Returns ``(loss, dZa, dZp, dZn)``.
62
+ """
63
+ Za = np.asarray(Za, dtype=np.float32)
64
+ Zp = np.asarray(Zp, dtype=np.float32)
65
+ Zn = np.asarray(Zn, dtype=np.float32)
66
+ n = Za.shape[0]
67
+
68
+ d_ap = np.sum((Za - Zp) ** 2, axis=1, keepdims=True)
69
+ d_an = np.sum((Za - Zn) ** 2, axis=1, keepdims=True)
70
+ viol = d_ap - d_an + margin
71
+ active = (viol > 0.0).astype(np.float32) # (n, 1)
72
+ loss = float(np.sum(np.maximum(viol, 0.0))) / n
73
+
74
+ # gradients of (d_ap - d_an) on active rows, scaled by 1/n:
75
+ # dZa = 2(Za-Zp) - 2(Za-Zn) = 2(Zn-Zp)
76
+ # dZp = -2(Za-Zp) ; dZn = 2(Za-Zn)
77
+ s = (active * (2.0 / n)).astype(np.float32)
78
+ dZa = s * (Zn - Zp)
79
+ dZp = s * (Zp - Za)
80
+ dZn = s * (Za - Zn)
81
+ return loss, dZa.astype(np.float32), dZp.astype(np.float32), dZn.astype(np.float32)
@@ -0,0 +1,299 @@
1
+ """Embedder: a CNN trunk trained by metric learning (contrastive / triplet).
2
+
3
+ Same custom training loop as the rest of the family (mantissa_cnn.Sequential,
4
+ mantissa_autoencoder.Autoencoder), with the classifier/regression head swapped
5
+ for a metric loss over embeddings:
6
+
7
+ - Shuffled mini-batches (seeded ``np.random.default_rng``), plain SGD.
8
+ - Each batch forms pairs (contrastive) or triplets (triplet) from the labels,
9
+ then uses the family's one clean trick: the two/three legs of every
10
+ pair/triplet are STACKED into a single batch, pushed through the trunk in
11
+ ONE forward pass, the loss + per-row gradient are computed in numpy (see
12
+ :mod:`mantissa_embed.losses`), and that gradient is seeded straight into the
13
+ backward chain — one backward, then ``step`` every layer. No second forward,
14
+ no shared-weight bookkeeping: a Siamese/triplet network is just one network
15
+ run on a stacked batch.
16
+ - The trunk's convolutions run in the C engine (or the numpy backend); only
17
+ the metric loss — a memory-bound reduction over embedding vectors — is numpy.
18
+
19
+ Data contract: X is NCHW float32 (the datasets module gives [0, 1]); y is
20
+ integer class ids, used only to decide which samples are "same" and which are
21
+ "different" — the embedding itself is trained without ever seeing a class
22
+ *name*, which is the point of metric learning.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import numpy as np
27
+
28
+ from mantissa_nn import _numpy_backend
29
+ from mantissa_cnn._engine import cnn_engine # CNN feature gate: the trunk uses Conv2D
30
+
31
+ from .losses import contrastive_loss_grad, triplet_loss_grad
32
+
33
+ __all__ = ["Embedder"]
34
+
35
+ _DEFAULT_MARGIN = {"triplet": 0.2, "contrastive": 1.0}
36
+
37
+
38
+ class Embedder:
39
+ """A convolutional trunk that maps images to embedding vectors, trained so
40
+ that same-class images land close and different-class images land far.
41
+
42
+ Parameters
43
+ ----------
44
+ layers : list of Layer
45
+ A Conv2D/MaxPool2D/Flatten/Dense trunk ending in
46
+ ``Dense(embed_dim, act="identity")`` — see
47
+ :func:`mantissa_embed.models.small_cnn_embedder`.
48
+ loss : {"triplet", "contrastive"}
49
+ The metric objective. "triplet" (FaceNet) forms (anchor, positive,
50
+ negative) triples; "contrastive" (Hadsell-Chopra-LeCun) forms
51
+ same/different pairs.
52
+ margin : float, optional
53
+ Loss margin. Defaults to 0.2 for triplet, 1.0 for contrastive.
54
+ seed : int
55
+ Seeds one rng stream for weight init, epoch shuffling and pair/triplet
56
+ sampling — two models with the same seed and backend train identically.
57
+ backend : {"mantissa", "numpy"}
58
+ "mantissa" (default) requires the C engine and raises
59
+ ImportError/RuntimeError with the exact fix otherwise.
60
+
61
+ Fitted attributes
62
+ -----------------
63
+ history_ : dict with "loss" (per-epoch mean training loss).
64
+ embed_dim_ : embedding width, i.e. the trunk's output size, after build().
65
+ """
66
+
67
+ def __init__(self, layers, loss: str = "triplet", margin=None,
68
+ seed: int = 0, backend: str = "mantissa"):
69
+ if loss not in _DEFAULT_MARGIN:
70
+ raise ValueError(f"loss must be 'triplet' or 'contrastive', got {loss!r}")
71
+ if backend == "mantissa":
72
+ tk = cnn_engine() # raises with the exact fix
73
+ # mantissa >= 0.2.2: a per-model Session memoizes each buffer's
74
+ # ctypes pointer by identity. Same signatures; older engines just
75
+ # take the plain methods.
76
+ self._backend = tk.session() if hasattr(tk, "session") else tk
77
+ elif backend == "numpy":
78
+ self._backend = _numpy_backend
79
+ else:
80
+ raise ValueError(f"backend must be 'mantissa' or 'numpy', got {backend!r}")
81
+ self.backend = backend
82
+ self.layers = list(layers)
83
+ self.loss = loss
84
+ self.margin = float(margin) if margin is not None else _DEFAULT_MARGIN[loss]
85
+ self.seed = int(seed)
86
+ self._rng = np.random.default_rng(self.seed)
87
+ self._built = False
88
+
89
+ # -- construction ---------------------------------------------------------
90
+
91
+ def build(self, in_shape):
92
+ """Initialize parameters for input shape (c, h, w). Called by fit()
93
+ automatically; call it yourself to inspect summary() before training."""
94
+ shape = tuple(int(d) for d in in_shape)
95
+ self.in_shape_ = shape
96
+ for layer in self.layers:
97
+ shape = layer.build(shape, self._rng)
98
+ if len(shape) != 1:
99
+ raise ValueError(f"embedder output must be a flat vector, got shape "
100
+ f"{shape} — end the trunk with Flatten()/Dense(embed_dim)")
101
+ self.embed_dim_ = shape[0]
102
+ self._built = True
103
+ return self
104
+
105
+ def summary(self) -> str:
106
+ """Per-layer output shapes and parameter counts (build() first)."""
107
+ if not self._built:
108
+ raise RuntimeError("summary() needs parameters — call build(in_shape) or fit() first")
109
+ rows = [(type(l).__name__, str(l.out_shape), l.param_count())
110
+ for l in self.layers]
111
+ total = sum(r[2] for r in rows)
112
+ w = max(len(r[0]) for r in rows)
113
+ lines = [f"{'layer':<{w}} {'out shape':<16} params",
114
+ "-" * (w + 26)]
115
+ lines += [f"{name:<{w}} {shape:<16} {p:,}" for name, shape, p in rows]
116
+ lines.append(f"embedding dim: {self.embed_dim_}")
117
+ lines.append(f"total params: {total:,}")
118
+ return "\n".join(lines)
119
+
120
+ # -- training -------------------------------------------------------------
121
+
122
+ def fit(self, X, y, epochs: int = 10, batch_size: int = 32,
123
+ lr: float = 0.01, verbose: bool = False):
124
+ """Train the embedding by metric learning on labeled images.
125
+
126
+ Labels ``y`` are used only to sample same/different pairs (contrastive)
127
+ or (anchor, positive, negative) triples (triplet) — one pair/triple per
128
+ anchor per batch, sampled from the whole training set.
129
+ """
130
+ X = self._check(X, name="X")
131
+ y = np.ascontiguousarray(y, dtype=np.int64).ravel()
132
+ n = len(X)
133
+ if len(y) != n:
134
+ raise ValueError(f"X has {n} samples but y has {len(y)}")
135
+ if not self._built:
136
+ self.build(X.shape[1:])
137
+
138
+ classes = np.unique(y)
139
+ if len(classes) < 2:
140
+ raise ValueError("metric learning needs at least 2 classes in y")
141
+ pos_pool = {int(c): np.flatnonzero(y == c) for c in classes}
142
+ neg_pool = {int(c): np.flatnonzero(y != c) for c in classes}
143
+ for c in classes:
144
+ if len(pos_pool[int(c)]) < 2:
145
+ raise ValueError(f"class {int(c)} has <2 samples — cannot form a "
146
+ f"positive pair")
147
+
148
+ backend = self._backend
149
+ layers = self.layers
150
+ bs = min(int(batch_size), n)
151
+ self.history_ = {"loss": []}
152
+
153
+ for epoch in range(int(epochs)):
154
+ order = self._rng.permutation(n)
155
+ loss_sum = 0.0
156
+ count = 0
157
+ for start in range(0, n, bs):
158
+ a_idx = order[start:start + bs]
159
+ ya = y[a_idx]
160
+ pos = self._sample(pos_pool, ya, classes)
161
+ neg = self._sample(neg_pool, ya, classes)
162
+
163
+ if self.loss == "triplet":
164
+ batch = np.concatenate([X[a_idx], X[pos], X[neg]])
165
+ Z = self._forward(batch, layers, backend)
166
+ t = len(a_idx)
167
+ loss, dZa, dZp, dZn = triplet_loss_grad(
168
+ Z[:t], Z[t:2 * t], Z[2 * t:], self.margin)
169
+ dZ = np.concatenate([dZa, dZp, dZn])
170
+ else: # contrastive
171
+ same = (self._rng.random(len(a_idx)) < 0.5)
172
+ b_idx = np.where(same, pos, neg)
173
+ batch = np.concatenate([X[a_idx], X[b_idx]])
174
+ Z = self._forward(batch, layers, backend)
175
+ p = len(a_idx)
176
+ loss, dZa, dZb = contrastive_loss_grad(
177
+ Z[:p], Z[p:], same, self.margin)
178
+ dZ = np.concatenate([dZa, dZb])
179
+
180
+ grad = np.ascontiguousarray(dZ, dtype=np.float32)
181
+ for i in range(len(layers) - 1, -1, -1):
182
+ grad = layers[i].backward(grad, backend, need_dx=i > 0)
183
+ for layer in layers: # after ALL grads: dX of layer i
184
+ layer.step(backend, lr) # depends on its pre-step params
185
+
186
+ loss_sum += loss * len(a_idx)
187
+ count += len(a_idx)
188
+
189
+ self.history_["loss"].append(loss_sum / count)
190
+ if verbose:
191
+ print(f"epoch {epoch + 1}/{epochs} "
192
+ f"loss {self.history_['loss'][-1]:.6f}")
193
+ return self
194
+
195
+ def _sample(self, pool, ya, classes):
196
+ """One index per anchor drawn (with replacement) from ``pool[label]``
197
+ — the same-class pool for positives, the complement pool for
198
+ negatives. Vectorized per class (there are only a handful)."""
199
+ out = np.empty(len(ya), dtype=np.int64)
200
+ for c in classes:
201
+ mask = ya == c
202
+ m = int(mask.sum())
203
+ if m:
204
+ out[mask] = self._rng.choice(pool[int(c)], size=m)
205
+ return out
206
+
207
+ # -- inference ------------------------------------------------------------
208
+
209
+ def embed(self, X):
210
+ """Embedding vectors for images X, shape (n, embed_dim_)."""
211
+ self._require_built()
212
+ X = self._check(X, expect=self.in_shape_, name="X")
213
+ return self._embed_batched(X)
214
+
215
+ def retrieve(self, query, gallery, k: int = 5):
216
+ """k nearest gallery items to each query in embedding space.
217
+
218
+ ``query`` is one image ``(c, h, w)`` or a batch ``(nq, c, h, w)``;
219
+ ``gallery`` is a batch of images ``(m, c, h, w)``. Either may instead
220
+ be a pre-computed embedding matrix ``(., embed_dim_)`` — handy when the
221
+ gallery is fixed across many queries. Returns ``(indices, distances)``,
222
+ each ``(k,)`` for a single query or ``(nq, k)`` for a batch, sorted
223
+ nearest-first by Euclidean distance.
224
+ """
225
+ self._require_built()
226
+ single = (np.ndim(query) == len(self.in_shape_)) # one image, no batch axis
227
+ Q = self._as_embeddings(query if not single else np.asarray(query)[None])
228
+ G = self._as_embeddings(gallery)
229
+ k = min(int(k), G.shape[0])
230
+
231
+ # ||q - g||^2 = |q|^2 + |g|^2 - 2 q.g ; argpartition for the k smallest.
232
+ d2 = (np.sum(Q * Q, axis=1, keepdims=True)
233
+ + np.sum(G * G, axis=1)[None, :]
234
+ - 2.0 * Q @ G.T) # (nq, m)
235
+ np.maximum(d2, 0.0, out=d2)
236
+ part = np.argpartition(d2, k - 1, axis=1)[:, :k]
237
+ rows = np.arange(Q.shape[0])[:, None]
238
+ order = np.argsort(d2[rows, part], axis=1)
239
+ idx = part[rows, order]
240
+ dist = np.sqrt(d2[rows, idx])
241
+ if single:
242
+ return idx[0], dist[0]
243
+ return idx, dist
244
+
245
+ def verify(self, a, b, threshold=None):
246
+ """Euclidean distance between embeddings of ``a`` and ``b`` (lower =
247
+ more likely the same identity). ``a``/``b`` are single images or equal-
248
+ length batches. With ``threshold`` given, returns a boolean "same"
249
+ decision (``distance <= threshold``) instead of the distance.
250
+ """
251
+ self._require_built()
252
+ Za = self._as_embeddings(a)
253
+ Zb = self._as_embeddings(b)
254
+ dist = np.sqrt(np.maximum(np.sum((Za - Zb) ** 2, axis=1), 0.0))
255
+ out = dist if threshold is None else (dist <= float(threshold))
256
+ return out[0] if out.shape[0] == 1 else out
257
+
258
+ # -- internals ------------------------------------------------------------
259
+
260
+ def _as_embeddings(self, A):
261
+ """Coerce images (2, 3 or 4-D) or a ready (., embed_dim_) matrix to a
262
+ 2-D embedding batch."""
263
+ A = np.asarray(A, dtype=np.float32)
264
+ if A.ndim == 2 and A.shape[1] == self.embed_dim_:
265
+ return A # already embeddings
266
+ if A.ndim == len(self.in_shape_):
267
+ A = A[None] # single image -> batch
268
+ return self._embed_batched(self._check(A, expect=self.in_shape_))
269
+
270
+ def _embed_batched(self, X, chunk: int = 256):
271
+ out = np.empty((len(X), self.embed_dim_), dtype=np.float32)
272
+ for s in range(0, len(X), chunk):
273
+ h = X[s:s + chunk] # contiguous slice view
274
+ for layer in self.layers:
275
+ h = layer.forward(h, self._backend)
276
+ out[s:s + chunk] = h # copy out: scratch is reused
277
+ return out
278
+
279
+ @staticmethod
280
+ def _forward(batch, layers, backend):
281
+ out = np.ascontiguousarray(batch, dtype=np.float32)
282
+ for layer in layers:
283
+ out = layer.forward(out, backend)
284
+ return out
285
+
286
+ def _require_built(self):
287
+ if not self._built:
288
+ raise RuntimeError("model has no parameters — call build(in_shape) or fit() first")
289
+
290
+ def _check(self, X, expect=None, name="X"):
291
+ X = np.ascontiguousarray(X, dtype=np.float32)
292
+ want_ndim = 1 + (len(expect) if expect is not None else 3)
293
+ if X.ndim != want_ndim:
294
+ raise ValueError(f"{name} must have shape (n,) + {expect or '(c, h, w)'} "
295
+ f"float32, got ndim={X.ndim}")
296
+ if expect is not None and tuple(X.shape[1:]) != tuple(expect):
297
+ raise ValueError(f"{name} has sample shape {tuple(X.shape[1:])}, "
298
+ f"expected {tuple(expect)}")
299
+ return X
@@ -0,0 +1,38 @@
1
+ """Trunk factory: a small convolutional embedding network.
2
+
3
+ Returns a *layer list* (not a built model) — hand it to
4
+ :class:`mantissa_embed.Embedder`, which owns the backend and seeds weight init
5
+ at build time. Built the same way as :func:`mantissa_cnn.models.lenet5`: a
6
+ Conv/Pool/Flatten/Dense stack, but the head is ``Dense(embed_dim,
7
+ act="identity")`` — it emits an embedding vector, not class logits. The metric
8
+ loss lives on top of that vector (see :mod:`mantissa_embed.losses`).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from mantissa_cnn import Conv2D, Dense, Flatten, MaxPool2D
13
+
14
+ __all__ = ["small_cnn_embedder"]
15
+
16
+
17
+ def small_cnn_embedder(in_shape=(1, 28, 28), embed_dim: int = 16, seed: int = 0):
18
+ """A LeNet-shaped trunk mapping an image to an ``embed_dim`` vector.
19
+
20
+ Conv 8@3x3 -> pool -> Conv 16@3x3 -> pool -> Flatten -> Dense 64 ->
21
+ Dense(embed_dim). relu convolutions (He init), identity embedding head
22
+ (Glorot init) — the metric loss, not a softmax, sits on the head.
23
+
24
+ ``in_shape`` is accepted for symmetry with the family's model factories and
25
+ to document the intended input; the layers infer their shapes at build
26
+ time. ``seed`` is likewise reserved for signature symmetry — weight init is
27
+ seeded by the ``Embedder`` when it builds this list, not here (an unbuilt
28
+ layer carries no randomness).
29
+ """
30
+ return [
31
+ Conv2D(8, 3, pad=1, act="relu"),
32
+ MaxPool2D(2),
33
+ Conv2D(16, 3, pad=1, act="relu"),
34
+ MaxPool2D(2),
35
+ Flatten(),
36
+ Dense(64, act="relu"),
37
+ Dense(int(embed_dim), act="identity"),
38
+ ]
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: mantissa-embed
3
+ Version: 0.1.0
4
+ Summary: CNN metric learning on the mantissa C engine: image embeddings for similarity, verification and retrieval
5
+ Author: Tekin Ertekin
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tekinertekin/mantissa-embed
8
+ Project-URL: Base, https://github.com/tekinertekin/mantissa-cnn
9
+ Project-URL: Engine, https://github.com/tekinertekin/mantissa
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: numpy>=1.20
14
+ Requires-Dist: mantissa-cnn>=0.2.2
15
+ Provides-Extra: viz
16
+ Requires-Dist: matplotlib>=3.5; extra == "viz"
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # mantissa-embed
22
+
23
+ ![License](https://img.shields.io/badge/license-MIT-blue.svg)
24
+ ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB.svg)
25
+ [![Base](https://img.shields.io/badge/base-mantissa--cnn-4B8BBE.svg)](https://github.com/tekinertekin/mantissa-cnn)
26
+ [![Engine](https://img.shields.io/badge/engine-mantissa-00599C.svg)](https://github.com/tekinertekin/mantissa)
27
+
28
+ **Learning what "similar" means, with a C engine.** A classifier answers *which
29
+ class*; an embedder answers *how alike* — it maps each image to a point in a
30
+ vector space where same-thing images land close together and different-thing
31
+ images land far apart. That single geometry powers similarity search,
32
+ verification ("are these two the same?") and retrieval ("show me the nearest
33
+ matches"), for classes the model was never trained to name.
34
+
35
+ `mantissa-embed` trains that embedding with **metric learning** — the
36
+ contrastive and triplet losses — on top of a
37
+ [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn) trunk: its
38
+ Conv2D / MaxPool2D / Flatten / Dense layers, its
39
+ [mantissa](https://github.com/tekinertekin/mantissa) C-engine and pure-numpy
40
+ backends, and its dataset loaders are reused, not reimplemented. This package
41
+ adds only what metric learning needs on top of a classifier: the two losses,
42
+ an `Embedder` that trains a trunk with them, and the four things embeddings buy
43
+ you — `fit` / `embed` / `retrieve` / `verify`.
44
+
45
+ ## The mantissa family
46
+
47
+ Part of the **mantissa** family: a low-precision engine written in C, with
48
+ small Python packages built on top. Each package sits under the one it depends
49
+ on — ⭐ marks where you are, and every other name links to its repo.
50
+
51
+ - [mantissa](https://github.com/tekinertekin/mantissa) — low-precision neural-network engine in C (the core)
52
+ - [mantissa-perceptron](https://github.com/tekinertekin/mantissa-perceptron) — perceptron & ADALINE, the linear classics
53
+ - [mantissa-nn](https://github.com/tekinertekin/mantissa-nn) — shared neural-net primitives (layers, engine binding)
54
+ - [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn) — convolutional networks for images
55
+ - [mantissa-auto-encoder](https://github.com/tekinertekin/mantissa-auto-encoder) — autoencoders for denoising & super-resolution
56
+ - [mantissa-interpret](https://github.com/tekinertekin/mantissa-interpret) — CNN interpretability (occlusion, saliency, Grad-CAM)
57
+ - ⭐ **mantissa-embed** — CNN metric learning (image embeddings for similarity & retrieval) *(you are here)*
58
+ - [mantissa-mlp](https://github.com/tekinertekin/mantissa-mlp) — multilayer perceptrons, fully-connected nets
59
+
60
+
61
+ ## New to metric learning?
62
+
63
+ A classifier learns a fixed set of labels and a decision boundary between them.
64
+ Metric learning learns something more basic and more reusable: a **distance**.
65
+ It trains the network so that the Euclidean distance between two images'
66
+ embeddings *is* a measure of how similar they are — and it does that using only
67
+ the relation between examples ("these two are the same kind", "these two are
68
+ not"), never a class *name*. Two consequences follow:
69
+
70
+ - **It generalizes to classes it never trained on.** Because the loss only ever
71
+ says "closer" or "farther", a model trained on some identities produces useful
72
+ distances for identities it has never seen — the basis of face verification,
73
+ where you cannot retrain for every new person.
74
+ - **One embedding serves many tasks.** Compute it once per image, then:
75
+ *verify* by thresholding a distance, *retrieve* by nearest-neighbour search,
76
+ *cluster* by feeding the vectors to any clustering method.
77
+
78
+ Two classic losses do the training, and this package implements both:
79
+
80
+ - **Contrastive loss** works on **pairs**. A same-class pair pays its squared
81
+ distance (pulled together); a different-class pair pays a hinge that is zero
82
+ once the pair is at least a `margin` apart (pushed apart, but only until far
83
+ enough) — Hadsell, Chopra & LeCun (2006), "Dimensionality Reduction by
84
+ Learning an Invariant Mapping", *CVPR*.
85
+ - **Triplet loss** works on **triples** of (anchor, positive, negative). It asks
86
+ only that the negative be farther from the anchor than the positive, by at
87
+ least a `margin` — a *relative* constraint, which is often easier to satisfy
88
+ and to scale than pinning absolute distances — Schroff, Kalenichenko &
89
+ Philbin (2015), "FaceNet: A Unified Embedding for Face Recognition and
90
+ Clustering", *CVPR*.
91
+
92
+ **A Siamese/triplet network is just one network run more than once.** The two
93
+ legs of a pair (or three of a triplet) share the *same* weights, so there is no
94
+ second network to manage: stack the legs into one batch, run a single forward
95
+ pass, compute the loss and its per-row gradient on the resulting embeddings,
96
+ and backpropagate that gradient through the one trunk. That is exactly what
97
+ `Embedder.fit` does — the same custom forward/loss/backward loop the rest of the
98
+ family uses, with a metric loss where the classifier's softmax would be.
99
+
100
+ ## Install
101
+
102
+ ```sh
103
+ pip install mantissa-embed
104
+ ```
105
+
106
+ Pulls in `mantissa-cnn >= 0.2.2` (and transitively `mantissa-nn` + the
107
+ `mantissa-core` engine). For the demo's plots, `pip install mantissa-embed[viz]`.
108
+
109
+ From checkouts (works today, no PyPI needed): clone this repo, `cnn`,
110
+ `mantissa-nn` and [mantissa](https://github.com/tekinertekin/mantissa) side by
111
+ side, build the engine (`make dist` there), then here:
112
+
113
+ ```sh
114
+ pip install -e ../mantissa -e ../mantissa-nn -e ../cnn && pip install -e ".[viz]"
115
+ ```
116
+
117
+ mantissa-cnn finds the sibling engine checkout automatically, and its dataset
118
+ loaders find a `data/` directory via `MANTISSA_CNN_DATA` (the demo points this
119
+ at the sibling `cnn/data/` for you).
120
+
121
+ ## Quickstart
122
+
123
+ ```sh
124
+ # datasets are mantissa-cnn's; nothing downloads implicitly — fetch once:
125
+ python -m mantissa_cnn.datasets download mnist
126
+ ```
127
+
128
+ ```python
129
+ from mantissa_cnn import datasets
130
+ from mantissa_embed import Embedder, models
131
+
132
+ Xtr, ytr, Xte, yte = datasets.subset("mnist", 6000, 2000, seed=0)
133
+
134
+ emb = Embedder(models.small_cnn_embedder(embed_dim=16), loss="triplet") # or "contrastive"
135
+ emb.fit(Xtr, ytr, epochs=8, batch_size=64, lr=0.05, verbose=True)
136
+
137
+ Z = emb.embed(Xte) # (2000, 16) embedding vectors
138
+ idx, dist = emb.retrieve(Xte[0], Z, k=5) # 5 nearest test images to query 0
139
+ same = emb.verify(Xte[0], Xte[1], threshold=1.0) # same identity? (bool)
140
+ ```
141
+
142
+ Or compose your own trunk from mantissa-cnn's layers — any Conv/Pool/Flatten
143
+ stack ending in `Dense(embed_dim, act="identity")`:
144
+
145
+ ```python
146
+ from mantissa_cnn import Conv2D, MaxPool2D, Flatten, Dense
147
+ from mantissa_embed import Embedder
148
+
149
+ emb = Embedder(
150
+ [Conv2D(16, 3, pad=1), MaxPool2D(2), Flatten(), Dense(32)], # 32-D embedding
151
+ loss="contrastive", margin=1.0, seed=0)
152
+ ```
153
+
154
+ ## The API
155
+
156
+ `Embedder(layers, loss="triplet"|"contrastive", margin=None, seed=0, backend="mantissa")`,
157
+ then:
158
+
159
+ | method | does | returns |
160
+ |---|---|---|
161
+ | `fit(X, y, epochs, batch_size, lr, verbose)` | trains the trunk by metric learning; `y` only decides same/different | `self` (`history_["loss"]` per epoch) |
162
+ | `embed(X)` | one forward pass, chunked | `(n, embed_dim)` embeddings |
163
+ | `retrieve(query, gallery, k)` | k nearest gallery items in embedding space (numpy `argpartition`, no sklearn) | `(indices, distances)`, nearest-first |
164
+ | `verify(a, b, threshold=None)` | embedding distance between `a` and `b`; with `threshold`, a boolean same/different | distance, or bool |
165
+
166
+ `retrieve` and `verify` accept raw images **or** a pre-computed `(., embed_dim)`
167
+ matrix, so a fixed gallery is embedded once and reused across queries.
168
+
169
+ Deliberately minimal, like the rest of the family: NCHW float32 images, plain
170
+ SGD, the two classic losses, plain Euclidean distances. No autograd graph, no
171
+ optimizer zoo, no ANN index. The trunk's convolutions run in C on zero-copy
172
+ float32 buffers; the losses are memory-bound reductions over embedding vectors
173
+ and honestly stay in numpy (`mantissa_embed.losses`, importable and gradient-
174
+ checked on their own). Layers allocate scratch once per batch shape and reuse
175
+ it.
176
+
177
+ ## Results
178
+
179
+ A `small_cnn_embedder` (52,528 params, 16-D embedding) trained on a 6k-image
180
+ MNIST subset with the triplet loss and the mantissa C engine (8 epochs, triplet
181
+ loss **0.090 → 0.016**), then embedding the held-out 2k test set. Reproduce with
182
+ `python examples/embeddings_demo.py`.
183
+
184
+ The embedding separates the digits with no label ever entering the loss — colour
185
+ is the true digit, shown only for the plot:
186
+
187
+ ![MNIST test embeddings, 2-D PCA, coloured by true digit — visible per-digit clusters](https://raw.githubusercontent.com/tekinertekin/mantissa-embed/main/assets/embedding_manifold.png)
188
+
189
+ And nearest-neighbour retrieval returns same-digit images (green border = a
190
+ correct match, i.e. the neighbour's true digit equals the query's):
191
+
192
+ ![retrieval gallery: five query digits, each with its five nearest neighbours in embedding space](https://raw.githubusercontent.com/tekinertekin/mantissa-embed/main/assets/retrieval_gallery.png)
193
+
194
+ ## License
195
+
196
+ MIT — © Tekin Ertekin. Base package:
197
+ [mantissa-cnn](https://github.com/tekinertekin/mantissa-cnn); engine:
198
+ [mantissa](https://github.com/tekinertekin/mantissa) — same author, MIT.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ mantissa_embed/__init__.py
5
+ mantissa_embed/losses.py
6
+ mantissa_embed/model.py
7
+ mantissa_embed/models.py
8
+ mantissa_embed.egg-info/PKG-INFO
9
+ mantissa_embed.egg-info/SOURCES.txt
10
+ mantissa_embed.egg-info/dependency_links.txt
11
+ mantissa_embed.egg-info/requires.txt
12
+ mantissa_embed.egg-info/top_level.txt
@@ -0,0 +1,8 @@
1
+ numpy>=1.20
2
+ mantissa-cnn>=0.2.2
3
+
4
+ [dev]
5
+ pytest>=7
6
+
7
+ [viz]
8
+ matplotlib>=3.5
@@ -0,0 +1 @@
1
+ mantissa_embed
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mantissa-embed"
7
+ version = "0.1.0"
8
+ description = "CNN metric learning on the mantissa C engine: image embeddings for similarity, verification and retrieval"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Tekin Ertekin" }]
13
+ dependencies = [
14
+ "numpy>=1.20",
15
+ # Trains a Conv2D/MaxPool2D/Flatten/Dense trunk with the same custom
16
+ # forward/backward loop the rest of the family uses, so it runs on the
17
+ # mantissa C engine (or the pure-numpy backend). mantissa-cnn transitively
18
+ # pulls in mantissa-nn and the mantissa-core engine. For the dev layout,
19
+ # `pip install -e ../cnn ../mantissa-nn ../mantissa` first.
20
+ "mantissa-cnn>=0.2.2",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ viz = ["matplotlib>=3.5"]
25
+ dev = ["pytest>=7"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/tekinertekin/mantissa-embed"
29
+ Base = "https://github.com/tekinertekin/mantissa-cnn"
30
+ Engine = "https://github.com/tekinertekin/mantissa"
31
+
32
+ [tool.setuptools]
33
+ packages = ["mantissa_embed"]
34
+
35
+ [tool.pytest.ini_options]
36
+ filterwarnings = [
37
+ # numpy 2.x on Apple Accelerate emits spurious FPE RuntimeWarnings from the
38
+ # BLAS matmul kernel even on finite inputs (same issue documented across
39
+ # the family: mantissa-cnn / mantissa-autoencoder pyproject).
40
+ "ignore:.*encountered in matmul:RuntimeWarning",
41
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+