""" Unit tests for ModelEvaluator (app/ml/evaluator.py). Tests verify that accuracy, precision, recall, F1-score, and confusion-matrix counts are computed correctly for known prediction scenarios. """ import numpy as np import pytest from sklearn.dummy import DummyClassifier from app.ml.evaluator import EvaluationMetrics, ModelEvaluator # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class _FixedPredictor: """Minimal model stub that always returns a pre-defined prediction array.""" def __init__(self, predictions): self._predictions = np.asarray(predictions) def predict(self, X): # noqa: N802 return self._predictions # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestEvaluationMetricsDataclass: def test_fields_accessible(self): m = EvaluationMetrics( accuracy=0.9, precision=0.85, recall=0.80, f1_score=0.82, tp=45, tn=45, fp=5, fn=5, ) assert m.accuracy == 0.9 assert m.precision == 0.85 assert m.recall == 0.80 assert m.f1_score == 0.82 assert m.tp == 45 assert m.tn == 45 assert m.fp == 5 assert m.fn == 5 class TestModelEvaluatorBinaryClassification: """Tests using a simple binary (0/1) classification scenario.""" def setup_method(self): self.evaluator = ModelEvaluator() def _make_data(self, y_true, y_pred): """Return (model_stub, X_dummy, y_true_array).""" model = _FixedPredictor(y_pred) X = np.zeros((len(y_true), 1)) # features are irrelevant for stub return model, X, np.asarray(y_true) # --- perfect predictions --- def test_perfect_predictions_accuracy_is_one(self): y_true = [1, 1, 0, 0, 1, 0] model, X, y = self._make_data(y_true, y_true) result = self.evaluator.evaluate(model, X, y) assert result.accuracy == pytest.approx(1.0) def test_perfect_predictions_confusion_matrix(self): y_true = [1, 1, 0, 0] model, X, y = self._make_data(y_true, y_true) result = self.evaluator.evaluate(model, X, y) assert result.tp == 2 assert result.tn == 2 assert result.fp == 0 assert result.fn == 0 def test_perfect_predictions_f1_is_one(self): y_true = [1, 1, 0, 0] model, X, y = self._make_data(y_true, y_true) result = self.evaluator.evaluate(model, X, y) assert result.f1_score == pytest.approx(1.0) # --- all wrong predictions --- def test_all_wrong_accuracy_is_zero(self): y_true = [1, 1, 0, 0] y_pred = [0, 0, 1, 1] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert result.accuracy == pytest.approx(0.0) def test_all_wrong_confusion_matrix(self): y_true = [1, 1, 0, 0] y_pred = [0, 0, 1, 1] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert result.tp == 0 assert result.tn == 0 assert result.fp == 2 assert result.fn == 2 # --- mixed predictions --- def test_mixed_accuracy(self): # 3 correct out of 4 y_true = [1, 1, 0, 0] y_pred = [1, 0, 0, 0] # one FN, rest correct model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert result.accuracy == pytest.approx(0.75) def test_mixed_confusion_matrix_counts(self): # TP=1, TN=2, FP=0, FN=1 y_true = [1, 1, 0, 0] y_pred = [1, 0, 0, 0] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert result.tp == 1 assert result.tn == 2 assert result.fp == 0 assert result.fn == 1 def test_confusion_matrix_counts_sum_to_total_samples(self): y_true = [1, 0, 1, 0, 1, 1, 0, 0] y_pred = [1, 0, 0, 1, 1, 0, 0, 1] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) total = result.tp + result.tn + result.fp + result.fn assert total == len(y_true) # --- return type --- def test_returns_evaluation_metrics_instance(self): y_true = [1, 0, 1, 0] model, X, y = self._make_data(y_true, y_true) result = self.evaluator.evaluate(model, X, y) assert isinstance(result, EvaluationMetrics) def test_metrics_are_floats(self): y_true = [1, 0, 1, 0] model, X, y = self._make_data(y_true, y_true) result = self.evaluator.evaluate(model, X, y) assert isinstance(result.accuracy, float) assert isinstance(result.precision, float) assert isinstance(result.recall, float) assert isinstance(result.f1_score, float) def test_confusion_matrix_counts_are_ints(self): y_true = [1, 0, 1, 0] model, X, y = self._make_data(y_true, y_true) result = self.evaluator.evaluate(model, X, y) assert isinstance(result.tp, int) assert isinstance(result.tn, int) assert isinstance(result.fp, int) assert isinstance(result.fn, int) # --- metrics are in [0, 1] --- def test_accuracy_in_unit_interval(self): y_true = [1, 0, 1, 0, 1] y_pred = [1, 0, 0, 1, 1] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert 0.0 <= result.accuracy <= 1.0 def test_precision_in_unit_interval(self): y_true = [1, 0, 1, 0, 1] y_pred = [1, 0, 0, 1, 1] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert 0.0 <= result.precision <= 1.0 def test_recall_in_unit_interval(self): y_true = [1, 0, 1, 0, 1] y_pred = [1, 0, 0, 1, 1] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert 0.0 <= result.recall <= 1.0 def test_f1_in_unit_interval(self): y_true = [1, 0, 1, 0, 1] y_pred = [1, 0, 0, 1, 1] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert 0.0 <= result.f1_score <= 1.0 # --- works with a real sklearn model --- def test_works_with_sklearn_dummy_classifier(self): rng = np.random.default_rng(42) X_train = rng.random((100, 5)) y_train = rng.integers(0, 2, size=100) X_test = rng.random((20, 5)) y_test = rng.integers(0, 2, size=20) clf = DummyClassifier(strategy="most_frequent") clf.fit(X_train, y_train) result = self.evaluator.evaluate(clf, X_test, y_test) assert isinstance(result, EvaluationMetrics) assert 0.0 <= result.accuracy <= 1.0 total = result.tp + result.tn + result.fp + result.fn assert total == len(y_test) # --- original ILPD label encoding (1 = disease, 2 = no disease) --- def test_works_with_labels_1_and_2(self): """Evaluator must handle the original ILPD dataset label encoding.""" y_true = [1, 1, 2, 2, 1, 2] y_pred = [1, 2, 2, 1, 1, 2] model, X, y = self._make_data(y_true, y_pred) result = self.evaluator.evaluate(model, X, y) assert isinstance(result, EvaluationMetrics) total = result.tp + result.tn + result.fp + result.fn assert total == len(y_true)