TCRmeta 0.1.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.
- tcrmeta/__init__.py +22 -0
- tcrmeta/_dataset.py +53 -0
- tcrmeta/_model.py +131 -0
- tcrmeta/_tokenizer.py +23 -0
- tcrmeta/_utils.py +185 -0
- tcrmeta/_weights.py +119 -0
- tcrmeta/css.py +230 -0
- tcrmeta/embedding.py +334 -0
- tcrmeta/energy.py +322 -0
- tcrmeta/reference.py +262 -0
- tcrmeta/umap_plot.py +178 -0
- tcrmeta-0.1.0.dist-info/METADATA +154 -0
- tcrmeta-0.1.0.dist-info/RECORD +15 -0
- tcrmeta-0.1.0.dist-info/WHEEL +5 -0
- tcrmeta-0.1.0.dist-info/top_level.txt +1 -0
tcrmeta/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""TCRmeta: antigen-aware TCR repertoire embeddings and downstream
|
|
2
|
+
repertoire-level analysis (CSS scoring, UMAP projection, energy-distance
|
|
3
|
+
shift), built on a pretrained-ESM2 + contrastive-fine-tuned encoder.
|
|
4
|
+
"""
|
|
5
|
+
from .embedding import embed_repertoire
|
|
6
|
+
from .reference import build_reference, load_reference, save_reference
|
|
7
|
+
from .css import compute_css, load_default_reference
|
|
8
|
+
from .umap_plot import plot_umap
|
|
9
|
+
from .energy import energy_shift
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"embed_repertoire",
|
|
13
|
+
"build_reference",
|
|
14
|
+
"save_reference",
|
|
15
|
+
"load_reference",
|
|
16
|
+
"load_default_reference",
|
|
17
|
+
"compute_css",
|
|
18
|
+
"plot_umap",
|
|
19
|
+
"energy_shift",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
tcrmeta/_dataset.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Fixed-length tokenization of (CDR1 + CDR2 + CDR2.5 + CDR3) TCR
|
|
2
|
+
sequences for the ESM2 base encoder.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Dict, List, Tuple
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import torch
|
|
10
|
+
from torch.utils.data import Dataset
|
|
11
|
+
|
|
12
|
+
DEFAULT_MAX_LEN = 48
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TCRDatasetFixedLen(Dataset):
|
|
16
|
+
def __init__(self, df: pd.DataFrame, token2idx: Dict[str, int], max_len: int = DEFAULT_MAX_LEN):
|
|
17
|
+
self.df = df.reset_index(drop=True)
|
|
18
|
+
self.t2i = token2idx
|
|
19
|
+
self.max_len = max_len
|
|
20
|
+
self.cls = token2idx["[CLS]"]
|
|
21
|
+
self.pad = token2idx["[PAD]"]
|
|
22
|
+
self.eos = token2idx["[EOS]"]
|
|
23
|
+
self.unk = token2idx["[UNK]"]
|
|
24
|
+
self.gap = token2idx["X"] # separator between CDR segments
|
|
25
|
+
|
|
26
|
+
def _encode_seq(self, s: str) -> List[int]:
|
|
27
|
+
return [self.t2i.get(a, self.unk) for a in s]
|
|
28
|
+
|
|
29
|
+
def __getitem__(self, i) -> Tuple[Tuple[str, str], List[int]]:
|
|
30
|
+
r = self.df.iloc[i]
|
|
31
|
+
v_seq = r.cdr1 + "X" + r.cdr2 + "X" + r.cdr2_5
|
|
32
|
+
tokens = (
|
|
33
|
+
[self.cls]
|
|
34
|
+
+ self._encode_seq(v_seq)
|
|
35
|
+
+ [self.gap]
|
|
36
|
+
+ self._encode_seq(r.cdr3aa)
|
|
37
|
+
+ [self.eos]
|
|
38
|
+
)
|
|
39
|
+
if len(tokens) < self.max_len:
|
|
40
|
+
tokens = tokens + [self.pad] * (self.max_len - len(tokens))
|
|
41
|
+
else:
|
|
42
|
+
tokens = tokens[: self.max_len]
|
|
43
|
+
key = (r.cdr3aa, r.v_gene)
|
|
44
|
+
return key, tokens
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self.df)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fixedlen_collate(batch):
|
|
51
|
+
keys, seqs = zip(*batch)
|
|
52
|
+
input_ids = torch.tensor(seqs, dtype=torch.long)
|
|
53
|
+
return keys, input_ids
|
tcrmeta/_model.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Model architectures: the ESM2-backbone masked-LM encoder used during
|
|
2
|
+
pretraining (only its encoder + CLS embedding is used at inference time),
|
|
3
|
+
and the ResMLP Siamese projection head used during contrastive fine-tuning.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
import torch.nn as nn
|
|
9
|
+
import torch.nn.functional as F
|
|
10
|
+
from transformers import EsmModel
|
|
11
|
+
|
|
12
|
+
from ._weights import ESM2_BACKBONE
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TCRMaskedModel(nn.Module):
|
|
16
|
+
"""ESM2 encoder + masked-LM head. Only `encoder` (and its CLS-token
|
|
17
|
+
embedding) is used downstream by TCRmeta; `lm_head` is retained so
|
|
18
|
+
pretrained checkpoints load without a state_dict mismatch.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, backbone_name: str = ESM2_BACKBONE, vocab_size: int = 33):
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.encoder = EsmModel.from_pretrained(backbone_name)
|
|
24
|
+
self.hidden_size = self.encoder.config.hidden_size
|
|
25
|
+
self.lm_head = nn.Linear(self.hidden_size, vocab_size)
|
|
26
|
+
|
|
27
|
+
def forward(self, input_tokens, attention_mask=None, return_embedding=False):
|
|
28
|
+
outputs = self.encoder(input_ids=input_tokens, attention_mask=attention_mask)
|
|
29
|
+
sequence_output = outputs.last_hidden_state # (batch, seq_len, hidden)
|
|
30
|
+
logits = self.lm_head(sequence_output)
|
|
31
|
+
return (logits, sequence_output) if return_embedding else logits
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResMLPBlock(nn.Module):
|
|
35
|
+
"""Pre-norm residual MLP block: LayerNorm -> Linear -> GELU -> Dropout
|
|
36
|
+
-> Linear -> Dropout, added back with a learnable (small-initialized)
|
|
37
|
+
residual scale so the block starts close to identity.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, dim: int, hidden: int = 512, dropout: float = 0.1):
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.norm = nn.LayerNorm(dim)
|
|
43
|
+
self.fc1 = nn.Linear(dim, hidden)
|
|
44
|
+
self.fc2 = nn.Linear(hidden, dim)
|
|
45
|
+
self.act = nn.GELU()
|
|
46
|
+
self.drop = nn.Dropout(dropout)
|
|
47
|
+
self.res_scale = nn.Parameter(torch.tensor(0.1))
|
|
48
|
+
|
|
49
|
+
nn.init.xavier_uniform_(self.fc1.weight)
|
|
50
|
+
nn.init.zeros_(self.fc2.weight)
|
|
51
|
+
nn.init.zeros_(self.fc2.bias)
|
|
52
|
+
|
|
53
|
+
def forward(self, x):
|
|
54
|
+
h = self.norm(x)
|
|
55
|
+
h = self.fc1(h)
|
|
56
|
+
h = self.act(h)
|
|
57
|
+
h = self.drop(h)
|
|
58
|
+
h = self.fc2(h)
|
|
59
|
+
h = self.drop(h)
|
|
60
|
+
return x + self.res_scale * h
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ResMLPStack(nn.Module):
|
|
64
|
+
"""`depth` stacked ResMLPBlocks."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, dim: int, hidden: int = 512, dropout: float = 0.1, depth: int = 2):
|
|
67
|
+
super().__init__()
|
|
68
|
+
self.blocks = nn.ModuleList([ResMLPBlock(dim, hidden, dropout) for _ in range(depth)])
|
|
69
|
+
|
|
70
|
+
def forward(self, x):
|
|
71
|
+
for blk in self.blocks:
|
|
72
|
+
x = blk(x)
|
|
73
|
+
return x
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SiameseOnEmb_ResMLP(nn.Module):
|
|
77
|
+
"""Contrastive fine-tuning projection head: ResMLP re-mixes the
|
|
78
|
+
480-dim base embedding, then a linear projection + L2 normalization
|
|
79
|
+
maps it onto the unit sphere in the antigen-aware embedding space.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
in_dim: int,
|
|
85
|
+
proj_dim: int = 64,
|
|
86
|
+
hidden: int = 768,
|
|
87
|
+
dropout: float = 0.1,
|
|
88
|
+
depth: int = 3,
|
|
89
|
+
):
|
|
90
|
+
super().__init__()
|
|
91
|
+
self.mlp = ResMLPStack(in_dim, hidden=hidden, dropout=dropout, depth=depth)
|
|
92
|
+
self.proj = nn.Linear(in_dim, proj_dim, bias=False)
|
|
93
|
+
# Learnable scalars carried over from contrastive training; unused
|
|
94
|
+
# at inference (encode()/forward() distance is computed downstream),
|
|
95
|
+
# kept only so pretrained state_dicts load without a mismatch.
|
|
96
|
+
self.alpha = nn.Parameter(torch.tensor(10.0))
|
|
97
|
+
self.m = nn.Parameter(torch.tensor(1.0))
|
|
98
|
+
self.scale = nn.Parameter(torch.tensor(1.0))
|
|
99
|
+
|
|
100
|
+
def encode(self, x: torch.Tensor) -> torch.Tensor:
|
|
101
|
+
x = self.mlp(x)
|
|
102
|
+
z = self.proj(x)
|
|
103
|
+
return F.normalize(z, dim=-1)
|
|
104
|
+
|
|
105
|
+
def forward(self, x1, x2):
|
|
106
|
+
z1 = self.encode(x1)
|
|
107
|
+
z2 = self.encode(x2)
|
|
108
|
+
d = torch.norm(z1 - z2, dim=-1) * self.scale
|
|
109
|
+
return z1, z2, d
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_state_dict_flexible(model: nn.Module, ckpt) -> None:
|
|
113
|
+
"""Load a checkpoint saved under any of the common key conventions
|
|
114
|
+
(raw state_dict, {"model_state_dict": ...}, {"state_dict": ...},
|
|
115
|
+
an EMA shadow dict, etc.).
|
|
116
|
+
"""
|
|
117
|
+
if isinstance(ckpt, dict) and all(torch.is_tensor(v) for v in ckpt.values()):
|
|
118
|
+
model.load_state_dict(ckpt, strict=True)
|
|
119
|
+
return
|
|
120
|
+
for key in ("model_state_dict", "ema_state_dict", "state_dict", "model"):
|
|
121
|
+
if key in ckpt:
|
|
122
|
+
model.load_state_dict(ckpt[key], strict=True)
|
|
123
|
+
return
|
|
124
|
+
if "ema_shadow" in ckpt:
|
|
125
|
+
sd = model.state_dict()
|
|
126
|
+
for k, v in ckpt["ema_shadow"].items():
|
|
127
|
+
if k in sd:
|
|
128
|
+
sd[k] = v
|
|
129
|
+
model.load_state_dict(sd, strict=False)
|
|
130
|
+
return
|
|
131
|
+
raise RuntimeError(f"Unrecognized checkpoint format, top-level keys: {list(ckpt.keys())}")
|
tcrmeta/_tokenizer.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""ESM2 tokenizer -> amino-acid token id lookup, built once and cached."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Dict
|
|
6
|
+
|
|
7
|
+
from transformers import EsmTokenizer
|
|
8
|
+
|
|
9
|
+
from ._weights import ESM2_BACKBONE
|
|
10
|
+
|
|
11
|
+
AMINO_ACIDS = list("ACDEFGHIKLMNPQRSTVWYX")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@lru_cache(maxsize=1)
|
|
15
|
+
def build_token2idx(backbone_name: str = ESM2_BACKBONE) -> Dict[str, int]:
|
|
16
|
+
tokenizer = EsmTokenizer.from_pretrained(backbone_name)
|
|
17
|
+
token2idx = {aa: tokenizer.convert_tokens_to_ids(aa) for aa in AMINO_ACIDS}
|
|
18
|
+
token2idx["[PAD]"] = tokenizer.convert_tokens_to_ids("<pad>")
|
|
19
|
+
token2idx["[MASK]"] = tokenizer.convert_tokens_to_ids("<mask>")
|
|
20
|
+
token2idx["[UNK]"] = tokenizer.convert_tokens_to_ids("<unk>")
|
|
21
|
+
token2idx["[CLS]"] = tokenizer.convert_tokens_to_ids("<cls>")
|
|
22
|
+
token2idx["[EOS]"] = tokenizer.convert_tokens_to_ids("<eos>")
|
|
23
|
+
return token2idx
|
tcrmeta/_utils.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Shared, dependency-light utilities used across TCRmeta's embedding,
|
|
2
|
+
CSS, UMAP, and energy-shift modules.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# --------------------------------------------------------------------------- #
|
|
13
|
+
# Device handling
|
|
14
|
+
# --------------------------------------------------------------------------- #
|
|
15
|
+
def resolve_device(device: Optional[str] = "auto"):
|
|
16
|
+
"""Resolve a user-facing device string into a torch.device.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
device:
|
|
21
|
+
One of "auto" (default, uses CUDA if available), "cpu", "cuda",
|
|
22
|
+
"cuda:0", "mps", or an already-constructed torch.device.
|
|
23
|
+
|
|
24
|
+
Note: torch is imported lazily here (rather than at module level) so
|
|
25
|
+
that the pure numpy/pandas utilities in this module (downsampling,
|
|
26
|
+
column validation) can be used/tested without requiring torch to be
|
|
27
|
+
installed.
|
|
28
|
+
"""
|
|
29
|
+
import torch
|
|
30
|
+
|
|
31
|
+
if isinstance(device, torch.device):
|
|
32
|
+
return device
|
|
33
|
+
if device is None or device == "auto":
|
|
34
|
+
if torch.cuda.is_available():
|
|
35
|
+
return torch.device("cuda")
|
|
36
|
+
if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
|
|
37
|
+
return torch.device("mps")
|
|
38
|
+
return torch.device("cpu")
|
|
39
|
+
return torch.device(device)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
# Downsampling
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
def downsample_multinomial(
|
|
46
|
+
counts: np.ndarray,
|
|
47
|
+
target: int,
|
|
48
|
+
rng: Optional[np.random.Generator] = None,
|
|
49
|
+
seed: int = 0,
|
|
50
|
+
) -> np.ndarray:
|
|
51
|
+
"""Multinomial downsampling of clone counts to a fixed target depth.
|
|
52
|
+
|
|
53
|
+
If the repertoire's total count is already <= target, counts are
|
|
54
|
+
returned unchanged (no upsampling).
|
|
55
|
+
"""
|
|
56
|
+
if rng is None:
|
|
57
|
+
rng = np.random.default_rng(seed)
|
|
58
|
+
counts = np.asarray(counts, dtype=np.int64)
|
|
59
|
+
total = int(counts.sum())
|
|
60
|
+
if total <= target:
|
|
61
|
+
return counts.copy()
|
|
62
|
+
p = counts / total
|
|
63
|
+
return rng.multinomial(target, p)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_weights(counts: np.ndarray) -> Optional[np.ndarray]:
|
|
67
|
+
"""Normalize a count/weight vector to sum to 1. Returns None if the
|
|
68
|
+
vector sums to <= 0 (nothing usable to weight by)."""
|
|
69
|
+
counts = np.asarray(counts, dtype=np.float64)
|
|
70
|
+
s = counts.sum()
|
|
71
|
+
if s <= 0:
|
|
72
|
+
return None
|
|
73
|
+
return counts / s
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def downsample_then_embed(
|
|
77
|
+
df: pd.DataFrame,
|
|
78
|
+
downsample: int,
|
|
79
|
+
seed: int,
|
|
80
|
+
device: str,
|
|
81
|
+
embed_kwargs: dict,
|
|
82
|
+
) -> pd.DataFrame:
|
|
83
|
+
"""Downsample a repertoire's raw counts FIRST, then embed only the
|
|
84
|
+
clones that survive downsampling — used by compute_css/plot_umap.
|
|
85
|
+
|
|
86
|
+
Multinomial downsampling to `downsample` total reads keeps at most
|
|
87
|
+
`downsample` unique (cdr3aa, v_gene) rows (each surviving row needs
|
|
88
|
+
>=1 read). Embedding only those rows — instead of embedding the
|
|
89
|
+
repertoire's full unique-clone set and downsampling afterward — can
|
|
90
|
+
be a large speedup for repertoires with many more unique clones than
|
|
91
|
+
`downsample`.
|
|
92
|
+
|
|
93
|
+
Caveat: a handful of surviving rows can still get dropped afterward
|
|
94
|
+
if their V gene has no CDR1/CDR2/CDR2.5 entry in TCRmeta's germline
|
|
95
|
+
lookup table (attach_embeddings' internal dropna) — for most real
|
|
96
|
+
repertoires this is rare/negligible, but it means results can differ
|
|
97
|
+
very slightly from embedding-then-downsampling in the (uncommon)
|
|
98
|
+
case where such unrecognized-V-gene rows exist and would otherwise
|
|
99
|
+
have consumed some of the downsampling budget.
|
|
100
|
+
|
|
101
|
+
Returns the downsampled + embedded dataframe with an added 'ds'
|
|
102
|
+
float64 column (post-downsample weight per surviving row).
|
|
103
|
+
|
|
104
|
+
Always embeds with embedding_type="final" (the antigen-aware,
|
|
105
|
+
contrastively fine-tuned embedding) regardless of what's in
|
|
106
|
+
embed_kwargs — compute_css/plot_umap's reference maps are built on
|
|
107
|
+
that embedding space, so scoring against them requires it. Only
|
|
108
|
+
embed_repertoire() itself exposes a choice of embedding_type.
|
|
109
|
+
"""
|
|
110
|
+
from .embedding import attach_embeddings # lazy: keeps this module
|
|
111
|
+
|
|
112
|
+
# importable/testable without torch/transformers.
|
|
113
|
+
df = validate_repertoire_columns(df)
|
|
114
|
+
rng = np.random.default_rng(seed)
|
|
115
|
+
counts = df["count"].to_numpy(dtype=np.int64)
|
|
116
|
+
ds = downsample_multinomial(counts, downsample, rng=rng)
|
|
117
|
+
keep = ds > 0
|
|
118
|
+
df = df.loc[keep].copy()
|
|
119
|
+
df["ds"] = ds[keep].astype(np.float64)
|
|
120
|
+
embed_kwargs = {**embed_kwargs, "embedding_type": "final"}
|
|
121
|
+
df_e = attach_embeddings(df, device=device, **embed_kwargs)
|
|
122
|
+
return df_e
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
# Repertoire column validation
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
REQUIRED_COLUMNS = ("cdr3aa", "v_gene", "count")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_repertoire_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
132
|
+
"""Validate and lightly normalize a TCR repertoire dataframe.
|
|
133
|
+
|
|
134
|
+
TCRmeta requires the exact columns 'cdr3aa', 'v_gene', 'count' —
|
|
135
|
+
there is no alias-guessing. If your data uses different column
|
|
136
|
+
names (e.g. Adaptive's 'aminoAcid'/'vGeneName'/'count (templates/reads)',
|
|
137
|
+
or AIRR's 'junction_aa'/'v_call'/'duplicate_count'), rename them
|
|
138
|
+
yourself before calling into TCRmeta.
|
|
139
|
+
|
|
140
|
+
Normalization performed (does not change column names):
|
|
141
|
+
- cdr3aa is cast to str and given canonical IMGT flanks (leading
|
|
142
|
+
C, trailing F) if missing.
|
|
143
|
+
- v_gene is cast to str, allele suffix (e.g. '*01') stripped.
|
|
144
|
+
- count is coerced to int; rows with count <= 0 are dropped.
|
|
145
|
+
"""
|
|
146
|
+
missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
|
|
147
|
+
if missing:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"Repertoire dataframe is missing required column(s): {missing}. "
|
|
150
|
+
f"TCRmeta requires exactly these columns: {REQUIRED_COLUMNS}. "
|
|
151
|
+
"Rename your columns before calling this function (no aliases are "
|
|
152
|
+
"auto-detected)."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
df = df.copy()
|
|
156
|
+
df["cdr3aa"] = df["cdr3aa"].astype(str)
|
|
157
|
+
df["cdr3aa"] = df["cdr3aa"].str.replace(r"^(?!C)", "C", regex=True)
|
|
158
|
+
df["cdr3aa"] = df["cdr3aa"].str.replace(r"(?<!F)$", "F", regex=True)
|
|
159
|
+
|
|
160
|
+
df["v_gene"] = df["v_gene"].astype(str).str.replace(r"\*.*$", "", regex=True).str.strip()
|
|
161
|
+
|
|
162
|
+
df["count"] = pd.to_numeric(df["count"], errors="coerce").fillna(0).astype(np.int64)
|
|
163
|
+
df = df[df["count"] > 0].reset_index(drop=True)
|
|
164
|
+
return df
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def aggregate_duplicate_clones(df: pd.DataFrame, sort: bool = True) -> pd.DataFrame:
|
|
168
|
+
"""Collapse duplicate (cdr3aa, v_gene) rows by summing 'count'.
|
|
169
|
+
|
|
170
|
+
`sort` controls whether the result is reordered alphabetically by
|
|
171
|
+
(cdr3aa, v_gene) (pandas groupby's default) or left in first-occurrence
|
|
172
|
+
order. This matters for reproducibility: multinomial downsampling
|
|
173
|
+
consumes the RNG stream in row order, so which convention to use
|
|
174
|
+
depends on what the calling code's original (pre-packaging) script
|
|
175
|
+
did. energy_shift's original script deduplicated with pandas'
|
|
176
|
+
default sort=True before downsampling, so energy.py relies on the
|
|
177
|
+
default here. CSS/UMAP's original scoring scripts never deduplicated
|
|
178
|
+
rows at all (raw CSV order preserved throughout) — those code paths
|
|
179
|
+
(attach_embeddings) intentionally skip calling this function.
|
|
180
|
+
"""
|
|
181
|
+
agg_cols = {"count": "sum"}
|
|
182
|
+
other_cols = [c for c in df.columns if c not in ("cdr3aa", "v_gene", "count")]
|
|
183
|
+
for c in other_cols:
|
|
184
|
+
agg_cols[c] = "first"
|
|
185
|
+
return df.groupby(["cdr3aa", "v_gene"], as_index=False, sort=sort).agg(agg_cols)
|
tcrmeta/_weights.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Model artifact + reference map download/caching.
|
|
2
|
+
|
|
3
|
+
All non-code artifacts (the fine-tuned base encoder checkpoint, the 7
|
|
4
|
+
projection-head checkpoints, the GPA rotation matrices, the V-gene CDR
|
|
5
|
+
lookup table, and the default shipped reference map) are hosted on the
|
|
6
|
+
Hugging Face Hub rather than bundled into the PyPI package, and are
|
|
7
|
+
downloaded once and cached locally (via huggingface_hub, which handles
|
|
8
|
+
its own on-disk cache).
|
|
9
|
+
|
|
10
|
+
Set the TCRMETA_HF_REPO environment variable to point at a different
|
|
11
|
+
Hub repo (e.g. a private mirror), and TCRMETA_WEIGHTS_DIR to bypass the
|
|
12
|
+
Hub entirely and load artifacts from a local directory instead (useful
|
|
13
|
+
offline, or before the Hub repo exists).
|
|
14
|
+
|
|
15
|
+
If the Hub repo is PRIVATE, authentication is required to download from
|
|
16
|
+
it. Either run `huggingface-cli login` once (huggingface_hub then reuses
|
|
17
|
+
that cached token automatically), or set the TCRMETA_HF_TOKEN
|
|
18
|
+
environment variable to an access token explicitly (useful in
|
|
19
|
+
non-interactive environments like CI or a shared cluster).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
# TODO: replace with the actual Hugging Face Hub repo id once created,
|
|
28
|
+
# e.g. "mingyaopan/tcrmeta-weights". Can be overridden without a code
|
|
29
|
+
# change via the TCRMETA_HF_REPO environment variable.
|
|
30
|
+
DEFAULT_HF_REPO = os.environ.get("TCRMETA_HF_REPO", "mingyaopan/tcrmeta-weights")
|
|
31
|
+
|
|
32
|
+
# Filenames as they should exist in the Hub repo / local weights dir.
|
|
33
|
+
BASE_CKPT_FILE = "base_encoder/best_model.pth"
|
|
34
|
+
ROTATIONS_FILE = "finetune/rotations_gpa.pkl"
|
|
35
|
+
VGENE_CDR_FILE = "reference_data/vgene_cdr.csv"
|
|
36
|
+
PROJ_HEAD_FILES = [f"finetune/proj_head_{i}.pt" for i in range(7)]
|
|
37
|
+
DEFAULT_REFERENCE_FILE = "reference_maps/young_reference.pkl"
|
|
38
|
+
|
|
39
|
+
ESM2_BACKBONE = "facebook/esm2_t12_35M_UR50D"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _local_weights_dir() -> Optional[Path]:
|
|
43
|
+
d = os.environ.get("TCRMETA_WEIGHTS_DIR")
|
|
44
|
+
return Path(d) if d else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_cache_dir() -> Path:
|
|
48
|
+
cache_dir = Path(os.environ.get("TCRMETA_CACHE_DIR", Path.home() / ".cache" / "tcrmeta"))
|
|
49
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
return cache_dir
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_artifact(relative_path: str, repo_id: str = DEFAULT_HF_REPO) -> Path:
|
|
54
|
+
"""Return a local filesystem path for a named artifact, downloading
|
|
55
|
+
(and caching) it from the Hugging Face Hub if it isn't available
|
|
56
|
+
locally via TCRMETA_WEIGHTS_DIR.
|
|
57
|
+
"""
|
|
58
|
+
local_dir = _local_weights_dir()
|
|
59
|
+
if local_dir is not None:
|
|
60
|
+
local_path = local_dir / relative_path
|
|
61
|
+
if not local_path.exists():
|
|
62
|
+
raise FileNotFoundError(
|
|
63
|
+
f"TCRMETA_WEIGHTS_DIR is set to {local_dir}, but {relative_path} "
|
|
64
|
+
"was not found under it."
|
|
65
|
+
)
|
|
66
|
+
return local_path
|
|
67
|
+
|
|
68
|
+
# Common mistake: pointing TCRMETA_HF_REPO at a local filesystem path
|
|
69
|
+
# (that's what TCRMETA_WEIGHTS_DIR is for) instead of a Hub repo id
|
|
70
|
+
# ("namespace/repo_name"). Catch it here with a clear message rather
|
|
71
|
+
# than letting huggingface_hub's HFValidationError surface instead.
|
|
72
|
+
looks_like_local_path = repo_id.startswith(("/", "~", ".")) or os.path.exists(repo_id)
|
|
73
|
+
if looks_like_local_path:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"repo_id resolved to '{repo_id}', which looks like a local filesystem "
|
|
76
|
+
"path, not a Hugging Face Hub repo id ('namespace/repo_name'). If you "
|
|
77
|
+
"meant to load weights from a local directory, set the "
|
|
78
|
+
"TCRMETA_WEIGHTS_DIR environment variable instead of TCRMETA_HF_REPO:\n"
|
|
79
|
+
f' os.environ["TCRMETA_WEIGHTS_DIR"] = "{repo_id}"\n'
|
|
80
|
+
"TCRMETA_HF_REPO is only for overriding which Hub repo to download from."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
from huggingface_hub import hf_hub_download
|
|
85
|
+
except ImportError as e: # pragma: no cover
|
|
86
|
+
raise ImportError(
|
|
87
|
+
"huggingface_hub is required to auto-download TCRmeta model weights. "
|
|
88
|
+
"Install it with `pip install huggingface_hub`, or set TCRMETA_WEIGHTS_DIR "
|
|
89
|
+
"to a local directory containing the weights."
|
|
90
|
+
) from e
|
|
91
|
+
|
|
92
|
+
path = hf_hub_download(
|
|
93
|
+
repo_id=repo_id,
|
|
94
|
+
filename=relative_path,
|
|
95
|
+
cache_dir=str(get_cache_dir()),
|
|
96
|
+
token=os.environ.get("TCRMETA_HF_TOKEN"), # None -> huggingface_hub
|
|
97
|
+
# falls back to the cached `huggingface-cli login` token, if any.
|
|
98
|
+
)
|
|
99
|
+
return Path(path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_base_checkpoint() -> Path:
|
|
103
|
+
return resolve_artifact(BASE_CKPT_FILE)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_rotations() -> Path:
|
|
107
|
+
return resolve_artifact(ROTATIONS_FILE)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_vgene_table() -> Path:
|
|
111
|
+
return resolve_artifact(VGENE_CDR_FILE)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_projection_heads() -> list[Path]:
|
|
115
|
+
return [resolve_artifact(f) for f in PROJ_HEAD_FILES]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_default_reference() -> Path:
|
|
119
|
+
return resolve_artifact(DEFAULT_REFERENCE_FILE)
|