whiteh4t's picture
Release final BGC retrieval checkpoints and model card
c87881a verified
Raw
History Blame Contribute Delete
16.5 kB
"""Baseline-preserving gene weighting for species-scoped BGC retrieval."""
from __future__ import annotations
import copy
from collections.abc import Mapping, Sequence
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 .baselines import cosine_scores, ensemble_scores, pfam_jaccard_scores
from .checkpoints import save_checkpoint
from .data import BGCEmbeddingDataset, collate_bgcs
from .evaluation import evaluate_retrieval
from .losses import augment_gene_sets
from .model import ModelConfig
from .sampling import UniqueGroupBatchSampler
from .training import choose_device, embedding_validation_metric, set_reproducible
class ResidualGeneWeightingEncoder(nn.Module):
"""Reweight genes in frozen ESM space while preserving mean pooling."""
input_names = ("gene_embeddings", "relative_positions", "padding_mask")
def __init__(self, config: ModelConfig) -> None:
super().__init__()
self.config = config
dimension = config.hidden_dimension
self.embedding_norm = nn.LayerNorm(config.esm_dimension)
self.gene_projection = nn.Linear(config.esm_dimension, dimension)
self.position_projection = nn.Sequential(
nn.Linear(1, dimension),
nn.GELU(),
nn.Linear(dimension, dimension),
)
self.gate = nn.Sequential(
nn.GELU(),
nn.Dropout(config.dropout),
nn.Linear(dimension, 1),
)
nn.init.zeros_(self.gate[-1].weight)
nn.init.zeros_(self.gate[-1].bias)
def components(
self,
gene_embeddings: torch.Tensor,
relative_positions: torch.Tensor,
padding_mask: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
if padding_mask is None:
padding_mask = torch.zeros(
gene_embeddings.shape[:2],
dtype=torch.bool,
device=gene_embeddings.device,
)
valid = ~padding_mask
if torch.any(valid.sum(dim=1) == 0):
raise ValueError("Every BGC must contain at least one unmasked gene")
counts = valid.sum(dim=1, keepdim=True).to(gene_embeddings.dtype)
raw = (gene_embeddings * valid.unsqueeze(-1)).sum(dim=1) / counts
features = self.gene_projection(self.embedding_norm(gene_embeddings))
features = features + self.position_projection(relative_positions.unsqueeze(-1))
logits = self.gate(features).squeeze(-1)
logits = logits.masked_fill(padding_mask, float("-inf"))
weights = torch.softmax(logits, dim=1)
weighted = (gene_embeddings * weights.unsqueeze(-1)).sum(dim=1)
return (
F.normalize(raw, p=2, dim=-1),
F.normalize(weighted, p=2, dim=-1),
weights,
)
def forward(
self,
gene_embeddings: torch.Tensor,
relative_positions: torch.Tensor,
padding_mask: torch.Tensor | None = None,
) -> torch.Tensor:
return self.components(gene_embeddings, relative_positions, padding_mask)[1]
@staticmethod
def combine(
raw_embeddings: torch.Tensor,
learned_embeddings: torch.Tensor,
alpha: float,
) -> torch.Tensor:
if not 0.0 <= alpha <= 1.0:
raise ValueError("Residual alpha must be in [0, 1]")
raw = F.normalize(raw_embeddings, p=2, dim=-1)
learned = F.normalize(learned_embeddings, p=2, dim=-1)
return torch.cat(
[
np.sqrt(1.0 - alpha) * raw,
np.sqrt(alpha) * learned,
],
dim=-1,
)
def pfam_teacher_matrix(
bgc_ids: Sequence[str],
pfam_sets: Mapping[str, set[str]],
device: torch.device,
) -> torch.Tensor:
values = torch.empty((len(bgc_ids), len(bgc_ids)), dtype=torch.float32)
for row, left_id in enumerate(bgc_ids):
left = pfam_sets.get(str(left_id), set())
for column, right_id in enumerate(bgc_ids):
right = pfam_sets.get(str(right_id), set())
union = left | right
values[row, column] = len(left & right) / len(union) if union else 0.0
return values.to(device)
def soft_relational_loss(
learned_embeddings: torch.Tensor,
raw_embeddings: torch.Tensor,
group_ids: Sequence[str],
teacher_similarities: torch.Tensor,
student_temperature: float,
teacher_temperature: float,
preservation_weight: float,
) -> torch.Tensor:
"""Match soft Pfam/family geometry without hard-negative false assumptions."""
count = len(group_ids)
if learned_embeddings.shape[0] != count or teacher_similarities.shape != (count, count):
raise ValueError("Residual loss inputs have inconsistent batch dimensions")
if student_temperature <= 0.0 or teacher_temperature <= 0.0:
raise ValueError("Temperatures must be positive")
if preservation_weight < 0.0:
raise ValueError("Preservation weight must be non-negative")
learned = F.normalize(learned_embeddings, dim=1)
raw = F.normalize(raw_embeddings, dim=1)
identity = torch.eye(count, dtype=torch.bool, device=learned.device)
same_group = torch.tensor(
[[left == right for right in group_ids] for left in group_ids],
dtype=torch.bool,
device=learned.device,
)
teacher = teacher_similarities.to(learned.device).clone()
teacher = torch.where(same_group & ~identity, torch.ones_like(teacher), teacher)
teacher_logits = (teacher / teacher_temperature).masked_fill(identity, float("-inf"))
teacher_probabilities = torch.softmax(teacher_logits, dim=1)
student_logits = (learned @ learned.T / student_temperature).masked_fill(
identity, float("-inf")
)
student_log_probabilities = torch.log_softmax(student_logits, dim=1)
student_log_probabilities = student_log_probabilities.masked_fill(identity, 0.0)
relational = F.kl_div(
student_log_probabilities,
teacher_probabilities,
reduction="batchmean",
)
preservation = F.mse_loss(
(learned @ learned.T)[~identity],
(raw @ raw.T)[~identity],
)
return relational + preservation_weight * preservation
@torch.no_grad()
def encode_residual_components(
model: ResidualGeneWeightingEncoder,
dataset: BGCEmbeddingDataset,
device: torch.device,
num_workers: int = 0,
) -> tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]]:
loader = DataLoader(
dataset,
batch_size=32,
shuffle=False,
num_workers=num_workers,
collate_fn=collate_bgcs,
)
model.eval()
raw_result: dict[str, torch.Tensor] = {}
learned_result: dict[str, torch.Tensor] = {}
for batch in loader:
embeddings = batch["gene_embeddings"].to(device)
positions = batch["relative_positions"].to(device)
padding_mask = batch["padding_mask"].to(device)
raw, learned, _ = model.components(embeddings, positions, padding_mask)
raw_result.update(zip(batch["bgc_ids"], raw.cpu()))
learned_result.update(zip(batch["bgc_ids"], learned.cpu()))
return raw_result, learned_result
def train_residual_gene_weighting(
model: ResidualGeneWeightingEncoder,
model_config: ModelConfig,
train_dataset: BGCEmbeddingDataset,
validation_dataset: BGCEmbeddingDataset,
validation_assignments: pd.DataFrame,
pfam_sets: Mapping[str, set[str]],
split_path: str | Path,
input_paths: list[str | Path],
output_dir: str | Path,
training_config: dict[str, Any],
residual_config: dict[str, Any],
seed: int,
) -> Path:
set_reproducible(seed)
device = choose_device()
model.to(device)
optimizer = torch.optim.AdamW(
model.parameters(),
lr=float(residual_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,
)
mixed_precision = bool(training_config["mixed_precision"]) and device.type == "cuda"
scaler = torch.amp.GradScaler("cuda", enabled=mixed_precision)
history: list[dict[str, float | int]] = []
best_metric = -float("inf")
best_state: dict[str, Any] | None = None
patience = 0
for epoch in range(int(residual_config["epochs"])):
sampler.set_epoch(epoch)
model.train()
epoch_losses: list[float] = []
for batch in loader:
embeddings = batch["gene_embeddings"].to(device)
positions = batch["relative_positions"].to(device)
padding_mask = batch["padding_mask"].to(device)
embeddings, positions, padding_mask = augment_gene_sets(
embeddings,
positions,
padding_mask,
float(training_config["gene_dropout"]),
float(training_config["position_jitter"]),
)
teacher = pfam_teacher_matrix(batch["bgc_ids"], pfam_sets, device)
optimizer.zero_grad(set_to_none=True)
with torch.amp.autocast(
device_type=device.type,
enabled=mixed_precision,
):
raw, learned, _ = model.components(embeddings, positions, padding_mask)
loss = soft_relational_loss(
learned,
raw,
batch["group_ids"],
teacher,
float(residual_config["student_temperature"]),
float(residual_config["teacher_temperature"]),
float(residual_config["preservation_weight"]),
)
if not torch.isfinite(loss):
raise FloatingPointError("Non-finite residual training 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_residual_components(
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(residual_config["patience"]):
break
if best_state is None:
raise RuntimeError("Residual training did not produce a valid checkpoint")
model.load_state_dict(best_state["model"])
output = Path(output_dir)
checkpoint_path = output / "residual_best.pt"
save_checkpoint(
checkpoint_path,
model,
model_config,
split_path,
input_paths,
{
"stage": "residual_gene_weighting",
"best_epoch": best_state["epoch"],
"best_validation_recall@50": best_metric,
"objective": "soft_pfam_family_relational_distillation",
},
optimizer,
)
write_json_immutable(output / "residual_history.json", history)
return checkpoint_path
class RetrievalScoreCache:
"""Cache three base scorers once per deterministic retrieval draw."""
def __init__(
self,
raw_embeddings: Mapping[str, torch.Tensor],
learned_embeddings: Sequence[Mapping[str, torch.Tensor]],
pfam_sets: Mapping[str, set[str]],
) -> None:
self.raw_embeddings = raw_embeddings
self.learned_embeddings = list(learned_embeddings)
self.pfam_sets = pfam_sets
self.key: tuple[tuple[str, ...], tuple[str, ...]] | None = None
self.values: tuple[dict[str, float], dict[str, float], dict[str, float]] | None = None
def components(
self,
candidates: Sequence[str],
references: Sequence[str],
) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
key = (tuple(candidates), tuple(references))
if key == self.key and self.values is not None:
return self.values
raw = cosine_scores(candidates, references, self.raw_embeddings, "mean")
learned_by_model = [
cosine_scores(candidates, references, embeddings, "mean")
for embeddings in self.learned_embeddings
]
learned = {
identifier: float(
np.mean([scores[identifier] for scores in learned_by_model])
)
for identifier in candidates
}
pfam = pfam_jaccard_scores(candidates, references, self.pfam_sets, "max")
self.key = key
self.values = (raw, learned, pfam)
return self.values
def residual(
self,
candidates: Sequence[str],
references: Sequence[str],
alpha: float,
) -> dict[str, float]:
raw, learned, _ = self.components(candidates, references)
return {
identifier: (1.0 - alpha) * raw[identifier] + alpha * learned[identifier]
for identifier in candidates
}
def hybrid(
self,
candidates: Sequence[str],
references: Sequence[str],
alpha: float,
beta: float,
) -> dict[str, float]:
residual = self.residual(candidates, references, alpha)
_, _, pfam = self.components(candidates, references)
return ensemble_scores(residual, pfam, beta)
def validation_grid(
assignments: pd.DataFrame,
cache: RetrievalScoreCache,
alphas: Sequence[float],
betas: Sequence[float],
reference_size: int,
draws: int,
seed: int,
recall_at: Sequence[int],
ndcg_at: Sequence[int],
) -> pd.DataFrame:
methods: dict[str, Any] = {}
for alpha in alphas:
methods[f"residual_a{alpha:g}"] = (
lambda candidates, references, weight=float(alpha): cache.residual(
candidates, references, weight
)
)
for beta in betas:
methods[f"hybrid_a{alpha:g}_b{beta:g}"] = (
lambda candidates, references, a=float(alpha), b=float(beta): cache.hybrid(
candidates, references, a, b
)
)
return evaluate_retrieval(
assignments,
"validation",
methods,
reference_size,
draws,
seed,
recall_at,
ndcg_at,
)
def select_validation_weights(
results: pd.DataFrame,
metric: str,
prefix: str,
) -> tuple[float, float | None]:
selected = results[results["method"].str.startswith(prefix)].copy()
if selected.empty:
raise ValueError(f"No validation methods start with {prefix}")
means = selected.groupby("method")[metric].mean()
best = float(means.max())
candidates = sorted(str(name) for name, value in means.items() if np.isclose(value, best))
parsed: list[tuple[float, float | None, str]] = []
for name in candidates:
alpha = float(name.split("_a", 1)[1].split("_", 1)[0])
beta = float(name.rsplit("_b", 1)[1]) if "_b" in name else None
parsed.append((alpha, beta, name))
if prefix == "hybrid_":
alpha, beta, _ = min(
parsed,
key=lambda item: (
float(item[1]),
item[0],
),
)
else:
alpha, beta, _ = min(parsed, key=lambda item: item[0])
return alpha, beta