from __future__ import annotations import json from pathlib import Path from typing import Any import matplotlib.pyplot as plt import numpy as np import pandas as pd import seaborn as sns from sklearn.metrics import ( accuracy_score, classification_report, confusion_matrix, f1_score, multilabel_confusion_matrix, roc_auc_score, ) from config import CLASS_DESCRIPTIONS, LEGAL_WARNING, THRESHOLD def save_json(path: str | Path, payload: dict[str, Any]) -> None: destination = Path(path) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") def load_json(path: str | Path, default: Any = None) -> Any: source = Path(path) if not source.exists(): return default return json.loads(source.read_text(encoding="utf-8")) def flatten_history(phases: dict[str, dict[str, list[float]]]) -> dict[str, list[float]]: flat: dict[str, list[float]] = {} for phase_name in ("phase1", "phase2"): phase = phases.get(phase_name, {}) for metric_name, values in phase.items(): flat.setdefault(metric_name, []) flat[metric_name].extend(values) return flat def plot_training_history(history_payload: dict[str, Any], output_path: str | Path) -> None: flat = flatten_history(history_payload) if not flat: return metrics_to_plot = [ ("loss", "val_loss", "Pérdida"), ("acc", "val_acc", "Exactitud"), ("auc", "val_auc", "AUC"), ] plt.figure(figsize=(16, 4)) for index, (train_metric, val_metric, title) in enumerate(metrics_to_plot, start=1): plt.subplot(1, 3, index) if train_metric in flat: plt.plot(flat[train_metric], label=train_metric) if val_metric in flat: plt.plot(flat[val_metric], label=val_metric) plt.title(title) plt.xlabel("Época") plt.grid(alpha=0.3) plt.legend() plt.tight_layout() plt.savefig(output_path, dpi=180, bbox_inches="tight") plt.close() def _safe_macro_auc(y_true: np.ndarray, y_prob: np.ndarray, multilabel: bool) -> float | None: try: if multilabel: valid_scores = [] for index in range(y_true.shape[1]): column = y_true[:, index] if len(np.unique(column)) < 2: continue valid_scores.append(roc_auc_score(column, y_prob[:, index])) if not valid_scores: return None return float(np.mean(valid_scores)) return float(roc_auc_score(y_true, y_prob, multi_class="ovr", average="macro")) except Exception: return None def evaluate_predictions( y_true: np.ndarray, y_prob: np.ndarray, class_names: list[str], task_type: str, threshold: float = THRESHOLD, ) -> dict[str, Any]: multilabel = task_type == "multilabel" if multilabel: y_pred = (y_prob >= threshold).astype(int) exact_match = float(accuracy_score(y_true, y_pred)) macro_f1 = float(f1_score(y_true, y_pred, average="macro", zero_division=0)) micro_f1 = float(f1_score(y_true, y_pred, average="micro", zero_division=0)) auc_value = _safe_macro_auc(y_true, y_prob, multilabel=True) per_class = {} for index, class_name in enumerate(class_names): report = classification_report( y_true[:, index], y_pred[:, index], output_dict=True, zero_division=0, ) per_class[class_name] = report confusion_payload = multilabel_confusion_matrix(y_true, y_pred).tolist() return { "task_type": task_type, "accuracy": exact_match, "macro_f1": macro_f1, "micro_f1": micro_f1, "auc_roc": auc_value, "classification_report": per_class, "confusion_matrix": confusion_payload, } y_true_idx = np.argmax(y_true, axis=1) y_pred_idx = np.argmax(y_prob, axis=1) accuracy = float(accuracy_score(y_true_idx, y_pred_idx)) macro_f1 = float(f1_score(y_true_idx, y_pred_idx, average="macro", zero_division=0)) auc_value = _safe_macro_auc(y_true, y_prob, multilabel=False) report = classification_report( y_true_idx, y_pred_idx, target_names=class_names, output_dict=True, zero_division=0, ) cm = confusion_matrix(y_true_idx, y_pred_idx).tolist() return { "task_type": task_type, "accuracy": accuracy, "macro_f1": macro_f1, "micro_f1": macro_f1, "auc_roc": auc_value, "classification_report": report, "confusion_matrix": cm, } def plot_confusion_figure( y_true: np.ndarray, y_prob: np.ndarray, class_names: list[str], task_type: str, output_path: str | Path, threshold: float = THRESHOLD, ) -> None: multilabel = task_type == "multilabel" if multilabel: y_pred = (y_prob >= threshold).astype(int) matrices = multilabel_confusion_matrix(y_true, y_pred) columns = 3 rows = int(np.ceil(len(class_names) / columns)) plt.figure(figsize=(5 * columns, 4 * rows)) for index, class_name in enumerate(class_names, start=1): plt.subplot(rows, columns, index) sns.heatmap( matrices[index - 1], annot=True, fmt="d", cmap="Blues", cbar=False, xticklabels=["Negativo", "Positivo"], yticklabels=["Negativo", "Positivo"], ) plt.title(class_name) plt.xlabel("Predicción") plt.ylabel("Real") plt.tight_layout() plt.savefig(output_path, dpi=180, bbox_inches="tight") plt.close() return y_true_idx = np.argmax(y_true, axis=1) y_pred_idx = np.argmax(y_prob, axis=1) cm = confusion_matrix(y_true_idx, y_pred_idx) plt.figure(figsize=(8, 6)) sns.heatmap(cm, annot=True, fmt="d", cmap="Blues", xticklabels=class_names, yticklabels=class_names) plt.xlabel("Predicción") plt.ylabel("Real") plt.title("Matriz de confusión") plt.tight_layout() plt.savefig(output_path, dpi=180, bbox_inches="tight") plt.close() def probabilities_to_table(class_names: list[str], probabilities: np.ndarray) -> pd.DataFrame: rows = [{"Clase": name, "Probabilidad": float(prob)} for name, prob in zip(class_names, probabilities)] frame = pd.DataFrame(rows) return frame.sort_values("Probabilidad", ascending=False).reset_index(drop=True) def format_diagnosis_message( class_names: list[str], probabilities: np.ndarray, task_type: str, threshold: float = THRESHOLD, ) -> str: if task_type == "multilabel": positives = [(name, float(prob)) for name, prob in zip(class_names, probabilities) if prob >= threshold] positives = sorted(positives, key=lambda item: item[1], reverse=True) if positives and positives[0][0] == "normal": return "✅ Radiografía NORMAL — Sin hallazgos patológicos en las clases objetivo del modelo." if positives: top_name, top_prob = positives[0] description = CLASS_DESCRIPTIONS.get(top_name, "Hallazgo compatible con la clase predicha.") return ( f"⚠️ DIAGNÓSTICO PROBABLE: {top_name}\n" f"Confianza: {top_prob * 100:.2f}%\n" f"¿Qué significa? {description}" ) top_index = int(np.argmax(probabilities)) top_name = class_names[top_index] top_prob = float(probabilities[top_index]) description = CLASS_DESCRIPTIONS.get(top_name, "Hallazgo compatible con la clase predicha.") return ( f"⚠️ Hallazgo de baja confianza. Clase más probable: {top_name}\n" f"Confianza: {top_prob * 100:.2f}%\n" f"¿Qué significa? {description}" ) top_index = int(np.argmax(probabilities)) top_name = class_names[top_index] top_prob = float(probabilities[top_index]) if top_name == "normal": return "✅ Radiografía NORMAL — Sin hallazgos patológicos." description = CLASS_DESCRIPTIONS.get(top_name, "Hallazgo compatible con la clase predicha.") return ( f"⚠️ DIAGNÓSTICO PROBABLE: {top_name}\n" f"Confianza: {top_prob * 100:.2f}%\n" f"¿Qué significa? {description}" ) def legal_warning_text() -> str: return LEGAL_WARNING