Spaces:
Runtime error
Runtime error
| #!/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() | |