Amin-Saeidi commited on
Commit
3fff97d
·
verified ·
1 Parent(s): 92234cf

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ data/envhog_phrog2/all_positive_viseq_pairs.csv filter=lfs diff=lfs merge=lfs -text
37
+ data/envhog_phrog2/envhog_filtered_proteins.fasta filter=lfs diff=lfs merge=lfs -text
38
+ data/envhog_phrog2/envhog_phrog2__low_thr_enriched_final.csv filter=lfs diff=lfs merge=lfs -text
39
+ data/envhog_phrog2/envhog_test_filtered_proteins_annotated_with_no_nan.csv filter=lfs diff=lfs merge=lfs -text
40
+ data/envhog_phrog2/envhog_test_final_no_leakage.csv filter=lfs diff=lfs merge=lfs -text
41
+ data/envhog_phrog2/envhog_train_final_no_leakage.csv filter=lfs diff=lfs merge=lfs -text
42
+ data/envhog_phrog2/envhog_train_final_no_leakage_no_nan.csv filter=lfs diff=lfs merge=lfs -text
data/envhog_phrog2/all_positive_viseq_pairs.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:54a0de0f3904630947fb75b84f7d8133cca2dcdd831d04a526db979472ea94ca
3
+ size 107596414
data/envhog_phrog2/envhog_filtered_proteins.fasta ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f3106d5aeec0a237466f270b7f5d6be237a60b0785ce20ca2f53dc87de84282d
3
+ size 459033917
data/envhog_phrog2/envhog_phrog2__low_thr_enriched_final.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:813be5289fcc867ca29ea968110243b83909ae2e8cc651dba739c013cae51d8f
3
+ size 23396438
data/envhog_phrog2/envhog_test_filtered_proteins_annotated_with_no_nan.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8317cb0e229544ecb46eb25540c784eb1fe9310146acddb2cf2640f090f96a17
3
+ size 101224690
data/envhog_phrog2/envhog_test_final_no_leakage.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1bbf2b9261ed883257449765eb3203f96b06307dba4bbb32ba3a028707519157
3
+ size 182087311
data/envhog_phrog2/envhog_train_final_no_leakage.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9a4805494ceb4499ff5a29d34fe66b61c42d762b3e210c3b1bfe1a7c1906081b
3
+ size 95012046
data/envhog_phrog2/envhog_train_final_no_leakage_no_nan.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0b3c03c6694c853e503b75ba32b4a50777bf4a002c0a2c353f5fc4510d4db752
3
+ size 70167125
src/eval_EmbeddingSpace.py ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Embedding Space Evaluation — Base vs All Fine-tuned ProtT5 XL Models.
4
+
5
+ Loads the base model + all fine-tuned variants (Here Just: ContraMLM) and produces exactly three publication-quality plots:
6
+
7
+ Plot 1 — 2×2 t-SNE grid, one panel per model, coloured by PhrogCat.
8
+ Plot 2 — 1×3 scatter of pairwise L2 distances (base x-axis, fine-tuned
9
+ y-axis) for the same ~N_PAIRS protein pairs across all subplots.
10
+ Plot 3 — Same as Plot 2 but using cosine similarity.
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import json
16
+ import glob
17
+ import re
18
+ import argparse
19
+ from itertools import combinations
20
+ from typing import Any, Dict, List
21
+
22
+ import torch
23
+ import pandas as pd
24
+ import numpy as np
25
+ import matplotlib
26
+ matplotlib.use("Agg")
27
+ import matplotlib.pyplot as plt
28
+ import matplotlib.patches as mpatches
29
+ from sklearn.manifold import TSNE
30
+ from sklearn.decomposition import PCA
31
+ from sklearn.metrics import silhouette_score
32
+ from transformers import T5Tokenizer, T5ForConditionalGeneration
33
+ from peft import PeftModel
34
+
35
+ import warnings
36
+ warnings.filterwarnings("ignore")
37
+
38
+ # ============================================================================
39
+ # CONFIGURATION
40
+ # ============================================================================
41
+
42
+
43
+ VERSIONS: List[str] = ["ContraMLM_v1_1"]
44
+ ALL_MODEL_LABELS: List[str] = ["base"] + VERSIONS
45
+
46
+ DEFAULT_BASE_MODEL_NAME = "Rostlab/prot_t5_xl_uniref50"
47
+ DEFAULT_DATA_PATH = "./data/envhog_phrog2/envhog_test_final_no_leakage.csv"
48
+ DEFAULT_MAX_LENGTH = 512
49
+ DEFAULT_SAMPLE_SIZE = 5000
50
+ DEFAULT_BATCH_SIZE = 2
51
+ DEFAULT_RANDOM_STATE = 42
52
+ DEFAULT_N_PAIRS = 150 # protein pairs used in scatter plots 2 & 3
53
+
54
+
55
+ def parse_args():
56
+ parser = argparse.ArgumentParser(
57
+ description="Multi-model embedding space evaluation for ProtT5 XL"
58
+ )
59
+ parser.add_argument("--base-model", type=str, default=DEFAULT_BASE_MODEL_NAME)
60
+ parser.add_argument("--data-path", type=str, default=DEFAULT_DATA_PATH)
61
+ parser.add_argument("--output-dir", type=str, default="./evaluation_results_EmbeddingSpace")
62
+ parser.add_argument("--max-length", type=int, default=DEFAULT_MAX_LENGTH)
63
+ parser.add_argument("--sample-size", type=int, default=DEFAULT_SAMPLE_SIZE)
64
+ parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
65
+ parser.add_argument("--random-state", type=int, default=DEFAULT_RANDOM_STATE)
66
+ parser.add_argument(
67
+ "--n-pairs",
68
+ type=int,
69
+ default=DEFAULT_N_PAIRS,
70
+ help="Number of protein pairs for L2/cosine scatter plots (100–200 recommended)",
71
+ )
72
+ return parser.parse_args()
73
+
74
+
75
+ args = parse_args()
76
+
77
+ BASE_MODEL_NAME = args.base_model
78
+ DATA_PATH = args.data_path
79
+ OUTPUT_DIR = args.output_dir
80
+ IMAGES_DIR = os.path.join(OUTPUT_DIR, "images")
81
+ TEXT_DIR = os.path.join(OUTPUT_DIR, "text")
82
+ MAX_LENGTH = args.max_length
83
+ SAMPLE_SIZE = args.sample_size
84
+ BATCH_SIZE = args.batch_size
85
+ RANDOM_STATE = args.random_state
86
+ N_PAIRS_TARGET = max(2, args.n_pairs)
87
+
88
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
89
+ os.makedirs(IMAGES_DIR, exist_ok=True)
90
+ os.makedirs(TEXT_DIR, exist_ok=True)
91
+
92
+
93
+ # ============================================================================
94
+ # LOGGING
95
+ # ============================================================================
96
+
97
+ class Tee:
98
+ def __init__(self, *streams):
99
+ self.streams = streams
100
+ def write(self, data):
101
+ for s in self.streams:
102
+ s.write(data)
103
+ s.flush()
104
+ def flush(self):
105
+ for s in self.streams:
106
+ s.flush()
107
+
108
+
109
+ run_log_path = os.path.join(TEXT_DIR, "run_log.txt")
110
+ log_file = open(run_log_path, "w", encoding="utf-8")
111
+ sys.stdout = Tee(sys.__stdout__, log_file)
112
+ sys.stderr = Tee(sys.__stderr__, log_file)
113
+
114
+ print("=" * 80)
115
+ print("MULTI-MODEL EMBEDDING SPACE EVALUATION")
116
+ print("=" * 80)
117
+ print(f"\nBase model : {BASE_MODEL_NAME}")
118
+ print(f"Versions : {VERSIONS}")
119
+ print(f"Data : {DATA_PATH}")
120
+ print(f"Sample size : {SAMPLE_SIZE}")
121
+ print(f"N pairs : {N_PAIRS_TARGET}")
122
+ print(f"Output : {OUTPUT_DIR}")
123
+
124
+ # ============================================================================
125
+ # DEVICE
126
+ # ============================================================================
127
+
128
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
129
+ print(f"\nDevice: {device}")
130
+ if torch.cuda.is_available():
131
+ print(f"GPU : {torch.cuda.get_device_name(0)}")
132
+
133
+ # ============================================================================
134
+ # LOAD DATA
135
+ # ============================================================================
136
+
137
+ print("\n" + "=" * 80)
138
+ print("LOADING DATA")
139
+ print("=" * 80)
140
+
141
+ df = pd.read_csv(DATA_PATH)
142
+ print(f"Total proteins: {len(df)}")
143
+
144
+ if SAMPLE_SIZE > 0 and len(df) > SAMPLE_SIZE:
145
+ df_sample = df.sample(n=SAMPLE_SIZE, random_state=RANDOM_STATE).reset_index(drop=True)
146
+ print(f"Sampled {SAMPLE_SIZE} proteins")
147
+ else:
148
+ df_sample = df.reset_index(drop=True)
149
+ print(f"Using all {len(df)} proteins")
150
+
151
+ sequences = df_sample["sequence"].tolist()
152
+ lengths = df_sample["length"].tolist()
153
+ protein_ids = df_sample["id"].tolist()
154
+
155
+ # PhrogCat — used as colour label in t-SNE
156
+ if "PhrogCat" in df_sample.columns:
157
+ phrog_cats = df_sample["PhrogCat"].fillna("unknown").tolist()
158
+ else:
159
+ print("WARNING: 'PhrogCat' column not found — using 'unknown' for all proteins")
160
+ phrog_cats = ["unknown"] * len(df_sample)
161
+
162
+
163
+ def prepare_t5_seq(seq: str) -> str:
164
+ return " ".join(list(str(seq).replace(" ", "")))
165
+
166
+
167
+ sequences = [prepare_t5_seq(s) for s in sequences]
168
+
169
+ # ============================================================================
170
+ # TOKENIZER
171
+ # ============================================================================
172
+
173
+ print("\n" + "=" * 80)
174
+ print("LOADING TOKENIZER")
175
+ print("=" * 80)
176
+
177
+ tokenizer = T5Tokenizer.from_pretrained(
178
+ BASE_MODEL_NAME, do_lower_case=False, legacy=True
179
+ )
180
+ print(f"Tokenizer loaded: {BASE_MODEL_NAME}")
181
+
182
+ # ============================================================================
183
+ # HELPERS
184
+ # ============================================================================
185
+
186
+ def get_encoder(model):
187
+ """Return the encoder module regardless of wrapper type."""
188
+ if hasattr(model, "encoder"):
189
+ return model.encoder
190
+ fn = getattr(model, "get_encoder", None)
191
+ if callable(fn):
192
+ return fn()
193
+ for attr in ("base_model", "model"):
194
+ inner = getattr(model, attr, None)
195
+ if inner is not None:
196
+ if hasattr(inner, "encoder"):
197
+ return inner.encoder
198
+ fn2 = getattr(inner, "get_encoder", None)
199
+ if callable(fn2):
200
+ return fn2()
201
+ return model
202
+
203
+
204
+ def select_best_adapter_dir(version: str) -> str:
205
+ """Return the first valid (NaN/Inf-free) adapter directory for *version*."""
206
+ finetuned_path = f"./runs/protrans_XL_Full_lora_envhog_{version}/lora_adapters"
207
+ checkpoint_root = f"./runs/protrans_XL_Full_lora_envhog_{version}"
208
+
209
+ candidates = []
210
+ if os.path.isdir(finetuned_path):
211
+ candidates.append(finetuned_path)
212
+
213
+ ckpt_paths = sorted(
214
+ glob.glob(os.path.join(checkpoint_root, "checkpoint-*")),
215
+ key=lambda p: int(re.search(r"checkpoint-(\d+)", p).group(1))
216
+ if re.search(r"checkpoint-(\d+)", p) else -1,
217
+ reverse=True,
218
+ )
219
+ for cp in ckpt_paths:
220
+ if os.path.isdir(cp):
221
+ candidates.append(cp)
222
+
223
+ def _resolve(candidate):
224
+ for subdir in (candidate, os.path.join(candidate, "lora_adapters")):
225
+ if (os.path.isfile(os.path.join(subdir, "adapter_model.safetensors"))
226
+ or os.path.isfile(os.path.join(subdir, "adapter_model.bin"))):
227
+ return subdir
228
+ return None
229
+
230
+ for candidate in candidates:
231
+ resolved = _resolve(candidate)
232
+ if resolved is None:
233
+ continue
234
+ safe = os.path.join(resolved, "adapter_model.safetensors")
235
+ bin_ = os.path.join(resolved, "adapter_model.bin")
236
+ if os.path.isfile(safe):
237
+ from safetensors.torch import load_file
238
+ state_dict = load_file(safe, device="cpu")
239
+ else:
240
+ state_dict = torch.load(bin_, map_location="cpu")
241
+
242
+ has_nan = any(torch.isnan(v).any().item() for v in state_dict.values())
243
+ has_inf = any(torch.isinf(v).any().item() for v in state_dict.values())
244
+ if not has_nan and not has_inf:
245
+ print(f" [{version}] Using adapter: {resolved}")
246
+ return resolved
247
+
248
+ raise RuntimeError(
249
+ f"No valid (NaN/Inf-free) adapter found for version '{version}'. "
250
+ f"Searched: {candidates}"
251
+ )
252
+
253
+
254
+ def get_embeddings(model, seqs: List[str], batch_size: int = 16) -> np.ndarray:
255
+ """Mean-pooled encoder embeddings for a list of pre-formatted sequences."""
256
+ encoder = get_encoder(model)
257
+ encoder.eval()
258
+ all_embs = []
259
+
260
+ with torch.no_grad():
261
+ for i in range(0, len(seqs), batch_size):
262
+ batch = seqs[i : i + batch_size]
263
+ inputs = tokenizer(
264
+ batch,
265
+ return_tensors="pt",
266
+ padding=True,
267
+ truncation=True,
268
+ max_length=MAX_LENGTH,
269
+ )
270
+ inputs = {k: v.to(device) for k, v in inputs.items()}
271
+ out = encoder(
272
+ input_ids=inputs["input_ids"],
273
+ attention_mask=inputs["attention_mask"],
274
+ )
275
+ hidden = out.last_hidden_state
276
+ mask = inputs["attention_mask"].unsqueeze(-1).to(hidden.dtype)
277
+ pooled = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1.0)
278
+ all_embs.append(pooled.float().cpu().numpy())
279
+
280
+ if (i // batch_size) % 10 == 0:
281
+ print(f" {i}/{len(seqs)} sequences processed...")
282
+
283
+ return np.vstack(all_embs)
284
+
285
+
286
+ # ============================================================================
287
+ # LOAD BASE MODEL
288
+ # ============================================================================
289
+
290
+ print("\n" + "=" * 80)
291
+ print("LOADING BASE MODEL")
292
+ print("=" * 80)
293
+
294
+ model_dtype = torch.float16 if torch.cuda.is_available() else torch.float32
295
+
296
+ base_model = T5ForConditionalGeneration.from_pretrained(
297
+ BASE_MODEL_NAME, torch_dtype=model_dtype, low_cpu_mem_usage=True
298
+ )
299
+ base_model = base_model.to(device)
300
+ base_model.eval()
301
+ print("Base model loaded")
302
+
303
+ # ============================================================================
304
+ # GENERATE BASE EMBEDDINGS
305
+ # ============================================================================
306
+
307
+ print("\n" + "=" * 80)
308
+ print("GENERATING BASE EMBEDDINGS")
309
+ print("=" * 80)
310
+
311
+ base_embeddings = get_embeddings(base_model, sequences, BATCH_SIZE)
312
+ print(f"Base embeddings shape: {base_embeddings.shape}")
313
+
314
+ # Free base model GPU memory before loading fine-tuned models one-by-one
315
+ # (keep the numpy array — it is small)
316
+ del base_model
317
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
318
+
319
+ # ============================================================================
320
+ # LOAD FINE-TUNED MODELS AND GENERATE EMBEDDINGS
321
+ # ============================================================================
322
+
323
+ # Stores: { version_name: np.ndarray }
324
+ ft_embeddings: Dict[str, np.ndarray] = {}
325
+
326
+ for version in VERSIONS:
327
+ print("\n" + "=" * 80)
328
+ print(f"LOADING FINE-TUNED MODEL: {version}")
329
+ print("=" * 80)
330
+
331
+ adapter_dir = select_best_adapter_dir(version)
332
+
333
+ ft_model = T5ForConditionalGeneration.from_pretrained(
334
+ BASE_MODEL_NAME, torch_dtype=model_dtype, low_cpu_mem_usage=True
335
+ )
336
+ ft_model = PeftModel.from_pretrained(ft_model, adapter_dir)
337
+
338
+ merge_fn = getattr(ft_model, "merge_and_unload", None)
339
+ if callable(merge_fn):
340
+ ft_model = merge_fn()
341
+ print(" LoRA adapters merged")
342
+
343
+ ft_model = ft_model.to(device)
344
+ ft_model.eval()
345
+
346
+ print(f" Generating embeddings for {version}...")
347
+ ft_embeddings[version] = get_embeddings(ft_model, sequences, BATCH_SIZE)
348
+ print(f" {version} embeddings shape: {ft_embeddings[version].shape}")
349
+
350
+ del ft_model
351
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
352
+
353
+ # ============================================================================
354
+ # SHARED t-SNE COLOUR MAP (PhrogCat)
355
+ # ============================================================================
356
+
357
+ print("\n" + "=" * 80)
358
+ print("PREPARING t-SNE COLOUR MAP")
359
+ print("=" * 80)
360
+
361
+ unique_cats = sorted(set(phrog_cats))
362
+ n_cats = len(unique_cats)
363
+ cmap_name = "tab20" if n_cats > 10 else "tab10"
364
+ cmap = plt.get_cmap(cmap_name, n_cats)
365
+ cat_to_idx = {cat: i for i, cat in enumerate(unique_cats)}
366
+ colour_values = np.array([cat_to_idx[c] for c in phrog_cats])
367
+
368
+ print(f"Unique PhrogCat categories: {n_cats}")
369
+ print(f"Categories: {unique_cats}")
370
+
371
+ # ============================================================================
372
+ # t-SNE FOR ALL FOUR MODELS
373
+ # ============================================================================
374
+
375
+ print("\n" + "=" * 80)
376
+ print("RUNNING t-SNE (4 MODELS)")
377
+ print("=" * 80)
378
+
379
+ n_tsne = min(2000, len(sequences))
380
+ rng_tsne = np.random.default_rng(RANDOM_STATE)
381
+ tsne_idx = rng_tsne.choice(len(sequences), n_tsne, replace=False)
382
+ perplexity = 30 if n_tsne > 30 else max(5, n_tsne - 1)
383
+
384
+ # Collect all embedding matrices for the 4 models
385
+ all_embeddings_ordered: Dict[str, np.ndarray] = {
386
+ "base": base_embeddings,
387
+ **ft_embeddings,
388
+ }
389
+
390
+ tsne_results: Dict[str, np.ndarray] = {}
391
+ tsne_silhouette: Dict[str, float] = {}
392
+
393
+ for label, emb in all_embeddings_ordered.items():
394
+ print(f" PCA → t-SNE for [{label}]...")
395
+ pca = PCA(n_components=50, random_state=RANDOM_STATE)
396
+ emb_pca = pca.fit_transform(emb)
397
+ tsne = TSNE(n_components=2, random_state=RANDOM_STATE, perplexity=perplexity)
398
+ tsne_results[label] = tsne.fit_transform(emb_pca[tsne_idx])
399
+
400
+ # Silhouette index on the 2D t-SNE map using PhrogCat categories as labels.
401
+ labels_tsne = np.array(phrog_cats, dtype=object)[tsne_idx]
402
+ n_label_values = len(set(labels_tsne.tolist()))
403
+ if 2 <= n_label_values < len(labels_tsne):
404
+ try:
405
+ tsne_silhouette[label] = float(silhouette_score(tsne_results[label], labels_tsne))
406
+ except Exception:
407
+ tsne_silhouette[label] = float("nan")
408
+ else:
409
+ tsne_silhouette[label] = float("nan")
410
+
411
+ print(f" Done.")
412
+ if np.isnan(tsne_silhouette[label]):
413
+ print(" Silhouette(PhrogCat): n/a")
414
+ else:
415
+ print(f" Silhouette(PhrogCat): {tsne_silhouette[label]:.4f}")
416
+
417
+ colours_tsne = colour_values[tsne_idx]
418
+
419
+ # ============================================================================
420
+ # SHARED PROTEIN PAIRS FOR SCATTER PLOTS
421
+ # ============================================================================
422
+
423
+ print("\n" + "=" * 80)
424
+ print("BUILDING SHARED PROTEIN PAIRS")
425
+ print("=" * 80)
426
+
427
+ # We need N_PAIRS_TARGET pairs from a pool of proteins.
428
+ # Minimum proteins needed so combinations >= N_PAIRS_TARGET:
429
+ # n*(n-1)/2 >= N_PAIRS_TARGET → n ≈ ceil((1 + sqrt(1+8k))/2)
430
+ import math
431
+ n_prot_needed = math.ceil((1 + math.sqrt(1 + 8 * N_PAIRS_TARGET)) / 2)
432
+ n_prot_needed = max(n_prot_needed, 2)
433
+ n_prot_needed = min(n_prot_needed, len(sequences))
434
+
435
+ rng_pairs = np.random.default_rng(RANDOM_STATE + 1)
436
+ pair_indices = rng_pairs.choice(len(sequences), n_prot_needed, replace=False)
437
+ pair_indices = pair_indices.tolist()
438
+
439
+ all_pairs = list(combinations(pair_indices, 2))
440
+
441
+ # Randomly subsample to exactly N_PAIRS_TARGET pairs if we have more
442
+ if len(all_pairs) > N_PAIRS_TARGET:
443
+ rng_sub = np.random.default_rng(RANDOM_STATE + 2)
444
+ chosen = rng_sub.choice(len(all_pairs), N_PAIRS_TARGET, replace=False)
445
+ all_pairs = [all_pairs[i] for i in chosen]
446
+
447
+ n_pairs_actual = len(all_pairs)
448
+ print(f"Protein pool size : {n_prot_needed}")
449
+ print(f"Pairs generated : {n_pairs_actual}")
450
+
451
+
452
+ def pairwise_l2(emb: np.ndarray, pairs: list) -> np.ndarray:
453
+ return np.array([
454
+ np.linalg.norm(emb[a] - emb[b])
455
+ for a, b in pairs
456
+ ])
457
+
458
+
459
+ def pairwise_cosine(emb: np.ndarray, pairs: list, eps: float = 1e-12) -> np.ndarray:
460
+ sims = []
461
+ for a, b in pairs:
462
+ va, vb = emb[a], emb[b]
463
+ denom = np.linalg.norm(va) * np.linalg.norm(vb)
464
+ sim = np.dot(va, vb) / max(denom, eps)
465
+ sims.append(float(np.clip(sim, -1.0, 1.0)))
466
+ return np.array(sims)
467
+
468
+
469
+ # Compute for base
470
+ base_pair_l2 = pairwise_l2(base_embeddings, all_pairs)
471
+ base_pair_cos = pairwise_cosine(base_embeddings, all_pairs)
472
+
473
+ # Compute for each fine-tuned version
474
+ ft_pair_l2: Dict[str, np.ndarray] = {}
475
+ ft_pair_cos: Dict[str, np.ndarray] = {}
476
+
477
+ for version in VERSIONS:
478
+ emb = ft_embeddings[version]
479
+ ft_pair_l2[version] = pairwise_l2(emb, all_pairs)
480
+ ft_pair_cos[version] = pairwise_cosine(emb, all_pairs)
481
+
482
+ # ============================================================================
483
+ # PLOT 1 — 2×2 t-SNE GRID (coloured by PhrogCat)
484
+ # ============================================================================
485
+
486
+ print("\n" + "=" * 80)
487
+ print("PLOT 1: 2×2 t-SNE GRID")
488
+ print("=" * 80)
489
+
490
+ fig, axes = plt.subplots(2, 2, figsize=(16, 14))
491
+ axes_flat = axes.flatten()
492
+
493
+ panel_order = ["base", "ContraMLM_v1_0", "Default_v2_1", "MLP_v0"]
494
+ panel_titles = {
495
+ "base": "Base Model",
496
+ "ContraMLM_v1_0": "ContraMLM v1.0",
497
+ "Default_v2_1": "Default v2.1",
498
+ "MLP_v0": "MLP v0",
499
+ }
500
+
501
+ for ax, label in zip(axes_flat, panel_order):
502
+ xy = tsne_results[label]
503
+ sc = ax.scatter(
504
+ xy[:, 0], xy[:, 1],
505
+ c=colours_tsne,
506
+ cmap=cmap_name,
507
+ vmin=0, vmax=n_cats - 1,
508
+ alpha=0.65,
509
+ s=8,
510
+ linewidths=0,
511
+ )
512
+ sil_txt = (
513
+ f"Silhouette(PhrogCat): {tsne_silhouette[label]:.3f}"
514
+ if not np.isnan(tsne_silhouette[label])
515
+ else "Silhouette(PhrogCat): n/a"
516
+ )
517
+ ax.set_title(
518
+ f"{panel_titles[label]}\n{sil_txt}",
519
+ fontsize=14,
520
+ fontweight="bold",
521
+ pad=8,
522
+ )
523
+ ax.set_xlabel("t-SNE 1", fontsize=10)
524
+ ax.set_ylabel("t-SNE 2", fontsize=10)
525
+ ax.tick_params(labelsize=8)
526
+
527
+ # Shared legend for PhrogCat categories
528
+ legend_handles = [
529
+ mpatches.Patch(color=cmap(cat_to_idx[cat] / max(n_cats - 1, 1)), label=cat)
530
+ for cat in unique_cats
531
+ ]
532
+ fig.legend(
533
+ handles=legend_handles,
534
+ title="PhrogCat",
535
+ title_fontsize=14,
536
+ fontsize=12,
537
+ loc="lower center",
538
+ ncol=min(n_cats, 6),
539
+ bbox_to_anchor=(0.5, -0.02),
540
+ frameon=True,
541
+ )
542
+
543
+ fig.suptitle(
544
+ f"t-SNE Embedding Space — Base vs Fine-tuned Models\n"
545
+ f"(n={n_tsne} proteins, coloured by PhrogCat)",
546
+ fontsize=15,
547
+ fontweight="bold",
548
+ y=1.01,
549
+ )
550
+ plt.tight_layout()
551
+
552
+ plot1_path = os.path.join(IMAGES_DIR, "plot1_tsne_4models.png")
553
+ fig.savefig(plot1_path, dpi=300, bbox_inches="tight")
554
+ plt.close(fig)
555
+ print(f"Saved: {plot1_path}")
556
+
557
+ # ============================================================================
558
+ # PLOT 2 — PAIRWISE L2 SCATTER (base x-axis, fine-tuned y-axis)
559
+ # ============================================================================
560
+
561
+ print("\n" + "=" * 80)
562
+ print("PLOT 2: PAIRWISE L2 DISTANCE SCATTER")
563
+ print("=" * 80)
564
+
565
+ fig, axes = plt.subplots(1, 3, figsize=(18, 6))
566
+
567
+ version_titles = {
568
+ "ContraMLM_v1_0": "ContraMLM v1.0",
569
+ "Default_v2_1": "Default v2.1",
570
+ "MLP_v0": "MLP v0",
571
+ }
572
+
573
+ for ax, version in zip(axes, VERSIONS):
574
+ x = base_pair_l2
575
+ y = ft_pair_l2[version]
576
+
577
+ # Diagonal reference line
578
+ lim_min = min(x.min(), y.min()) * 0.98
579
+ lim_max = max(x.max(), y.max()) * 1.02
580
+ ax.plot([lim_min, lim_max], [lim_min, lim_max],
581
+ color="gray", linestyle="--", linewidth=1.0, alpha=0.7, label="y = x")
582
+
583
+ ax.scatter(x, y, alpha=0.55, s=20, color="#2E86AB", linewidths=0)
584
+
585
+ # Pearson r annotation
586
+ r = float(np.corrcoef(x, y)[0, 1])
587
+ ax.text(
588
+ 0.05, 0.93, f"r = {r:.3f}",
589
+ transform=ax.transAxes,
590
+ fontsize=10,
591
+ verticalalignment="top",
592
+ bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.7),
593
+ )
594
+
595
+ ax.set_xlim(lim_min, lim_max)
596
+ ax.set_ylim(lim_min, lim_max)
597
+ ax.set_xlabel("Base model — L2 distance", fontsize=11)
598
+ ax.set_ylabel(f"{version_titles[version]} — L2 distance", fontsize=11)
599
+ ax.set_title(f"L2: Base vs {version_titles[version]}", fontsize=13, fontweight="bold")
600
+ ax.set_aspect("equal", adjustable="box")
601
+ ax.grid(True, alpha=0.25)
602
+ ax.legend(fontsize=9)
603
+
604
+ fig.suptitle(
605
+ f"Pairwise L2 Distance: Base vs Fine-tuned Models\n"
606
+ f"({n_pairs_actual} protein pairs, same pairs across all subplots)",
607
+ fontsize=14,
608
+ fontweight="bold",
609
+ )
610
+ plt.tight_layout()
611
+
612
+ plot2_path = os.path.join(IMAGES_DIR, "plot2_pairwise_l2_scatter.png")
613
+ fig.savefig(plot2_path, dpi=300, bbox_inches="tight")
614
+ plt.close(fig)
615
+ print(f"Saved: {plot2_path}")
616
+
617
+ # ============================================================================
618
+ # PLOT 3 — PAIRWISE COSINE SIMILARITY SCATTER
619
+ # ============================================================================
620
+
621
+ print("\n" + "=" * 80)
622
+ print("PLOT 3: PAIRWISE COSINE SIMILARITY SCATTER")
623
+ print("=" * 80)
624
+
625
+ fig, axes = plt.subplots(1, 3, figsize=(18, 6))
626
+
627
+ for ax, version in zip(axes, VERSIONS):
628
+ x = base_pair_cos
629
+ y = ft_pair_cos[version]
630
+
631
+ lim_min = min(x.min(), y.min()) - 0.02
632
+ lim_max = max(x.max(), y.max()) + 0.02
633
+ ax.plot([lim_min, lim_max], [lim_min, lim_max],
634
+ color="gray", linestyle="--", linewidth=1.0, alpha=0.7, label="y = x")
635
+
636
+ ax.scatter(x, y, alpha=0.55, s=20, color="#E84855", linewidths=0)
637
+
638
+ r = float(np.corrcoef(x, y)[0, 1])
639
+ ax.text(
640
+ 0.05, 0.93, f"r = {r:.3f}",
641
+ transform=ax.transAxes,
642
+ fontsize=10,
643
+ verticalalignment="top",
644
+ bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.7),
645
+ )
646
+
647
+ ax.set_xlim(lim_min, lim_max)
648
+ ax.set_ylim(lim_min, lim_max)
649
+ ax.set_xlabel("Base model — cosine similarity", fontsize=11)
650
+ ax.set_ylabel(f"{version_titles[version]} — cosine similarity", fontsize=11)
651
+ ax.set_title(f"Cosine: Base vs {version_titles[version]}", fontsize=13, fontweight="bold")
652
+ ax.set_aspect("equal", adjustable="box")
653
+ ax.grid(True, alpha=0.25)
654
+ ax.legend(fontsize=9)
655
+
656
+ fig.suptitle(
657
+ f"Pairwise Cosine Similarity: Base vs Fine-tuned Models\n"
658
+ f"({n_pairs_actual} protein pairs, same pairs across all subplots)",
659
+ fontsize=14,
660
+ fontweight="bold",
661
+ )
662
+ plt.tight_layout()
663
+
664
+ plot3_path = os.path.join(IMAGES_DIR, "plot3_pairwise_cosine_scatter.png")
665
+ fig.savefig(plot3_path, dpi=300, bbox_inches="tight")
666
+ plt.close(fig)
667
+ print(f"Saved: {plot3_path}")
668
+
669
+ # ============================================================================
670
+ # SAVE PAIR DATA AS CSV (reproducibility)
671
+ # ============================================================================
672
+
673
+ pair_records = []
674
+ for k, (a, b) in enumerate(all_pairs):
675
+ row = {
676
+ "pair_index": k,
677
+ "protein_id_a": protein_ids[a],
678
+ "protein_id_b": protein_ids[b],
679
+ "base_l2": float(base_pair_l2[k]),
680
+ "base_cosine": float(base_pair_cos[k]),
681
+ }
682
+ for version in VERSIONS:
683
+ row[f"{version}_l2"] = float(ft_pair_l2[version][k])
684
+ row[f"{version}_cosine"] = float(ft_pair_cos[version][k])
685
+ pair_records.append(row)
686
+
687
+ pairs_csv_path = os.path.join(TEXT_DIR, "pairwise_distances_all_models.csv")
688
+ pd.DataFrame(pair_records).to_csv(pairs_csv_path, index=False)
689
+ print(f"\nPair data saved: {pairs_csv_path}")
690
+
691
+ # ============================================================================
692
+ # DONE
693
+ # ============================================================================
694
+
695
+ print("\n" + "=" * 80)
696
+ print("EVALUATION COMPLETE")
697
+ print("=" * 80)
698
+ print(f"\nOutputs written to : {OUTPUT_DIR}")
699
+ print(f" Plot 1 (t-SNE) : {plot1_path}")
700
+ print(f" Plot 2 (L2) : {plot2_path}")
701
+ print(f" Plot 3 (cosine) : {plot3_path}")
702
+ print(f" Pair CSV : {pairs_csv_path}")
703
+ print(f" Run log : {run_log_path}")
src/train.py ADDED
@@ -0,0 +1,1010 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import sys
4
+ from collections import defaultdict
5
+ from typing import cast
6
+ import pandas as pd
7
+ import numpy as np
8
+ import inspect
9
+ import random
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ from transformers import (
15
+ T5Tokenizer,
16
+ T5ForConditionalGeneration,
17
+ TrainingArguments,
18
+ Trainer,
19
+ TrainerCallback,
20
+ )
21
+ from transformers.optimization import Adafactor, AdafactorSchedule
22
+ from peft import get_peft_model, LoraConfig, TaskType
23
+ import matplotlib.pyplot as plt
24
+
25
+
26
+ print("=" * 80)
27
+ print("PROTRANS LORA FINE-TUNING: CONTRASTIVE + MLM LOSS (ContraMLM v1)")
28
+ print("=" * 80)
29
+
30
+ # ============================================================================
31
+ # CONFIGURATION
32
+ # ============================================================================
33
+
34
+ print("\n" + "=" * 80)
35
+ print("CONFIGURATION")
36
+ print("=" * 80)
37
+
38
+ # Model configuration
39
+ MODEL_NAME = "Rostlab/prot_t5_xl_uniref50"
40
+
41
+ # LoRA configuration
42
+ LORA_R = 32
43
+ LORA_ALPHA = 64
44
+ LORA_DROPOUT = 0.1
45
+ LORA_TARGET_MODULES = ["q", "k", "v", "o"]
46
+ LORA_TASK_TYPE = TaskType.SEQ_2_SEQ_LM
47
+
48
+ # Training configuration
49
+ # BATCH_SIZE = number of proteins drawn from the dataset per forward pass.
50
+ # For each non-orphan, the collator samples 1 positive on-the-fly, so the
51
+ # actual forward-pass batch has BATCH_SIZE..2*BATCH_SIZE unique proteins.
52
+ BATCH_SIZE = 32
53
+ GRADIENT_ACCUMULATION_STEPS = 2
54
+ NUM_EPOCHS = 2
55
+ MAX_LENGTH = 512
56
+ NOISE_DENSITY = 0.15
57
+
58
+ # Contrastive loss — Contrastive (Con) with full adjacency matrix
59
+ # Total loss = (1 - CONTRASTIVE_LAMBDA) * MLM_loss + CONTRASTIVE_LAMBDA * Con_loss
60
+ # A per-batch adjacency matrix (N×N) is built from the VISEQ pair graph.
61
+ # Every known positive pair in the batch contributes to the numerator;
62
+ # false negatives are impossible by construction (adj built from ground-truth graph).
63
+ # CONTRASTIVE_LAMBDA is the convex-combination weight for the contrastive term.
64
+ CONTRASTIVE_LAMBDA = 0.2
65
+ CONTRASTIVE_TEMPERATURE = 0.1 # lower → sharper distribution → harder loss
66
+
67
+ # Curriculum settings (same flags as Default_v2, applied to MLM component only)
68
+ USE_LOSS_CLIPPING_CURRICULUM = False
69
+ NUM_STAGES = 10
70
+ KEEP_FRACTION_START = 0.20
71
+ KEEP_FRACTION_END = 1.00
72
+ LARGEST = False # False = keep easiest losses first
73
+
74
+ version = "v1_1"
75
+
76
+ OUTPUT_DIR = f"./runs/protrans_XL_Full_lora_envhog_ContraMLM_{version}"
77
+
78
+ # Data paths
79
+ FASTA_FILE = "./data/envhog_phrog2/envhog_filtered_proteins.fasta"
80
+ CSV_FILE = "./data/envhog_phrog2/envhog_phrog2__low_thr_enriched_final.csv"
81
+ PAIRS_FILE = "./data/envhog_phrog2/all_positive_viseq_pairs.csv"
82
+
83
+ # Sample caps (applied to the pair list; existing ~168 K pairs are well below cap)
84
+ MAX_TRAIN_SAMPLES = 400000
85
+ MAX_EVAL_SAMPLES = 50000
86
+
87
+ print(f"Model: {MODEL_NAME}")
88
+ print(f"LoRA rank: {LORA_R}")
89
+ print(f"Batch size (drawn): {BATCH_SIZE} ({BATCH_SIZE}–{2*BATCH_SIZE} unique proteins per step)")
90
+ print(f"Gradient accumulation: {GRADIENT_ACCUMULATION_STEPS}")
91
+ print(f"Epochs: {NUM_EPOCHS}")
92
+ print(f"Max sequence length: {MAX_LENGTH}")
93
+ print(f"Noise density (MLM): {NOISE_DENSITY}")
94
+ print(f"Contrastive lambda: {CONTRASTIVE_LAMBDA}")
95
+ print(f"Contrastive temperature: {CONTRASTIVE_TEMPERATURE}")
96
+ print(f"Loss clipping curriculum: {USE_LOSS_CLIPPING_CURRICULUM}")
97
+ print(f"Output directory: {OUTPUT_DIR}")
98
+
99
+ # ============================================================================
100
+ # CHECK PYTORCH AND GPU
101
+ # ============================================================================
102
+
103
+ print("\n" + "=" * 80)
104
+ print("GPU STATUS")
105
+ print("=" * 80)
106
+
107
+ print(f"PyTorch version: {torch.__version__}")
108
+ print(f"CUDA available: {torch.cuda.is_available()}")
109
+ if torch.cuda.is_available():
110
+ print(f"CUDA device: {torch.cuda.get_device_name(0)}")
111
+ print(f"Number of GPUs: {torch.cuda.device_count()}")
112
+
113
+ # ============================================================================
114
+ # LOAD DATA
115
+ # ============================================================================
116
+
117
+ print("\n" + "=" * 80)
118
+ print("LOADING DATA")
119
+ print("=" * 80)
120
+
121
+ for _f in [FASTA_FILE, CSV_FILE, PAIRS_FILE]:
122
+ if not os.path.exists(_f):
123
+ print(f"ERROR: File not found: {_f}")
124
+ sys.exit(1)
125
+
126
+ # --- 1. FASTA sequences ---
127
+ print("Reading FASTA sequences...")
128
+ fasta_seqs = {} # {envhog_id: raw_aa_sequence}
129
+ _cur_id = None
130
+ _cur_seq = []
131
+ with open(FASTA_FILE) as fh:
132
+ for line in fh:
133
+ line = line.rstrip()
134
+ if line.startswith(">"):
135
+ if _cur_id is not None:
136
+ fasta_seqs[_cur_id] = "".join(_cur_seq)
137
+ _cur_id = line[1:].split()[0]
138
+ _cur_seq = []
139
+ else:
140
+ _cur_seq.append(line)
141
+ if _cur_id is not None:
142
+ fasta_seqs[_cur_id] = "".join(_cur_seq)
143
+ print(f" Sequences in FASTA: {len(fasta_seqs):,}")
144
+
145
+ # --- 2. Protein metadata CSV ---
146
+ print("Reading protein metadata CSV...")
147
+ meta_df = pd.read_csv(CSV_FILE)
148
+ print(f" Loaded {len(meta_df):,} rows | columns: {meta_df.columns.tolist()}")
149
+
150
+ # Keep only proteins that have a sequence in the FASTA
151
+ meta_df = meta_df[meta_df["ENVHOG"].isin(fasta_seqs)].reset_index(drop=True)
152
+ print(f" After FASTA intersection: {len(meta_df):,} proteins retained")
153
+
154
+ # --- 3. Build lookup structures ---
155
+ envhog_to_viseq = dict(zip(meta_df["ENVHOG"], meta_df["VISEQ"]))
156
+ viseq_to_proteins = defaultdict(list) # viseq → [envhog_id, ...]
157
+ for row in meta_df.itertuples(index=False):
158
+ viseq_to_proteins[row.VISEQ].append(row.ENVHOG)
159
+
160
+ # --- 4. Load positive VISEQ pairs ---
161
+ print("Reading positive VISEQ pairs CSV...")
162
+ pairs_df = pd.read_csv(PAIRS_FILE)
163
+ print(f" Loaded {len(pairs_df):,} positive VISEQ pairs")
164
+
165
+ # Build bidirectional map: viseq → [list of positive viseqs]
166
+ positive_viseq_map = defaultdict(list)
167
+ for row in pairs_df.itertuples(index=False):
168
+ positive_viseq_map[row.viseq_A].append(row.viseq_B)
169
+ positive_viseq_map[row.viseq_B].append(row.viseq_A)
170
+ print(f" VISEQs with cross-VISEQ positives: {len(positive_viseq_map):,}")
171
+
172
+ # Convert map values to sets for O(1) lookup during false-negative filtering
173
+ positive_viseq_set = {k: set(v) for k, v in positive_viseq_map.items()}
174
+
175
+ # ============================================================================
176
+ # BUILD PROTEIN POOL
177
+ # ============================================================================
178
+
179
+ print("\n" + "=" * 80)
180
+ print("BUILDING PROTEIN POOL")
181
+ print("=" * 80)
182
+
183
+
184
+ def prepare_t5_seq(seq: str) -> str:
185
+ """Remove gaps, replace rare AAs, space-separate for T5 tokeniser."""
186
+ seq = seq.replace(" ", "")
187
+ seq = re.sub(r"[UZOB]", "X", seq)
188
+ return " ".join(list(seq))
189
+
190
+
191
+ def sample_positive_protein(anchor_envhog: str, anchor_viseq: str):
192
+ """
193
+ Return one positive protein for the given anchor, or None if none exists.
194
+
195
+ Positive candidates:
196
+ 1. Other proteins sharing the same VISEQ.
197
+ 2. A protein from a cross-VISEQ positive (drawn from positive_viseq_map).
198
+
199
+ When both options are available, one is chosen at random to expose the model
200
+ to both types of similarity signal across the training epoch.
201
+ """
202
+ same_viseq = [p for p in viseq_to_proteins[anchor_viseq] if p != anchor_envhog]
203
+ cross_viseqs = positive_viseq_map.get(anchor_viseq, [])
204
+
205
+ has_same = bool(same_viseq)
206
+ has_cross = bool(cross_viseqs)
207
+
208
+ if not has_same and not has_cross:
209
+ return None # orphan: no confirmed positive exists
210
+
211
+ if has_same and has_cross:
212
+ strategy = random.choice(["same", "cross"])
213
+ elif has_same:
214
+ strategy = "same"
215
+ else:
216
+ strategy = "cross"
217
+
218
+ if strategy == "same":
219
+ return random.choice(same_viseq)
220
+
221
+ # Cross-VISEQ: pick a random positive VISEQ, then a random protein from it
222
+ pos_viseq = random.choice(cross_viseqs)
223
+ pos_prots = viseq_to_proteins.get(pos_viseq, [])
224
+ if pos_prots:
225
+ return random.choice(pos_prots)
226
+ # The cross-VISEQ has no protein in our filtered dataset → fall back to same
227
+ if same_viseq:
228
+ return random.choice(same_viseq)
229
+ return None
230
+
231
+
232
+ # Flat list of ALL proteins (orphans and non-orphans alike).
233
+ # Positives are sampled on-the-fly in the collator; no pre-building of pairs needed.
234
+ all_proteins = [] # list of (seq_t5, viseq, envhog_id)
235
+
236
+ n_no_fasta = 0
237
+ for row in meta_df.itertuples(index=False):
238
+ envhog = row.ENVHOG
239
+ viseq = row.VISEQ
240
+ raw_seq = fasta_seqs.get(envhog)
241
+ if raw_seq is None:
242
+ n_no_fasta += 1
243
+ continue
244
+ all_proteins.append((prepare_t5_seq(raw_seq), viseq, envhog))
245
+
246
+ print(f" Total proteins in pool: {len(all_proteins):,}")
247
+ print(f" Skipped (no FASTA sequence): {n_no_fasta:,}")
248
+ print()
249
+ print(" Orphan proteins automatically get adj_matrix rows of all-False:")
250
+ print(" they contribute to MLM only, excluded from the Con mean.")
251
+
252
+ if len(all_proteins) == 0:
253
+ print("ERROR: No proteins found. Check data paths.")
254
+ sys.exit(1)
255
+
256
+ # ============================================================================
257
+ # SPLIT DATA
258
+ # ============================================================================
259
+
260
+ print("\n" + "=" * 80)
261
+ print("SPLITTING DATA")
262
+ print("=" * 80)
263
+
264
+ random.shuffle(all_proteins)
265
+ n_val_proteins = max(1, int(len(all_proteins) * 0.1))
266
+ val_proteins = all_proteins[:n_val_proteins]
267
+ train_proteins = all_proteins[n_val_proteins:]
268
+
269
+ if MAX_TRAIN_SAMPLES and len(train_proteins) > MAX_TRAIN_SAMPLES:
270
+ train_proteins = train_proteins[:MAX_TRAIN_SAMPLES]
271
+ if MAX_EVAL_SAMPLES and len(val_proteins) > MAX_EVAL_SAMPLES:
272
+ val_proteins = val_proteins[:MAX_EVAL_SAMPLES]
273
+
274
+ print(f"Training proteins: {len(train_proteins):,}")
275
+ print(f"Validation proteins: {len(val_proteins):,}")
276
+
277
+ # ============================================================================
278
+ # DATASET
279
+ # ============================================================================
280
+
281
+ print("\n" + "=" * 80)
282
+ print("BUILDING DATASET")
283
+ print("=" * 80)
284
+
285
+
286
+ class ProteinGraphDataset(torch.utils.data.Dataset):
287
+ """
288
+ A flat pool of all proteins. Each item is a single protein.
289
+ The PairGraphCollator samples positives on-the-fly and builds the
290
+ per-batch adjacency matrix for Contrastive loss.
291
+ """
292
+
293
+ def __init__(self, proteins):
294
+ # proteins: list of (seq_t5, viseq, envhog_id)
295
+ self.items = [
296
+ {"seq": s, "viseq": v, "envhog_id": e}
297
+ for s, v, e in proteins
298
+ ]
299
+
300
+ def __len__(self):
301
+ return len(self.items)
302
+
303
+ def __getitem__(self, idx):
304
+ return self.items[idx]
305
+
306
+
307
+ train_dataset = ProteinGraphDataset(train_proteins)
308
+ val_dataset = ProteinGraphDataset(val_proteins)
309
+
310
+ print(f"Train dataset: {len(train_dataset):,} proteins")
311
+ print(f"Val dataset: {len(val_dataset):,} proteins")
312
+ print(f"Each batch of {BATCH_SIZE} drawn proteins → {BATCH_SIZE}–{2*BATCH_SIZE} unique proteins after positive sampling")
313
+
314
+ # ============================================================================
315
+ # LOAD TOKENIZER AND MODEL
316
+ # ============================================================================
317
+
318
+ print("\n" + "=" * 80)
319
+ print("LOADING TOKENIZER AND MODEL")
320
+ print("=" * 80)
321
+
322
+ tokenizer = T5Tokenizer.from_pretrained(MODEL_NAME, do_lower_case=False, legacy=True)
323
+ print(f"Tokenizer loaded: {MODEL_NAME}")
324
+
325
+ use_bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
326
+ model_dtype = torch.bfloat16 if use_bf16 else torch.float32
327
+
328
+ model = T5ForConditionalGeneration.from_pretrained(MODEL_NAME, torch_dtype=model_dtype)
329
+ model.config.use_cache = False
330
+ if hasattr(model, "gradient_checkpointing_enable"):
331
+ try:
332
+ model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
333
+ except TypeError:
334
+ model.gradient_checkpointing_enable()
335
+
336
+ print(f"Full Encoder-Decoder Model loaded: {MODEL_NAME}")
337
+ print(f"Total parameters: {model.num_parameters():,}")
338
+
339
+ # ============================================================================
340
+ # CONFIGURE AND APPLY LORA
341
+ # ============================================================================
342
+
343
+ print("\n" + "=" * 80)
344
+ print("CONFIGURING LORA")
345
+ print("=" * 80)
346
+
347
+ lora_config = LoraConfig(
348
+ r=LORA_R,
349
+ lora_alpha=LORA_ALPHA,
350
+ target_modules=LORA_TARGET_MODULES,
351
+ lora_dropout=LORA_DROPOUT,
352
+ bias="none",
353
+ task_type=LORA_TASK_TYPE,
354
+ )
355
+
356
+ model = get_peft_model(model, lora_config)
357
+
358
+
359
+ def _get_input_embedding_layer(model_obj):
360
+ getter = getattr(model_obj, "get_input_embeddings", None)
361
+ if callable(getter):
362
+ return getter()
363
+ base_model = getattr(model_obj, "base_model", None)
364
+ if base_model is not None:
365
+ base_getter = getattr(base_model, "get_input_embeddings", None)
366
+ if callable(base_getter):
367
+ return base_getter()
368
+ raise AttributeError("Could not resolve input embedding layer for model")
369
+
370
+
371
+ def _make_inputs_require_grad(module, inputs, output):
372
+ output.requires_grad_(True)
373
+
374
+
375
+ embedding_layer = cast(nn.Embedding, _get_input_embedding_layer(model))
376
+ embedding_layer.register_forward_hook(_make_inputs_require_grad)
377
+
378
+ model.print_trainable_parameters()
379
+
380
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
381
+ model = model.to(device)
382
+ print(f"Model moved to device: {device}")
383
+
384
+ # ============================================================================
385
+ # DATA COLLATOR
386
+ # ============================================================================
387
+
388
+ print("\n" + "=" * 80)
389
+ print("PREPARING DATA COLLATOR")
390
+ print("=" * 80)
391
+
392
+ # Build amino acid token IDs for random-replacement masking
393
+ _aa_set = set()
394
+ for _aa in "ACDEFGHIKLMNPQRSTVWY":
395
+ for _tid in tokenizer.encode(_aa, add_special_tokens=False):
396
+ if _tid not in (tokenizer.unk_token_id, tokenizer.eos_token_id, tokenizer.pad_token_id):
397
+ _aa_set.add(_tid)
398
+ AA_TOKENS = list(_aa_set)
399
+ if not AA_TOKENS:
400
+ AA_TOKENS = list(range(3, tokenizer.vocab_size))
401
+
402
+
403
+ class PairGraphCollator:
404
+ """
405
+ Collates BATCH_SIZE drawn protein items into a graph-structured batch.
406
+
407
+ On-the-fly positive sampling:
408
+ For each drawn protein that has at least one known positive, sample 1
409
+ positive protein and add it to the pool (deduplicated by envhog_id).
410
+
411
+ Adjacency matrix A (N×N):
412
+ A[i,j] = True iff proteins i and j are known positives:
413
+ same VISEQ OR cross-VISEQ positive in the pair graph.
414
+ Diagonal is always False (self-loops excluded).
415
+ Orphans (no positives anywhere) have all-False rows → contribute to MLM
416
+ only, automatically excluded from the Con mean by pos_count == 0.
417
+
418
+ Output keys:
419
+ all_input_ids : (N, L) — BART-masked token ids
420
+ all_attention_mask : (N, L)
421
+ all_labels : (N, L) — original tokens; padding → -100
422
+ adj_matrix : Python list[list[bool]] (N×N), passed through as-is
423
+ """
424
+
425
+ def __init__(self, tokenizer, mlm_probability=0.15, pad_to_multiple_of=8):
426
+ self.tokenizer = tokenizer
427
+ self.mlm_probability = mlm_probability
428
+ self.pad_to_multiple_of = pad_to_multiple_of
429
+
430
+ mask_id = tokenizer.mask_token_id
431
+ if mask_id is None:
432
+ mask_id = tokenizer.convert_tokens_to_ids("<extra_id_0>")
433
+ self.mask_token_id = mask_id
434
+ self.pad_token_id = tokenizer.pad_token_id
435
+ self.eos_token_id = tokenizer.eos_token_id
436
+
437
+ def _apply_bart_mask(self, input_tensor, attention_tensor):
438
+ """Apply BART-style masking: 90% → <mask>, 10% → random amino acid."""
439
+ corrupted = input_tensor.clone()
440
+ special_ids = {self.pad_token_id, self.eos_token_id}
441
+
442
+ prob_matrix = torch.full(input_tensor.shape, self.mlm_probability)
443
+ for sid in special_ids:
444
+ prob_matrix[input_tensor == sid] = 0.0
445
+ prob_matrix[attention_tensor == 0] = 0.0
446
+
447
+ mask_positions = torch.bernoulli(prob_matrix).bool()
448
+ replace_with_mask = torch.bernoulli(
449
+ torch.full(mask_positions.shape, 0.9)
450
+ ).bool() & mask_positions
451
+ corrupted[replace_with_mask] = self.mask_token_id
452
+
453
+ replace_with_random = mask_positions & ~replace_with_mask
454
+ n_random = int(replace_with_random.sum().item())
455
+ if n_random > 0:
456
+ corrupted[replace_with_random] = torch.tensor(
457
+ random.choices(AA_TOKENS, k=n_random), dtype=torch.long
458
+ )
459
+ return corrupted
460
+
461
+ def __call__(self, features):
462
+ # features: list of BATCH_SIZE dicts {seq, viseq, envhog_id}
463
+
464
+ # Deduplicate drawn proteins by envhog_id (rare but possible)
465
+ pool_by_id = {}
466
+ for f in features:
467
+ eid = f["envhog_id"]
468
+ if eid not in pool_by_id:
469
+ pool_by_id[eid] = f
470
+
471
+ # For each drawn protein, sample 1 positive and add if not already in pool
472
+ for f in list(pool_by_id.values()):
473
+ pos_eid = sample_positive_protein(f["envhog_id"], f["viseq"])
474
+ if pos_eid is not None and pos_eid not in pool_by_id:
475
+ pos_viseq = envhog_to_viseq.get(pos_eid)
476
+ if pos_viseq is not None:
477
+ pool_by_id[pos_eid] = {
478
+ "seq": prepare_t5_seq(fasta_seqs[pos_eid]),
479
+ "viseq": pos_viseq,
480
+ "envhog_id": pos_eid,
481
+ }
482
+
483
+ pool = list(pool_by_id.values()) # N = 8..16 unique proteins
484
+ N = len(pool)
485
+ all_seqs = [p["seq"] for p in pool]
486
+ all_viseqs = [p["viseq"] for p in pool]
487
+
488
+ # Build adjacency matrix (N×N) from the VISEQ pair graph
489
+ adj = []
490
+ for i in range(N):
491
+ vi = all_viseqs[i]
492
+ pos_set = positive_viseq_set.get(vi, set()) | {vi}
493
+ adj.append([
494
+ (j != i and all_viseqs[j] in pos_set)
495
+ for j in range(N)
496
+ ])
497
+
498
+ # Tokenise all N sequences
499
+ encoding = self.tokenizer(
500
+ all_seqs, truncation=True, max_length=MAX_LENGTH, add_special_tokens=True
501
+ )
502
+ all_ids = encoding["input_ids"]
503
+ all_masks = encoding["attention_mask"]
504
+
505
+ # Pad to longest (aligned to pad_to_multiple_of)
506
+ max_len = max(len(ids) for ids in all_ids)
507
+ if self.pad_to_multiple_of:
508
+ max_len = (
509
+ (max_len + self.pad_to_multiple_of - 1)
510
+ // self.pad_to_multiple_of
511
+ * self.pad_to_multiple_of
512
+ )
513
+
514
+ pad_id = self.pad_token_id
515
+ padded_ids = []
516
+ padded_masks = []
517
+ for ids, mask in zip(all_ids, all_masks):
518
+ pad_len = max_len - len(ids)
519
+ padded_ids.append(ids + [pad_id] * pad_len)
520
+ padded_masks.append(mask + [0] * pad_len)
521
+
522
+ input_tensor = torch.tensor(padded_ids, dtype=torch.long)
523
+ attn_tensor = torch.tensor(padded_masks, dtype=torch.long)
524
+
525
+ # Labels for MLM: original tokens; padding positions → -100
526
+ labels = input_tensor.clone()
527
+ labels[attn_tensor == 0] = -100
528
+
529
+ corrupted = self._apply_bart_mask(input_tensor, attn_tensor)
530
+
531
+ return {
532
+ "adj_matrix": adj, # Python list[list[bool]], passed through as-is
533
+ "all_input_ids": corrupted,
534
+ "all_attention_mask": attn_tensor,
535
+ "all_labels": labels,
536
+ }
537
+
538
+
539
+ data_collator = PairGraphCollator(
540
+ tokenizer=tokenizer,
541
+ mlm_probability=NOISE_DENSITY,
542
+ pad_to_multiple_of=8,
543
+ )
544
+ print("Data collator: PairGraphCollator — on-the-fly positive sampling + Con adjacency matrix")
545
+ print(f" Each batch: {BATCH_SIZE} drawn proteins → {BATCH_SIZE}–{2*BATCH_SIZE} unique proteins after positive sampling")
546
+ print(" Adjacency matrix built from VISEQ pair graph; orphan rows are all-False")
547
+
548
+ # ============================================================================
549
+ # CONTRASTIVE LOSS HELPERS
550
+ # ============================================================================
551
+
552
+
553
+ def mean_pool(hidden_states, attention_mask):
554
+ """
555
+ Mean-pool encoder last hidden states over non-padding token positions.
556
+ hidden_states : (N, seq_len, hidden_dim)
557
+ attention_mask : (N, seq_len) — 1 for real tokens, 0 for padding
558
+ returns : (N, hidden_dim)
559
+ """
560
+ mask = attention_mask.unsqueeze(-1).float()
561
+ return (hidden_states * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9)
562
+
563
+
564
+ def con_loss(z, adj, temperature):
565
+ """
566
+ Contrastive Loss (Con) using a prebuilt adjacency matrix.
567
+
568
+ z : (N, D) — unit-normalized encoder embeddings (F.normalize applied before calling)
569
+ adj : (N, N) bool — adj[i,j] = True means i and j are known positives
570
+ temperature : scalar
571
+
572
+ For each protein i that has at least one positive in the batch:
573
+ loss_i = -1/|P(i)| * sum_{j in P(i)} [sim(i,j)/T - log(sum_{k≠i} exp(sim(i,k)/T))]
574
+
575
+ Returns the mean over proteins with at least one positive.
576
+ Returns 0 (no grad) if no protein has a positive in the batch.
577
+ """
578
+ N = z.size(0)
579
+ dev = z.device
580
+
581
+ # Full pairwise similarity matrix, temperature-scaled
582
+ sim = torch.matmul(z, z.T) / temperature # (N, N)
583
+
584
+ # Mask diagonal so it does not contribute to the denominator
585
+ self_mask = torch.eye(N, dtype=torch.bool, device=dev)
586
+ sim_masked = sim.masked_fill(self_mask, float("-inf"))
587
+
588
+ # log-sum-exp over all k≠i → log denominator for each anchor i
589
+ log_denom = torch.logsumexp(sim_masked, dim=1) # (N,)
590
+
591
+ # log p(j | i) = sim[i,j]/T - log_denom[i] for each j
592
+ log_prob = sim - log_denom.unsqueeze(1) # (N, N)
593
+
594
+ # Number of positives per protein
595
+ n_positives = adj.float().sum(dim=1) # (N,)
596
+ has_positive = n_positives > 0 # (N,) bool
597
+
598
+ if not has_positive.any():
599
+ return torch.tensor(0.0, device=dev, requires_grad=True)
600
+
601
+ # Per-anchor loss: -1/|P(i)| * sum_{j: adj[i,j]} log_prob[i,j]
602
+ pos_log_sum = (adj.float() * log_prob).sum(dim=1) # (N,)
603
+ per_anchor = -pos_log_sum / n_positives.clamp(min=1) # (N,)
604
+
605
+ return per_anchor[has_positive].mean()
606
+
607
+
608
+
609
+ # ============================================================================
610
+ # CURRICULUM LOSS (applied to MLM component — identical to Default_v2)
611
+ # ============================================================================
612
+
613
+ curriculum_state = {"global_step": 0, "max_steps": 1, "phase": "train"}
614
+
615
+ # Shared state for component loss logging.
616
+ # Written by ContraMLMTrainer.compute_loss at every training step;
617
+ # read by ComponentLossLogCallback.on_log to inject into the Trainer log dict.
618
+ _component_losses: dict = {"mlm_loss": None, "con_loss": None}
619
+
620
+
621
+ def curriculum_keep_fraction(progress):
622
+ current_stage = int(np.floor(progress * NUM_STAGES))
623
+ if current_stage >= NUM_STAGES:
624
+ return KEEP_FRACTION_END
625
+ stage_size = (KEEP_FRACTION_END - KEEP_FRACTION_START) / (NUM_STAGES - 1)
626
+ return KEEP_FRACTION_START + (current_stage * stage_size)
627
+
628
+
629
+ def curriculum_loss_from_outputs(outputs, labels):
630
+ logits = outputs.logits
631
+ token_losses = F.cross_entropy(
632
+ logits.view(-1, logits.size(-1)),
633
+ labels.view(-1),
634
+ ignore_index=-100,
635
+ reduction="none",
636
+ )
637
+ valid_tokens = labels.view(-1) != -100
638
+
639
+ if USE_LOSS_CLIPPING_CURRICULUM and torch.is_grad_enabled():
640
+ max_steps = max(1, int(curriculum_state["max_steps"]))
641
+ progress = min(1.0, float(curriculum_state["global_step"]) / float(max_steps))
642
+ keep_fraction = curriculum_keep_fraction(progress)
643
+
644
+ valid_indices = torch.nonzero(valid_tokens, as_tuple=False).squeeze(-1)
645
+ valid_losses = token_losses[valid_tokens]
646
+ k = max(1, int(valid_losses.numel() * keep_fraction))
647
+ selected_pos = torch.topk(valid_losses, k=k, largest=LARGEST).indices
648
+ keep_indices = valid_indices[selected_pos]
649
+ keep_tokens = torch.zeros_like(valid_tokens, dtype=torch.bool)
650
+ keep_tokens[keep_indices] = True
651
+ else:
652
+ keep_tokens = valid_tokens
653
+
654
+ if not keep_tokens.any():
655
+ raise FloatingPointError("No valid tokens available for loss computation.")
656
+
657
+ return token_losses[keep_tokens].mean()
658
+
659
+
660
+ # ============================================================================
661
+ # CONFIGURE TRAINING ARGUMENTS
662
+ # ============================================================================
663
+
664
+ print("\n" + "=" * 80)
665
+ print("CONFIGURING TRAINING ARGUMENTS")
666
+ print("=" * 80)
667
+
668
+ dataloader_workers = min(8, os.cpu_count() or 1)
669
+
670
+ training_kwargs = {
671
+ "output_dir": OUTPUT_DIR,
672
+ "save_strategy": "steps",
673
+ "eval_strategy": "steps",
674
+ "save_steps": 1000,
675
+ "eval_steps": 1000, # 5000
676
+ "gradient_accumulation_steps": GRADIENT_ACCUMULATION_STEPS,
677
+ "per_device_train_batch_size": BATCH_SIZE,
678
+ "per_device_eval_batch_size": 16,
679
+ "num_train_epochs": NUM_EPOCHS,
680
+ "dataloader_num_workers": dataloader_workers,
681
+ "dataloader_pin_memory": True,
682
+ "logging_dir": f"{OUTPUT_DIR}/logs",
683
+ "logging_steps": 100,
684
+ "save_total_limit": 3,
685
+ "fp16": False,
686
+ "bf16": use_bf16,
687
+ # REQUIRED: our batch dict uses custom keys (all_input_ids, etc.)
688
+ # that are not in the model's forward signature.
689
+ "remove_unused_columns": False,
690
+ "load_best_model_at_end": False,
691
+ "report_to": "none",
692
+ "push_to_hub": False,
693
+ }
694
+
695
+ training_signature = inspect.signature(TrainingArguments.__init__).parameters
696
+ if "eval_strategy" not in training_signature:
697
+ training_kwargs.pop("eval_strategy", None)
698
+ training_kwargs["evaluation_strategy"] = "steps"
699
+
700
+ if "bf16_full_eval" in training_signature:
701
+ training_kwargs["bf16_full_eval"] = use_bf16
702
+
703
+ training_args = TrainingArguments(**training_kwargs)
704
+ print("Training arguments configured")
705
+ print(f" remove_unused_columns = False (required for custom batch keys)")
706
+ print(
707
+ f" Effective batch: {BATCH_SIZE * GRADIENT_ACCUMULATION_STEPS} drawn proteins "
708
+ f"({BATCH_SIZE * GRADIENT_ACCUMULATION_STEPS}–{2 * BATCH_SIZE * GRADIENT_ACCUMULATION_STEPS} unique proteins per gradient step)"
709
+ )
710
+
711
+ # ============================================================================
712
+ # CALLBACKS
713
+ # ============================================================================
714
+
715
+ print("\n" + "=" * 80)
716
+ print("INITIALIZING CALLBACKS")
717
+ print("=" * 80)
718
+
719
+
720
+ class LiveLossPlotCallback(TrainerCallback):
721
+ def __init__(self, output_dir):
722
+ self.output_dir = output_dir
723
+
724
+ def on_evaluate(self, args, state, control, metrics=None, **kwargs):
725
+ if metrics and "eval_loss" in metrics:
726
+ print(f"\n>>> [Step {state.global_step}] Evaluation Loss: {metrics['eval_loss']:.4f} <<<\n")
727
+ self._update_plot(state)
728
+
729
+ def on_log(self, args, state, control, logs=None, **kwargs):
730
+ self._update_plot(state)
731
+
732
+ def _update_plot(self, state):
733
+ history = state.log_history
734
+ train_loss = [x["loss"] for x in history if "loss" in x]
735
+ train_steps = [x["step"] for x in history if "loss" in x]
736
+ eval_loss = [x["eval_loss"] for x in history if "eval_loss" in x]
737
+ eval_steps = [x["step"] for x in history if "eval_loss" in x]
738
+ mlm_loss = [x["train_mlm_loss"] for x in history if "train_mlm_loss" in x]
739
+ con_loss = [x["train_con_loss"] for x in history if "train_con_loss" in x]
740
+ comp_steps = [x["step"] for x in history if "train_mlm_loss" in x]
741
+
742
+ if not train_loss:
743
+ return
744
+
745
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 10), sharex=True)
746
+
747
+ # Top panel: total loss + eval loss
748
+ ax1.plot(train_steps, train_loss, label="Train Loss (total)",
749
+ color="blue", alpha=0.6)
750
+ if eval_loss:
751
+ ax1.plot(eval_steps, eval_loss, label="Eval Loss (MLM only)",
752
+ color="red", marker="o", linewidth=2)
753
+ ax1.set_ylabel("Loss")
754
+ ax1.grid(True, linestyle="--", alpha=0.6)
755
+ ax1.legend(loc="upper right")
756
+ ax1.set_title("Live Training Loss — ContraMLM v1")
757
+
758
+ # Bottom panel: MLM vs Con components
759
+ if mlm_loss:
760
+ ax2.plot(comp_steps, mlm_loss, label="MLM loss", color="green", alpha=0.7)
761
+ ax2.plot(comp_steps, con_loss, label="Con loss", color="orange", alpha=0.7)
762
+ ax2.legend(loc="upper right")
763
+ ax2.set_xlabel("Training Steps")
764
+ ax2.set_ylabel("Component Loss")
765
+ ax2.grid(True, linestyle="--", alpha=0.6)
766
+
767
+ plt.tight_layout()
768
+ plt.savefig(os.path.join(self.output_dir, "live_loss_curve.png"), dpi=300)
769
+ plt.close()
770
+
771
+
772
+ class CurriculumStateCallback(TrainerCallback):
773
+ def on_train_begin(self, args, state, control, **kwargs):
774
+ curriculum_state["global_step"] = state.global_step
775
+ curriculum_state["max_steps"] = state.max_steps if state.max_steps and state.max_steps > 0 else 1
776
+ curriculum_state["phase"] = "train"
777
+
778
+ def on_step_begin(self, args, state, control, **kwargs):
779
+ curriculum_state["global_step"] = state.global_step
780
+ curriculum_state["phase"] = "train"
781
+
782
+ def on_evaluate(self, args, state, control, **kwargs):
783
+ curriculum_state["phase"] = "eval"
784
+
785
+
786
+ # ============================================================================
787
+ # TRAINER
788
+ # ============================================================================
789
+
790
+ print("\n" + "=" * 80)
791
+ print("INITIALIZING TRAINER")
792
+ print("=" * 80)
793
+
794
+
795
+ class AdaFactorTrainer(Trainer):
796
+ def create_optimizer_and_scheduler(self, num_training_steps: int):
797
+ self.optimizer = Adafactor(
798
+ [p for p in self.model.parameters() if p.requires_grad],
799
+ scale_parameter=True,
800
+ relative_step=True,
801
+ warmup_init=True,
802
+ lr=None,
803
+ )
804
+ self.lr_scheduler = AdafactorSchedule(self.optimizer)
805
+
806
+
807
+ class ContraMLMTrainer(AdaFactorTrainer):
808
+ """
809
+ Trainer combining BART-style MLM with Contrastive (Con) loss.
810
+
811
+ Batch layout (set by PairGraphCollator):
812
+ all_input_ids / all_attention_mask / all_labels : (N, L)
813
+ adj_matrix : Python list[list[bool]] (N×N)
814
+ N ranges from BATCH_SIZE (all orphans) to 2*BATCH_SIZE (all non-orphans).
815
+
816
+ Loss:
817
+ total = (1 - CONTRASTIVE_LAMBDA) * mlm_loss + CONTRASTIVE_LAMBDA * con_loss
818
+ Con is skipped (returns 0) if no protein in the batch has a positive.
819
+ """
820
+
821
+ def prediction_step(self, model, inputs, prediction_loss_only, ignore_keys=None):
822
+ # adj_matrix is not a model argument; remove it before the standard eval forward pass
823
+ inputs.pop("adj_matrix", None)
824
+ # Remap custom collator keys to the standard model argument names
825
+ if "all_input_ids" in inputs:
826
+ inputs["input_ids"] = inputs.pop("all_input_ids")
827
+ if "all_attention_mask" in inputs:
828
+ inputs["attention_mask"] = inputs.pop("all_attention_mask")
829
+ if "all_labels" in inputs:
830
+ inputs["labels"] = inputs.pop("all_labels")
831
+ return super().prediction_step(model, inputs, prediction_loss_only, ignore_keys=ignore_keys)
832
+
833
+ def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
834
+ # During training: custom keys (all_input_ids, etc.) + adj_matrix present.
835
+ # During eval: prediction_step remaps keys to standard names, adj_matrix is gone.
836
+ adj_matrix = inputs.get("adj_matrix", None) # None during eval
837
+ all_input_ids = inputs.get("all_input_ids", inputs.get("input_ids"))
838
+ all_attention_mask = inputs.get("all_attention_mask", inputs.get("attention_mask"))
839
+ all_labels = inputs.get("all_labels", inputs.get("labels"))
840
+
841
+ # ---- Single encoder-decoder forward pass for all N proteins ----
842
+ outputs = model(
843
+ input_ids=all_input_ids,
844
+ attention_mask=all_attention_mask,
845
+ labels=all_labels,
846
+ )
847
+
848
+ # ---- MLM loss (curriculum-aware, same as Default_v2) ----
849
+ mlm_loss = curriculum_loss_from_outputs(outputs, all_labels)
850
+
851
+ # ---- Skip Con during eval (no adj_matrix) or all-orphan batch ----
852
+ if adj_matrix is None:
853
+ return (mlm_loss, outputs) if return_outputs else mlm_loss
854
+
855
+ # ---- Convert adjacency list to bool tensor ----
856
+ adj_tensor = torch.tensor(adj_matrix, dtype=torch.bool, device=all_input_ids.device)
857
+
858
+ if not adj_tensor.any():
859
+ _component_losses["mlm_loss"] = mlm_loss.detach().float().item()
860
+ _component_losses["con_loss"] = 0.0
861
+ return (mlm_loss, outputs) if return_outputs else mlm_loss
862
+
863
+ # ---- Encoder embeddings → mean-pooled, unit-normalized representations ----
864
+ enc_hidden = outputs.encoder_last_hidden_state
865
+ z = mean_pool(enc_hidden, all_attention_mask)
866
+
867
+ # ---- Contrastive loss ----
868
+ contrastive_loss = con_loss(
869
+ F.normalize(z, dim=-1), adj_tensor, CONTRASTIVE_TEMPERATURE
870
+ )
871
+
872
+ _component_losses["mlm_loss"] = mlm_loss.detach().float().item()
873
+ _component_losses["con_loss"] = contrastive_loss.detach().float().item()
874
+
875
+ total_loss = (1.0 - CONTRASTIVE_LAMBDA) * mlm_loss + CONTRASTIVE_LAMBDA * contrastive_loss
876
+ return (total_loss, outputs) if return_outputs else total_loss
877
+
878
+ def log(self, logs):
879
+ # Inject component losses into the log dict BEFORE the base class freezes
880
+ # it into state.log_history — this is the only way they appear in the history
881
+ # that _update_plot reads.
882
+ if _component_losses["mlm_loss"] is not None and "loss" in logs:
883
+ logs["train_mlm_loss"] = round(_component_losses["mlm_loss"], 6)
884
+ logs["train_con_loss"] = round(_component_losses["con_loss"], 6)
885
+ super().log(logs)
886
+
887
+
888
+ callbacks = [LiveLossPlotCallback(OUTPUT_DIR)]
889
+ if USE_LOSS_CLIPPING_CURRICULUM:
890
+ callbacks.append(CurriculumStateCallback())
891
+
892
+ trainer = ContraMLMTrainer(
893
+ model=model,
894
+ args=training_args,
895
+ train_dataset=train_dataset,
896
+ eval_dataset=val_dataset,
897
+ data_collator=data_collator,
898
+ callbacks=callbacks,
899
+ )
900
+
901
+ print("ContraMLMTrainer initialised")
902
+ print(f" MLM loss weight: {1.0 - CONTRASTIVE_LAMBDA:.2f} (= 1 - CONTRASTIVE_LAMBDA)")
903
+ print(f" Con loss weight: {CONTRASTIVE_LAMBDA}")
904
+ print(f" Contrastive temperature: {CONTRASTIVE_TEMPERATURE}")
905
+ print(f" Orphans: adj row all-False → auto-excluded from Con mean, MLM only")
906
+
907
+ # ============================================================================
908
+ # TRAIN THE MODEL
909
+ # ============================================================================
910
+
911
+ print("\n" + "=" * 80)
912
+ print("STARTING TRAINING")
913
+ print("=" * 80)
914
+
915
+
916
+ def find_latest_checkpoint(output_dir):
917
+ if not os.path.isdir(output_dir):
918
+ return None
919
+ latest_path = None
920
+ latest_step = -1
921
+ for entry in os.listdir(output_dir):
922
+ if not entry.startswith("checkpoint-"):
923
+ continue
924
+ step_str = entry.split("checkpoint-")[-1]
925
+ if not step_str.isdigit():
926
+ continue
927
+ full_path = os.path.join(output_dir, entry)
928
+ if not os.path.isdir(full_path):
929
+ continue
930
+ step = int(step_str)
931
+ if step > latest_step:
932
+ latest_step = step
933
+ latest_path = full_path
934
+ return latest_path
935
+
936
+
937
+ def quarantine_rng_state_files(checkpoint_dir):
938
+ moved_files = []
939
+ for entry in os.listdir(checkpoint_dir):
940
+ if not (entry.startswith("rng_state") and entry.endswith(".pth")):
941
+ continue
942
+ src = os.path.join(checkpoint_dir, entry)
943
+ if not os.path.isfile(src):
944
+ continue
945
+ dst = src + ".bak"
946
+ os.replace(src, dst)
947
+ moved_files.append((src, dst))
948
+ return moved_files
949
+
950
+
951
+ try:
952
+ resume_checkpoint = find_latest_checkpoint(OUTPUT_DIR)
953
+ if resume_checkpoint is not None:
954
+ print(f"Resuming training from checkpoint: {resume_checkpoint}")
955
+ moved_rng_files = quarantine_rng_state_files(resume_checkpoint)
956
+ if moved_rng_files:
957
+ print(
958
+ f"Skipped rng_state*.pth files for PyTorch 2.6 compatibility: "
959
+ f"{len(moved_rng_files)} file(s)."
960
+ )
961
+ train_result = trainer.train(resume_from_checkpoint=resume_checkpoint)
962
+ else:
963
+ print("No checkpoint found. Starting training from scratch.")
964
+ train_result = trainer.train()
965
+
966
+ print("\n" + "=" * 80)
967
+ print("TRAINING COMPLETED!")
968
+ print("=" * 80)
969
+ print(f"Train loss: {train_result.training_loss:.4f}")
970
+ print(f"Training time: {train_result.metrics['train_runtime']:.2f} seconds")
971
+
972
+ except Exception as e:
973
+ print(f"\nERROR during training: {e}")
974
+ import traceback
975
+ traceback.print_exc()
976
+ sys.exit(1)
977
+
978
+ # ============================================================================
979
+ # EVALUATE THE MODEL
980
+ # ============================================================================
981
+
982
+ print("\n" + "=" * 80)
983
+ print("EVALUATING MODEL")
984
+ print("=" * 80)
985
+
986
+ try:
987
+ eval_results = trainer.evaluate()
988
+ print("Evaluation Results:")
989
+ for key, value in eval_results.items():
990
+ print(f" {key}: {value:.4f}")
991
+ except Exception as e:
992
+ print(f"ERROR during evaluation: {e}")
993
+
994
+ # ============================================================================
995
+ # SAVE THE MODEL
996
+ # ============================================================================
997
+
998
+ print("\n" + "=" * 80)
999
+ print("SAVING MODEL")
1000
+ print("=" * 80)
1001
+
1002
+ lora_output_dir = f"{OUTPUT_DIR}/lora_adapters"
1003
+ model.save_pretrained(lora_output_dir)
1004
+ tokenizer.save_pretrained(lora_output_dir)
1005
+
1006
+ print(f"LoRA adapters saved to: {lora_output_dir}")
1007
+
1008
+ print("\n" + "=" * 80)
1009
+ print("FINE-TUNING COMPLETE!")
1010
+ print("=" * 80)