Spaces:
Sleeping
Sleeping
| """Evaluation helpers for Phase 3 classifier outputs.""" | |
| from __future__ import annotations | |
| import logging | |
| from collections import Counter, defaultdict | |
| from typing import Any, Dict, Iterable, List, Sequence | |
| from prert.phase3.classifier import TextClassifier | |
| from prert.phase3.types import ClauseExample | |
| _LOGGER = logging.getLogger(__name__) | |
| def evaluate_classifier( | |
| model: TextClassifier, | |
| examples: Sequence[ClauseExample], | |
| labels: Sequence[str], | |
| ) -> Dict[str, Any]: | |
| if not examples: | |
| return { | |
| "rows": 0, | |
| "accuracy": 0.0, | |
| "macro_precision": 0.0, | |
| "macro_recall": 0.0, | |
| "macro_f1": 0.0, | |
| "weighted_f1": 0.0, | |
| "per_class": {label: _empty_metrics() for label in labels}, | |
| "confusion": _empty_confusion(labels), | |
| "probability_diagnostics": { | |
| "renormalized_rows": 0, | |
| "uniform_fallback_rows": 0, | |
| }, | |
| } | |
| predictions: List[Dict[str, Any]] = [] | |
| confusion: Dict[str, Dict[str, int]] = { | |
| actual: {predicted: 0 for predicted in labels} | |
| for actual in labels | |
| } | |
| # C8: track how often the classifier emits a non-normalized probability | |
| # vector so we can flag silently-broken predict_proba implementations. | |
| renormalized_count = 0 | |
| fallback_uniform_count = 0 | |
| correct = 0 | |
| for example in examples: | |
| predicted_label = model.predict(example.text) | |
| proba = model.predict_proba(example.text) | |
| probabilities = {label: float(proba.get(label, 0.0)) for label in labels} | |
| total_probability = sum(probabilities.values()) | |
| if total_probability > 0: | |
| if abs(total_probability - 1.0) > 1e-6: | |
| renormalized_count += 1 | |
| probabilities = { | |
| label: (value / total_probability) | |
| for label, value in probabilities.items() | |
| } | |
| else: | |
| fallback_uniform_count += 1 | |
| uniform = 1.0 / max(len(labels), 1) | |
| probabilities = {label: uniform for label in labels} | |
| confidence = float(probabilities.get(predicted_label, 0.0)) | |
| actual_label = example.label | |
| if actual_label in confusion and predicted_label in confusion[actual_label]: | |
| confusion[actual_label][predicted_label] += 1 | |
| if predicted_label == actual_label: | |
| correct += 1 | |
| predictions.append( | |
| { | |
| "example_id": example.example_id, | |
| "policy_uid": example.policy_uid, | |
| "actual_label": actual_label, | |
| "predicted_label": predicted_label, | |
| "confidence": round(confidence, 6), | |
| "probabilities": { | |
| label: round(float(probabilities.get(label, 0.0)), 6) | |
| for label in labels | |
| }, | |
| "text": example.text, | |
| } | |
| ) | |
| per_class = {} | |
| precision_values: List[float] = [] | |
| recall_values: List[float] = [] | |
| f1_values: List[float] = [] | |
| weighted_f1_numerator = 0.0 | |
| weighted_f1_denominator = 0 | |
| for label in labels: | |
| tp = confusion[label][label] | |
| fp = sum(confusion[actual][label] for actual in labels if actual != label) | |
| fn = sum(confusion[label][pred] for pred in labels if pred != label) | |
| support = sum(confusion[label].values()) | |
| precision = tp / (tp + fp) if tp + fp > 0 else 0.0 | |
| recall = tp / (tp + fn) if tp + fn > 0 else 0.0 | |
| f1 = (2 * precision * recall / (precision + recall)) if precision + recall > 0 else 0.0 | |
| metrics = { | |
| "precision": round(precision, 6), | |
| "recall": round(recall, 6), | |
| "f1": round(f1, 6), | |
| "support": support, | |
| } | |
| per_class[label] = metrics | |
| precision_values.append(precision) | |
| recall_values.append(recall) | |
| f1_values.append(f1) | |
| weighted_f1_numerator += f1 * support | |
| weighted_f1_denominator += support | |
| row_count = len(examples) | |
| accuracy = correct / row_count if row_count else 0.0 | |
| # C3: weighted F1 reports support-weighted performance; macro F1 already | |
| # reports unweighted performance. Both are needed because OPP-115 has | |
| # heavy organization-level imbalance. | |
| weighted_f1 = ( | |
| weighted_f1_numerator / weighted_f1_denominator | |
| if weighted_f1_denominator > 0 | |
| else 0.0 | |
| ) | |
| if renormalized_count or fallback_uniform_count: | |
| _LOGGER.warning( | |
| "predict_proba returned non-normalized vectors: renormalized=%d, uniform_fallback=%d (rows=%d)", | |
| renormalized_count, | |
| fallback_uniform_count, | |
| row_count, | |
| ) | |
| return { | |
| "rows": row_count, | |
| "accuracy": round(accuracy, 6), | |
| "macro_precision": round(_mean(precision_values), 6), | |
| "macro_recall": round(_mean(recall_values), 6), | |
| "macro_f1": round(_mean(f1_values), 6), | |
| "weighted_f1": round(weighted_f1, 6), | |
| "per_class": per_class, | |
| "confusion": confusion, | |
| "predictions": predictions, | |
| "probability_diagnostics": { | |
| "renormalized_rows": renormalized_count, | |
| "uniform_fallback_rows": fallback_uniform_count, | |
| }, | |
| } | |
| def _mean(values: Iterable[float]) -> float: | |
| values_list = list(values) | |
| if not values_list: | |
| return 0.0 | |
| return sum(values_list) / len(values_list) | |
| def _empty_metrics() -> Dict[str, Any]: | |
| return { | |
| "precision": 0.0, | |
| "recall": 0.0, | |
| "f1": 0.0, | |
| "support": 0, | |
| } | |
| def _empty_confusion(labels: Sequence[str]) -> Dict[str, Dict[str, int]]: | |
| return { | |
| actual: {predicted: 0 for predicted in labels} | |
| for actual in labels | |
| } | |