AI4deeperScience's picture
Add files using upload-large-folder tool
6cf9dac verified
Raw
History Blame Contribute Delete
27.8 kB
"""Leakage-controlled neural reanalysis using the existing training functions."""
from __future__ import annotations
import copy
import json
import time
from pathlib import Path
from typing import Any, Dict, Mapping, Sequence
import joblib
import numpy as np
import pandas as pd
import torch
from sklearn.ensemble import ExtraTreesRegressor
from sklearn.preprocessing import LabelEncoder, StandardScaler
from torch_geometric.loader import DataLoader as PyGDataLoader
from src.data import MolecularFeatureExtractor
from src.models import GATModel, GraphConvModel
from src.trainers import train_fingerprint_nn_fold, train_hybrid_model_fold
from revision.scripts.reanalysis_core import (
compute_regression_metrics,
ensure_new_output_dir,
paired_group_bootstrap,
per_lab_metrics,
)
PRIMARY_STACK_MODEL = "stack_all_plus_descriptors"
BASE_MODEL_ORDER = ("gat", "gcn", "fpnn")
def estimate_neural_workload(config: Mapping[str, Any]) -> Dict[str, int]:
"""Count the predeclared model fits before launching long training."""
outer_runs = len(config.get("outer_seeds", [])) * len(config.get("split_strategies", {}))
inner_folds = int(config.get("inner_folds", 6))
base_models = config.get("neural", {}).get("base_models", list(BASE_MODEL_ORDER))
per_outer = inner_folds * len(base_models)
return {
"outer_runs": int(outer_runs),
"base_model_fits_per_outer_run": int(per_outer),
"total_base_model_fits": int(outer_runs * per_outer),
}
def _tree_parameters(estimator_config: Mapping[str, Any], seed: int) -> Dict[str, Any]:
parameters = {
"n_estimators": int(estimator_config.get("n_estimators", 800)),
"max_depth": estimator_config.get("max_depth", 12),
"min_samples_split": int(estimator_config.get("min_samples_split", 5)),
"min_samples_leaf": int(estimator_config.get("min_samples_leaf", 3)),
"max_features": estimator_config.get("max_features", 0.8),
"bootstrap": bool(estimator_config.get("bootstrap", True)),
"random_state": int(seed),
"n_jobs": int(estimator_config.get("n_jobs", -1)),
}
if parameters["bootstrap"]:
parameters["max_samples"] = estimator_config.get("max_samples", 0.85)
return parameters
def build_stack_ablations(
*,
oof_predictions: Mapping[str, np.ndarray],
test_predictions: Mapping[str, np.ndarray],
development_descriptors: np.ndarray,
test_descriptors: np.ndarray,
development_targets: Sequence[float],
seed: int,
estimator_config: Mapping[str, Any],
) -> Dict[str, Any]:
"""Fit predeclared full, descriptor-free, and pairwise stacks."""
missing = [name for name in BASE_MODEL_ORDER if name not in oof_predictions or name not in test_predictions]
if missing:
raise ValueError(f"Missing base predictions for: {missing}")
y = np.asarray(development_targets, dtype=float)
oof_columns = {name: np.asarray(oof_predictions[name], dtype=float) for name in BASE_MODEL_ORDER}
test_columns = {name: np.asarray(test_predictions[name], dtype=float) for name in BASE_MODEL_ORDER}
base_oof = np.column_stack([oof_columns[name] for name in BASE_MODEL_ORDER])
base_test = np.column_stack([test_columns[name] for name in BASE_MODEL_ORDER])
development_descriptors = np.asarray(development_descriptors, dtype=np.float32)
test_descriptors = np.asarray(test_descriptors, dtype=np.float32)
feature_sets = {
"stack_all_base_only": (base_oof, base_test),
"stack_all_plus_descriptors": (
np.column_stack([base_oof, development_descriptors]),
np.column_stack([base_test, test_descriptors]),
),
"stack_gat_gcn": (base_oof[:, [0, 1]], base_test[:, [0, 1]]),
"stack_gat_fpnn": (base_oof[:, [0, 2]], base_test[:, [0, 2]]),
"stack_gcn_fpnn": (base_oof[:, [1, 2]], base_test[:, [1, 2]]),
}
predictions: Dict[str, np.ndarray] = {
"arithmetic_mean": np.mean(base_test, axis=1),
}
models: Dict[str, ExtraTreesRegressor] = {}
for name, (train_features, heldout_features) in feature_sets.items():
model = ExtraTreesRegressor(**_tree_parameters(estimator_config, seed))
model.fit(train_features, y)
predictions[name] = np.asarray(model.predict(heldout_features), dtype=float)
models[name] = model
return {
"primary_model": PRIMARY_STACK_MODEL,
"predictions": predictions,
"models": models,
"feature_dimensions": {name: int(values[0].shape[1]) for name, values in feature_sets.items()},
}
def _default_neural_config(num_labs: int, input_dim: int, fingerprint_dim: int) -> Dict[str, Any]:
return {
"base_models": ["gat", "gcn", "fpnn"],
"gat": {
"model": {
"graph_model_kwargs": {
"input_dim": input_dim,
"hidden_dim": 320,
"num_layers": 4,
"dropout": 0.18,
"num_labs": num_labs,
"lab_embed_dim": 32,
},
"graph_feature_dim": 320,
"descriptor_hidden_dims": [160, 80],
"final_hidden_dims": [160, 80],
"dropout": 0.2,
"use_batch_norm": True,
},
"training": {
"epochs": 600,
"lr": 0.0003,
"weight_decay": 0.000005,
"patience": 60,
"plateau_patience": 40,
"factor": 0.6,
"min_lr": 0.000001,
"batch_size": 32,
"gradient_clip": 1.0,
},
},
"gcn": {
"model": {
"graph_model_kwargs": {
"input_dim": input_dim,
"hidden_dim": 256,
"num_layers": 5,
"dropout": 0.2,
"num_labs": num_labs,
"lab_embed_dim": 32,
},
"graph_feature_dim": 256,
"descriptor_hidden_dims": [128, 64],
"final_hidden_dims": [128, 64],
"dropout": 0.2,
"use_batch_norm": True,
},
"training": {
"epochs": 600,
"lr": 0.0004,
"weight_decay": 0.00001,
"patience": 60,
"plateau_patience": 35,
"factor": 0.6,
"min_lr": 0.000001,
"batch_size": 32,
"gradient_clip": 1.0,
},
},
"fpnn": {
"model": {
"input_dim": fingerprint_dim,
"hidden_dims": [768, 384, 192, 96],
"dropout": 0.15,
"use_batch_norm": True,
"num_labs": num_labs,
"lab_embed_dim": 64,
"input_dropout": 0.1,
},
"training": {
"epochs": 600,
"lr": 0.0015,
"weight_decay": 0.00001,
"patience": 60,
"warmup_epochs": 15,
"batch_size": 128,
"gradient_clip": 1.0,
},
},
"meta_learner": {
"n_estimators": 800,
"max_depth": 12,
"min_samples_split": 5,
"min_samples_leaf": 3,
"max_features": 0.8,
"bootstrap": True,
"max_samples": 0.85,
},
}
def _merge_neural_config(defaults: Mapping[str, Any], overrides: Mapping[str, Any]) -> Dict[str, Any]:
merged = copy.deepcopy(dict(defaults))
for key, value in overrides.items():
if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
merged[key] = _merge_neural_config(merged[key], value)
else:
merged[key] = copy.deepcopy(value)
return merged
def _set_seed(seed: int) -> None:
np.random.seed(int(seed))
torch.manual_seed(int(seed))
if torch.cuda.is_available():
torch.cuda.manual_seed_all(int(seed))
def _predict_hybrid_model(
model: torch.nn.Module,
graphs: Sequence[Any],
labs: np.ndarray,
descriptors: np.ndarray,
device: torch.device,
) -> np.ndarray:
prepared = []
for index, graph in enumerate(graphs):
graph_copy = graph.clone()
graph_copy.lab_feature = torch.tensor([int(labs[index])], dtype=torch.long)
graph_copy.descriptors = torch.tensor(descriptors[index], dtype=torch.float32).reshape(1, -1)
prepared.append(graph_copy)
loader = PyGDataLoader(prepared, batch_size=64, shuffle=False)
predictions = []
model = model.to(device)
model.eval()
with torch.no_grad():
for batch in loader:
batch = batch.to(device)
lab_tensor = batch.lab_feature.squeeze(-1) if batch.lab_feature.dim() > 1 else batch.lab_feature
descriptor_tensor = batch.descriptors.reshape(batch.num_graphs, -1)
values = model(
batch.x,
batch.edge_index,
batch.batch,
lab_tensor,
descriptor_tensor,
getattr(batch, "edge_attr", None),
)
values = values.detach().cpu().numpy().reshape(-1)
predictions.extend(
(values * float(getattr(model, "target_std", 1.0)) + float(getattr(model, "target_mean", 0.0))).tolist()
)
model.to("cpu")
return np.asarray(predictions, dtype=np.float32)
def _predict_fpnn_model(
model: torch.nn.Module,
fingerprints: np.ndarray,
labs: np.ndarray,
device: torch.device,
) -> np.ndarray:
feature_tensor = torch.tensor(fingerprints, dtype=torch.float32)
lab_tensor = torch.tensor(labs, dtype=torch.long)
loader = torch.utils.data.DataLoader(
torch.utils.data.TensorDataset(feature_tensor, lab_tensor),
batch_size=256,
shuffle=False,
)
predictions = []
model = model.to(device)
model.eval()
with torch.no_grad():
for batch_features, batch_labs in loader:
values = model(batch_features.to(device), batch_labs.to(device)).detach().cpu().numpy().reshape(-1)
predictions.extend(
(values * float(getattr(model, "target_std", 1.0)) + float(getattr(model, "target_mean", 0.0))).tolist()
)
model.to("cpu")
return np.asarray(predictions, dtype=np.float32)
def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
def default(value: Any) -> Any:
if isinstance(value, np.integer):
return int(value)
if isinstance(value, np.floating):
return float(value)
if isinstance(value, np.ndarray):
return value.tolist()
raise TypeError(type(value).__name__)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False, default=default) + "\n", encoding="utf-8")
def _maximum_train_tanimoto(
train_fingerprints: np.ndarray,
test_fingerprints: np.ndarray,
*,
chunk_size: int = 64,
) -> np.ndarray:
"""Compute each test row's maximum binary Tanimoto to development rows."""
train = (np.asarray(train_fingerprints) > 0).astype(np.float32)
test = (np.asarray(test_fingerprints) > 0).astype(np.float32)
train_counts = train.sum(axis=1, dtype=np.float64)
maxima = np.zeros(len(test), dtype=np.float32)
for start in range(0, len(test), chunk_size):
stop = min(start + chunk_size, len(test))
chunk = test[start:stop]
intersections = chunk @ train.T
unions = (
chunk.sum(axis=1, dtype=np.float64)[:, None]
+ train_counts[None, :]
- intersections
)
similarities = np.divide(
intersections,
unions,
out=np.zeros_like(intersections, dtype=np.float32),
where=unions > 0,
)
maxima[start:stop] = similarities.max(axis=1)
return maxima
def _prospective_domain_summary(
*,
y_true: np.ndarray,
y_pred: np.ndarray,
maximum_similarity: np.ndarray,
base_model_spread: np.ndarray,
thresholds: Sequence[float],
) -> Dict[str, Any]:
absolute_error = np.abs(np.asarray(y_pred) - np.asarray(y_true))
similarity = np.asarray(maximum_similarity, dtype=float)
spread = np.asarray(base_model_spread, dtype=float)
bins = np.asarray([0.0, 0.2, 0.4, 0.6, 0.8, 1.000001], dtype=float)
bin_rows = []
for lower, upper in zip(bins[:-1], bins[1:]):
mask = (similarity >= lower) & (similarity < upper)
bin_rows.append(
{
"similarity_lower": float(lower),
"similarity_upper": float(min(upper, 1.0)),
"n": int(mask.sum()),
"mae": float(absolute_error[mask].mean()) if mask.any() else float("nan"),
}
)
threshold_rows = []
for threshold in thresholds:
accepted = similarity >= float(threshold)
rejected = ~accepted
threshold_rows.append(
{
"threshold": float(threshold),
"accepted_n": int(accepted.sum()),
"rejected_n": int(rejected.sum()),
"accepted_coverage": float(accepted.mean()),
"accepted_mae": float(absolute_error[accepted].mean()) if accepted.any() else float("nan"),
"rejected_mae": float(absolute_error[rejected].mean()) if rejected.any() else float("nan"),
}
)
return {
"interpretation": (
"Maximum training-set Tanimoto is a prospective structural-domain diagnostic. "
"Base-model standard deviation is uncalibrated model disagreement, not a confidence interval."
),
"similarity_bins": bin_rows,
"threshold_sensitivity": threshold_rows,
"spearman_similarity_vs_absolute_error": float(
pd.Series(similarity).corr(pd.Series(absolute_error), method="spearman")
),
"spearman_model_spread_vs_absolute_error": float(
pd.Series(spread).corr(pd.Series(absolute_error), method="spearman")
),
}
def run_neural_stack(
config: Mapping[str, Any],
*,
annotated: pd.DataFrame,
descriptor_matrix: np.ndarray,
fingerprint_matrix: np.ndarray,
train_indices: Sequence[int],
test_indices: Sequence[int],
inner_folds: Sequence[tuple[np.ndarray, np.ndarray]],
output_dir: Path,
seed: int,
smoke_only: bool = False,
) -> Path:
"""Retrain the fixed neural stack for one predeclared outer split.
All checkpoints, scalers, OOF predictions, and metrics are written below
``output_dir``. The function never references ``hybrid_oof_models``.
"""
run_started = time.perf_counter()
output_dir = ensure_new_output_dir(output_dir)
extractor = MolecularFeatureExtractor()
graphs = []
for row_index, smiles in enumerate(annotated["SMILES"].astype(str)):
graph = extractor.smiles_to_graph(smiles)
if graph is None:
raise ValueError(f"Graph conversion failed at annotated row {row_index}.")
graphs.append(graph)
lab_encoder = LabelEncoder()
lab_indices = lab_encoder.fit_transform(annotated["Lab"].astype(str)).astype(np.int64)
development = np.asarray(train_indices, dtype=int)
test = np.asarray(test_indices, dtype=int)
untrained_test_labs = sorted(
set(lab_indices[test]) - set(lab_indices[development])
)
if untrained_test_labs and not smoke_only:
raise ValueError("A test laboratory is absent from development; categorical embeddings cannot extrapolate it.")
targets = annotated["RT"].to_numpy(dtype=np.float32)
default_config = _default_neural_config(
num_labs=len(lab_encoder.classes_),
input_dim=int(graphs[0].x.shape[1]),
fingerprint_dim=int(fingerprint_matrix.shape[1]),
)
neural_config = _merge_neural_config(default_config, config.get("neural", {}))
if tuple(neural_config.get("base_models", BASE_MODEL_ORDER)) != BASE_MODEL_ORDER:
raise ValueError(f"base_models must remain predeclared as {BASE_MODEL_ORDER}.")
if smoke_only:
for name in BASE_MODEL_ORDER:
neural_config[name]["training"]["epochs"] = 1
neural_config[name]["training"]["patience"] = 1
neural_config["meta_learner"]["n_estimators"] = min(
8, int(neural_config["meta_learner"].get("n_estimators", 8))
)
requested_device = config.get("neural", {}).get("device", "auto")
device = torch.device(
"cuda" if requested_device == "auto" and torch.cuda.is_available() else
"cpu" if requested_device == "auto" else requested_device
)
oof_predictions = {name: np.full(len(annotated), np.nan, dtype=np.float32) for name in BASE_MODEL_ORDER}
fold_test_predictions = {name: [] for name in BASE_MODEL_ORDER}
cv_scores = {name: [] for name in BASE_MODEL_ORDER}
parameter_counts: Dict[str, int] = {}
training_seconds: Dict[str, float] = {name: 0.0 for name in BASE_MODEL_ORDER}
checkpoint_root = output_dir / "checkpoints"
preprocessing_root = output_dir / "fold_preprocessing"
preprocessing_root.mkdir(parents=True, exist_ok=False)
_write_json(
preprocessing_root / "lab_encoder.json",
{"classes_in_index_order": [str(value) for value in lab_encoder.classes_]},
)
for fold_index, (fold_train, fold_validation) in enumerate(inner_folds):
fold_seed = int(seed) + int(fold_index)
_set_seed(fold_seed)
scaler = StandardScaler()
descriptors_train = scaler.fit_transform(descriptor_matrix[fold_train]).astype(np.float32)
descriptors_validation = scaler.transform(descriptor_matrix[fold_validation]).astype(np.float32)
descriptors_test = scaler.transform(descriptor_matrix[test]).astype(np.float32)
joblib.dump(
{
"scaler": scaler,
"descriptor_features": list(config["descriptor_features"]),
"fold_train_indices": np.asarray(fold_train, dtype=np.int32),
"fold_validation_indices": np.asarray(fold_validation, dtype=np.int32),
},
preprocessing_root / f"fold_{fold_index}.joblib",
)
for model_name, model_class in (("gat", GATModel), ("gcn", GraphConvModel)):
fit_started = time.perf_counter()
model, validation_predictions, metrics = train_hybrid_model_fold(
model_name=model_name,
graph_model_class=model_class,
config=neural_config[model_name]["model"],
training_config=neural_config[model_name]["training"],
fold_train_graphs=[graphs[index].clone() for index in fold_train],
fold_val_graphs=[graphs[index].clone() for index in fold_validation],
fold_train_lab=lab_indices[fold_train],
fold_val_lab=lab_indices[fold_validation],
fold_train_targets=targets[fold_train],
fold_val_targets=targets[fold_validation],
fold_train_descriptors=descriptors_train,
fold_val_descriptors=descriptors_validation,
fold_idx=fold_index,
device=device,
save_dir=str(checkpoint_root / model_name),
verbose_interval=50,
)
elapsed = time.perf_counter() - fit_started
training_seconds[model_name] += elapsed
metrics = dict(metrics)
metrics["training_seconds"] = float(elapsed)
parameter_counts.setdefault(
model_name,
int(sum(parameter.numel() for parameter in model.parameters())),
)
oof_predictions[model_name][fold_validation] = validation_predictions
cv_scores[model_name].append(metrics)
fold_test_predictions[model_name].append(
_predict_hybrid_model(
model,
[graphs[index].clone() for index in test],
lab_indices[test],
descriptors_test,
device,
)
)
fit_started = time.perf_counter()
fpnn_model, validation_predictions, metrics = train_fingerprint_nn_fold(
train_fingerprints=fingerprint_matrix[fold_train],
val_fingerprints=fingerprint_matrix[fold_validation],
train_targets=targets[fold_train],
val_targets=targets[fold_validation],
fold_idx=fold_index,
device=device,
config=neural_config["fpnn"]["model"],
training_config=neural_config["fpnn"]["training"],
save_dir=str(checkpoint_root / "fpnn"),
train_lab_indices=lab_indices[fold_train],
val_lab_indices=lab_indices[fold_validation],
)
elapsed = time.perf_counter() - fit_started
training_seconds["fpnn"] += elapsed
metrics = dict(metrics)
metrics["training_seconds"] = float(elapsed)
parameter_counts.setdefault(
"fpnn",
int(sum(parameter.numel() for parameter in fpnn_model.parameters())),
)
oof_predictions["fpnn"][fold_validation] = validation_predictions
cv_scores["fpnn"].append(metrics)
fold_test_predictions["fpnn"].append(
_predict_fpnn_model(fpnn_model, fingerprint_matrix[test], lab_indices[test], device)
)
for model_name in BASE_MODEL_ORDER:
if np.isnan(oof_predictions[model_name][development]).any():
raise RuntimeError(f"Incomplete OOF predictions for {model_name}.")
test_predictions = {
name: np.mean(np.stack(values, axis=0), axis=0)
for name, values in fold_test_predictions.items()
}
stack = build_stack_ablations(
oof_predictions={name: oof_predictions[name][development] for name in BASE_MODEL_ORDER},
test_predictions=test_predictions,
development_descriptors=descriptor_matrix[development],
test_descriptors=descriptor_matrix[test],
development_targets=targets[development],
seed=seed,
estimator_config=neural_config["meta_learner"],
)
all_test_predictions = {**test_predictions, **stack["predictions"]}
metrics = {
name: compute_regression_metrics(targets[test], predictions)
for name, predictions in all_test_predictions.items()
}
maximum_similarity = _maximum_train_tanimoto(
fingerprint_matrix[development],
fingerprint_matrix[test],
)
base_model_spread = np.std(
np.column_stack([test_predictions[name] for name in BASE_MODEL_ORDER]),
axis=1,
ddof=0,
)
primary_predictions = stack["predictions"][stack["primary_model"]]
domain_summary = _prospective_domain_summary(
y_true=targets[test],
y_pred=primary_predictions,
maximum_similarity=maximum_similarity,
base_model_spread=base_model_spread,
thresholds=config.get("prospective_domain", {}).get(
"similarity_thresholds",
[0.3, 0.4, 0.5],
),
)
prediction_frame = annotated.loc[
test,
[
"record_index",
"SMILES",
"Lab",
"RT",
"structure_group",
"scaffold_group",
"scaffold_component_group",
],
].copy()
for name, values in all_test_predictions.items():
prediction_frame[f"prediction_{name}"] = values
prediction_frame["maximum_development_tanimoto"] = maximum_similarity
prediction_frame["base_model_spread"] = base_model_spread
prediction_frame["primary_absolute_error"] = np.abs(
primary_predictions - targets[test]
)
prediction_frame.to_csv(output_dir / "test_predictions.csv", index=False)
development_ranges = (
annotated.loc[development]
.groupby("Lab")["RT"]
.agg(lambda values: float(values.max() - values.min()))
.to_dict()
)
per_lab_tables = []
for name, values in all_test_predictions.items():
table = per_lab_metrics(
targets[test],
values,
annotated.loc[test, "Lab"].to_numpy(),
normalization_ranges=development_ranges,
)
table.insert(0, "model", name)
per_lab_tables.append(table)
pd.concat(per_lab_tables, ignore_index=True).to_csv(output_dir / "per_lab_metrics.csv", index=False)
primary = primary_predictions
paired = {}
for name, reference in all_test_predictions.items():
if name == stack["primary_model"]:
continue
paired[name] = paired_group_bootstrap(
y_true=targets[test],
candidate=primary,
reference=reference,
groups=annotated.loc[test, "structure_group"].to_numpy(),
n_resamples=int(config["bootstrap"]["n_resamples"]),
confidence=float(config["bootstrap"].get("confidence", 0.95)),
seed=seed,
)
np.savez_compressed(
output_dir / "oof_predictions.npz",
development_indices=development.astype(np.int32),
y_true=targets[development],
**{name: oof_predictions[name][development] for name in BASE_MODEL_ORDER},
)
np.savez_compressed(
output_dir / "test_predictions.npz",
test_indices=test.astype(np.int32),
y_true=targets[test],
maximum_development_tanimoto=maximum_similarity,
base_model_spread=base_model_spread,
**all_test_predictions,
)
joblib.dump(stack["models"], output_dir / "stack_models.joblib")
_write_json(
output_dir / "metrics.json",
{
"primary_model_predeclared": stack["primary_model"],
"metrics": metrics,
"feature_dimensions": stack["feature_dimensions"],
"selection_rule": "No outer-test model selection; all fixed ablations are reported.",
},
)
_write_json(output_dir / "paired_group_bootstrap.json", paired)
_write_json(output_dir / "prospective_domain_diagnostics.json", domain_summary)
_write_json(output_dir / "cv_scores.json", cv_scores)
_write_json(
output_dir / "RUN_METADATA.json",
{
"seed": int(seed),
"device": str(device),
"smoke_only": bool(smoke_only),
"smoke_only_untrained_test_lab_indices": untrained_test_labs,
"descriptor_features_predeclared": list(config["descriptor_features"]),
"neural_config": neural_config,
"parameter_counts": parameter_counts,
"training_seconds_by_model": training_seconds,
"total_run_seconds": float(time.perf_counter() - run_started),
"gpu_name": torch.cuda.get_device_name(device) if device.type == "cuda" else None,
"test_set_used_for_selection": False,
"checkpoint_root": "checkpoints",
},
)
return output_dir