F4RUKYLDRM's picture
Fix BRCA training collapse with lab-grid defaults
62a8628
Raw History Blame
18.4 kB
from __future__ import annotations
import copy
import math
import random
from dataclasses import asdict, dataclass
from typing import Callable
import numpy as np
import pandas as pd
import torch
from sklearn.metrics import (
accuracy_score,
confusion_matrix,
f1_score,
precision_score,
recall_score,
)
from sklearn.model_selection import train_test_split
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from .data import PreparedWorkspace
from .model import BioLMNet
ProgressCallback = Callable[[float, str], None]
# 75% training / 25% validation. The validation partition drives early
# stopping and best-checkpoint selection, so it is not an untouched test set.
DEFAULT_VALIDATION_FRACTION = 0.25
# Activations BioLMNet accepts.
ACTIVATIONS = ("relu", "sigmoid", "tanh")
# Defaults follow the Bozdag Lab grid (J. Rifat, 2026-10-17): the configuration that stayed
# stable across 8 seeds on BRCA (macro F1 0.749 +/- 0.018). The published activations
# (relu/sigmoid/tanh) collapse at this learning rate and L2; see
# diagnostics/2026-10-30-lab-hyperparameter-grid.
@dataclass(frozen=True)
class Hyperparameters:
epochs: int = 200
batch_size: int = 32
learning_rate: float = 0.01
weight_decay: float = 0.01
dropout: float = 0.3
projection_dim: int = 64
fusion_dim: int = 12
validation_fraction: float = DEFAULT_VALIDATION_FRACTION
optimizer: str = "Adam"
class_weighting: bool = True
early_stopping_patience: int = 12
seed: int = 42
biological_activation: str = "tanh"
projection_activation: str = "relu"
fusion_activation: str = "tanh"
sgd_momentum: float = 0.03
def validate(self) -> None:
if not 1 <= self.epochs <= 1000:
raise ValueError("Epochs must be between 1 and 1,000.")
if not 2 <= self.batch_size <= 1024:
raise ValueError("Batch size must be between 2 and 1,024.")
if not 0 < self.learning_rate <= 1:
raise ValueError("Learning rate must be in (0, 1].")
if not 0 <= self.dropout < 1:
raise ValueError("Dropout must be in [0, 1).")
if not 0.05 <= self.validation_fraction <= 0.5:
raise ValueError("Validation fraction must be between 0.05 and 0.5.")
if self.optimizer.lower() not in {"adam", "sgd"}:
raise ValueError("Optimizer must be Adam or SGD.")
for name in ("biological_activation", "projection_activation", "fusion_activation"):
if getattr(self, name) not in ACTIVATIONS:
raise ValueError(f"{name.replace('_', ' ').capitalize()} must be one of {', '.join(ACTIVATIONS)}.")
if not 0 <= self.sgd_momentum < 1:
raise ValueError("SGD momentum must be in [0, 1).")
@dataclass
class ModelBundle:
model: BioLMNet
gene_features: list[str]
dna_features: list[str]
label_names: list[str]
gene_mean: np.ndarray
gene_scale: np.ndarray
dna_mean: np.ndarray
dna_scale: np.ndarray
config: dict
metrics: dict
history: list[dict[str, float]]
@dataclass
class TrainingResult:
bundle: ModelBundle
validation_predictions: pd.DataFrame
confusion: np.ndarray
def set_reproducible_seed(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)
def split_train_validation(
labels: np.ndarray, validation_fraction: float, seed: int
) -> tuple[np.ndarray, np.ndarray]:
"""Stratified sample-index split shared by both omics and the labels."""
classes, class_counts = np.unique(labels, return_counts=True)
if class_counts.min() < 2:
raise ValueError(
"Each class needs at least two samples for a stratified train/validation split."
)
sample_count = len(labels)
validation_count = math.ceil(validation_fraction * sample_count)
train_count = sample_count - validation_count
if min(validation_count, train_count) < len(classes):
raise ValueError(
f"A validation fraction of {validation_fraction:.2f} on {sample_count} samples "
f"gives {train_count} training and {validation_count} validation samples; "
f"each partition needs at least one sample per class ({len(classes)} classes) "
"for a stratified split. Adjust the validation fraction or add samples."
)
indices = np.arange(sample_count)
train_index, validation_index = train_test_split(
indices,
test_size=validation_fraction,
random_state=seed,
stratify=labels,
)
return train_index, validation_index
def _fit_scaler(values: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
mean = values.mean(axis=0, dtype=np.float64).astype(np.float32)
scale = values.std(axis=0, dtype=np.float64).astype(np.float32)
scale[scale < 1e-8] = 1.0
return mean, scale
def _scale(values: np.ndarray, mean: np.ndarray, scale: np.ndarray) -> np.ndarray:
return ((values.astype(np.float32) - mean) / scale).astype(np.float32)
def _make_model(
workspace: PreparedWorkspace, hyperparameters: Hyperparameters
) -> BioLMNet:
gene = workspace.gene_branch
dna = workspace.dna_branch
if (
gene.embeddings is None
or dna.embeddings is None
or gene.pathway_mask is None
or dna.pathway_mask is None
):
raise ValueError("Workspace priors are incomplete; run data preparation first.")
return BioLMNet(
gene_biological_mask=torch.from_numpy(gene.biological_mask),
dna_biological_mask=torch.from_numpy(dna.biological_mask),
gene_embeddings=torch.from_numpy(gene.embeddings),
dna_embeddings=torch.from_numpy(dna.embeddings),
gene_pathway_mask=torch.from_numpy(gene.pathway_mask),
dna_pathway_mask=torch.from_numpy(dna.pathway_mask),
n_classes=len(workspace.label_names),
projection_dim=hyperparameters.projection_dim,
fusion_dim=hyperparameters.fusion_dim,
dropout=hyperparameters.dropout,
biological_activation=hyperparameters.biological_activation,
projection_activation=hyperparameters.projection_activation,
fusion_activation=hyperparameters.fusion_activation,
)
def _evaluate(
model: BioLMNet,
gene_values: np.ndarray,
dna_values: np.ndarray,
labels: np.ndarray,
device: torch.device,
) -> tuple[float, np.ndarray, np.ndarray]:
model.eval()
with torch.no_grad():
logits = model(
torch.from_numpy(gene_values).to(device),
torch.from_numpy(dna_values).to(device),
)
loss = nn.functional.cross_entropy(
logits, torch.from_numpy(labels).to(device)
).item()
probabilities = torch.softmax(logits, dim=1).cpu().numpy()
predictions = probabilities.argmax(axis=1)
return float(loss), probabilities, predictions
def train(
workspace: PreparedWorkspace,
hyperparameters: Hyperparameters,
progress: ProgressCallback | None = None,
) -> TrainingResult:
hyperparameters.validate()
set_reproducible_seed(hyperparameters.seed)
labels = workspace.labels
train_index, validation_index = split_train_validation(
labels, hyperparameters.validation_fraction, hyperparameters.seed
)
gene_mean, gene_scale = _fit_scaler(
workspace.gene_expression[train_index]
)
dna_mean, dna_scale = _fit_scaler(workspace.dna_methylation[train_index])
gene_train = _scale(
workspace.gene_expression[train_index], gene_mean, gene_scale
)
gene_validation = _scale(
workspace.gene_expression[validation_index], gene_mean, gene_scale
)
dna_train = _scale(
workspace.dna_methylation[train_index], dna_mean, dna_scale
)
dna_validation = _scale(
workspace.dna_methylation[validation_index], dna_mean, dna_scale
)
y_train = labels[train_index]
y_validation = labels[validation_index]
dataset = TensorDataset(
torch.from_numpy(gene_train),
torch.from_numpy(dna_train),
torch.from_numpy(y_train),
)
generator = torch.Generator().manual_seed(hyperparameters.seed)
loader = DataLoader(
dataset,
batch_size=min(hyperparameters.batch_size, len(dataset)),
shuffle=True,
generator=generator,
)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = _make_model(workspace, hyperparameters).to(device)
if hyperparameters.optimizer.lower() == "sgd":
optimizer = torch.optim.SGD(
model.parameters(),
lr=hyperparameters.learning_rate,
momentum=hyperparameters.sgd_momentum,
weight_decay=hyperparameters.weight_decay,
)
else:
optimizer = torch.optim.Adam(
model.parameters(),
lr=hyperparameters.learning_rate,
weight_decay=hyperparameters.weight_decay,
)
class_weights: torch.Tensor | None = None
if hyperparameters.class_weighting:
count_by_class = np.bincount(
y_train, minlength=len(workspace.label_names)
).astype(np.float32)
weights = len(y_train) / (len(count_by_class) * count_by_class)
class_weights = torch.from_numpy(weights).to(device)
criterion = nn.CrossEntropyLoss(weight=class_weights)
history: list[dict[str, float]] = []
best_state: dict[str, torch.Tensor] | None = None
best_loss = float("inf")
patience = 0
for epoch in range(hyperparameters.epochs):
model.train()
running_loss = 0.0
seen = 0
for gene_batch, dna_batch, label_batch in loader:
gene_batch = gene_batch.to(device)
dna_batch = dna_batch.to(device)
label_batch = label_batch.to(device)
optimizer.zero_grad(set_to_none=True)
logits = model(gene_batch, dna_batch)
loss = criterion(logits, label_batch)
loss.backward()
optimizer.step()
running_loss += loss.item() * len(label_batch)
seen += len(label_batch)
validation_loss, _, validation_predictions = _evaluate(
model,
gene_validation,
dna_validation,
y_validation,
device,
)
epoch_row = {
"epoch": float(epoch + 1),
"training_loss": float(running_loss / max(seen, 1)),
"validation_loss": validation_loss,
"validation_accuracy": float(
accuracy_score(y_validation, validation_predictions)
),
"validation_f1_macro": float(
f1_score(
y_validation,
validation_predictions,
average="macro",
zero_division=0,
)
),
}
history.append(epoch_row)
if progress:
progress(
(epoch + 1) / hyperparameters.epochs,
(
f"Epoch {epoch + 1}/{hyperparameters.epochs} · "
f"validation F1 {epoch_row['validation_f1_macro']:.3f}"
),
)
if validation_loss < best_loss - 1e-5:
best_loss = validation_loss
best_state = copy.deepcopy(model.state_dict())
patience = 0
else:
patience += 1
if patience >= hyperparameters.early_stopping_patience:
break
if best_state is not None:
model.load_state_dict(best_state)
validation_loss, probabilities, predictions = _evaluate(
model,
gene_validation,
dna_validation,
y_validation,
device,
)
confusion = confusion_matrix(
y_validation,
predictions,
labels=np.arange(len(workspace.label_names)),
)
metrics = {
"validation_loss": validation_loss,
"accuracy": float(accuracy_score(y_validation, predictions)),
"f1_macro": float(
f1_score(
y_validation, predictions, average="macro", zero_division=0
)
),
"f1_weighted": float(
f1_score(
y_validation, predictions, average="weighted", zero_division=0
)
),
"precision_macro": float(
precision_score(
y_validation, predictions, average="macro", zero_division=0
)
),
"recall_macro": float(
recall_score(
y_validation, predictions, average="macro", zero_division=0
)
),
"epochs_completed": len(history),
"training_samples": int(len(train_index)),
"validation_samples": int(len(validation_index)),
"device": str(device),
}
predictions_frame = pd.DataFrame(
{
"sample_row": validation_index,
"observed": [
workspace.label_names[value] for value in y_validation
],
"predicted": [
workspace.label_names[value] for value in predictions
],
}
)
for index, label in enumerate(workspace.label_names):
predictions_frame[f"P({label})"] = probabilities[:, index]
gene = workspace.gene_branch
dna = workspace.dna_branch
config = {
"format_version": 1,
"source_name": workspace.source_name,
"gene_features": gene.input_genes,
"dna_features": dna.input_genes,
"label_names": workspace.label_names,
"gene_hidden_genes": gene.hidden_genes,
"dna_hidden_genes": dna.hidden_genes,
"gene_pathways": gene.pathways,
"dna_pathways": dna.pathways,
"hyperparameters": asdict(hyperparameters),
"architecture": {
"projection_dim": hyperparameters.projection_dim,
"fusion_dim": hyperparameters.fusion_dim,
"dropout": hyperparameters.dropout,
"biological_activation": hyperparameters.biological_activation,
"projection_activation": hyperparameters.projection_activation,
"fusion_activation": hyperparameters.fusion_activation,
},
}
bundle = ModelBundle(
model=model.cpu(),
gene_features=gene.input_genes,
dna_features=dna.input_genes,
label_names=workspace.label_names,
gene_mean=gene_mean,
gene_scale=gene_scale,
dna_mean=dna_mean,
dna_scale=dna_scale,
config=config,
metrics=metrics,
history=history,
)
return TrainingResult(
bundle=bundle,
validation_predictions=predictions_frame,
confusion=confusion,
)
def validate_prediction_frames(
gene_frame: pd.DataFrame,
dna_frame: pd.DataFrame,
bundle: ModelBundle,
) -> tuple[np.ndarray, np.ndarray]:
if len(gene_frame) != len(dna_frame):
raise ValueError(
"Prediction gene-expression and DNA-methylation files must have "
"the same number of rows."
)
missing_gene = sorted(set(bundle.gene_features) - set(gene_frame.columns))
missing_dna = sorted(set(bundle.dna_features) - set(dna_frame.columns))
if missing_gene or missing_dna:
details = []
if missing_gene:
details.append(
"gene-expression: " + ", ".join(missing_gene[:8])
)
if missing_dna:
details.append("DNA-methylation: " + ", ".join(missing_dna[:8]))
raise ValueError(
"Prediction files are missing trained features (" + "; ".join(details) + ")."
)
gene_values = gene_frame.loc[:, bundle.gene_features].apply(
pd.to_numeric, errors="coerce"
)
dna_values = dna_frame.loc[:, bundle.dna_features].apply(
pd.to_numeric, errors="coerce"
)
if gene_values.isna().any().any() or dna_values.isna().any().any():
raise ValueError("Prediction inputs contain missing or non-numeric values.")
return (
_scale(gene_values.to_numpy(), bundle.gene_mean, bundle.gene_scale),
_scale(dna_values.to_numpy(), bundle.dna_mean, bundle.dna_scale),
)
def predict(
gene_frame: pd.DataFrame,
dna_frame: pd.DataFrame,
bundle: ModelBundle,
) -> pd.DataFrame:
gene_values, dna_values = validate_prediction_frames(
gene_frame, dna_frame, bundle
)
bundle.model.eval()
with torch.no_grad():
logits = bundle.model(
torch.from_numpy(gene_values), torch.from_numpy(dna_values)
)
probabilities = torch.softmax(logits, dim=1).numpy()
predicted = probabilities.argmax(axis=1)
output = pd.DataFrame(
{
"sample_row": np.arange(len(gene_frame)),
"predicted_class": [
bundle.label_names[index] for index in predicted
],
"confidence": probabilities.max(axis=1),
}
)
for index, label in enumerate(bundle.label_names):
output[f"P({label})"] = probabilities[:, index]
return output
def pathway_importance(bundle: ModelBundle) -> pd.DataFrame:
bundle.model.eval()
with torch.no_grad():
attention = bundle.model.pathway_attention()
records: list[dict[str, float | str]] = []
for branch_name, config_key in (
("Gene expression", "gene_pathways"),
("DNA methylation", "dna_pathways"),
):
key = "gene_expression" if branch_name == "Gene expression" else "dna_methylation"
weights = attention[key].cpu().numpy()
pathways = bundle.config[config_key]
peak_attention = weights.max(axis=0)
entropy = -(
weights * np.log(np.clip(weights, 1e-12, None))
).sum(axis=0)
for pathway, peak, entropy_value in zip(
pathways, peak_attention, entropy, strict=True
):
records.append(
{
"branch": branch_name,
"pathway": pathway,
"peak_gene_attention": float(peak),
"attention_entropy": float(entropy_value),
}
)
return (
pd.DataFrame(records)
.sort_values("peak_gene_attention", ascending=False)
.reset_index(drop=True)
)