variant-risk-explainer / training /evaluate_saved_model.py
faisalAI27
training completed
4bf7f42
Raw
History Blame Contribute Delete
21 kB
#!/usr/bin/env python3
"""Memory-safe full evaluation for a saved DNABERT-2 ClinVar model.
This script does not train. It loads the saved model, evaluates the full
validation and test CSV files in small batches, and writes metrics to JSON.
"""
from __future__ import annotations
import argparse
import gc
import json
import os
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import torch
from safetensors.torch import load_file as load_safetensors_file
from sklearn.metrics import (
accuracy_score,
confusion_matrix,
f1_score,
matthews_corrcoef,
precision_score,
recall_score,
roc_auc_score,
)
from tqdm.auto import tqdm
from transformers import AutoConfig, AutoModelForSequenceClassification, AutoTokenizer
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from training.train_smoke_test import ( # noqa: E402
LOCAL_DNABERT2_PATCH_DIR,
clear_local_patch_module_cache,
create_local_dnabert2_patch,
disable_flash_attention_on_config,
load_sequence_classification_model,
)
OUTPUT_DIR = PROJECT_ROOT / "training" / "outputs" / "dnabert2_clinvar"
MODEL_DIR = OUTPUT_DIR / "final_model"
UPLOADED_MODEL_DIR = PROJECT_ROOT / "training" / "training_model_files"
TRAINING_METRICS_PATH = OUTPUT_DIR / "metrics.json"
FULL_EVAL_METRICS_PATH = OUTPUT_DIR / "full_eval_metrics.json"
ALT_SPLIT_FILES = {
"validation": "val_with_alt_sequences.csv",
"test": "test_with_alt_sequences.csv",
}
DATASET_CANDIDATES = [
PROJECT_ROOT / "training" / "csv_files_20k_alt",
PROJECT_ROOT / "training" / "csv_files_10k_alt",
PROJECT_ROOT / "training" / "csv_files_large_alt",
PROJECT_ROOT / "training" / "csv_files_alt",
PROJECT_ROOT / "data" / "processed",
PROJECT_ROOT / "training" / "csv_files",
]
SEQUENCE_COLUMN = "sequence"
LABEL_COLUMN = "label"
MAX_LENGTH = 512
VARIANT_CENTER_INDEX = 512
BATCH_SIZE = 1
MPS_CACHE_EVERY = 25
def parse_bool(value: str | bool) -> bool:
if isinstance(value, bool):
return value
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "y"}:
return True
if normalized in {"false", "0", "no", "n"}:
return False
raise argparse.ArgumentTypeError("Use true or false.")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Memory-safe full validation/test evaluation for the saved DNABERT-2 ClinVar model.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--tune_threshold",
type=parse_bool,
nargs="?",
const=True,
default=True,
help="Tune the decision threshold on the full validation set.",
)
parser.add_argument(
"--threshold",
type=float,
default=None,
help="Use this fixed decision threshold instead of tuning.",
)
parser.add_argument(
"--threshold_min",
type=float,
default=0.1,
help="Minimum threshold to test when tuning.",
)
parser.add_argument(
"--threshold_max",
type=float,
default=0.9,
help="Maximum threshold to test when tuning.",
)
parser.add_argument(
"--threshold_step",
type=float,
default=0.01,
help="Threshold step size when tuning.",
)
parser.add_argument(
"--model_dir",
type=Path,
default=None,
help=(
"Saved model folder to evaluate. Defaults to training/outputs/dnabert2_clinvar/final_model; "
"if that is missing, falls back to training/training_model_files."
),
)
return parser.parse_args()
def validate_args(args: argparse.Namespace) -> None:
if args.threshold is not None and not 0.0 <= args.threshold <= 1.0:
raise ValueError("--threshold must be between 0 and 1.")
if args.threshold_step <= 0:
raise ValueError("--threshold_step must be greater than 0.")
if args.threshold_min > args.threshold_max:
raise ValueError("--threshold_min must be less than or equal to --threshold_max.")
def resolve_project_path(path: Path) -> Path:
if path.is_absolute():
return path
return PROJECT_ROOT / path
def choose_model_dir(requested_model_dir: Path | None) -> Path:
if requested_model_dir is not None:
model_dir = resolve_project_path(requested_model_dir)
if not model_dir.exists():
raise FileNotFoundError(f"Requested saved model directory not found: {model_dir}")
return model_dir
if MODEL_DIR.exists():
return MODEL_DIR
if UPLOADED_MODEL_DIR.exists():
return UPLOADED_MODEL_DIR
raise FileNotFoundError(
"Saved model directory not found. Searched:\n"
f"{MODEL_DIR}\n"
f"{UPLOADED_MODEL_DIR}"
)
def choose_device() -> str:
if torch.cuda.is_available():
return "cuda"
mps_backend = getattr(torch.backends, "mps", None)
if mps_backend is not None and mps_backend.is_available():
return "mps"
return "cpu"
def find_dataset_dir() -> Path:
for directory in DATASET_CANDIDATES:
if all((directory / filename).exists() for filename in ALT_SPLIT_FILES.values()):
return directory
searched = "\n".join(str(directory) for directory in DATASET_CANDIDATES)
raise FileNotFoundError(
"Could not find validation/test alternate-sequence CSV files.\n"
f"Searched:\n{searched}"
)
def load_threshold() -> float:
if not TRAINING_METRICS_PATH.exists():
return 0.5
try:
metrics = json.loads(TRAINING_METRICS_PATH.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return 0.5
threshold = metrics.get("selected_threshold")
if threshold is None:
return 0.5
try:
return float(threshold)
except (TypeError, ValueError):
return 0.5
def clean_sequence(value: object) -> str:
return str(value).strip().upper()
def crop_sequence_around_variant(sequence: str, max_length: int, variant_center_index: int) -> str:
if len(sequence) <= max_length:
return sequence
start = max(0, variant_center_index - max_length // 2)
end = start + max_length
if end > len(sequence):
end = len(sequence)
start = max(0, end - max_length)
return sequence[start:end]
def load_eval_dataframe(csv_path: Path, split_name: str) -> pd.DataFrame:
df = pd.read_csv(csv_path)
required_columns = {SEQUENCE_COLUMN, LABEL_COLUMN}
missing_columns = sorted(required_columns - set(df.columns))
if missing_columns:
raise ValueError(f"{csv_path} is missing required columns: {missing_columns}")
df = df.copy()
df[LABEL_COLUMN] = pd.to_numeric(df[LABEL_COLUMN], errors="coerce")
df = df.loc[df[LABEL_COLUMN].isin([0, 1])].copy()
df[LABEL_COLUMN] = df[LABEL_COLUMN].astype(int)
df[SEQUENCE_COLUMN] = df[SEQUENCE_COLUMN].fillna("").apply(clean_sequence)
df = df.loc[df[SEQUENCE_COLUMN] != ""].copy()
df[SEQUENCE_COLUMN] = df[SEQUENCE_COLUMN].apply(
lambda sequence: crop_sequence_around_variant(sequence, MAX_LENGTH, VARIANT_CENTER_INDEX)
)
print(f"{split_name} CSV: {csv_path}")
print(f"{split_name} rows loaded for full evaluation: {len(df):,}")
print(f"{split_name} label distribution:")
print(df[LABEL_COLUMN].value_counts().sort_index().to_string())
print()
if df.empty:
raise ValueError(f"No usable rows remain for {split_name}.")
return df.reset_index(drop=True)
def clear_mps_cache_if_needed(device: str) -> None:
if device != "mps":
return
mps_backend = getattr(torch, "mps", None)
if mps_backend is not None and hasattr(mps_backend, "empty_cache"):
mps_backend.empty_cache()
def extract_logits(outputs) -> torch.Tensor:
if hasattr(outputs, "logits"):
return outputs.logits
if isinstance(outputs, (tuple, list)):
for item in outputs:
if torch.is_tensor(item) and item.ndim >= 2 and item.shape[-1] == 2:
return item
return outputs[0]
raise TypeError("Could not find logits in model outputs.")
def local_patch_is_ready(patch_dir: Path) -> bool:
required_files = [
"config.json",
"configuration_bert.py",
"bert_layers.py",
"bert_padding.py",
"tokenizer.json",
"tokenizer_config.json",
]
if not all((patch_dir / filename).exists() for filename in required_files):
return False
bert_layers_text = (patch_dir / "bert_layers.py").read_text(encoding="utf-8")
return "from .flash_attn_triton import" not in bert_layers_text
def create_patch_from_project_root() -> Path:
previous_cwd = Path.cwd()
try:
os.chdir(PROJECT_ROOT)
patch_dir = create_local_dnabert2_patch()
finally:
os.chdir(previous_cwd)
return resolve_project_path(patch_dir)
def get_local_patch_dir() -> Path:
patch_dir = resolve_project_path(LOCAL_DNABERT2_PATCH_DIR)
if patch_dir.exists() and local_patch_is_ready(patch_dir):
return patch_dir
print("Local Mac-safe DNABERT-2 patch was not found or is incomplete.")
return create_patch_from_project_root()
def load_saved_state_dict(model_dir: Path) -> dict[str, torch.Tensor]:
safetensors_path = model_dir / "model.safetensors"
pytorch_path = model_dir / "pytorch_model.bin"
if safetensors_path.exists():
print(f"Loading fine-tuned weights: {safetensors_path}")
return load_safetensors_file(str(safetensors_path), device="cpu")
if pytorch_path.exists():
print(f"Loading fine-tuned weights: {pytorch_path}")
return torch.load(pytorch_path, map_location="cpu")
raise FileNotFoundError(
"Could not find saved model weights. Expected model.safetensors or pytorch_model.bin in "
f"{model_dir}"
)
def load_saved_model_with_local_patch(model_dir: Path):
"""Load Mac-safe DNABERT-2 code, then load the saved fine-tuned weights."""
patch_dir = get_local_patch_dir()
print(f"Using local Mac-safe DNABERT-2 code: {patch_dir}")
print("Triton/flash attention disabled for Mac.")
clear_local_patch_module_cache()
config = AutoConfig.from_pretrained(str(patch_dir), trust_remote_code=True)
config = disable_flash_attention_on_config(config)
saved_config_path = model_dir / "config.json"
if saved_config_path.exists():
saved_config = json.loads(saved_config_path.read_text(encoding="utf-8"))
if saved_config.get("id2label"):
config.id2label = {int(key): value for key, value in saved_config["id2label"].items()}
if saved_config.get("label2id"):
config.label2id = saved_config["label2id"]
model = load_sequence_classification_model(str(patch_dir), config)
state_dict = load_saved_state_dict(model_dir)
missing_keys, unexpected_keys = model.load_state_dict(state_dict, strict=False)
if missing_keys:
print(f"Warning: missing keys while loading saved weights: {len(missing_keys)}")
print(missing_keys[:10])
if unexpected_keys:
print(f"Warning: unexpected keys while loading saved weights: {len(unexpected_keys)}")
print(unexpected_keys[:10])
print("Model loaded successfully.")
return model
def load_saved_model(model_dir: Path, device: str):
print(f"Trying to load saved model directly from: {model_dir}")
print(f"Selected device: {device}")
try:
model = AutoModelForSequenceClassification.from_pretrained(
str(model_dir),
trust_remote_code=True,
low_cpu_mem_usage=False,
)
print("Saved model loaded cleanly")
return model
except Exception as error:
print("Direct saved-model loading failed.")
print(f"Reason: {type(error).__name__}: {error}")
print("Falling back to training/local_dnabert2_patch.")
return load_saved_model_with_local_patch(model_dir)
def predict_in_small_batches(model, tokenizer, df: pd.DataFrame, device: str) -> tuple[np.ndarray, np.ndarray]:
device_object = torch.device(device)
model.to(device_object)
model.eval()
probabilities: list[float] = []
labels: list[int] = []
for row_number, row in enumerate(tqdm(df.itertuples(index=False), total=len(df), desc="Evaluating"), start=1):
sequence = getattr(row, SEQUENCE_COLUMN)
label = int(getattr(row, LABEL_COLUMN))
encoded = tokenizer(
sequence,
max_length=MAX_LENGTH,
padding="max_length",
truncation=True,
return_tensors="pt",
)
encoded = {key: value.to(device_object) for key, value in encoded.items()}
with torch.no_grad():
outputs = model(**encoded)
logits = extract_logits(outputs)
probability = torch.softmax(logits.float(), dim=-1)[0, 1].detach().cpu().item()
probabilities.append(float(probability))
labels.append(label)
del encoded, outputs, logits
if row_number % MPS_CACHE_EVERY == 0:
clear_mps_cache_if_needed(device)
gc.collect()
clear_mps_cache_if_needed(device)
return np.asarray(probabilities, dtype=np.float64), np.asarray(labels, dtype=int)
def metrics_at_threshold(probabilities: np.ndarray, labels: np.ndarray, threshold: float) -> dict:
predictions = (probabilities >= threshold).astype(int)
matrix = confusion_matrix(labels, predictions, labels=[0, 1]).astype(int)
metrics = {
"threshold": float(threshold),
"accuracy": float(accuracy_score(labels, predictions)),
"precision": float(precision_score(labels, predictions, zero_division=0)),
"recall": float(recall_score(labels, predictions, zero_division=0)),
"f1": float(f1_score(labels, predictions, zero_division=0)),
"mcc": float(matthews_corrcoef(labels, predictions)),
"auc_roc": None,
"confusion_matrix": matrix.tolist(),
"rows": int(len(labels)),
}
if len(np.unique(labels)) == 2:
try:
metrics["auc_roc"] = float(roc_auc_score(labels, probabilities))
except ValueError:
metrics["auc_roc"] = None
return metrics
def build_threshold_grid(threshold_min: float, threshold_max: float, threshold_step: float) -> np.ndarray:
thresholds = np.arange(threshold_min, threshold_max + threshold_step / 2.0, threshold_step)
thresholds = thresholds[thresholds <= threshold_max + 1e-12]
return np.round(thresholds, 10)
def tune_threshold_on_validation(
probabilities: np.ndarray,
labels: np.ndarray,
threshold_min: float,
threshold_max: float,
threshold_step: float,
) -> tuple[float, dict]:
thresholds = build_threshold_grid(threshold_min, threshold_max, threshold_step)
if len(thresholds) == 0:
raise ValueError("No thresholds were generated. Check threshold_min, threshold_max, and threshold_step.")
best_threshold = float(thresholds[0])
best_metrics = metrics_at_threshold(probabilities, labels, best_threshold)
for threshold in thresholds[1:]:
candidate_metrics = metrics_at_threshold(probabilities, labels, float(threshold))
if candidate_metrics["mcc"] > best_metrics["mcc"]:
best_threshold = float(threshold)
best_metrics = candidate_metrics
tuning_summary = {
"threshold_min": float(threshold_min),
"threshold_max": float(threshold_max),
"threshold_step": float(threshold_step),
"thresholds_tested": int(len(thresholds)),
"best_threshold": best_threshold,
"best_validation_mcc": float(best_metrics["mcc"]),
}
print(f"Best full-validation threshold: {best_threshold:.4f}")
print(f"Best full-validation MCC: {best_metrics['mcc']:.4f}")
print()
return best_threshold, tuning_summary
def choose_threshold(args: argparse.Namespace, probabilities: np.ndarray, labels: np.ndarray) -> tuple[float, dict]:
if args.threshold is not None:
print(f"Using threshold provided by --threshold: {args.threshold:.4f}")
print()
return float(args.threshold), {
"mode": "manual",
"selected_threshold": float(args.threshold),
}
if args.tune_threshold:
print("Tuning threshold on the full validation set.")
threshold, tuning_summary = tune_threshold_on_validation(
probabilities,
labels,
args.threshold_min,
args.threshold_max,
args.threshold_step,
)
tuning_summary["mode"] = "full_validation_mcc"
tuning_summary["selected_threshold"] = threshold
return threshold, tuning_summary
saved_threshold = load_threshold()
print("--tune_threshold is false and no --threshold was provided.")
print(f"Falling back to threshold from metrics.json/default: {saved_threshold:.4f}")
print()
return saved_threshold, {
"mode": "saved_or_default",
"selected_threshold": float(saved_threshold),
}
def print_metrics(split_name: str, metrics: dict) -> None:
print("=" * 80)
print(f"{split_name.upper()} FULL EVALUATION")
print("=" * 80)
print(f"Rows: {metrics['rows']:,}")
print(f"Threshold used: {metrics['threshold']:.4f}")
for key in ["accuracy", "precision", "recall", "f1", "mcc", "auc_roc"]:
value = metrics[key]
if value is None:
print(f"{key}: n/a")
else:
print(f"{key}: {value:.4f}")
matrix = metrics["confusion_matrix"]
print("Confusion matrix:")
print(" predicted_0 predicted_1")
print(f"actual_0 {matrix[0][0]:>11} {matrix[0][1]:>11}")
print(f"actual_1 {matrix[1][0]:>11} {matrix[1][1]:>11}")
print()
def main() -> None:
args = parse_args()
validate_args(args)
model_dir = choose_model_dir(args.model_dir)
dataset_dir = find_dataset_dir()
device = choose_device()
print("Memory-safe saved model evaluation")
print(f"Saved model directory: {model_dir}")
print(f"Selected dataset directory: {dataset_dir}")
print(f"Selected device: {device}")
print(f"Tune threshold on full validation set: {args.tune_threshold}")
if args.threshold is not None:
print(f"Manual threshold requested: {args.threshold:.4f}")
else:
print(
"Threshold search range: "
f"{args.threshold_min:.4f} to {args.threshold_max:.4f} "
f"by {args.threshold_step:.4f}"
)
print("Using manual small-batch evaluation. No retraining. No HuggingFace Trainer evaluation.")
print()
print("Loading tokenizer and model.")
tokenizer = AutoTokenizer.from_pretrained(str(model_dir), trust_remote_code=True)
model = load_saved_model(model_dir, device)
all_metrics = {
"model_dir": str(model_dir),
"dataset_dir": str(dataset_dir),
"device": device,
"max_length": MAX_LENGTH,
"variant_center_index": VARIANT_CENTER_INDEX,
"batch_size": BATCH_SIZE,
"threshold_args": {
"tune_threshold": bool(args.tune_threshold),
"threshold": args.threshold,
"threshold_min": float(args.threshold_min),
"threshold_max": float(args.threshold_max),
"threshold_step": float(args.threshold_step),
},
}
predictions_by_split = {}
for split_name, filename in ALT_SPLIT_FILES.items():
csv_path = dataset_dir / filename
df = load_eval_dataframe(csv_path, split_name)
probabilities, labels = predict_in_small_batches(model, tokenizer, df, device)
predictions_by_split[split_name] = {
"probabilities": probabilities,
"labels": labels,
}
validation_predictions = predictions_by_split["validation"]
threshold, threshold_selection = choose_threshold(
args,
validation_predictions["probabilities"],
validation_predictions["labels"],
)
all_metrics["threshold"] = threshold
all_metrics["selected_threshold"] = threshold
all_metrics["threshold_selection"] = threshold_selection
for split_name, prediction_data in predictions_by_split.items():
probabilities = prediction_data["probabilities"]
labels = prediction_data["labels"]
split_metrics = metrics_at_threshold(probabilities, labels, threshold)
all_metrics[f"{split_name}_metrics"] = split_metrics
print_metrics(split_name, split_metrics)
FULL_EVAL_METRICS_PATH.parent.mkdir(parents=True, exist_ok=True)
FULL_EVAL_METRICS_PATH.write_text(json.dumps(all_metrics, indent=2), encoding="utf-8")
print(f"Saved full evaluation metrics to: {FULL_EVAL_METRICS_PATH}")
if __name__ == "__main__":
main()