protlabel 4.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- protlabel/__init__.py +20 -0
- protlabel/backends.py +183 -0
- protlabel/lookup.py +75 -0
- protlabel/reliability.py +45 -0
- protlabel/transfer.py +97 -0
- protlabel-4.6.0.dist-info/METADATA +63 -0
- protlabel-4.6.0.dist-info/RECORD +8 -0
- protlabel-4.6.0.dist-info/WHEEL +4 -0
protlabel/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""protlabel — Embedding Annotation Transfer (EAT) engine.
|
|
2
|
+
|
|
3
|
+
Nearest-neighbour label transfer in protein-language-model embedding space,
|
|
4
|
+
with the goPredSim reliability index. Pure numpy (plus the standard library);
|
|
5
|
+
no protspace imports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from protlabel.lookup import Lookup
|
|
11
|
+
from protlabel.transfer import Prediction, eat
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
# Report the installed distribution version rather than a hard-coded literal
|
|
15
|
+
# that would silently drift across releases.
|
|
16
|
+
__version__ = version("protlabel")
|
|
17
|
+
except PackageNotFoundError: # pragma: no cover - source/uninstalled fallback
|
|
18
|
+
__version__ = "0.0.0"
|
|
19
|
+
|
|
20
|
+
__all__ = ["Lookup", "Prediction", "eat", "__version__"]
|
protlabel/backends.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Exact (brute-force) k-nearest-neighbour search over reference embeddings.
|
|
2
|
+
|
|
3
|
+
Distances are computed with a chunked BLAS matrix product (numpy ``@``) plus
|
|
4
|
+
``argpartition`` — the GEMM path, which is roughly an order of magnitude faster
|
|
5
|
+
than a naive per-pair distance loop while staying pure-numpy (no third-party
|
|
6
|
+
distance/ANN library).
|
|
7
|
+
|
|
8
|
+
The query axis is chunked and, adaptively, bounded against the reference axis so
|
|
9
|
+
the per-chunk distance block is kept at or below ``max_block_bytes`` (default
|
|
10
|
+
256 MiB) regardless of ``n_refs``. This keeps peak memory close to the
|
|
11
|
+
reference matrix itself even at Swiss-Prot scale (~570 000 references).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import warnings
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
_METRICS = {"euclidean", "cosine"}
|
|
21
|
+
|
|
22
|
+
# The float32 GEMM distance loses precision to catastrophic cancellation for
|
|
23
|
+
# high-norm pLM embeddings, so its top-k *selection* (argpartition) can drop a
|
|
24
|
+
# true nearest whose float32 distance is noise. The exact float64 rerank can
|
|
25
|
+
# only reorder the candidates selection kept — it cannot recover a nearest that
|
|
26
|
+
# was never selected. So over-fetch a wider candidate pool before the rerank;
|
|
27
|
+
# the pad gives small k (e.g. the default k=1) a real margin, the factor scales
|
|
28
|
+
# it for larger k. Cost is still O(b * k_pool * d) << the O(b * n_refs) GEMM.
|
|
29
|
+
_RERANK_OVERFETCH = 2 # multiplicative widening of the candidate pool
|
|
30
|
+
_RERANK_PAD = 16 # additive floor so small k still over-fetches
|
|
31
|
+
|
|
32
|
+
# Peak bytes-per-element the float64 rerank holds at once for its
|
|
33
|
+
# (eff_chunk, k_pool, d) tensors: _exact_distances materialises two float64
|
|
34
|
+
# copies simultaneously (the upcast candidates and their difference/normalised
|
|
35
|
+
# form) = 16 B/elem; 24 leaves headroom for the transient f32->f64 upcast.
|
|
36
|
+
_RERANK_PEAK_BYTES = 24
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _rerank_pool_size(k: int, n_refs: int) -> int:
|
|
40
|
+
"""Number of float32-selected candidates to rerank in float64 for a top-k."""
|
|
41
|
+
return min(n_refs, max(k * _RERANK_OVERFETCH, k + _RERANK_PAD))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _exact_distances(block: np.ndarray, sel: np.ndarray, metric: str) -> np.ndarray:
|
|
45
|
+
"""Distances from each query (block[i]) to its pool candidates (sel[i]) in float64.
|
|
46
|
+
|
|
47
|
+
The fast GEMM block selects a candidate pool; this recomputes their
|
|
48
|
+
distances by direct subtraction in float64 to avoid the catastrophic
|
|
49
|
+
cancellation of ``||q||^2 - 2 q.r + ||r||^2`` for near-identical vectors.
|
|
50
|
+
Cost is O(b * k_pool * d), not O(b * n_refs), so it is cheap.
|
|
51
|
+
"""
|
|
52
|
+
blk = block[:, None, :].astype(np.float64) # (b, 1, d)
|
|
53
|
+
sel = sel.astype(np.float64) # (b, k, d)
|
|
54
|
+
if metric == "euclidean":
|
|
55
|
+
diff = blk - sel
|
|
56
|
+
return np.sqrt(np.einsum("bkd,bkd->bk", diff, diff))
|
|
57
|
+
bn = np.linalg.norm(blk, axis=2, keepdims=True)
|
|
58
|
+
bn = np.where(bn == 0.0, 1.0, bn)
|
|
59
|
+
sn = np.linalg.norm(sel, axis=2, keepdims=True)
|
|
60
|
+
sn = np.where(sn == 0.0, 1.0, sn)
|
|
61
|
+
cos = np.einsum("bkd,bkd->bk", blk / bn, sel / sn)
|
|
62
|
+
return np.clip(1.0 - cos, 0.0, 2.0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def nearest(
|
|
66
|
+
queries: np.ndarray,
|
|
67
|
+
refs: np.ndarray,
|
|
68
|
+
k: int,
|
|
69
|
+
metric: str = "euclidean",
|
|
70
|
+
chunk: int = 4096,
|
|
71
|
+
max_block_bytes: int = 256 * 1024 * 1024,
|
|
72
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
73
|
+
"""Return (idx, dist) of the k nearest *references* per query.
|
|
74
|
+
|
|
75
|
+
idx[i] -> indices into ``refs`` of the k nearest, ascending by distance.
|
|
76
|
+
dist[i] -> the corresponding distances.
|
|
77
|
+
k is capped to the number of references.
|
|
78
|
+
|
|
79
|
+
Memory behaviour
|
|
80
|
+
----------------
|
|
81
|
+
The per-chunk distance block has shape ``(query_chunk, n_refs)``. When
|
|
82
|
+
``n_refs`` is large (e.g. Swiss-Prot ~570 000), even a modest ``chunk`` of
|
|
83
|
+
4096 yields a multi-GiB block. To bound this, the effective query-chunk
|
|
84
|
+
size ``eff_chunk`` is computed as ``min(chunk, max_block_bytes // (n_refs *
|
|
85
|
+
8))`` so each block stays at or below ``max_block_bytes`` (default 256 MiB)
|
|
86
|
+
independent of ``n_refs``. The factor 8 budgets for a float64-sized block;
|
|
87
|
+
each metric holds a single copy of the reference matrix (cosine folds the
|
|
88
|
+
per-reference norm into the dot product rather than storing a normalized
|
|
89
|
+
second copy) plus a few float32 ``(query_chunk, n_refs)`` temporaries, so the
|
|
90
|
+
real peak is comparable to (not far below) that budget. The exact-distance
|
|
91
|
+
recompute touches the over-fetched candidate pool (a small multiple of k, not
|
|
92
|
+
n_refs); ``eff_chunk`` is additionally capped so those ``(eff_chunk, k_pool,
|
|
93
|
+
d)`` float64 temporaries also stay within ``max_block_bytes`` (they scale with
|
|
94
|
+
the embedding dim, so a small reference set queried at high dim would
|
|
95
|
+
otherwise blow the budget). Peak memory therefore stays bounded — close to
|
|
96
|
+
the reference matrix at Swiss-Prot scale, laptop-feasible.
|
|
97
|
+
"""
|
|
98
|
+
if metric not in _METRICS:
|
|
99
|
+
raise ValueError(f"Unknown metric {metric!r}; expected 'euclidean' or 'cosine'")
|
|
100
|
+
|
|
101
|
+
if k < 1:
|
|
102
|
+
raise ValueError("k must be >= 1")
|
|
103
|
+
|
|
104
|
+
queries = np.ascontiguousarray(queries, dtype=np.float32)
|
|
105
|
+
refs = np.ascontiguousarray(refs, dtype=np.float32)
|
|
106
|
+
n_refs = refs.shape[0]
|
|
107
|
+
if n_refs == 0:
|
|
108
|
+
raise ValueError("refs must contain at least one reference embedding")
|
|
109
|
+
k = min(k, n_refs)
|
|
110
|
+
k_pool = _rerank_pool_size(k, n_refs)
|
|
111
|
+
|
|
112
|
+
# Adaptively shrink the query chunk so BOTH per-chunk tensors stay within
|
|
113
|
+
# max_block_bytes:
|
|
114
|
+
# * the (eff_chunk, n_refs) distance block (float64-sized = 8 B/row-elem),
|
|
115
|
+
# * the (eff_chunk, k_pool, d) float64 rerank temporaries, which scale with
|
|
116
|
+
# the embedding dim d (not n_refs), so the block budget alone leaves them
|
|
117
|
+
# unbounded for small-reference / high-dim / many-query workloads.
|
|
118
|
+
dim = refs.shape[1]
|
|
119
|
+
block_row_bytes = max(1, n_refs * 8)
|
|
120
|
+
rerank_row_bytes = max(1, k_pool * dim * _RERANK_PEAK_BYTES)
|
|
121
|
+
eff_chunk = max(
|
|
122
|
+
1,
|
|
123
|
+
min(
|
|
124
|
+
chunk,
|
|
125
|
+
max_block_bytes // block_row_bytes,
|
|
126
|
+
max_block_bytes // rerank_row_bytes,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
idx_out = np.empty((queries.shape[0], k), dtype=np.int64)
|
|
131
|
+
dist_out = np.empty((queries.shape[0], k), dtype=np.float32)
|
|
132
|
+
|
|
133
|
+
# Per-reference squared norms (shape (n_refs,), tiny) — used by both metrics:
|
|
134
|
+
# euclidean: ||q-r||^2 = ||q||^2 - 2 q.r + ||r||^2
|
|
135
|
+
# cosine: cos = q.r / (||q|| ||r||) — folding ||r|| in here (rather than
|
|
136
|
+
# storing a second, full-size normalized copy of the reference
|
|
137
|
+
# matrix) keeps cosine at 1x reference memory, like euclidean.
|
|
138
|
+
refs_sq = np.einsum("ij,ij->i", refs, refs)
|
|
139
|
+
refs_norm = np.sqrt(refs_sq) if metric == "cosine" else None
|
|
140
|
+
|
|
141
|
+
# Row index for the per-chunk fancy-indexing; sliced to each block's length
|
|
142
|
+
# so it is allocated once rather than per chunk.
|
|
143
|
+
rows_full = np.arange(eff_chunk)[:, None]
|
|
144
|
+
|
|
145
|
+
for start in range(0, queries.shape[0], eff_chunk):
|
|
146
|
+
block = queries[start : start + eff_chunk]
|
|
147
|
+
with warnings.catch_warnings():
|
|
148
|
+
# GEMM on near-overflowing float16-origin values can emit harmless
|
|
149
|
+
# RuntimeWarnings; the inputs are upcast float32 so values are safe.
|
|
150
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
151
|
+
if metric == "euclidean":
|
|
152
|
+
block_sq = np.einsum("ij,ij->i", block, block)
|
|
153
|
+
cross = block @ refs.T # (b, n_refs), BLAS GEMM
|
|
154
|
+
d2 = block_sq[:, None] - 2.0 * cross + refs_sq[None, :]
|
|
155
|
+
np.maximum(d2, 0.0, out=d2) # clip tiny negative fp artifacts
|
|
156
|
+
d = np.sqrt(d2, dtype=np.float32)
|
|
157
|
+
else: # cosine
|
|
158
|
+
cross = block @ refs.T # (b, n_refs) raw dot products, BLAS GEMM
|
|
159
|
+
block_norm = np.sqrt(np.einsum("ij,ij->i", block, block))
|
|
160
|
+
# Zero-magnitude rows -> safe norm 1; the dot product is 0 there,
|
|
161
|
+
# so the cosine distance stays a finite 1.0 (matches normalizing a
|
|
162
|
+
# zero vector to a zero vector).
|
|
163
|
+
safe_b = np.where(block_norm == 0.0, 1.0, block_norm)
|
|
164
|
+
safe_r = np.where(refs_norm == 0.0, 1.0, refs_norm)
|
|
165
|
+
d = (1.0 - cross / (safe_b[:, None] * safe_r[None, :])).astype(
|
|
166
|
+
np.float32
|
|
167
|
+
)
|
|
168
|
+
np.clip(d, 0.0, 2.0, out=d) # cosine distance in [0, 2]
|
|
169
|
+
|
|
170
|
+
# Over-fetch a candidate pool with the fast (float32) block, recompute
|
|
171
|
+
# the pool's distances exactly in float64, then take the true top-k from
|
|
172
|
+
# it — so both the *selection* and the reported distance are robust to
|
|
173
|
+
# the float32 GEMM's catastrophic cancellation for near-identical (or
|
|
174
|
+
# high-norm) vectors. Reranking a too-narrow pool cannot recover a true
|
|
175
|
+
# nearest the float32 selection dropped; see _rerank_pool_size.
|
|
176
|
+
cand = np.argpartition(d, kth=k_pool - 1, axis=1)[:, :k_pool] # unsorted pool
|
|
177
|
+
rows = rows_full[: block.shape[0]]
|
|
178
|
+
exact = _exact_distances(block, refs[cand], metric) # (b, k_pool) float64
|
|
179
|
+
order = np.argsort(exact, axis=1)[:, :k] # true top-k, ascending by exact dist
|
|
180
|
+
idx_out[start : start + block.shape[0]] = cand[rows, order]
|
|
181
|
+
dist_out[start : start + block.shape[0]] = exact[rows, order].astype(np.float32)
|
|
182
|
+
|
|
183
|
+
return idx_out, dist_out
|
protlabel/lookup.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""A persistable reference lookup: embeddings + ids + labels, plus query().
|
|
2
|
+
|
|
3
|
+
Serialized as a single .npz sidecar so it can live next to a bundle or in a
|
|
4
|
+
cache dir and be rebuilt on demand from the source HDF5.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from protlabel.transfer import Prediction, eat
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Lookup:
|
|
19
|
+
"""Reference set for embedding annotation transfer."""
|
|
20
|
+
|
|
21
|
+
embeddings: np.ndarray # (N, D) float32
|
|
22
|
+
ids: list[str]
|
|
23
|
+
labels: list[str]
|
|
24
|
+
metric: str = "euclidean"
|
|
25
|
+
model: str = field(default="")
|
|
26
|
+
|
|
27
|
+
def query(
|
|
28
|
+
self, query_emb: np.ndarray, query_ids: list[str], *, k: int = 1
|
|
29
|
+
) -> list[Prediction]:
|
|
30
|
+
"""Transfer labels to query embeddings from this lookup."""
|
|
31
|
+
return eat(
|
|
32
|
+
query_emb,
|
|
33
|
+
query_ids,
|
|
34
|
+
self.embeddings,
|
|
35
|
+
self.ids,
|
|
36
|
+
self.labels,
|
|
37
|
+
k=k,
|
|
38
|
+
metric=self.metric,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def save(self, path: Path) -> None:
|
|
42
|
+
"""Serialize to a .npz sidecar.
|
|
43
|
+
|
|
44
|
+
Embeddings are stored as float32 (lossless round-trip); ids/labels are
|
|
45
|
+
stored as unicode arrays rather than pickled object arrays so the sidecar
|
|
46
|
+
can be loaded with ``allow_pickle=False`` (no arbitrary-code-execution
|
|
47
|
+
surface on load). ids/labels must not contain trailing NUL bytes, which
|
|
48
|
+
numpy's fixed-width unicode arrays strip on round-trip.
|
|
49
|
+
"""
|
|
50
|
+
path = Path(path)
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
np.savez(
|
|
53
|
+
path,
|
|
54
|
+
# asarray, not astype: no copy of the (potentially multi-GB) matrix
|
|
55
|
+
# when it is already float32 (the common case).
|
|
56
|
+
embeddings=np.asarray(self.embeddings, dtype=np.float32),
|
|
57
|
+
ids=np.asarray(self.ids, dtype=np.str_),
|
|
58
|
+
labels=np.asarray(self.labels, dtype=np.str_),
|
|
59
|
+
metric=np.asarray(self.metric, dtype=np.str_),
|
|
60
|
+
model=np.asarray(self.model, dtype=np.str_),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def load(cls, path: Path) -> Lookup:
|
|
65
|
+
"""Load a .npz sidecar (with pickling disabled)."""
|
|
66
|
+
with np.load(path, allow_pickle=False) as data:
|
|
67
|
+
return cls(
|
|
68
|
+
# asarray, not astype: the npz array is already float32 (save
|
|
69
|
+
# writes it as such), so this avoids a redundant full copy.
|
|
70
|
+
embeddings=np.asarray(data["embeddings"], dtype=np.float32),
|
|
71
|
+
ids=[str(x) for x in data["ids"]],
|
|
72
|
+
labels=[str(x) for x in data["labels"]],
|
|
73
|
+
metric=str(data["metric"]),
|
|
74
|
+
model=str(data["model"]),
|
|
75
|
+
)
|
protlabel/reliability.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""goPredSim reliability index: map an embedding distance to a [0,1] confidence.
|
|
2
|
+
|
|
3
|
+
Cosine: s(d) = 1 - d (clamped to [0,1]; cosine distance in [0,2])
|
|
4
|
+
Euclidean: s(d) = 0.5 / (0.5 + d) (1.0 at d=0, 0.5 at d=0.5, ->0 as d->inf)
|
|
5
|
+
|
|
6
|
+
Both branches return a value in [0, 1], and a non-finite distance (NaN/inf) maps
|
|
7
|
+
to 0.0 so an invalid neighbour never yields a spuriously high confidence.
|
|
8
|
+
|
|
9
|
+
The ``protspace transfer`` CLI defaults to cosine — ``1 - d`` is the cosine
|
|
10
|
+
similarity, which is directly interpretable and naturally bounded. The lower-level
|
|
11
|
+
``protlabel`` engine API (``eat``/``Lookup``/``nearest``) keeps euclidean as its
|
|
12
|
+
default to match the published goPredSim reference implementation.
|
|
13
|
+
|
|
14
|
+
The euclidean transform ``0.5 / (0.5 + d)`` is the published goPredSim constant,
|
|
15
|
+
calibrated against ProtT5 distances. On embedding spaces whose raw distances are
|
|
16
|
+
much larger than ~0.5 (common) it collapses toward 0 even for near neighbours, so
|
|
17
|
+
the *ordering* of confidences stays meaningful but the absolute value is hard to
|
|
18
|
+
interpret.
|
|
19
|
+
|
|
20
|
+
Reference: Littmann et al., Sci Rep 2021 (Eq. 5); goPredSim calc_reliability_index.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def similarity(distance: float, metric: str) -> float:
|
|
29
|
+
"""Per-neighbour distance->similarity (the goPredSim reliability transform).
|
|
30
|
+
|
|
31
|
+
Always returns a value in [0, 1]. The kNN backend never emits a negative
|
|
32
|
+
distance (euclidean is a clamped ``sqrt``; cosine distance is in [0, 2]), so
|
|
33
|
+
the negative-distance branch is purely defensive for direct callers of this
|
|
34
|
+
function: a negative distance is treated as 0, and a non-finite distance
|
|
35
|
+
(NaN/inf) maps to 0.0 so an invalid neighbour never yields a spuriously high
|
|
36
|
+
confidence.
|
|
37
|
+
"""
|
|
38
|
+
if not math.isfinite(distance):
|
|
39
|
+
return 0.0
|
|
40
|
+
if metric == "cosine":
|
|
41
|
+
return min(1.0, max(0.0, 1.0 - distance))
|
|
42
|
+
if metric == "euclidean":
|
|
43
|
+
# d >= 0 makes 0.5 / (0.5 + d) fall in (0, 1] already; no clamp needed.
|
|
44
|
+
return 0.5 / (0.5 + max(0.0, distance))
|
|
45
|
+
raise ValueError(f"Unknown metric {metric!r}; expected 'euclidean' or 'cosine'")
|
protlabel/transfer.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Embedding annotation transfer: kNN -> reliability index -> transferred label.
|
|
2
|
+
|
|
3
|
+
Implements the goPredSim aggregation (Littmann et al. 2021, Eq. 5):
|
|
4
|
+
RI(p) = (1/eff_k) * sum over neighbours carrying label p of similarity(d),
|
|
5
|
+
where ``eff_k`` is the number of neighbours actually used (``k`` capped to the
|
|
6
|
+
number of references). The transferred label is argmax RI(p); its source is the
|
|
7
|
+
nearest neighbour carrying that label. Ties are broken deterministically by
|
|
8
|
+
smallest source distance, then by lexically smallest label, so the result never
|
|
9
|
+
depends on the (arbitrary) ordering of equidistant neighbours.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from protlabel.backends import nearest
|
|
19
|
+
from protlabel.reliability import similarity
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Prediction:
|
|
24
|
+
"""One transferred annotation for a query protein."""
|
|
25
|
+
|
|
26
|
+
query_id: str
|
|
27
|
+
label: str
|
|
28
|
+
source_id: str
|
|
29
|
+
distance: float
|
|
30
|
+
reliability: float
|
|
31
|
+
k: int
|
|
32
|
+
metric: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def eat(
|
|
36
|
+
query_emb: np.ndarray,
|
|
37
|
+
query_ids: list[str],
|
|
38
|
+
ref_emb: np.ndarray,
|
|
39
|
+
ref_ids: list[str],
|
|
40
|
+
ref_labels: list[str],
|
|
41
|
+
*,
|
|
42
|
+
k: int = 1,
|
|
43
|
+
metric: str = "euclidean",
|
|
44
|
+
) -> list[Prediction]:
|
|
45
|
+
"""Transfer the best-guess label to each query from its k nearest references."""
|
|
46
|
+
if not (len(ref_ids) == len(ref_labels) == ref_emb.shape[0]):
|
|
47
|
+
raise ValueError("ref_emb, ref_ids and ref_labels must have equal length")
|
|
48
|
+
if ref_emb.shape[0] == 0:
|
|
49
|
+
raise ValueError("No reference embeddings to transfer from")
|
|
50
|
+
if len(query_ids) != query_emb.shape[0]:
|
|
51
|
+
raise ValueError("query_emb and query_ids must have equal length")
|
|
52
|
+
|
|
53
|
+
idx, dist = nearest(query_emb, ref_emb, k=k, metric=metric)
|
|
54
|
+
eff_k = idx.shape[1]
|
|
55
|
+
predictions: list[Prediction] = []
|
|
56
|
+
|
|
57
|
+
for qi, query_id in enumerate(query_ids):
|
|
58
|
+
neigh_idx = idx[qi]
|
|
59
|
+
neigh_dist = dist[qi]
|
|
60
|
+
# Accumulate RI per label and track the nearest source per label.
|
|
61
|
+
ri_by_label: dict[str, float] = {}
|
|
62
|
+
nearest_src: dict[str, tuple[float, str]] = {}
|
|
63
|
+
for j, ref_i in enumerate(neigh_idx):
|
|
64
|
+
lab = ref_labels[ref_i]
|
|
65
|
+
d = float(neigh_dist[j])
|
|
66
|
+
ri_by_label[lab] = ri_by_label.get(lab, 0.0) + similarity(d, metric)
|
|
67
|
+
# Break exact-distance ties on the lexically smallest source id so the
|
|
68
|
+
# reported provenance is independent of the (arbitrary) neighbour order
|
|
69
|
+
# among equidistant references, matching the label tie-break below.
|
|
70
|
+
candidate = (d, ref_ids[ref_i])
|
|
71
|
+
if lab not in nearest_src or candidate < nearest_src[lab]:
|
|
72
|
+
nearest_src[lab] = candidate
|
|
73
|
+
# Pick the highest-RI label; break ties deterministically by smallest
|
|
74
|
+
# source distance, then lexically smallest label. This makes the choice
|
|
75
|
+
# independent of the order of equidistant neighbours (whose argsort order
|
|
76
|
+
# is otherwise arbitrary). For distinct distances the nearest neighbour's
|
|
77
|
+
# label wins, as before.
|
|
78
|
+
best_label = min(
|
|
79
|
+
ri_by_label,
|
|
80
|
+
key=lambda p: (-ri_by_label[p], nearest_src[p][0], p),
|
|
81
|
+
)
|
|
82
|
+
# Normalise by eff_k (the goPredSim 1/k term, k capped to n_refs).
|
|
83
|
+
ri = ri_by_label[best_label] / eff_k
|
|
84
|
+
src_dist, src_id = nearest_src[best_label]
|
|
85
|
+
predictions.append(
|
|
86
|
+
Prediction(
|
|
87
|
+
query_id=query_id,
|
|
88
|
+
label=best_label,
|
|
89
|
+
source_id=src_id,
|
|
90
|
+
distance=src_dist,
|
|
91
|
+
reliability=ri,
|
|
92
|
+
k=eff_k,
|
|
93
|
+
metric=metric,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return predictions
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: protlabel
|
|
3
|
+
Version: 4.6.0
|
|
4
|
+
Summary: Embedding Annotation Transfer (EAT): nearest-neighbour label transfer in protein-language-model embedding space
|
|
5
|
+
Project-URL: Homepage, https://github.com/tsenoner/protspace
|
|
6
|
+
Project-URL: Repository, https://github.com/tsenoner/protspace
|
|
7
|
+
Author-email: Tobias Senoner <tobias.senoner@tum.de>
|
|
8
|
+
License-Expression: GPL-3.0
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: numpy>=1.23.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# protlabel
|
|
14
|
+
|
|
15
|
+
**Embedding Annotation Transfer (EAT) engine** — nearest-neighbour label transfer in
|
|
16
|
+
protein-language-model (pLM) embedding space, with the goPredSim reliability index.
|
|
17
|
+
|
|
18
|
+
`protlabel` is a small, dependency-light library (numpy only). It is
|
|
19
|
+
ProtSpace-agnostic by design — it imports nothing from `protspace` — so it is
|
|
20
|
+
independently testable and reusable from notebooks, `protspace_uniprot`, or any
|
|
21
|
+
other project. The `protspace transfer` CLI is a thin consumer of this engine.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install protlabel
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import numpy as np
|
|
33
|
+
from protlabel import eat, Lookup
|
|
34
|
+
|
|
35
|
+
preds = eat(
|
|
36
|
+
query_emb=np.random.rand(3, 1024).astype("float32"),
|
|
37
|
+
query_ids=["Q1", "Q2", "Q3"],
|
|
38
|
+
ref_emb=np.random.rand(100, 1024).astype("float32"),
|
|
39
|
+
ref_ids=[f"R{i}" for i in range(100)],
|
|
40
|
+
ref_labels=["toxin", "enzyme"] * 50,
|
|
41
|
+
k=1,
|
|
42
|
+
metric="cosine", # or "euclidean" (the goPredSim default)
|
|
43
|
+
)
|
|
44
|
+
for p in preds:
|
|
45
|
+
print(p.query_id, p.label, round(p.reliability, 3))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`Lookup` builds and serialises a reusable reference set (`.npz` sidecar) so the
|
|
49
|
+
reference matrix can be rebuilt on demand rather than shipped.
|
|
50
|
+
|
|
51
|
+
## Method
|
|
52
|
+
|
|
53
|
+
Nearest-neighbour transfer in the *original* pLM embedding space (not a 2-D/3-D
|
|
54
|
+
projection), with the goPredSim reliability index — see Littmann et al.,
|
|
55
|
+
*Sci Rep* 2021 (Eq. 5) and Heinzinger et al., *NAR Genom Bioinform* 2022.
|
|
56
|
+
|
|
57
|
+
Distances are computed with an exact, chunked brute-force search (numpy BLAS GEMM
|
|
58
|
+
+ `argpartition`); queries are processed in batches. No approximate-nearest-neighbour
|
|
59
|
+
index is needed at Swiss-Prot scale.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
GPL-3.0 — part of the [ProtSpace](https://github.com/tsenoner/protspace) project.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
protlabel/__init__.py,sha256=jPPzf3kdVoIBe7iH6ysIKevIQfoYKNcmr6soTZEqduk,726
|
|
2
|
+
protlabel/backends.py,sha256=bhLVnRtEjPD4yEbLU9vEv3GxTe95Sk5eNQGL3ICaFh8,9147
|
|
3
|
+
protlabel/lookup.py,sha256=XoFzeS5_lhHTZQ4BS7QqOQZx2mEfE2rjp_T8yy1hrrs,2689
|
|
4
|
+
protlabel/reliability.py,sha256=B58s27sjecxan2SLZ18GqD34-mvbks_iOwa_z_vSpQw,2118
|
|
5
|
+
protlabel/transfer.py,sha256=4dYGiS0iITZMtqqX1ryqyR8OHD87mQPwq-Zt-TK8ISg,3677
|
|
6
|
+
protlabel-4.6.0.dist-info/METADATA,sha256=Xa4P_x7DgocsIQ8CrGbvFaYBdMR2-_N8T0-8a7O9p7g,2179
|
|
7
|
+
protlabel-4.6.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
protlabel-4.6.0.dist-info/RECORD,,
|