Download source/src/bgc_retrieval/training.py from rustambekurokov/bgc-setnet: direct link, hf CLI and curl.
- Browser
- Download file 19.7 kB
-
https://huggingface.co/rustambekurokov/bgc-setnet/resolve/main/source/src/bgc_retrieval/training.py
- Command line
-
hf download hf://rustambekurokov/bgc-setnet/source/src/bgc_retrieval/training.py
-
curl -L -o training.py https://huggingface.co/rustambekurokov/bgc-setnet/resolve/main/source/src/bgc_retrieval/training.py
19.7 kB
| """One-GPU Phase 1 and Phase 2 training with immutable artifacts.""" | |
| from __future__ import annotations | |
| import copy | |
| import random | |
| from collections.abc import Iterable | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| from torch import nn | |
| from torch.nn import functional as F | |
| from torch.utils.data import DataLoader | |
| from .artifacts import write_json_immutable | |
| from .checkpoints import load_checkpoint, save_checkpoint | |
| from .data import BGCEmbeddingDataset, collate_bgcs | |
| from .losses import augment_gene_sets, masked_gene_loss, supervised_contrastive_loss | |
| from .metrics import expected_tie_aware_metrics | |
| from .model import LeakageFreeBGCSetNet, MaskedGenePredictionHead, ModelConfig, WeightedPfamJaccard | |
| from .sampling import UniqueGroupBatchSampler | |
| def set_reproducible(seed: int) -> None: | |
| random.seed(seed) | |
| np.random.seed(seed) | |
| torch.manual_seed(seed) | |
| if torch.cuda.is_available(): | |
| torch.cuda.manual_seed_all(seed) | |
| torch.backends.cudnn.benchmark = False | |
| torch.backends.cudnn.deterministic = True | |
| def choose_device() -> torch.device: | |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| def _move_batch(batch: dict[str, object], device: torch.device) -> tuple[torch.Tensor, ...]: | |
| return ( | |
| batch["gene_embeddings"].to(device), | |
| batch["relative_positions"].to(device), | |
| batch["padding_mask"].to(device), | |
| batch["pfam_tokens"].to(device), | |
| ) | |
| def _encode_genes( | |
| model: nn.Module, | |
| embeddings: torch.Tensor, | |
| positions: torch.Tensor, | |
| padding_mask: torch.Tensor, | |
| pfam_tokens: torch.Tensor, | |
| ) -> torch.Tensor: | |
| if getattr(model, "uses_pfam", False): | |
| return model.encode_genes(embeddings, positions, padding_mask, pfam_tokens) | |
| return model.encode_genes(embeddings, positions, padding_mask) | |
| def _encode_bgc( | |
| model: nn.Module, | |
| embeddings: torch.Tensor, | |
| positions: torch.Tensor, | |
| padding_mask: torch.Tensor, | |
| pfam_tokens: torch.Tensor, | |
| ) -> torch.Tensor: | |
| if getattr(model, "uses_pfam", False): | |
| return model(embeddings, positions, padding_mask, pfam_tokens) | |
| return model(embeddings, positions, padding_mask) | |
| def _mask_gene_batch( | |
| embeddings: torch.Tensor, | |
| padding_mask: torch.Tensor, | |
| probability: float, | |
| seed: int, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| generator = torch.Generator(device="cpu").manual_seed(seed) | |
| random_values = torch.rand(padding_mask.shape, generator=generator) | |
| masked = (random_values < probability) & ~padding_mask.cpu() | |
| for row in range(masked.shape[0]): | |
| if not masked[row].any(): | |
| valid = torch.nonzero(~padding_mask[row].cpu(), as_tuple=False).flatten() | |
| masked[row, valid[torch.randint(len(valid), (1,), generator=generator)]] = True | |
| masked = masked.to(embeddings.device) | |
| model_input = embeddings.clone() | |
| model_input[masked] = 0.0 | |
| return model_input, masked | |
| def _phase1_epoch( | |
| model: LeakageFreeBGCSetNet, | |
| head: MaskedGenePredictionHead, | |
| loader: DataLoader, | |
| device: torch.device, | |
| mask_probability: float, | |
| seed: int, | |
| optimizer: torch.optim.Optimizer | None, | |
| mixed_precision: bool, | |
| ) -> float: | |
| training = optimizer is not None | |
| model.train(training) | |
| head.train(training) | |
| losses: list[float] = [] | |
| scaler = torch.amp.GradScaler("cuda", enabled=mixed_precision and device.type == "cuda") | |
| context = torch.enable_grad() if training else torch.no_grad() | |
| with context: | |
| for batch_index, batch in enumerate(loader): | |
| embeddings, positions, padding_mask, pfam_tokens = _move_batch(batch, device) | |
| model_input, masked = _mask_gene_batch( | |
| embeddings, padding_mask, mask_probability, seed + batch_index | |
| ) | |
| if optimizer: | |
| optimizer.zero_grad(set_to_none=True) | |
| with torch.amp.autocast(device_type=device.type, enabled=mixed_precision and device.type == "cuda"): | |
| contextual = _encode_genes( | |
| model, model_input, positions, padding_mask, pfam_tokens | |
| ) | |
| prediction = head(contextual) | |
| loss = masked_gene_loss(prediction, embeddings, masked) | |
| if not torch.isfinite(loss): | |
| raise FloatingPointError("Non-finite Phase 1 loss") | |
| if optimizer: | |
| scaler.scale(loss).backward() | |
| scaler.unscale_(optimizer) | |
| nn.utils.clip_grad_norm_(list(model.parameters()) + list(head.parameters()), 1.0) | |
| scaler.step(optimizer) | |
| scaler.update() | |
| losses.append(float(loss.detach().cpu())) | |
| return float(np.mean(losses)) | |
| def encode_dataset( | |
| model: LeakageFreeBGCSetNet, | |
| dataset: BGCEmbeddingDataset, | |
| device: torch.device, | |
| num_workers: int = 0, | |
| ) -> dict[str, torch.Tensor]: | |
| loader = DataLoader( | |
| dataset, batch_size=32, shuffle=False, num_workers=num_workers, collate_fn=collate_bgcs | |
| ) | |
| model.eval() | |
| result: dict[str, torch.Tensor] = {} | |
| for batch in loader: | |
| embeddings, positions, padding_mask, pfam_tokens = _move_batch(batch, device) | |
| encoded = _encode_bgc(model, embeddings, positions, padding_mask, pfam_tokens).cpu() | |
| result.update(zip(batch["bgc_ids"], encoded)) | |
| return result | |
| def embedding_validation_metric( | |
| embeddings: dict[str, torch.Tensor], assignments: pd.DataFrame, cutoff: int = 50 | |
| ) -> float: | |
| group_by_bgc = assignments.set_index("bgc_id")["group_id"].astype(str).to_dict() | |
| identifiers = sorted(embeddings) | |
| values: list[float] = [] | |
| for reference in identifiers: | |
| relevant = { | |
| item for item in identifiers if item != reference and group_by_bgc[item] == group_by_bgc[reference] | |
| } | |
| if not relevant: | |
| continue | |
| scores = { | |
| candidate: float(F.cosine_similarity(embeddings[reference], embeddings[candidate], dim=0)) | |
| for candidate in identifiers | |
| if candidate != reference | |
| } | |
| values.append(expected_tie_aware_metrics(scores, relevant, recall_at=(cutoff,), ndcg_at=())[f"recall@{cutoff}"]) | |
| if not values: | |
| raise ValueError("Validation split has no positive retrieval queries") | |
| return float(np.mean(values)) | |
| def train_phase1( | |
| model: LeakageFreeBGCSetNet, | |
| model_config: ModelConfig, | |
| train_dataset: BGCEmbeddingDataset, | |
| validation_dataset: BGCEmbeddingDataset, | |
| split_path: str | Path, | |
| input_paths: list[str | Path], | |
| output_dir: str | Path, | |
| training_config: dict[str, Any], | |
| seed: int, | |
| ) -> Path: | |
| set_reproducible(seed) | |
| device = choose_device() | |
| model.to(device) | |
| head = MaskedGenePredictionHead(model_config).to(device) | |
| optimizer = torch.optim.AdamW( | |
| list(model.parameters()) + list(head.parameters()), | |
| lr=float(training_config["learning_rate"]), | |
| weight_decay=float(training_config["weight_decay"]), | |
| ) | |
| generator = torch.Generator().manual_seed(seed) | |
| train_loader = DataLoader( | |
| train_dataset, | |
| batch_size=int(training_config["batch_groups"]), | |
| shuffle=True, | |
| generator=generator, | |
| num_workers=int(training_config["num_workers"]), | |
| collate_fn=collate_bgcs, | |
| ) | |
| validation_loader = DataLoader( | |
| validation_dataset, | |
| batch_size=int(training_config["batch_groups"]), | |
| shuffle=False, | |
| num_workers=int(training_config["num_workers"]), | |
| collate_fn=collate_bgcs, | |
| ) | |
| history: list[dict[str, float | int]] = [] | |
| best_loss = float("inf") | |
| best_state: dict[str, Any] | None = None | |
| patience = 0 | |
| for epoch in range(int(training_config["phase1_epochs"])): | |
| train_loss = _phase1_epoch( | |
| model, head, train_loader, device, float(training_config["mask_probability"]), | |
| seed + epoch * 10000, optimizer, bool(training_config["mixed_precision"]), | |
| ) | |
| validation_loss = _phase1_epoch( | |
| model, head, validation_loader, device, float(training_config["mask_probability"]), | |
| seed + 900000, None, bool(training_config["mixed_precision"]), | |
| ) | |
| history.append({"epoch": epoch, "train_loss": train_loss, "validation_loss": validation_loss}) | |
| if validation_loss < best_loss: | |
| best_loss = validation_loss | |
| best_state = { | |
| "model": copy.deepcopy(model.state_dict()), | |
| "head": copy.deepcopy(head.state_dict()), | |
| "epoch": epoch, | |
| } | |
| patience = 0 | |
| else: | |
| patience += 1 | |
| if patience >= int(training_config["patience"]): | |
| break | |
| if best_state is None: | |
| raise RuntimeError("Phase 1 did not produce a valid checkpoint") | |
| model.load_state_dict(best_state["model"]) | |
| output = Path(output_dir) | |
| checkpoint_path = output / "phase1_best.pt" | |
| save_checkpoint( | |
| checkpoint_path, model, model_config, split_path, input_paths, | |
| {"stage": "phase1", "best_epoch": best_state["epoch"], "best_loss": best_loss, | |
| "head_state": best_state["head"]}, optimizer, | |
| ) | |
| write_json_immutable(output / "phase1_history.json", history) | |
| return checkpoint_path | |
| def weighted_jaccard_contrastive_loss( | |
| similarities: torch.Tensor, | |
| group_ids: list[str], | |
| temperature: float, | |
| ) -> torch.Tensor: | |
| identity = torch.eye(len(group_ids), dtype=torch.bool, device=similarities.device) | |
| positives = torch.tensor( | |
| [[left == right for right in group_ids] for left in group_ids], | |
| dtype=torch.bool, | |
| device=similarities.device, | |
| ) & ~identity | |
| positive_counts = positives.sum(dim=1) | |
| if torch.any(positive_counts == 0): | |
| raise ValueError("Every weighted-Pfam batch item must have a positive") | |
| logits = similarities / temperature | |
| logits = logits.masked_fill(identity, float("-inf")) | |
| log_probabilities = logits - torch.logsumexp(logits, dim=1, keepdim=True) | |
| positive_log_probability = log_probabilities.masked_fill(~positives, 0.0).sum(dim=1) | |
| positive_log_probability = positive_log_probability / positive_counts | |
| return -positive_log_probability.mean() | |
| def weighted_jaccard_validation_metric( | |
| model: WeightedPfamJaccard, | |
| dataset: BGCEmbeddingDataset, | |
| assignments: pd.DataFrame, | |
| cutoff: int = 50, | |
| ) -> float: | |
| device = next(model.parameters()).device | |
| vocabulary = model.raw_weights.shape[0] | |
| presence = torch.zeros(len(dataset), vocabulary, device=device) | |
| for index in range(len(dataset)): | |
| tokens = dataset[index]["pfam_tokens"].to(device) | |
| if len(tokens): | |
| presence[index, tokens] = 1.0 | |
| presence[:, 0] = 0.0 | |
| weighted = presence * model.domain_weights().to(device) | |
| totals = weighted.sum(dim=1) | |
| intersection = weighted @ presence.T | |
| union = totals[:, None] + totals[None, :] - intersection | |
| similarities = (intersection / union.clamp_min(1e-8)).cpu() | |
| group_by_bgc = assignments.set_index("bgc_id")["group_id"].astype(str).to_dict() | |
| values: list[float] = [] | |
| for index, identifier in enumerate(dataset.bgc_ids): | |
| relevant = { | |
| other_index | |
| for other_index, other_id in enumerate(dataset.bgc_ids) | |
| if other_index != index and group_by_bgc[other_id] == group_by_bgc[identifier] | |
| } | |
| if not relevant: | |
| continue | |
| scores = { | |
| other_id: float(similarities[index, other_index]) | |
| for other_index, other_id in enumerate(dataset.bgc_ids) | |
| if other_index != index | |
| } | |
| values.append( | |
| expected_tie_aware_metrics( | |
| scores, {dataset.bgc_ids[item] for item in relevant}, recall_at=(cutoff,), ndcg_at=() | |
| )[f"recall@{cutoff}"] | |
| ) | |
| if not values: | |
| raise ValueError("Validation split has no positive weighted-Pfam queries") | |
| return float(np.mean(values)) | |
| def train_weighted_pfam( | |
| model: WeightedPfamJaccard, | |
| model_config: ModelConfig, | |
| train_dataset: BGCEmbeddingDataset, | |
| validation_dataset: BGCEmbeddingDataset, | |
| validation_assignments: pd.DataFrame, | |
| split_path: str | Path, | |
| input_paths: list[str | Path], | |
| output_dir: str | Path, | |
| training_config: dict[str, Any], | |
| seed: int, | |
| ) -> Path: | |
| set_reproducible(seed) | |
| device = choose_device() | |
| model.to(device) | |
| optimizer = torch.optim.AdamW( | |
| model.parameters(), | |
| lr=float(training_config["learning_rate"]), | |
| weight_decay=float(training_config["weight_decay"]), | |
| ) | |
| sampler = UniqueGroupBatchSampler( | |
| [train_dataset.group_by_bgc[item] for item in train_dataset.bgc_ids], | |
| groups_per_batch=int(training_config["batch_groups"]), | |
| examples_per_group=int(training_config["examples_per_group"]), | |
| seed=seed, | |
| ) | |
| loader = DataLoader( | |
| train_dataset, | |
| batch_sampler=sampler, | |
| num_workers=int(training_config["num_workers"]), | |
| collate_fn=collate_bgcs, | |
| ) | |
| best_metric = -float("inf") | |
| best_state: dict[str, Any] | None = None | |
| history: list[dict[str, float | int]] = [] | |
| patience = 0 | |
| regularization = float(training_config.get("pfam_weight_regularization", 0.001)) | |
| for epoch in range(int(training_config["phase2_epochs"])): | |
| sampler.set_epoch(epoch) | |
| model.train() | |
| epoch_losses: list[float] = [] | |
| for batch in loader: | |
| tokens = batch["pfam_tokens"].to(device) | |
| optimizer.zero_grad(set_to_none=True) | |
| similarities = model.pairwise_jaccard(tokens) | |
| loss = weighted_jaccard_contrastive_loss( | |
| similarities, batch["group_ids"], float(training_config["temperature"]) | |
| ) | |
| log_weights = torch.log(model.domain_weights()[1:].clamp_min(1e-8)) | |
| loss = loss + regularization * torch.mean(log_weights.square()) | |
| if not torch.isfinite(loss): | |
| raise FloatingPointError("Non-finite weighted-Pfam loss") | |
| loss.backward() | |
| nn.utils.clip_grad_norm_(model.parameters(), 1.0) | |
| optimizer.step() | |
| epoch_losses.append(float(loss.detach().cpu())) | |
| validation_recall = weighted_jaccard_validation_metric( | |
| model, validation_dataset, validation_assignments, cutoff=50 | |
| ) | |
| history.append( | |
| {"epoch": epoch, "train_loss": float(np.mean(epoch_losses)), | |
| "validation_recall@50": validation_recall} | |
| ) | |
| if validation_recall > best_metric: | |
| best_metric = validation_recall | |
| best_state = {"model": copy.deepcopy(model.state_dict()), "epoch": epoch} | |
| patience = 0 | |
| else: | |
| patience += 1 | |
| if patience >= int(training_config["patience"]): | |
| break | |
| if best_state is None: | |
| raise RuntimeError("Weighted Pfam training did not produce a valid checkpoint") | |
| model.load_state_dict(best_state["model"]) | |
| output = Path(output_dir) | |
| checkpoint_path = output / "phase2_best.pt" | |
| save_checkpoint( | |
| checkpoint_path, model, model_config, split_path, input_paths, | |
| {"stage": "weighted_pfam", "best_epoch": best_state["epoch"], | |
| "best_validation_recall@50": best_metric}, optimizer, | |
| ) | |
| write_json_immutable(output / "phase2_history.json", history) | |
| return checkpoint_path | |
| def train_phase2( | |
| model: LeakageFreeBGCSetNet, | |
| model_config: ModelConfig, | |
| train_dataset: BGCEmbeddingDataset, | |
| validation_dataset: BGCEmbeddingDataset, | |
| validation_assignments: pd.DataFrame, | |
| split_path: str | Path, | |
| input_paths: list[str | Path], | |
| output_dir: str | Path, | |
| training_config: dict[str, Any], | |
| seed: int, | |
| phase1_checkpoint: str | Path | None = None, | |
| ) -> Path: | |
| set_reproducible(seed) | |
| device = choose_device() | |
| if phase1_checkpoint: | |
| load_checkpoint(phase1_checkpoint, model, split_path) | |
| model.to(device) | |
| optimizer = torch.optim.AdamW( | |
| model.parameters(), lr=float(training_config["learning_rate"]), | |
| weight_decay=float(training_config["weight_decay"]), | |
| ) | |
| sampler = UniqueGroupBatchSampler( | |
| [train_dataset.group_by_bgc[item] for item in train_dataset.bgc_ids], | |
| groups_per_batch=int(training_config["batch_groups"]), | |
| examples_per_group=int(training_config["examples_per_group"]), seed=seed, | |
| ) | |
| loader = DataLoader( | |
| train_dataset, batch_sampler=sampler, num_workers=int(training_config["num_workers"]), | |
| collate_fn=collate_bgcs, | |
| ) | |
| scaler = torch.amp.GradScaler( | |
| "cuda", enabled=bool(training_config["mixed_precision"]) and device.type == "cuda" | |
| ) | |
| best_metric = -float("inf") | |
| best_state: dict[str, Any] | None = None | |
| history: list[dict[str, float | int]] = [] | |
| patience = 0 | |
| for epoch in range(int(training_config["phase2_epochs"])): | |
| sampler.set_epoch(epoch) | |
| model.train() | |
| epoch_losses: list[float] = [] | |
| for batch in loader: | |
| embeddings, positions, padding_mask, pfam_tokens = _move_batch(batch, device) | |
| embeddings, positions, padding_mask = augment_gene_sets( | |
| embeddings, positions, padding_mask, | |
| float(training_config["gene_dropout"]), float(training_config["position_jitter"]), | |
| ) | |
| optimizer.zero_grad(set_to_none=True) | |
| with torch.amp.autocast( | |
| device_type=device.type, | |
| enabled=bool(training_config["mixed_precision"]) and device.type == "cuda", | |
| ): | |
| encoded = _encode_bgc( | |
| model, embeddings, positions, padding_mask, pfam_tokens | |
| ) | |
| loss = supervised_contrastive_loss( | |
| encoded, batch["group_ids"], float(training_config["temperature"]) | |
| ) | |
| if not torch.isfinite(loss): | |
| raise FloatingPointError("Non-finite Phase 2 loss") | |
| scaler.scale(loss).backward() | |
| scaler.unscale_(optimizer) | |
| nn.utils.clip_grad_norm_(model.parameters(), 1.0) | |
| scaler.step(optimizer) | |
| scaler.update() | |
| epoch_losses.append(float(loss.detach().cpu())) | |
| validation_embeddings = encode_dataset( | |
| model, validation_dataset, device, int(training_config["num_workers"]) | |
| ) | |
| validation_recall = embedding_validation_metric( | |
| validation_embeddings, validation_assignments, cutoff=50 | |
| ) | |
| history.append( | |
| {"epoch": epoch, "train_loss": float(np.mean(epoch_losses)), | |
| "validation_recall@50": validation_recall} | |
| ) | |
| if validation_recall > best_metric: | |
| best_metric = validation_recall | |
| best_state = {"model": copy.deepcopy(model.state_dict()), "epoch": epoch} | |
| patience = 0 | |
| else: | |
| patience += 1 | |
| if patience >= int(training_config["patience"]): | |
| break | |
| if best_state is None: | |
| raise RuntimeError("Phase 2 did not produce a valid checkpoint") | |
| model.load_state_dict(best_state["model"]) | |
| output = Path(output_dir) | |
| checkpoint_path = output / "phase2_best.pt" | |
| save_checkpoint( | |
| checkpoint_path, model, model_config, split_path, input_paths, | |
| {"stage": "phase2", "best_epoch": best_state["epoch"], | |
| "best_validation_recall@50": best_metric, "phase1_checkpoint": str(phase1_checkpoint)}, | |
| optimizer, | |
| ) | |
| write_json_immutable(output / "phase2_history.json", history) | |
| return checkpoint_path | |