Source code for cursus.steps.scripts.model_metrics_computation

#!/usr/bin/env python
import os
import json
import sys

from subprocess import check_call
import boto3
import logging

# ============================================================================
# PACKAGE INSTALLATION CONFIGURATION
# ============================================================================

# Control which PyPI source to use via environment variable
# Set USE_SECURE_PYPI=true to use secure CodeArtifact PyPI
# Set USE_SECURE_PYPI=false or leave unset to use public PyPI
USE_SECURE_PYPI = os.environ.get("USE_SECURE_PYPI", "false").lower() == "true"

# Logging setup
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def _get_secure_pypi_access_token() -> str:
    """
    Get CodeArtifact access token for secure PyPI.

    Returns:
        str: Authorization token for CodeArtifact

    Raises:
        Exception: If token retrieval fails
    """
    try:
        os.environ["AWS_STS_REGIONAL_ENDPOINTS"] = "regional"
        sts = boto3.client("sts", region_name="us-east-1")
        caller_identity = sts.get_caller_identity()
        assumed_role_object = sts.assume_role(
            RoleArn="arn:aws:iam::675292366480:role/SecurePyPIReadRole_"
            + caller_identity["Account"],
            RoleSessionName="SecurePypiReadRole",
        )
        credentials = assumed_role_object["Credentials"]
        code_artifact_client = boto3.client(
            "codeartifact",
            aws_access_key_id=credentials["AccessKeyId"],
            aws_secret_access_key=credentials["SecretAccessKey"],
            aws_session_token=credentials["SessionToken"],
            region_name="us-west-2",
        )
        token = code_artifact_client.get_authorization_token(
            domain="amazon", domainOwner="149122183214"
        )["authorizationToken"]

        logger.info("Successfully retrieved secure PyPI access token")
        return token

    except Exception as e:
        logger.error(f"Failed to retrieve secure PyPI access token: {e}")
        raise


[docs] def install_packages_from_public_pypi(packages: list) -> None: """ Install packages from standard public PyPI. Args: packages: List of package specifications (e.g., ["pandas==1.5.0", "numpy"]) """ logger.info(f"Installing {len(packages)} packages from public PyPI") logger.info(f"Packages: {packages}") try: check_call([sys.executable, "-m", "pip", "install", *packages]) logger.info("✓ Successfully installed packages from public PyPI") except Exception as e: logger.error(f"✗ Failed to install packages from public PyPI: {e}") raise
[docs] def install_packages_from_secure_pypi(packages: list) -> None: """ Install packages from secure CodeArtifact PyPI. Args: packages: List of package specifications (e.g., ["pandas==1.5.0", "numpy"]) """ logger.info(f"Installing {len(packages)} packages from secure PyPI") logger.info(f"Packages: {packages}") try: token = _get_secure_pypi_access_token() index_url = f"https://aws:{token}@amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/pypi/secure-pypi/simple/" check_call( [ sys.executable, "-m", "pip", "install", "--index-url", index_url, *packages, ] ) logger.info("✓ Successfully installed packages from secure PyPI") except Exception as e: logger.error(f"✗ Failed to install packages from secure PyPI: {e}") raise
[docs] def install_packages(packages: list, use_secure: bool = USE_SECURE_PYPI) -> None: """ Install packages from PyPI source based on configuration. This is the main installation function that delegates to either public or secure PyPI based on the USE_SECURE_PYPI environment variable. Args: packages: List of package specifications (e.g., ["pandas==1.5.0", "numpy"]) use_secure: If True, use secure CodeArtifact PyPI; if False, use public PyPI. Defaults to USE_SECURE_PYPI environment variable. Environment Variables: USE_SECURE_PYPI: Set to "true" to use secure PyPI, "false" for public PyPI Example: # Install from public PyPI (default) install_packages(["pandas==1.5.0", "numpy"]) # Install from secure PyPI os.environ["USE_SECURE_PYPI"] = "true" install_packages(["pandas==1.5.0", "numpy"]) """ logger.info("=" * 70) logger.info("PACKAGE INSTALLATION") logger.info("=" * 70) logger.info(f"PyPI Source: {'SECURE (CodeArtifact)' if use_secure else 'PUBLIC'}") logger.info( f"Environment Variable USE_SECURE_PYPI: {os.environ.get('USE_SECURE_PYPI', 'not set')}" ) logger.info(f"Number of packages: {len(packages)}") logger.info("=" * 70) try: if use_secure: install_packages_from_secure_pypi(packages) else: install_packages_from_public_pypi(packages) logger.info("=" * 70) logger.info("✓ PACKAGE INSTALLATION COMPLETED SUCCESSFULLY") logger.info("=" * 70) except Exception as e: logger.error("=" * 70) logger.error("✗ PACKAGE INSTALLATION FAILED") logger.error("=" * 70) raise
# ============================================================================ # INSTALL REQUIRED PACKAGES # ============================================================================ # Define required packages for this script required_packages = [ "matplotlib==3.7.0", ] # Install packages using unified installation function install_packages(required_packages) print("***********************Package Installation Complete*********************") import argparse import pandas as pd import numpy as np from pathlib import Path from sklearn.metrics import ( roc_auc_score, average_precision_score, precision_recall_curve, roc_curve, f1_score, precision_score, recall_score, ) from scipy import stats from scipy.stats import pearsonr, spearmanr import matplotlib.pyplot as plt import time from datetime import datetime from typing import Dict, Any, Optional, List, Tuple, Union import logging # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) # Container path constants - aligned with script contract CONTAINER_PATHS = { "EVAL_DATA_DIR": "/opt/ml/processing/input/eval_data", "OUTPUT_METRICS_DIR": "/opt/ml/processing/output/metrics", "OUTPUT_PLOTS_DIR": "/opt/ml/processing/output/plots", } def _detect_file_format(file_path: str) -> str: """ Detect file format based on extension. Args: file_path: Path to file Returns: Format string: 'csv', 'tsv', 'parquet', or 'json' """ file_path_lower = file_path.lower() if file_path_lower.endswith((".parquet", ".pq")): return "parquet" elif file_path_lower.endswith(".tsv"): return "tsv" elif file_path_lower.endswith(".json"): return "json" elif file_path_lower.endswith(".csv"): return "csv" else: # Default to CSV for unknown extensions return "csv"
[docs] def detect_and_load_predictions( input_dir: str, preferred_format: str = None ) -> pd.DataFrame: """ Auto-detect and load predictions file in CSV, TSV, Parquet, or JSON format. Supports intelligent format detection and graceful fallback. Aligned with format preservation pattern used across cursus framework. """ # Determine order of formats to try formats_to_try = [] if preferred_format: formats_to_try.append(preferred_format) # Add other formats as fallback for fmt in ["parquet", "csv", "tsv", "json"]: if fmt not in formats_to_try: formats_to_try.append(fmt) # Try each format in order for fmt in formats_to_try: file_path = os.path.join(input_dir, f"predictions.{fmt}") if os.path.exists(file_path): detected_format = _detect_file_format(file_path) logger.info( f"Loading predictions from {file_path} (format: {detected_format})" ) if detected_format == "parquet": return pd.read_parquet(file_path) elif detected_format == "tsv": return pd.read_csv(file_path, sep="\t") elif detected_format == "json": return pd.read_json(file_path) else: # csv or default return pd.read_csv(file_path) # Also try eval_predictions.csv from xgboost_model_eval output eval_pred_path = os.path.join(input_dir, "eval_predictions.csv") if os.path.exists(eval_pred_path): logger.info(f"Loading predictions from {eval_pred_path} (format: csv)") return pd.read_csv(eval_pred_path) raise FileNotFoundError( "No predictions file found in supported formats (csv, tsv, parquet, json)" )
[docs] def parse_score_fields(environ_vars: Dict[str, str]) -> List[str]: """ Parse SCORE_FIELD or SCORE_FIELDS from environment variables. Pattern matching model_calibration.py Priority: 1. SCORE_FIELDS (multi-task) - comma-separated list 2. SCORE_FIELD (single-task) - backward compatible 3. Default: "prob_class_1" Returns: List of score field names """ # Check for SCORE_FIELDS first (multi-task) score_fields_str = environ_vars.get("SCORE_FIELDS", "").strip() if score_fields_str: score_fields = [ field.strip() for field in score_fields_str.split(",") if field.strip() ] if not score_fields: raise ValueError("SCORE_FIELDS is empty after parsing") logger.info( f"Multi-task mode: Found {len(score_fields)} score fields: {score_fields}" ) return score_fields # Fall back to SCORE_FIELD (single-task, backward compatible) score_field = environ_vars.get("SCORE_FIELD", "").strip() if score_field: logger.info(f"Single-task mode: Using score field: {score_field}") return [score_field] # Default default_field = "prob_class_1" logger.warning( f"Neither SCORE_FIELD nor SCORE_FIELDS provided, using default: {default_field}" ) return [default_field]
[docs] def parse_previous_score_fields( environ_vars: Dict[str, str], score_fields: List[str] ) -> List[str]: """ Parse PREVIOUS_SCORE_FIELDS or PREVIOUS_SCORE_FIELD from environment variables. Priority: 1. PREVIOUS_SCORE_FIELDS (multi-task) - comma-separated list 2. PREVIOUS_SCORE_FIELD (single-task) - backward compatible 3. Empty list if not in comparison mode Args: environ_vars: Environment variables dictionary score_fields: List of score field names (for validation) Returns: List of previous score field names (empty if no comparison) """ is_multitask = len(score_fields) > 1 # Check for PREVIOUS_SCORE_FIELDS first (multi-task) prev_score_fields_str = environ_vars.get("PREVIOUS_SCORE_FIELDS", "").strip() if prev_score_fields_str: prev_score_fields = [ field.strip() for field in prev_score_fields_str.split(",") if field.strip() ] if len(prev_score_fields) != len(score_fields): raise ValueError( f"PREVIOUS_SCORE_FIELDS length ({len(prev_score_fields)}) must match " f"SCORE_FIELDS length ({len(score_fields)}). " f"Score fields: {score_fields}, Previous score fields: {prev_score_fields}" ) logger.info( f"Multi-task comparison mode: Found {len(prev_score_fields)} previous score fields: {prev_score_fields}" ) return prev_score_fields # Fall back to PREVIOUS_SCORE_FIELD (single-task, backward compatible) prev_score_field = environ_vars.get("PREVIOUS_SCORE_FIELD", "").strip() if prev_score_field: if is_multitask: logger.warning( f"Multi-task mode detected but only PREVIOUS_SCORE_FIELD provided. " f"Use PREVIOUS_SCORE_FIELDS for multi-task comparison." ) return [] # Return empty to disable comparison logger.info( f"Single-task comparison mode: Using previous score field: {prev_score_field}" ) return [prev_score_field] # No comparison mode return []
[docs] def parse_task_label_fields( environ_vars: Dict[str, str], score_fields: List[str] ) -> List[str]: """ Parse TASK_LABEL_NAMES or infer from score_fields. Pattern matching model_calibration.py Priority: 1. Explicit TASK_LABEL_NAMES (preferred for multi-task) 2. Infer from score field names (_prob → removes suffix) 3. Single LABEL_FIELD (backward compatibility) Args: environ_vars: Environment variables dictionary score_fields: List of score field names Returns: List of label field names, one per score field """ is_multitask = len(score_fields) > 1 # Option 1: Explicit TASK_LABEL_NAMES (preferred for multi-task) task_labels_str = environ_vars.get("TASK_LABEL_NAMES", "").strip() if task_labels_str: task_labels = [ field.strip() for field in task_labels_str.split(",") if field.strip() ] if len(task_labels) != len(score_fields): raise ValueError( f"TASK_LABEL_NAMES length ({len(task_labels)}) must match " f"SCORE_FIELDS length ({len(score_fields)}). " f"Score fields: {score_fields}, Label fields: {task_labels}" ) logger.info(f"Using explicit task label fields: {task_labels}") return task_labels # Option 2: Infer from score_fields for multi-task if is_multitask: task_labels = [] for score_field in score_fields: # Standard naming: task_prob → task (remove _prob suffix) if score_field.endswith("_prob"): label_field = score_field.replace("_prob", "") # isFraud_prob → isFraud elif score_field.endswith("_score"): label_field = score_field.replace("_score", "_label") else: # Fallback: append _true logger.warning( f"Score field '{score_field}' doesn't follow standard naming. " f"Inferring label as '{score_field}_true'" ) label_field = f"{score_field}_true" task_labels.append(label_field) logger.info( f"Inferred {len(task_labels)} task label fields from score fields: " f"{dict(zip(score_fields, task_labels))}" ) return task_labels # Option 3: Single-task - use LABEL_FIELD (backward compatibility) label_field = environ_vars.get("LABEL_FIELD", "label") logger.info(f"Single-task mode: Using label field: {label_field}") return [label_field]
[docs] def validate_prediction_columns( df: pd.DataFrame, score_fields: List[str], label_fields: List[str], id_field: str, ) -> Dict[str, Any]: """ Validate that all required columns exist in DataFrame. Args: df: Input DataFrame score_fields: List of score column names label_fields: List of label column names id_field: ID column name Returns: Validation report dictionary Raises: ValueError: If critical columns are missing """ validation_report = { "is_valid": True, "errors": [], "warnings": [], "data_summary": {}, } # Check ID field if id_field not in df.columns: validation_report["errors"].append(f"ID field '{id_field}' not found") validation_report["is_valid"] = False # Check score fields missing_scores = [f for f in score_fields if f not in df.columns] if missing_scores: validation_report["errors"].append(f"Missing score fields: {missing_scores}") validation_report["is_valid"] = False # Check label fields missing_labels = [f for f in label_fields if f not in df.columns] if missing_labels: validation_report["errors"].append(f"Missing label fields: {missing_labels}") validation_report["is_valid"] = False # Generate data summary for multi-task. Include prediction_columns + has_amount_data so the # shape matches the single-task summary (validate_prediction_data) — the report-writer # (generate_text_summary) reads data_summary['prediction_columns'] unconditionally, so the # multi-task path must populate it too (its analog is the score columns; multi-task carries no # amount field, so has_amount_data is False). validation_report["data_summary"] = { "total_records": len(df), "score_columns": score_fields, "label_columns": label_fields, "prediction_columns": score_fields, "has_amount_data": False, } # Log results if not validation_report["is_valid"]: logger.error("Column validation failed:") for error in validation_report["errors"]: logger.error(f" - {error}") logger.info(f"Available columns: {df.columns.tolist()}") return validation_report
[docs] def validate_prediction_data( df: pd.DataFrame, id_field: str, label_field: str, amount_field: str = None ) -> Dict[str, Any]: """ Validate prediction data schema and return validation report. Legacy function for backward compatibility with single-task. """ validation_report = { "is_valid": True, "errors": [], "warnings": [], "data_summary": {}, } # Check required columns required_cols = [id_field, label_field] missing_cols = [col for col in required_cols if col not in df.columns] if missing_cols: validation_report["errors"].append(f"Missing required columns: {missing_cols}") validation_report["is_valid"] = False # Check prediction probability columns prob_cols = [col for col in df.columns if col.startswith("prob_class_")] if not prob_cols: validation_report["errors"].append("No prediction probability columns found") validation_report["is_valid"] = False # Check amount column if specified if amount_field and amount_field not in df.columns: validation_report["warnings"].append( f"Amount field '{amount_field}' not found - dollar recall will be skipped" ) # Generate data summary validation_report["data_summary"] = { "total_records": len(df), "prediction_columns": prob_cols, "has_amount_data": amount_field in df.columns if amount_field else False, "label_distribution": df[label_field].value_counts().to_dict() if label_field in df.columns else {}, } return validation_report
[docs] def compute_standard_metrics( y_true: np.ndarray, y_prob: np.ndarray, is_binary: bool = True ) -> Dict[str, float]: """ Compute comprehensive standard ML metrics matching xgboost_model_eval.py. Supports both binary and multiclass classification with full metric coverage. """ metrics = {} if is_binary: # Binary classification metrics - matching compute_metrics_binary() y_score = y_prob[:, 1] if y_prob.shape[1] > 1 else y_prob # Core metrics (exact match with original) metrics["auc_roc"] = roc_auc_score(y_true, y_score) metrics["average_precision"] = average_precision_score(y_true, y_score) metrics["f1_score"] = f1_score(y_true, y_score > 0.5) # Precision-Recall curve analysis precision, recall, thresholds = precision_recall_curve(y_true, y_score) metrics["precision_at_threshold_0.5"] = precision_score( y_true, (y_score > 0.5).astype(int) ) metrics["recall_at_threshold_0.5"] = recall_score( y_true, (y_score > 0.5).astype(int) ) # Threshold-based metrics (matching original) for threshold in [0.3, 0.5, 0.7]: y_pred = (y_score >= threshold).astype(int) metrics[f"f1_score_at_{threshold}"] = f1_score(y_true, y_pred) metrics[f"precision_at_{threshold}"] = precision_score(y_true, y_pred) metrics[f"recall_at_{threshold}"] = recall_score(y_true, y_pred) # Additional analysis metrics metrics["max_f1_score"] = np.max( 2 * precision * recall / (precision + recall + 1e-8) ) # ROC curve analysis fpr, tpr, roc_thresholds = roc_curve(y_true, y_score) metrics["optimal_threshold"] = roc_thresholds[np.argmax(tpr - fpr)] else: # Multiclass classification metrics - matching compute_metrics_multiclass() n_classes = y_prob.shape[1] # Per-class metrics (exact match with original) for i in range(n_classes): y_true_bin = (y_true == i).astype(int) y_score = y_prob[:, i] metrics[f"auc_roc_class_{i}"] = roc_auc_score(y_true_bin, y_score) metrics[f"average_precision_class_{i}"] = average_precision_score( y_true_bin, y_score ) metrics[f"f1_score_class_{i}"] = f1_score(y_true_bin, y_score > 0.5) # Micro and macro averages (exact match with original) metrics["auc_roc_micro"] = roc_auc_score( y_true, y_prob, multi_class="ovr", average="micro" ) metrics["auc_roc_macro"] = roc_auc_score( y_true, y_prob, multi_class="ovr", average="macro" ) metrics["average_precision_micro"] = average_precision_score( y_true, y_prob, average="micro" ) metrics["average_precision_macro"] = average_precision_score( y_true, y_prob, average="macro" ) y_pred = np.argmax(y_prob, axis=1) metrics["f1_score_micro"] = f1_score(y_true, y_pred, average="micro") metrics["f1_score_macro"] = f1_score(y_true, y_pred, average="macro") # Class distribution metrics (matching original) unique, counts = np.unique(y_true, return_counts=True) for cls, count in zip(unique, counts): metrics[f"class_{cls}_count"] = int(count) metrics[f"class_{cls}_ratio"] = float(count) / len(y_true) return metrics
[docs] def calculate_count_recall(scores, labels, amounts, cutoff=0.1): """ Calculate count recall - imported from evaluation.py """ assert len(scores) == len(labels), "Input lengths don't match!" threshold = np.quantile(scores, 1 - cutoff) abuse_order_total = len(labels[labels == 1]) abuse_order_above_threshold = len(labels[(labels == 1) & (scores >= threshold)]) order_count_recall = abuse_order_above_threshold / abuse_order_total return order_count_recall
[docs] def calculate_dollar_recall(scores, labels, amounts, fpr=0.1): """ Calculate dollar recall - imported from evaluation.py """ assert len(scores) == len(labels) == len(amounts), "Input lengths don't match!" threshold = np.quantile(scores[labels == 0], 1 - fpr) abuse_amount_total = amounts[labels == 1].sum() abuse_amount_above_threshold = amounts[(labels == 1) & (scores > threshold)].sum() dollar_recall = abuse_amount_above_threshold / abuse_amount_total return dollar_recall
[docs] def compute_domain_metrics( scores: np.ndarray, labels: np.ndarray, amounts: np.ndarray = None, compute_dollar_recall: bool = True, compute_count_recall: bool = True, dollar_recall_fpr: float = 0.1, count_recall_cutoff: float = 0.1, ) -> Dict[str, float]: """ Compute domain-specific metrics including dollar and count recall. Integrates functions from evaluation.py for business impact analysis. """ domain_metrics = {} if compute_count_recall: # Count recall - percentage of abuse orders caught count_recall = calculate_count_recall( scores=scores, labels=labels, amounts=amounts, # Not used but required by function signature cutoff=count_recall_cutoff, ) domain_metrics["count_recall"] = count_recall domain_metrics["count_recall_cutoff"] = count_recall_cutoff if compute_dollar_recall and amounts is not None: # Dollar recall - percentage of abuse dollar amount caught dollar_recall = calculate_dollar_recall( scores=scores, labels=labels, amounts=amounts, fpr=dollar_recall_fpr ) domain_metrics["dollar_recall"] = dollar_recall domain_metrics["dollar_recall_fpr"] = dollar_recall_fpr # Additional amount-based analysis domain_metrics["total_abuse_amount"] = amounts[labels == 1].sum() domain_metrics["average_abuse_amount"] = amounts[labels == 1].mean() domain_metrics["total_legitimate_amount"] = amounts[labels == 0].sum() domain_metrics["amount_ratio_abuse_to_total"] = ( amounts[labels == 1].sum() / amounts.sum() ) return domain_metrics
[docs] def plot_and_save_roc_curve( y_true: np.ndarray, y_score: np.ndarray, output_dir: str, prefix: str = "" ) -> str: """ Plot ROC curve and save as JPG (exact match with xgboost_model_eval.py). """ fpr, tpr, _ = roc_curve(y_true, y_score) auc = roc_auc_score(y_true, y_score) plt.figure() plt.plot(fpr, tpr, label=f"ROC curve (AUC = {auc:.2f})") plt.plot([0, 1], [0, 1], "k--", label="Random") plt.xlabel("False Positive Rate") plt.ylabel("True Positive Rate") plt.title("ROC Curve") plt.legend(loc="lower right") out_path = os.path.join(output_dir, f"{prefix}roc_curve.jpg") plt.savefig(out_path, format="jpg", dpi=300, bbox_inches="tight") plt.close() logger.info(f"Saved ROC curve to {out_path}") return out_path
[docs] def plot_and_save_pr_curve( y_true: np.ndarray, y_score: np.ndarray, output_dir: str, prefix: str = "" ) -> str: """ Plot Precision-Recall curve and save as JPG (exact match with xgboost_model_eval.py). """ precision, recall, _ = precision_recall_curve(y_true, y_score) ap = average_precision_score(y_true, y_score) plt.figure() plt.plot(recall, precision, label=f"PR curve (AP = {ap:.2f})") plt.xlabel("Recall") plt.ylabel("Precision") plt.title("Precision-Recall Curve") plt.legend(loc="lower left") out_path = os.path.join(output_dir, f"{prefix}pr_curve.jpg") plt.savefig(out_path, format="jpg", dpi=300, bbox_inches="tight") plt.close() logger.info(f"Saved PR curve to {out_path}") return out_path
[docs] def generate_performance_visualizations( y_true: np.ndarray, y_prob: np.ndarray, metrics: Dict[str, float], output_dir: str, is_binary: bool = True, ) -> Dict[str, str]: """ Generate comprehensive performance visualizations matching xgboost_model_eval.py. Returns dictionary of plot file paths. """ plot_paths = {} if is_binary: y_score = y_prob[:, 1] if y_prob.shape[1] > 1 else y_prob # ROC Curve (matching plot_and_save_roc_curve) plot_paths["roc_curve"] = plot_and_save_roc_curve(y_true, y_score, output_dir) # Precision-Recall Curve (matching plot_and_save_pr_curve) plot_paths["precision_recall_curve"] = plot_and_save_pr_curve( y_true, y_score, output_dir ) # Score Distribution (enhanced version) plt.figure(figsize=(10, 6)) plt.hist( y_score[y_true == 0], bins=50, alpha=0.7, label="Legitimate", density=True ) plt.hist(y_score[y_true == 1], bins=50, alpha=0.7, label="Abuse", density=True) plt.xlabel("Prediction Score") plt.ylabel("Density") plt.title("Score Distribution by Class") plt.legend() plt.grid(True, alpha=0.3) dist_path = os.path.join(output_dir, "score_distribution.jpg") plt.savefig(dist_path, format="jpg", dpi=300, bbox_inches="tight") plt.close() plot_paths["score_distribution"] = dist_path # Threshold Analysis (enhanced version) thresholds = np.linspace(0, 1, 101) f1_scores = [] precisions = [] recalls = [] for threshold in thresholds: y_pred = (y_score >= threshold).astype(int) if len(np.unique(y_pred)) > 1: # Avoid division by zero f1_scores.append(f1_score(y_true, y_pred)) precisions.append(precision_score(y_true, y_pred)) recalls.append(recall_score(y_true, y_pred)) else: f1_scores.append(0) precisions.append(0) recalls.append(0) plt.figure(figsize=(10, 6)) plt.plot(thresholds, f1_scores, label="F1 Score") plt.plot(thresholds, precisions, label="Precision") plt.plot(thresholds, recalls, label="Recall") plt.axvline( x=metrics.get("optimal_threshold", 0.5), color="red", linestyle="--", label="Optimal Threshold", ) plt.xlabel("Threshold") plt.ylabel("Score") plt.title("Threshold Analysis") plt.legend() plt.grid(True, alpha=0.3) threshold_path = os.path.join(output_dir, "threshold_analysis.jpg") plt.savefig(threshold_path, format="jpg", dpi=300, bbox_inches="tight") plt.close() plot_paths["threshold_analysis"] = threshold_path else: # Multiclass visualizations (matching original per-class approach) n_classes = y_prob.shape[1] # Per-class ROC curves (matching original with prefix) for i in range(n_classes): y_true_bin = (y_true == i).astype(int) if len(np.unique(y_true_bin)) > 1: # Only plot if class exists plot_paths[f"roc_curve_class_{i}"] = plot_and_save_roc_curve( y_true_bin, y_prob[:, i], output_dir, prefix=f"class_{i}_" ) plot_paths[f"pr_curve_class_{i}"] = plot_and_save_pr_curve( y_true_bin, y_prob[:, i], output_dir, prefix=f"class_{i}_" ) # Combined multiclass ROC curves plt.figure(figsize=(10, 8)) for i in range(n_classes): y_true_bin = (y_true == i).astype(int) if len(np.unique(y_true_bin)) > 1: fpr, tpr, _ = roc_curve(y_true_bin, y_prob[:, i]) auc = metrics.get(f"auc_roc_class_{i}", 0) plt.plot(fpr, tpr, label=f"Class {i} (AUC = {auc:.3f})") plt.plot([0, 1], [0, 1], "k--", label="Random") plt.xlabel("False Positive Rate") plt.ylabel("True Positive Rate") plt.title("Multi-class ROC Curves") plt.legend() plt.grid(True, alpha=0.3) multiclass_roc_path = os.path.join(output_dir, "multiclass_roc_curves.jpg") plt.savefig(multiclass_roc_path, format="jpg", dpi=300, bbox_inches="tight") plt.close() plot_paths["multiclass_roc_curves"] = multiclass_roc_path return plot_paths
[docs] def generate_performance_insights(metrics: Dict[str, float]) -> List[str]: """ Generate actionable performance insights based on metrics. """ insights = [] # AUC analysis auc = metrics.get("auc_roc", 0) if auc >= 0.9: insights.append("Excellent discrimination capability (AUC ≥ 0.9)") elif auc >= 0.8: insights.append("Good discrimination capability (AUC ≥ 0.8)") elif auc >= 0.7: insights.append("Fair discrimination capability (AUC ≥ 0.7)") else: insights.append( "Poor discrimination capability (AUC < 0.7) - model may need improvement" ) # Dollar vs Count recall comparison dollar_recall = metrics.get("dollar_recall") count_recall = metrics.get("count_recall") if dollar_recall and count_recall: if dollar_recall > count_recall * 1.2: insights.append( "Model is particularly effective at catching high-value abuse cases" ) elif count_recall > dollar_recall * 1.2: insights.append( "Model catches many abuse cases but may miss high-value ones" ) else: insights.append("Balanced performance across abuse case values") # Threshold analysis optimal_threshold = metrics.get("optimal_threshold") if optimal_threshold: if optimal_threshold < 0.3: insights.append( "Optimal threshold is low - consider if this aligns with business tolerance" ) elif optimal_threshold > 0.7: insights.append("Optimal threshold is high - model is conservative") return insights
[docs] def generate_recommendations(metrics: Dict[str, float]) -> List[str]: """ Generate actionable recommendations based on performance analysis. """ recommendations = [] auc = metrics.get("auc_roc", 0) if auc < 0.75: recommendations.append( "Consider feature engineering or model architecture improvements" ) recommendations.append("Investigate data quality and label accuracy") dollar_recall = metrics.get("dollar_recall") count_recall = metrics.get("count_recall") if dollar_recall and count_recall and dollar_recall < 0.6: recommendations.append("Focus on improving detection of high-value abuse cases") recommendations.append( "Consider amount-weighted loss functions during training" ) if count_recall and count_recall < 0.7: recommendations.append( "Consider lowering decision threshold to catch more abuse cases" ) recommendations.append("Evaluate if additional features could improve recall") # F1 score analysis max_f1 = metrics.get("max_f1_score", 0) if max_f1 < 0.6: recommendations.append( "Model shows poor precision-recall balance - consider class balancing techniques" ) return recommendations
[docs] def generate_comprehensive_report( standard_metrics: Dict[str, float], domain_metrics: Dict[str, float], plot_paths: Dict[str, str], validation_report: Dict[str, Any], output_dir: str, ) -> Dict[str, str]: """ Generate comprehensive metrics report with insights and recommendations. """ # Combine all metrics all_metrics = {**standard_metrics, **domain_metrics} # Generate JSON report json_report = { "timestamp": datetime.utcnow().isoformat(), "data_summary": validation_report["data_summary"], "standard_metrics": standard_metrics, "domain_metrics": domain_metrics, "visualizations": plot_paths, "performance_insights": generate_performance_insights(all_metrics), "recommendations": generate_recommendations(all_metrics), } json_path = os.path.join(output_dir, "metrics_report.json") with open(json_path, "w") as f: json.dump(json_report, f, indent=2) # Generate text summary text_summary = generate_text_summary(json_report) text_path = os.path.join(output_dir, "metrics_summary.txt") with open(text_path, "w") as f: f.write(text_summary) return {"json_report": json_path, "text_summary": text_path}
[docs] def compute_comparison_metrics( y_true: np.ndarray, y_new_score: np.ndarray, y_prev_score: np.ndarray, is_binary: bool = True, ) -> Dict[str, float]: """ Compute comparison metrics between new model and previous model scores. Identical to xgboost_model_eval.py implementation. """ logger.info("Computing model comparison metrics") comparison_metrics = {} # Basic correlation metrics - with error handling for scipy compatibility try: pearson_corr, pearson_p = pearsonr(y_new_score, y_prev_score) spearman_corr, spearman_p = spearmanr(y_new_score, y_prev_score) except (TypeError, AttributeError) as e: logger.warning( f"SciPy correlation computation failed: {e}. Using fallback numpy correlation." ) # Fallback to numpy correlation pearson_corr = float(np.corrcoef(y_new_score, y_prev_score)[0, 1]) pearson_p = 0.0 # p-value not available with numpy spearman_corr = pearson_corr # Use Pearson as fallback spearman_p = 0.0 comparison_metrics.update( { "pearson_correlation": pearson_corr, "pearson_p_value": pearson_p, "spearman_correlation": spearman_corr, "spearman_p_value": spearman_p, } ) # Performance comparison metrics if is_binary: # Binary classification comparison new_auc = roc_auc_score(y_true, y_new_score) prev_auc = roc_auc_score(y_true, y_prev_score) new_ap = average_precision_score(y_true, y_new_score) prev_ap = average_precision_score(y_true, y_prev_score) # Delta metrics comparison_metrics.update( { "new_model_auc": new_auc, "previous_model_auc": prev_auc, "auc_delta": new_auc - prev_auc, "auc_lift_percent": ((new_auc - prev_auc) / prev_auc) * 100 if prev_auc > 0 else 0, "new_model_ap": new_ap, "previous_model_ap": prev_ap, "ap_delta": new_ap - prev_ap, "ap_lift_percent": ((new_ap - prev_ap) / prev_ap) * 100 if prev_ap > 0 else 0, } ) # F1 score comparison at different thresholds for threshold in [0.3, 0.5, 0.7]: new_f1 = f1_score(y_true, (y_new_score >= threshold).astype(int)) prev_f1 = f1_score(y_true, (y_prev_score >= threshold).astype(int)) comparison_metrics[f"new_model_f1_at_{threshold}"] = new_f1 comparison_metrics[f"previous_model_f1_at_{threshold}"] = prev_f1 comparison_metrics[f"f1_delta_at_{threshold}"] = new_f1 - prev_f1 # Score distribution comparison comparison_metrics.update( { "new_score_mean": float(np.mean(y_new_score)), "previous_score_mean": float(np.mean(y_prev_score)), "new_score_std": float(np.std(y_new_score)), "previous_score_std": float(np.std(y_prev_score)), "score_mean_delta": float(np.mean(y_new_score) - np.mean(y_prev_score)), } ) # Agreement metrics if is_binary: for threshold in [0.3, 0.5, 0.7]: new_pred = (y_new_score >= threshold).astype(int) prev_pred = (y_prev_score >= threshold).astype(int) agreement = np.mean(new_pred == prev_pred) comparison_metrics[f"prediction_agreement_at_{threshold}"] = agreement logger.info( f"Comparison metrics computed: AUC delta={comparison_metrics.get('auc_delta', 'N/A'):.4f}, " f"Correlation={comparison_metrics.get('pearson_correlation', 'N/A'):.4f}" ) return comparison_metrics
[docs] def perform_statistical_tests( y_true: np.ndarray, y_new_score: np.ndarray, y_prev_score: np.ndarray, is_binary: bool = True, ) -> Dict[str, float]: """ Perform statistical significance tests comparing model performances. Identical to xgboost_model_eval.py implementation. """ logger.info("Performing statistical significance tests") test_results = {} if is_binary: # McNemar's test for binary classification new_pred = (y_new_score >= 0.5).astype(int) prev_pred = (y_prev_score >= 0.5).astype(int) # Create contingency table for McNemar's test correct_both = np.sum((new_pred == y_true) & (prev_pred == y_true)) new_correct_prev_wrong = np.sum((new_pred == y_true) & (prev_pred != y_true)) new_wrong_prev_correct = np.sum((new_pred != y_true) & (prev_pred == y_true)) wrong_both = np.sum((new_pred != y_true) & (prev_pred != y_true)) # McNemar's test statistic if (new_correct_prev_wrong + new_wrong_prev_correct) > 0: mcnemar_stat = ( (abs(new_correct_prev_wrong - new_wrong_prev_correct) - 1) ** 2 ) / (new_correct_prev_wrong + new_wrong_prev_correct) mcnemar_p_value = 1 - stats.chi2.cdf(mcnemar_stat, 1) else: mcnemar_stat = 0.0 mcnemar_p_value = 1.0 test_results.update( { "mcnemar_statistic": mcnemar_stat, "mcnemar_p_value": mcnemar_p_value, "mcnemar_significant": bool(mcnemar_p_value < 0.05), "correct_both": int(correct_both), "new_correct_prev_wrong": int(new_correct_prev_wrong), "new_wrong_prev_correct": int(new_wrong_prev_correct), "wrong_both": int(wrong_both), } ) # Paired t-test on prediction scores t_stat, t_p_value = stats.ttest_rel(y_new_score, y_prev_score) test_results.update( { "paired_t_statistic": t_stat, "paired_t_p_value": t_p_value, "paired_t_significant": bool(t_p_value < 0.05), } ) # Wilcoxon signed-rank test (non-parametric alternative) try: wilcoxon_stat, wilcoxon_p = stats.wilcoxon(y_new_score, y_prev_score) test_results.update( { "wilcoxon_statistic": wilcoxon_stat, "wilcoxon_p_value": wilcoxon_p, "wilcoxon_significant": bool(wilcoxon_p < 0.05), } ) except (ValueError, TypeError, AttributeError) as e: logger.warning(f"Could not perform Wilcoxon test: {e}") test_results.update( { "wilcoxon_statistic": np.nan, "wilcoxon_p_value": np.nan, "wilcoxon_significant": False, } ) logger.info( f"Statistical tests completed: McNemar p={test_results.get('mcnemar_p_value', 'N/A'):.4f}, " f"Paired t-test p={test_results.get('paired_t_p_value', 'N/A'):.4f}" ) return test_results
[docs] def plot_comparison_roc_curves( y_true: np.ndarray, y_new_score: np.ndarray, y_prev_score: np.ndarray, output_dir: str, ) -> str: """ Plot side-by-side ROC curves comparing new and previous models. Identical to xgboost_model_eval.py implementation. """ logger.info("Creating comparison ROC curves") # Calculate ROC curves for both models fpr_new, tpr_new, _ = roc_curve(y_true, y_new_score) fpr_prev, tpr_prev, _ = roc_curve(y_true, y_prev_score) auc_new = roc_auc_score(y_true, y_new_score) auc_prev = roc_auc_score(y_true, y_prev_score) plt.figure(figsize=(10, 6)) # Plot both ROC curves plt.plot( fpr_new, tpr_new, "b-", linewidth=2, label=f"New Model (AUC = {auc_new:.3f})" ) plt.plot( fpr_prev, tpr_prev, "r--", linewidth=2, label=f"Previous Model (AUC = {auc_prev:.3f})", ) plt.plot([0, 1], [0, 1], "k:", alpha=0.6, label="Random") plt.xlabel("False Positive Rate") plt.ylabel("True Positive Rate") plt.title(f"ROC Curve Comparison (Δ AUC = {auc_new - auc_prev:+.3f})") plt.legend(loc="lower right") plt.grid(True, alpha=0.3) out_path = os.path.join(output_dir, "comparison_roc_curves.jpg") plt.savefig(out_path, format="jpg", dpi=150, bbox_inches="tight") plt.close() logger.info(f"Saved comparison ROC curves to {out_path}") return out_path
[docs] def plot_comparison_pr_curves( y_true: np.ndarray, y_new_score: np.ndarray, y_prev_score: np.ndarray, output_dir: str, ) -> str: """ Plot side-by-side Precision-Recall curves comparing new and previous models. Identical to xgboost_model_eval.py implementation. """ logger.info("Creating comparison PR curves") # Calculate PR curves for both models precision_new, recall_new, _ = precision_recall_curve(y_true, y_new_score) precision_prev, recall_prev, _ = precision_recall_curve(y_true, y_prev_score) ap_new = average_precision_score(y_true, y_new_score) ap_prev = average_precision_score(y_true, y_prev_score) plt.figure(figsize=(10, 6)) # Plot both PR curves plt.plot( recall_new, precision_new, "b-", linewidth=2, label=f"New Model (AP = {ap_new:.3f})", ) plt.plot( recall_prev, precision_prev, "r--", linewidth=2, label=f"Previous Model (AP = {ap_prev:.3f})", ) # Add baseline (random classifier) baseline = np.mean(y_true) plt.axhline( y=baseline, color="k", linestyle=":", alpha=0.6, label=f"Random (AP = {baseline:.3f})", ) plt.xlabel("Recall") plt.ylabel("Precision") plt.title(f"Precision-Recall Curve Comparison (Δ AP = {ap_new - ap_prev:+.3f})") plt.legend(loc="lower left") plt.grid(True, alpha=0.3) out_path = os.path.join(output_dir, "comparison_pr_curves.jpg") plt.savefig(out_path, format="jpg", dpi=150, bbox_inches="tight") plt.close() logger.info(f"Saved comparison PR curves to {out_path}") return out_path
[docs] def plot_score_scatter( y_new_score: np.ndarray, y_prev_score: np.ndarray, y_true: np.ndarray, output_dir: str, ) -> str: """ Plot scatter plot of new vs previous model scores, colored by true labels. Identical to xgboost_model_eval.py implementation. """ logger.info("Creating score scatter plot") plt.figure(figsize=(10, 8)) # Separate positive and negative examples pos_mask = y_true == 1 neg_mask = y_true == 0 # Plot negative examples plt.scatter( y_prev_score[neg_mask], y_new_score[neg_mask], c="lightcoral", alpha=0.6, s=20, label="Negative (0)", ) # Plot positive examples plt.scatter( y_prev_score[pos_mask], y_new_score[pos_mask], c="lightblue", alpha=0.6, s=20, label="Positive (1)", ) # Add diagonal line (perfect correlation) min_score = min(np.min(y_prev_score), np.min(y_new_score)) max_score = max(np.max(y_prev_score), np.max(y_new_score)) plt.plot( [min_score, max_score], [min_score, max_score], "k--", alpha=0.8, label="Perfect Correlation", ) # Calculate and display correlation with error handling for SciPy compatibility try: correlation = pearsonr(y_new_score, y_prev_score)[0] except (TypeError, AttributeError) as e: logger.warning(f"SciPy pearsonr failed: {e}. Using numpy correlation.") correlation = float(np.corrcoef(y_new_score, y_prev_score)[0, 1]) plt.xlabel("Previous Model Score") plt.ylabel("New Model Score") plt.title(f"Model Score Comparison (Correlation = {correlation:.3f})") plt.legend() plt.grid(True, alpha=0.3) # Add correlation text box textstr = f"Pearson r = {correlation:.3f}" props = dict(boxstyle="round", facecolor="wheat", alpha=0.8) plt.text( 0.05, 0.95, textstr, transform=plt.gca().transAxes, fontsize=12, verticalalignment="top", bbox=props, ) out_path = os.path.join(output_dir, "score_scatter_plot.jpg") plt.savefig(out_path, format="jpg", dpi=150, bbox_inches="tight") plt.close() logger.info(f"Saved score scatter plot to {out_path}") return out_path
[docs] def plot_score_distributions( y_new_score: np.ndarray, y_prev_score: np.ndarray, y_true: np.ndarray, output_dir: str, ) -> str: """ Plot score distributions for both models, separated by true labels. Identical to xgboost_model_eval.py implementation. """ logger.info("Creating score distribution plots") # Set matplotlib backend explicitly for headless environments import matplotlib matplotlib.use("Agg") # Create figure and axes with comprehensive error handling fig = None axes = None try: # First attempt: standard subplots result = plt.subplots(2, 2, figsize=(15, 10)) if isinstance(result, tuple) and len(result) == 2: fig, axes = result # Ensure axes is always a 2D array if hasattr(axes, "ndim") and axes.ndim == 1: axes = axes.reshape(2, 2) else: raise ValueError("subplots returned unexpected format") except Exception as e: logger.warning(f"Standard subplots failed: {e}. Using fallback approach.") # Fallback approach: create figure and individual subplots try: fig = plt.figure(figsize=(15, 10)) axes = [] for i in range(4): ax = fig.add_subplot(2, 2, i + 1) axes.append(ax) axes = np.array(axes).reshape(2, 2) except Exception as e2: logger.error( f"Fallback subplot creation also failed: {e2}. Creating minimal plot." ) # Final fallback: create a simple single plot to satisfy test expectations fig = plt.figure(figsize=(8, 6)) ax = fig.add_subplot(1, 1, 1) ax.text(0.5, 0.5, "Plot generation failed", ha="center", va="center") ax.set_title("Score Distributions (Error)") # Continue to save this minimal plot out_path = os.path.join(output_dir, "score_distributions.jpg") plt.savefig(out_path, format="jpg", dpi=150, bbox_inches="tight") plt.close() logger.info(f"Saved minimal error plot to {out_path}") return out_path # Separate positive and negative examples pos_mask = y_true == 1 neg_mask = y_true == 0 # Plot 1: New model score distributions axes[0, 0].hist( y_new_score[neg_mask], bins=30, alpha=0.7, color="lightcoral", label="Negative (0)", density=True, ) axes[0, 0].hist( y_new_score[pos_mask], bins=30, alpha=0.7, color="lightblue", label="Positive (1)", density=True, ) axes[0, 0].set_title("New Model Score Distribution") axes[0, 0].set_xlabel("Score") axes[0, 0].set_ylabel("Density") axes[0, 0].legend() axes[0, 0].grid(True, alpha=0.3) # Plot 2: Previous model score distributions axes[0, 1].hist( y_prev_score[neg_mask], bins=30, alpha=0.7, color="lightcoral", label="Negative (0)", density=True, ) axes[0, 1].hist( y_prev_score[pos_mask], bins=30, alpha=0.7, color="lightblue", label="Positive (1)", density=True, ) axes[0, 1].set_title("Previous Model Score Distribution") axes[0, 1].set_xlabel("Score") axes[0, 1].set_ylabel("Density") axes[0, 1].legend() axes[0, 1].grid(True, alpha=0.3) # Plot 3: Score difference distribution score_diff = y_new_score - y_prev_score axes[1, 0].hist( score_diff[neg_mask], bins=30, alpha=0.7, color="lightcoral", label="Negative (0)", density=True, ) axes[1, 0].hist( score_diff[pos_mask], bins=30, alpha=0.7, color="lightblue", label="Positive (1)", density=True, ) axes[1, 0].axvline(x=0, color="black", linestyle="--", alpha=0.8) axes[1, 0].set_title("Score Difference Distribution (New - Previous)") axes[1, 0].set_xlabel("Score Difference") axes[1, 0].set_ylabel("Density") axes[1, 0].legend() axes[1, 0].grid(True, alpha=0.3) # Plot 4: Box plots comparing both models box_data = [ y_prev_score[neg_mask], y_new_score[neg_mask], y_prev_score[pos_mask], y_new_score[pos_mask], ] box_labels = ["Prev (Neg)", "New (Neg)", "Prev (Pos)", "New (Pos)"] box_colors = ["lightcoral", "lightcoral", "lightblue", "lightblue"] # Set tick labels AFTER plotting rather than via a boxplot kwarg: matplotlib renamed the # `labels` kwarg to `tick_labels` in 3.9 and removed `labels` in 3.11, so passing it raises # TypeError. set_xticklabels is stable across versions. bp = axes[1, 1].boxplot(box_data, patch_artist=True) axes[1, 1].set_xticklabels(box_labels) for patch, color in zip(bp["boxes"], box_colors): patch.set_facecolor(color) patch.set_alpha(0.7) axes[1, 1].set_title("Score Distribution Comparison") axes[1, 1].set_ylabel("Score") axes[1, 1].grid(True, alpha=0.3) plt.tight_layout() out_path = os.path.join(output_dir, "score_distributions.jpg") plt.savefig(out_path, format="jpg", dpi=150, bbox_inches="tight") plt.close() logger.info(f"Saved score distribution plots to {out_path}") return out_path
[docs] def generate_text_summary(json_report: Dict[str, Any]) -> str: """ Generate a human-readable text summary from the JSON report. """ summary = [] summary.append("MODEL METRICS COMPUTATION REPORT") summary.append("=" * 50) summary.append(f"Generated: {json_report['timestamp']}") summary.append("") # Data summary. Use .get() defaults so the report never crashes on a summary variant that omits # a key (the single-task and multi-task validators build slightly different shapes). data_summary = json_report["data_summary"] summary.append("DATA SUMMARY") summary.append("-" * 20) summary.append(f"Total Records: {data_summary.get('total_records', 'N/A')}") summary.append( f"Prediction Columns: {', '.join(data_summary.get('prediction_columns', []))}" ) summary.append(f"Has Amount Data: {data_summary.get('has_amount_data', False)}") summary.append("") # Standard metrics standard_metrics = json_report["standard_metrics"] summary.append("STANDARD ML METRICS") summary.append("-" * 20) for name, value in standard_metrics.items(): if isinstance(value, (int, float)): summary.append(f"{name}: {value:.4f}") else: summary.append(f"{name}: {value}") summary.append("") # Domain metrics domain_metrics = json_report["domain_metrics"] if domain_metrics: summary.append("DOMAIN-SPECIFIC METRICS") summary.append("-" * 25) for name, value in domain_metrics.items(): if isinstance(value, (int, float)): summary.append(f"{name}: {value:.4f}") else: summary.append(f"{name}: {value}") summary.append("") # Performance insights insights = json_report["performance_insights"] if insights: summary.append("PERFORMANCE INSIGHTS") summary.append("-" * 20) for insight in insights: summary.append(f"• {insight}") summary.append("") # Recommendations recommendations = json_report["recommendations"] if recommendations: summary.append("RECOMMENDATIONS") summary.append("-" * 15) for rec in recommendations: summary.append(f"• {rec}") summary.append("") # Visualizations visualizations = json_report["visualizations"] if visualizations: summary.append("GENERATED VISUALIZATIONS") summary.append("-" * 25) for name, path in visualizations.items(): summary.append(f"• {name}: {os.path.basename(path)}") return "\n".join(summary)
[docs] def log_metrics_summary( metrics: Dict[str, Union[int, float, str]], is_binary: bool = True ) -> None: """ Log a nicely formatted summary of metrics for easy visibility in logs. """ timestamp = time.strftime("%Y-%m-%d %H:%M:%S") logger.info("=" * 80) logger.info(f"METRICS SUMMARY - {timestamp}") logger.info("=" * 80) # Log each metric with a consistent format for name, value in metrics.items(): # Format numeric values to 4 decimal places if isinstance(value, (int, float)): formatted_value = f"{value:.4f}" else: formatted_value = str(value) # Add a special prefix for easy searching in logs logger.info(f"METRIC: {name.ljust(25)} = {formatted_value}") # Highlight key metrics based on task type logger.info("=" * 80) logger.info("KEY PERFORMANCE METRICS") logger.info("=" * 80) if is_binary: logger.info( f"METRIC_KEY: AUC-ROC = {metrics.get('auc_roc', 'N/A'):.4f}" ) logger.info( f"METRIC_KEY: Average Precision = {metrics.get('average_precision', 'N/A'):.4f}" ) logger.info( f"METRIC_KEY: F1 Score = {metrics.get('f1_score', 'N/A'):.4f}" ) else: logger.info( f"METRIC_KEY: Macro AUC-ROC = {metrics.get('auc_roc_macro', 'N/A'):.4f}" ) logger.info( f"METRIC_KEY: Micro AUC-ROC = {metrics.get('auc_roc_micro', 'N/A'):.4f}" ) ap_macro = metrics.get("average_precision_macro", "N/A") if isinstance(ap_macro, (int, float)): logger.info(f"METRIC_KEY: Macro Average Precision = {ap_macro:.4f}") else: logger.info(f"METRIC_KEY: Macro Average Precision = {ap_macro}") logger.info( f"METRIC_KEY: Macro F1 = {metrics.get('f1_score_macro', 'N/A'):.4f}" ) logger.info( f"METRIC_KEY: Micro F1 = {metrics.get('f1_score_micro', 'N/A'):.4f}" ) logger.info("=" * 80)
[docs] def save_metrics( metrics: Dict[str, Union[int, float, str]], output_metrics_dir: str ) -> None: """ Save computed metrics as a JSON file (matching xgboost_model_eval.py). """ # Convert numpy types to Python native types for JSON serialization serializable_metrics = {} for key, value in metrics.items(): if isinstance(value, np.bool_): serializable_metrics[key] = bool(value) elif isinstance(value, (np.integer, np.int64, np.int32)): serializable_metrics[key] = int(value) elif isinstance(value, (np.floating, np.float64, np.float32)): serializable_metrics[key] = float(value) elif isinstance(value, np.ndarray): serializable_metrics[key] = value.tolist() else: serializable_metrics[key] = value out_path = os.path.join(output_metrics_dir, "metrics.json") with open(out_path, "w") as f: json.dump(serializable_metrics, f, indent=2) logger.info(f"Saved metrics to {out_path}") # Also create a plain text summary for easy viewing summary_path = os.path.join(output_metrics_dir, "metrics_summary.txt") with open(summary_path, "w") as f: f.write("METRICS SUMMARY\n") f.write("=" * 50 + "\n") # Write key metrics at the top if "auc_roc" in metrics: # Binary classification f.write(f"AUC-ROC: {metrics['auc_roc']:.4f}\n") if "average_precision" in metrics: f.write(f"Average Precision: {metrics['average_precision']:.4f}\n") if "f1_score" in metrics: f.write(f"F1 Score: {metrics['f1_score']:.4f}\n") else: # Multiclass classification f.write(f"AUC-ROC (Macro): {metrics.get('auc_roc_macro', 'N/A'):.4f}\n") f.write(f"AUC-ROC (Micro): {metrics.get('auc_roc_micro', 'N/A'):.4f}\n") f.write(f"F1 Score (Macro): {metrics.get('f1_score_macro', 'N/A'):.4f}\n") f.write("=" * 50 + "\n\n") # Write all metrics f.write("ALL METRICS\n") f.write("=" * 50 + "\n") for name, value in sorted(metrics.items()): if isinstance(value, (int, float)): f.write(f"{name}: {value:.6f}\n") else: f.write(f"{name}: {value}\n") logger.info(f"Saved metrics summary to {summary_path}")
[docs] def compute_multitask_metrics( df: pd.DataFrame, score_fields: List[str], label_fields: List[str], amounts: np.ndarray = None, environ_vars: Dict[str, str] = None, ) -> Dict[str, Any]: """ Compute per-task and aggregate metrics for multi-task predictions. Pattern matching lightgbmmt_model_eval.py Args: df: DataFrame with predictions score_fields: List of score column names label_fields: List of label column names amounts: Optional array of transaction amounts environ_vars: Environment variables for domain metrics Returns: Dictionary with per-task and aggregate metrics """ logger.info("Computing multi-task metrics") metrics = {} # Per-task metrics auc_rocs = [] aps = [] f1s = [] for score_field, label_field in zip(score_fields, label_fields): logger.info(f"Computing metrics for task: {label_field}") y_true = df[label_field].values y_score = df[score_field].values # Reshape for binary classification y_prob = np.column_stack([1 - y_score, y_score]) # [prob_class_0, prob_class_1] # Compute standard metrics task_metrics = compute_standard_metrics(y_true, y_prob, is_binary=True) # Store with task prefix metrics[f"task_{label_field}"] = task_metrics # Collect for aggregation auc_rocs.append(task_metrics["auc_roc"]) aps.append(task_metrics["average_precision"]) f1s.append(task_metrics["f1_score"]) logger.info( f"Task {label_field}: AUC={task_metrics['auc_roc']:.4f}, " f"AP={task_metrics['average_precision']:.4f}, " f"F1={task_metrics['f1_score']:.4f}" ) # Aggregate metrics (matching lightgbmmt_model_eval.py pattern) if auc_rocs: metrics["aggregate"] = { "mean_auc_roc": float(np.mean(auc_rocs)), "median_auc_roc": float(np.median(auc_rocs)), "mean_average_precision": float(np.mean(aps)), "median_average_precision": float(np.median(aps)), "mean_f1_score": float(np.mean(f1s)), "median_f1_score": float(np.median(f1s)), } logger.info("Aggregate Metrics:") logger.info(f" Mean AUC-ROC: {metrics['aggregate']['mean_auc_roc']:.4f}") logger.info(f" Mean AP: {metrics['aggregate']['mean_average_precision']:.4f}") logger.info(f" Mean F1: {metrics['aggregate']['mean_f1_score']:.4f}") # Domain metrics per task (if amounts provided) if amounts is not None and environ_vars: compute_dollar = ( environ_vars.get("COMPUTE_DOLLAR_RECALL", "true").lower() == "true" ) compute_count = ( environ_vars.get("COMPUTE_COUNT_RECALL", "true").lower() == "true" ) if compute_dollar or compute_count: domain_metrics = compute_multitask_domain_metrics( df, score_fields, label_fields, amounts, environ_vars ) metrics.update(domain_metrics) return metrics
[docs] def compute_multitask_domain_metrics( df: pd.DataFrame, score_fields: List[str], label_fields: List[str], amounts: np.ndarray, environ_vars: Dict[str, str], ) -> Dict[str, Any]: """ Compute domain-specific metrics (dollar/count recall) for each task. Args: df: DataFrame with predictions score_fields: List of score column names label_fields: List of label column names amounts: Array of transaction amounts environ_vars: Environment variables Returns: Dictionary with per-task domain metrics """ domain_metrics = {} compute_dollar = environ_vars.get("COMPUTE_DOLLAR_RECALL", "true").lower() == "true" compute_count = environ_vars.get("COMPUTE_COUNT_RECALL", "true").lower() == "true" dollar_fpr = float(environ_vars.get("DOLLAR_RECALL_FPR", "0.1")) count_cutoff = float(environ_vars.get("COUNT_RECALL_CUTOFF", "0.1")) for score_field, label_field in zip(score_fields, label_fields): y_true = df[label_field].values y_score = df[score_field].values # Compute domain metrics for this task task_domain = compute_domain_metrics( scores=y_score, labels=y_true, amounts=amounts, compute_dollar_recall=compute_dollar, compute_count_recall=compute_count, dollar_recall_fpr=dollar_fpr, count_recall_cutoff=count_cutoff, ) # Store with task prefix for metric_name, value in task_domain.items(): domain_metrics[f"task_{label_field}_{metric_name}"] = value return domain_metrics
[docs] def generate_multitask_visualizations( df: pd.DataFrame, score_fields: List[str], label_fields: List[str], output_dir: str, ) -> Dict[str, str]: """ Generate per-task ROC and PR curves. Pattern matching lightgbmmt_model_eval.py Args: df: DataFrame with predictions score_fields: List of score column names label_fields: List of label column names output_dir: Output directory for plots Returns: Dictionary of plot file paths """ logger.info("Generating multi-task visualizations") plot_paths = {} for score_field, label_field in zip(score_fields, label_fields): logger.info(f"Generating plots for task: {label_field}") y_true = df[label_field].values y_score = df[score_field].values # Skip if only one class present if len(np.unique(y_true)) < 2: logger.warning( f"Task {label_field}: Only one class present, skipping plots" ) continue # ROC curve plot_paths[f"task_{label_field}_roc"] = plot_and_save_roc_curve( y_true, y_score, output_dir, prefix=f"task_{label_field}_" ) # PR curve plot_paths[f"task_{label_field}_pr"] = plot_and_save_pr_curve( y_true, y_score, output_dir, prefix=f"task_{label_field}_" ) logger.info(f"Generated {len(plot_paths)} visualization plots") return plot_paths
[docs] def compute_multitask_comparison_metrics( df: pd.DataFrame, score_fields: List[str], label_fields: List[str], prev_score_fields: List[str], ) -> Dict[str, Any]: """ Compute comparison metrics for multi-task predictions. Pattern matching single-task comparison but per-task. Args: df: DataFrame with predictions score_fields: List of current score column names label_fields: List of label column names prev_score_fields: List of previous score column names Returns: Dictionary with per-task and aggregate comparison metrics """ logger.info("Computing multi-task comparison metrics") comparison_metrics = {} # Per-task comparison metrics auc_deltas = [] ap_deltas = [] correlations = [] for score_field, label_field, prev_score_field in zip( score_fields, label_fields, prev_score_fields ): logger.info(f"Computing comparison for task: {label_field}") y_true = df[label_field].values y_new_score = df[score_field].values y_prev_score = df[prev_score_field].values # Compute task-specific comparison metrics task_comp = compute_comparison_metrics( y_true, y_new_score, y_prev_score, is_binary=True ) # Store with task prefix for metric_name, value in task_comp.items(): comparison_metrics[f"task_{label_field}_{metric_name}"] = value # Collect for aggregation auc_deltas.append(task_comp.get("auc_delta", 0)) ap_deltas.append(task_comp.get("ap_delta", 0)) correlations.append(task_comp.get("pearson_correlation", 0)) logger.info( f"Task {label_field}: AUC delta={task_comp.get('auc_delta', 0):.4f}, " f"Correlation={task_comp.get('pearson_correlation', 0):.4f}" ) # Aggregate comparison metrics if auc_deltas: comparison_metrics["aggregate_comparison"] = { "mean_auc_delta": float(np.mean(auc_deltas)), "median_auc_delta": float(np.median(auc_deltas)), "mean_ap_delta": float(np.mean(ap_deltas)), "median_ap_delta": float(np.median(ap_deltas)), "mean_correlation": float(np.mean(correlations)), "median_correlation": float(np.median(correlations)), } logger.info("Aggregate Comparison Metrics:") logger.info( f" Mean AUC Delta: {comparison_metrics['aggregate_comparison']['mean_auc_delta']:.4f}" ) logger.info( f" Mean Correlation: {comparison_metrics['aggregate_comparison']['mean_correlation']:.4f}" ) return comparison_metrics
[docs] def generate_multitask_comparison_plots( df: pd.DataFrame, score_fields: List[str], label_fields: List[str], prev_score_fields: List[str], output_dir: str, ) -> Dict[str, str]: """ Generate per-task comparison visualizations. Pattern matching single-task comparison but per-task. Args: df: DataFrame with predictions score_fields: List of current score column names label_fields: List of label column names prev_score_fields: List of previous score column names output_dir: Output directory for plots Returns: Dictionary of plot file paths """ logger.info("Generating multi-task comparison visualizations") plot_paths = {} for score_field, label_field, prev_score_field in zip( score_fields, label_fields, prev_score_fields ): logger.info(f"Generating comparison plots for task: {label_field}") y_true = df[label_field].values y_new_score = df[score_field].values y_prev_score = df[prev_score_field].values # Skip if only one class present if len(np.unique(y_true)) < 2: logger.warning( f"Task {label_field}: Only one class present, skipping comparison plots" ) continue # Generate per-task comparison plots with task prefix prefix = f"task_{label_field}_" # ROC curve comparison plot_paths[f"task_{label_field}_comparison_roc"] = plot_comparison_roc_curves( y_true, y_new_score, y_prev_score, output_dir ) # Rename to include task prefix old_path = plot_paths[f"task_{label_field}_comparison_roc"] new_path = os.path.join(output_dir, f"{prefix}comparison_roc_curves.jpg") if os.path.exists(old_path): os.rename(old_path, new_path) plot_paths[f"task_{label_field}_comparison_roc"] = new_path # PR curve comparison plot_paths[f"task_{label_field}_comparison_pr"] = plot_comparison_pr_curves( y_true, y_new_score, y_prev_score, output_dir ) # Rename to include task prefix old_path = plot_paths[f"task_{label_field}_comparison_pr"] new_path = os.path.join(output_dir, f"{prefix}comparison_pr_curves.jpg") if os.path.exists(old_path): os.rename(old_path, new_path) plot_paths[f"task_{label_field}_comparison_pr"] = new_path # Score scatter plot plot_paths[f"task_{label_field}_score_scatter"] = plot_score_scatter( y_new_score, y_prev_score, y_true, output_dir ) # Rename to include task prefix old_path = plot_paths[f"task_{label_field}_score_scatter"] new_path = os.path.join(output_dir, f"{prefix}score_scatter_plot.jpg") if os.path.exists(old_path): os.rename(old_path, new_path) plot_paths[f"task_{label_field}_score_scatter"] = new_path # Score distributions plot_paths[f"task_{label_field}_score_distributions"] = ( plot_score_distributions(y_new_score, y_prev_score, y_true, output_dir) ) # Rename to include task prefix old_path = plot_paths[f"task_{label_field}_score_distributions"] new_path = os.path.join(output_dir, f"{prefix}score_distributions.jpg") if os.path.exists(old_path): os.rename(old_path, new_path) plot_paths[f"task_{label_field}_score_distributions"] = new_path logger.info(f"Generated {len(plot_paths)} multi-task comparison plots") return plot_paths
[docs] def create_health_check_file(output_path: str) -> str: """Create a health check file to signal script completion.""" health_path = output_path with open(health_path, "w") as f: f.write(f"healthy: {datetime.now().isoformat()}") return health_path
[docs] def main( input_paths: Dict[str, str], output_paths: Dict[str, str], environ_vars: Dict[str, str], job_args: argparse.Namespace, ) -> None: """ Main entry point for Model Metrics Computation script. Loads prediction data, computes metrics, generates visualizations, and saves results. Args: input_paths (Dict[str, str]): Dictionary of input paths output_paths (Dict[str, str]): Dictionary of output paths environ_vars (Dict[str, str]): Dictionary of environment variables job_args (argparse.Namespace): Command line arguments """ # Extract paths from parameters - using contract-defined logical names eval_data_dir = input_paths.get("eval_output") output_metrics_dir = output_paths.get("metrics_output") # plots_output is an optional contract output; when absent, co-locate plots with the metrics # dir (a literal var default, not an undeclared-alias fallback) so makedirs never sees None. output_plots_dir = output_paths.get("plots_output", output_metrics_dir) # Extract environment variables id_field = environ_vars.get("ID_FIELD", "id") label_field = environ_vars.get("LABEL_FIELD", "label") amount_field = environ_vars.get("AMOUNT_FIELD", None) input_format = environ_vars.get("INPUT_FORMAT", "auto") compute_dollar_recall = ( environ_vars.get("COMPUTE_DOLLAR_RECALL", "true").lower() == "true" ) compute_count_recall = ( environ_vars.get("COMPUTE_COUNT_RECALL", "true").lower() == "true" ) dollar_recall_fpr = float(environ_vars.get("DOLLAR_RECALL_FPR", "0.1")) count_recall_cutoff = float(environ_vars.get("COUNT_RECALL_CUTOFF", "0.1")) generate_plots = environ_vars.get("GENERATE_PLOTS", "true").lower() == "true" # Extract comparison mode environment variables comparison_mode = environ_vars.get("COMPARISON_MODE", "false").lower() == "true" previous_score_field = environ_vars.get("PREVIOUS_SCORE_FIELD", "") comparison_metrics = environ_vars.get("COMPARISON_METRICS", "all") statistical_tests = environ_vars.get("STATISTICAL_TESTS", "true").lower() == "true" comparison_plots = environ_vars.get("COMPARISON_PLOTS", "true").lower() == "true" # Guard rail: If PREVIOUS_SCORE_FIELD is empty, disable comparison mode if comparison_mode and ( not previous_score_field or previous_score_field.strip() == "" ): logger.warning( "COMPARISON_MODE is enabled but PREVIOUS_SCORE_FIELD is empty. Disabling comparison mode." ) comparison_mode = False logger.info(f"Comparison mode: {comparison_mode}") if comparison_mode: logger.info(f"Previous score field: {previous_score_field}") logger.info(f"Comparison metrics: {comparison_metrics}") logger.info(f"Statistical tests: {statistical_tests}") logger.info(f"Comparison plots: {comparison_plots}") # Log job info logger.info("Running model metrics computation") # Ensure output directories exist os.makedirs(output_metrics_dir, exist_ok=True) os.makedirs(output_plots_dir, exist_ok=True) logger.info("Starting model metrics computation script") # ===== NEW: Multi-Task Detection ===== # Step 1: Parse score fields (determines single-task vs multi-task) score_fields = parse_score_fields(environ_vars) is_multitask = len(score_fields) > 1 logger.info(f"Detected mode: {'multi-task' if is_multitask else 'single-task'}") logger.info(f"Score fields: {score_fields}") # Step 2: Parse label fields (infer if needed) label_fields = parse_task_label_fields(environ_vars, score_fields) logger.info(f"Label fields: {label_fields}") # Step 2b: Parse previous score fields (for comparison mode) prev_score_fields = parse_previous_score_fields(environ_vars, score_fields) if prev_score_fields: logger.info(f"Previous score fields: {prev_score_fields}") logger.info( f"Comparison mode will be enabled for {'multi-task' if is_multitask else 'single-task'}" ) # Step 3: Load and validate prediction data df = detect_and_load_predictions( eval_data_dir, preferred_format=input_format if input_format != "auto" else None ) # Step 4: Validate columns exist validation_report = validate_prediction_columns( df, score_fields, label_fields, id_field ) if not validation_report["is_valid"]: logger.error("Data validation failed:") for error in validation_report["errors"]: logger.error(f" - {error}") raise ValueError("Input data validation failed") # Step 4b: Validate previous score fields if in comparison mode if prev_score_fields: missing_prev = [f for f in prev_score_fields if f not in df.columns] if missing_prev: logger.warning( f"Comparison mode requested but previous score fields missing: {missing_prev}. " f"Disabling comparison mode." ) prev_score_fields = [] # Disable comparison else: logger.info( f"Validated previous score fields exist in data: {prev_score_fields}" ) # Step 5: Get amounts if available amounts = ( df[amount_field].values if amount_field and amount_field in df.columns else None ) # ===== ROUTING: Multi-Task vs Single-Task ===== if is_multitask: logger.info( f"Running multi-task metrics computation for {len(score_fields)} tasks" ) # Compute multi-task metrics all_metrics = compute_multitask_metrics( df, score_fields, label_fields, amounts, environ_vars ) # Generate multi-task visualizations if generate_plots: plot_paths = generate_multitask_visualizations( df, score_fields, label_fields, output_plots_dir ) else: plot_paths = {} # Multi-task comparison mode if prev_score_fields: logger.info( f"Enabling multi-task comparison mode for {len(prev_score_fields)} tasks" ) # Compute multi-task comparison metrics mt_comp_metrics = compute_multitask_comparison_metrics( df, score_fields, label_fields, prev_score_fields ) all_metrics.update(mt_comp_metrics) # Generate multi-task comparison plots if generate_plots and comparison_plots: logger.info("Generating multi-task comparison visualizations") mt_comp_plots = generate_multitask_comparison_plots( df, score_fields, label_fields, prev_score_fields, output_plots_dir ) plot_paths.update(mt_comp_plots) logger.info( f"Generated {len(mt_comp_plots)} multi-task comparison plots" ) # Extract standard and domain metrics for reporting standard_metrics = all_metrics.get("aggregate", {}) domain_metrics = { k: v for k, v in all_metrics.items() if k.startswith("task_") and k not in ["aggregate"] } else: # Single-task mode (EXISTING CODE PATH - unchanged) logger.info("Running single-task metrics computation") # Extract single task data label_field = label_fields[0] score_field = score_fields[0] # Use legacy validation for backward compatibility legacy_validation = validate_prediction_data( df, id_field, label_field, amount_field ) # Log warnings for warning in legacy_validation.get("warnings", []): logger.warning(warning) y_true = df[label_field].values # Detect probability columns (original logic) prob_cols = [col for col in df.columns if col.startswith("prob_class_")] if not prob_cols: # Fallback: create binary prob columns from score field logger.info( f"No prob_class_* columns found, using {score_field} to create binary probabilities" ) prob_cols = ["prob_class_0", "prob_class_1"] df["prob_class_0"] = 1 - df[score_field] df["prob_class_1"] = df[score_field] y_prob = df[prob_cols].values is_binary = y_prob.shape[1] == 2 scores = y_prob[:, 1] if is_binary else np.max(y_prob, axis=1) logger.info( f"Computing metrics for {'binary' if is_binary else 'multiclass'} classification" ) logger.info(f"Data shape: {df.shape}, Predictions shape: {y_prob.shape}") # Compute standard metrics (EXISTING) standard_metrics = compute_standard_metrics(y_true, y_prob, is_binary=is_binary) log_metrics_summary(standard_metrics, is_binary=is_binary) # Compute domain metrics (EXISTING) domain_metrics = compute_domain_metrics( scores=scores, labels=y_true, amounts=amounts, compute_dollar_recall=compute_dollar_recall and amounts is not None, compute_count_recall=compute_count_recall, dollar_recall_fpr=dollar_recall_fpr, count_recall_cutoff=count_recall_cutoff, ) if domain_metrics: logger.info("Domain-specific metrics computed:") for name, value in domain_metrics.items(): if isinstance(value, (int, float)): logger.info(f" {name}: {value:.4f}") else: logger.info(f" {name}: {value}") # Generate visualizations (EXISTING) plot_paths = {} if generate_plots: logger.info("Generating performance visualizations") plot_paths = generate_performance_visualizations( y_true, y_prob, standard_metrics, output_plots_dir, is_binary=is_binary ) logger.info(f"Generated {len(plot_paths)} visualization plots") # Combine metrics all_metrics = {**standard_metrics, **domain_metrics} # Check for comparison mode previous_scores = None if comparison_mode: if previous_score_field in df.columns: previous_scores = df[previous_score_field].values logger.info( f"Found previous model scores in column '{previous_score_field}' with {len(previous_scores)} values" ) else: logger.warning( f"Comparison mode enabled but column '{previous_score_field}' not found in data. Proceeding with standard evaluation." ) comparison_mode = False # Combine all metrics for saving all_metrics = {**standard_metrics, **domain_metrics} # Add comparison metrics if comparison mode is enabled if comparison_mode and previous_scores is not None: logger.info("Computing comparison metrics") # Compute comparison metrics if comparison_metrics in ["all", "basic"]: comp_metrics = compute_comparison_metrics( y_true, scores, previous_scores, is_binary=is_binary ) all_metrics.update(comp_metrics) # Perform statistical tests if statistical_tests: stat_results = perform_statistical_tests( y_true, scores, previous_scores, is_binary=is_binary ) all_metrics.update(stat_results) # Generate comparison plots if comparison_plots and generate_plots: logger.info("Generating comparison visualizations") comparison_plot_paths = {} if is_binary: comparison_plot_paths["comparison_roc_curves"] = ( plot_comparison_roc_curves( y_true, scores, previous_scores, output_plots_dir ) ) comparison_plot_paths["comparison_pr_curves"] = ( plot_comparison_pr_curves( y_true, scores, previous_scores, output_plots_dir ) ) comparison_plot_paths["score_scatter_plot"] = plot_score_scatter( scores, previous_scores, y_true, output_plots_dir ) comparison_plot_paths["score_distributions"] = plot_score_distributions( scores, previous_scores, y_true, output_plots_dir ) # Add comparison plots to existing plot paths plot_paths.update(comparison_plot_paths) logger.info( f"Generated {len(comparison_plot_paths)} comparison visualization plots" ) # Save metrics in original format (matching xgboost_model_eval.py) save_metrics(all_metrics, output_metrics_dir) # Generate comprehensive report report_paths = generate_comprehensive_report( standard_metrics, domain_metrics, plot_paths, validation_report, output_metrics_dir, ) logger.info(f"Generated comprehensive report: {report_paths['json_report']}") logger.info(f"Generated text summary: {report_paths['text_summary']}") logger.info("Model metrics computation script complete")
if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--job_type", type=str, required=True) args = parser.parse_args() # Set up paths using contract-defined paths only input_paths = { "processed_data": CONTAINER_PATHS["EVAL_DATA_DIR"], } output_paths = { "metrics_output": CONTAINER_PATHS["OUTPUT_METRICS_DIR"], "plots_output": CONTAINER_PATHS["OUTPUT_PLOTS_DIR"], } # Collect environment variables environ_vars = { # Basic field configuration "ID_FIELD": os.environ.get("ID_FIELD", "id"), "LABEL_FIELD": os.environ.get("LABEL_FIELD", "label"), "AMOUNT_FIELD": os.environ.get("AMOUNT_FIELD", None), "INPUT_FORMAT": os.environ.get("INPUT_FORMAT", "auto"), # Multi-task configuration (NEW) "SCORE_FIELDS": os.environ.get( "SCORE_FIELDS", "" ), # Comma-separated score fields for multi-task "SCORE_FIELD": os.environ.get( "SCORE_FIELD", "" ), # Single score field for backward compatibility "TASK_LABEL_NAMES": os.environ.get( "TASK_LABEL_NAMES", "" ), # Optional explicit task labels "PREVIOUS_SCORE_FIELDS": os.environ.get( "PREVIOUS_SCORE_FIELDS", "" ), # Comma-separated previous score fields for multi-task comparison # Domain metrics configuration "COMPUTE_DOLLAR_RECALL": os.environ.get("COMPUTE_DOLLAR_RECALL", "true"), "COMPUTE_COUNT_RECALL": os.environ.get("COMPUTE_COUNT_RECALL", "true"), "DOLLAR_RECALL_FPR": os.environ.get("DOLLAR_RECALL_FPR", "0.1"), "COUNT_RECALL_CUTOFF": os.environ.get("COUNT_RECALL_CUTOFF", "0.1"), # Visualization configuration "GENERATE_PLOTS": os.environ.get("GENERATE_PLOTS", "true"), # Comparison mode configuration "COMPARISON_MODE": os.environ.get("COMPARISON_MODE", "false"), "PREVIOUS_SCORE_FIELD": os.environ.get("PREVIOUS_SCORE_FIELD", ""), "COMPARISON_METRICS": os.environ.get("COMPARISON_METRICS", "all"), "STATISTICAL_TESTS": os.environ.get("STATISTICAL_TESTS", "true"), "COMPARISON_PLOTS": os.environ.get("COMPARISON_PLOTS", "true"), } try: # Call main function with testability parameters main(input_paths, output_paths, environ_vars, args) # Signal success success_path = os.path.join(output_paths["metrics_output"], "_SUCCESS") Path(success_path).touch() logger.info(f"Created success marker: {success_path}") # Create health check file health_path = os.path.join(output_paths["metrics_output"], "_HEALTH") create_health_check_file(health_path) logger.info(f"Created health check file: {health_path}") sys.exit(0) except Exception as e: # Log error and create failure marker logger.exception(f"Script failed with error: {e}") failure_path = os.path.join( output_paths.get("metrics_output", "/tmp"), "_FAILURE" ) os.makedirs(os.path.dirname(failure_path), exist_ok=True) with open(failure_path, "w") as f: f.write(f"Error: {str(e)}") sys.exit(1)