Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |