scratchkit 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. mlscratch/__init__.py +56 -0
  2. mlscratch/__main__.py +118 -0
  3. mlscratch/bayesian/__init__.py +53 -0
  4. mlscratch/bayesian/bayesian_linear_regression.py +171 -0
  5. mlscratch/bayesian/bayesian_network.py +248 -0
  6. mlscratch/bayesian/bayesian_nn.py +315 -0
  7. mlscratch/bayesian/gaussian_process.py +207 -0
  8. mlscratch/bayesian/hmm.py +277 -0
  9. mlscratch/bayesian/init.py +52 -0
  10. mlscratch/bayesian/kalman_filter.py +182 -0
  11. mlscratch/bayesian/naive_bayes.py +209 -0
  12. mlscratch/metrics/__init__.py +59 -0
  13. mlscratch/metrics/classification.py +365 -0
  14. mlscratch/metrics/regression.py +79 -0
  15. mlscratch/neural/__init__.py +121 -0
  16. mlscratch/neural/attention.py +420 -0
  17. mlscratch/neural/autoencoder.py +543 -0
  18. mlscratch/neural/boltzmann.py +231 -0
  19. mlscratch/neural/cnn.py +593 -0
  20. mlscratch/neural/cvnn.py +322 -0
  21. mlscratch/neural/gan.py +364 -0
  22. mlscratch/neural/hopfield.py +193 -0
  23. mlscratch/neural/perceptron.py +398 -0
  24. mlscratch/neural/rbf_network.py +230 -0
  25. mlscratch/neural/recurrent.py +569 -0
  26. mlscratch/preprocessing/__init__.py +38 -0
  27. mlscratch/preprocessing/encoders.py +140 -0
  28. mlscratch/preprocessing/model_selection.py +119 -0
  29. mlscratch/preprocessing/polynomial.py +105 -0
  30. mlscratch/preprocessing/scalers.py +220 -0
  31. mlscratch/py.typed +0 -0
  32. mlscratch/reinforcement/__init__.py +59 -0
  33. mlscratch/reinforcement/ddpg.py +363 -0
  34. mlscratch/reinforcement/dqn.py +319 -0
  35. mlscratch/reinforcement/ppo.py +452 -0
  36. mlscratch/reinforcement/q_learning.py +352 -0
  37. mlscratch/reinforcement/sac.py +382 -0
  38. mlscratch/reinforcement/utils.py +594 -0
  39. mlscratch/supervised/__init__.py +76 -0
  40. mlscratch/supervised/_validation.py +50 -0
  41. mlscratch/supervised/adaboost.py +255 -0
  42. mlscratch/supervised/decision_tree.py +495 -0
  43. mlscratch/supervised/gradient_boosting.py +354 -0
  44. mlscratch/supervised/knn.py +234 -0
  45. mlscratch/supervised/lasso_regression.py +125 -0
  46. mlscratch/supervised/linear_models.py +459 -0
  47. mlscratch/supervised/linear_regression.py +197 -0
  48. mlscratch/supervised/logistic_regression.py +119 -0
  49. mlscratch/supervised/naive_bayes.py +113 -0
  50. mlscratch/supervised/random_forest.py +321 -0
  51. mlscratch/supervised/ridge_regression.py +93 -0
  52. mlscratch/supervised/svm.py +356 -0
  53. mlscratch/unsupervised/__init__.py +39 -0
  54. mlscratch/unsupervised/apriori.py +178 -0
  55. mlscratch/unsupervised/dbscan.py +141 -0
  56. mlscratch/unsupervised/gmm.py +204 -0
  57. mlscratch/unsupervised/hierarchical_clustering.py +137 -0
  58. mlscratch/unsupervised/ica.py +167 -0
  59. mlscratch/unsupervised/kmeans.py +135 -0
  60. mlscratch/unsupervised/kmedoids.py +133 -0
  61. mlscratch/unsupervised/pca.py +103 -0
  62. mlscratch/unsupervised/tsne.py +200 -0
  63. scratchkit-0.2.0.dist-info/METADATA +241 -0
  64. scratchkit-0.2.0.dist-info/RECORD +68 -0
  65. scratchkit-0.2.0.dist-info/WHEEL +5 -0
  66. scratchkit-0.2.0.dist-info/entry_points.txt +2 -0
  67. scratchkit-0.2.0.dist-info/licenses/LICENSE +201 -0
  68. scratchkit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,209 @@
1
+ """
2
+ Naive Bayes Classifiers
3
+ ========================
4
+ Three variants of the Naive Bayes family, all built on Bayes' theorem with
5
+ the "naive" conditional-independence assumption among features.
6
+
7
+ P(y | x) ∝ P(y) * ∏_i P(x_i | y)
8
+
9
+ Variants
10
+ --------
11
+ GaussianNB – continuous features modelled as Gaussians
12
+ MultinomialNB – integer/count features (e.g. word counts)
13
+ BernoulliNB – binary features (0/1)
14
+
15
+ All log-probabilities are used internally to avoid underflow.
16
+ Only numpy and Python stdlib are used.
17
+ """
18
+
19
+ import numpy as np
20
+
21
+
22
+ # ============================================================
23
+ # Base
24
+ # ============================================================
25
+
26
+ class _BaseNB:
27
+ """Shared prediction logic for all NB variants."""
28
+
29
+ def predict(self, X: np.ndarray) -> np.ndarray:
30
+ return np.array([
31
+ self.classes_[np.argmax(self._joint_log_likelihood(x))]
32
+ for x in X
33
+ ])
34
+
35
+ def predict_proba(self, X: np.ndarray) -> np.ndarray:
36
+ log_probs = np.array([self._joint_log_likelihood(x) for x in X])
37
+ # Numerically stable softmax per row
38
+ log_probs -= log_probs.max(axis=1, keepdims=True)
39
+ probs = np.exp(log_probs)
40
+ probs /= probs.sum(axis=1, keepdims=True)
41
+ return probs
42
+
43
+ def _joint_log_likelihood(self, x: np.ndarray) -> np.ndarray:
44
+ raise NotImplementedError
45
+
46
+
47
+ # ============================================================
48
+ # Gaussian Naive Bayes
49
+ # ============================================================
50
+
51
+ class GaussianNB(_BaseNB):
52
+ """
53
+ Gaussian Naive Bayes for continuous features.
54
+
55
+ Likelihood: P(x_i | y) = N(x_i; mu_{iy}, sigma_{iy}^2)
56
+
57
+ Parameters
58
+ ----------
59
+ var_smoothing : float
60
+ Small value added to variance for numerical stability.
61
+ """
62
+
63
+ def __init__(self, var_smoothing: float = 1e-9):
64
+ self.var_smoothing = var_smoothing
65
+ self.classes_ = None
66
+ self.class_prior_ = None # log P(y)
67
+ self.theta_ = None # means: (n_classes, n_features)
68
+ self.sigma_ = None # variances:(n_classes, n_features)
69
+
70
+ def fit(self, X: np.ndarray, y: np.ndarray) -> "GaussianNB":
71
+ self.classes_ = np.unique(y)
72
+ n_classes = len(self.classes_)
73
+ n_features = X.shape[1]
74
+ n_samples = len(y)
75
+
76
+ self.theta_ = np.zeros((n_classes, n_features))
77
+ self.sigma_ = np.zeros((n_classes, n_features))
78
+ self.class_prior_ = np.zeros(n_classes)
79
+
80
+ for k, c in enumerate(self.classes_):
81
+ Xc = X[y == c]
82
+ self.theta_[k] = Xc.mean(axis=0)
83
+ self.sigma_[k] = Xc.var(axis=0) + self.var_smoothing
84
+ self.class_prior_[k] = len(Xc) / n_samples
85
+
86
+ return self
87
+
88
+ def _joint_log_likelihood(self, x: np.ndarray) -> np.ndarray:
89
+ jll = np.log(self.class_prior_).copy()
90
+ for k in range(len(self.classes_)):
91
+ log_pdf = -0.5 * np.sum(
92
+ np.log(2 * np.pi * self.sigma_[k])
93
+ + ((x - self.theta_[k]) ** 2) / self.sigma_[k]
94
+ )
95
+ jll[k] += log_pdf
96
+ return jll
97
+
98
+
99
+ # ============================================================
100
+ # Multinomial Naive Bayes
101
+ # ============================================================
102
+
103
+ class MultinomialNB(_BaseNB):
104
+ """
105
+ Multinomial Naive Bayes for count/frequency features.
106
+
107
+ Likelihood: P(x_i | y) ∝ theta_{iy}^{x_i}
108
+
109
+ Parameters
110
+ ----------
111
+ alpha : float
112
+ Laplace / Lidstone smoothing parameter (default 1.0).
113
+ """
114
+
115
+ def __init__(self, alpha: float = 1.0):
116
+ self.alpha = alpha
117
+ self.classes_ = None
118
+ self.class_prior_ = None # log P(y)
119
+ self.feature_log_prob_ = None # log P(x_i | y): (n_classes, n_features)
120
+
121
+ def fit(self, X: np.ndarray, y: np.ndarray) -> "MultinomialNB":
122
+ self.classes_ = np.unique(y)
123
+ n_classes = len(self.classes_)
124
+ n_samples = len(y)
125
+
126
+ self.class_prior_ = np.zeros(n_classes)
127
+ feature_counts = np.zeros((n_classes, X.shape[1]))
128
+
129
+ for k, c in enumerate(self.classes_):
130
+ Xc = X[y == c]
131
+ self.class_prior_[k] = len(Xc) / n_samples
132
+ feature_counts[k] = Xc.sum(axis=0)
133
+
134
+ # Smoothed log probabilities
135
+ smoothed = feature_counts + self.alpha
136
+ self.feature_log_prob_ = np.log(smoothed) - np.log(
137
+ smoothed.sum(axis=1, keepdims=True)
138
+ )
139
+ return self
140
+
141
+ def _joint_log_likelihood(self, x: np.ndarray) -> np.ndarray:
142
+ return np.log(self.class_prior_) + self.feature_log_prob_ @ x
143
+
144
+
145
+ # ============================================================
146
+ # Bernoulli Naive Bayes
147
+ # ============================================================
148
+
149
+ class BernoulliNB(_BaseNB):
150
+ """
151
+ Bernoulli Naive Bayes for binary features.
152
+
153
+ Likelihood: P(x_i | y) = p_{iy}^{x_i} * (1-p_{iy})^{1-x_i}
154
+
155
+ Parameters
156
+ ----------
157
+ alpha : float
158
+ Laplace smoothing (default 1.0).
159
+ binarize : float or None
160
+ Threshold to binarize continuous inputs. If None, assume already
161
+ binary.
162
+ """
163
+
164
+ def __init__(self, alpha: float = 1.0, binarize: float | None = 0.0):
165
+ self.alpha = alpha
166
+ self.binarize = binarize
167
+ self.classes_ = None
168
+ self.class_prior_ = None
169
+ self.feature_log_prob_ = None # log P(x_i=1 | y)
170
+ self.feature_log_prob_neg_ = None # log P(x_i=0 | y)
171
+
172
+ def _binarize(self, X: np.ndarray) -> np.ndarray:
173
+ if self.binarize is not None:
174
+ return (X > self.binarize).astype(float)
175
+ return X
176
+
177
+ def fit(self, X: np.ndarray, y: np.ndarray) -> "BernoulliNB":
178
+ X = self._binarize(X)
179
+ self.classes_ = np.unique(y)
180
+ n_classes = len(self.classes_)
181
+ n_samples = len(y)
182
+
183
+ self.class_prior_ = np.zeros(n_classes)
184
+ pos_count = np.zeros((n_classes, X.shape[1]))
185
+
186
+ for k, c in enumerate(self.classes_):
187
+ Xc = X[y == c]
188
+ self.class_prior_[k] = len(Xc) / n_samples
189
+ pos_count[k] = Xc.sum(axis=0)
190
+
191
+ n_per_class = np.array([(y == c).sum() for c in self.classes_])
192
+ smoothed_pos = pos_count + self.alpha
193
+ smoothed_total = n_per_class[:, np.newaxis] + 2 * self.alpha
194
+
195
+ self.feature_log_prob_ = np.log(smoothed_pos / smoothed_total)
196
+ self.feature_log_prob_neg_ = np.log(
197
+ 1.0 - smoothed_pos / smoothed_total
198
+ )
199
+ return self
200
+
201
+ def _joint_log_likelihood(self, x: np.ndarray) -> np.ndarray:
202
+ x = self._binarize(x)
203
+ jll = np.log(self.class_prior_).copy()
204
+ for k in range(len(self.classes_)):
205
+ jll[k] += np.sum(
206
+ x * self.feature_log_prob_[k]
207
+ + (1 - x) * self.feature_log_prob_neg_[k]
208
+ )
209
+ return jll
@@ -0,0 +1,59 @@
1
+ """
2
+ mlscratch.metrics
3
+ ==================
4
+ Evaluation metrics for classifiers and regressors, implemented from
5
+ scratch in pure numpy — no scikit-learn dependency at runtime.
6
+
7
+ Classification
8
+ --------------
9
+ accuracy_score, precision_score, recall_score, f1_score,
10
+ precision_recall_fscore_support, confusion_matrix, classification_report,
11
+ roc_curve, roc_auc_score, log_loss
12
+
13
+ Regression
14
+ ----------
15
+ mean_squared_error, root_mean_squared_error, mean_absolute_error,
16
+ mean_absolute_percentage_error, r2_score, explained_variance_score
17
+ """
18
+
19
+ from .classification import ( # noqa: F401
20
+ accuracy_score,
21
+ classification_report,
22
+ confusion_matrix,
23
+ f1_score,
24
+ log_loss,
25
+ precision_recall_fscore_support,
26
+ precision_score,
27
+ recall_score,
28
+ roc_auc_score,
29
+ roc_curve,
30
+ )
31
+ from .regression import ( # noqa: F401
32
+ explained_variance_score,
33
+ mean_absolute_error,
34
+ mean_absolute_percentage_error,
35
+ mean_squared_error,
36
+ r2_score,
37
+ root_mean_squared_error,
38
+ )
39
+
40
+ __all__ = [
41
+ # Classification
42
+ "accuracy_score",
43
+ "precision_score",
44
+ "recall_score",
45
+ "f1_score",
46
+ "precision_recall_fscore_support",
47
+ "confusion_matrix",
48
+ "classification_report",
49
+ "roc_curve",
50
+ "roc_auc_score",
51
+ "log_loss",
52
+ # Regression
53
+ "mean_squared_error",
54
+ "root_mean_squared_error",
55
+ "mean_absolute_error",
56
+ "mean_absolute_percentage_error",
57
+ "r2_score",
58
+ "explained_variance_score",
59
+ ]
@@ -0,0 +1,365 @@
1
+ r"""
2
+ Classification Metrics
3
+ =======================
4
+ Evaluation metrics for classifiers, implemented from scratch in pure numpy.
5
+
6
+ confusion_matrix
7
+ -----------------
8
+ Row *i*, column *j* counts samples with true label ``labels[i]``
9
+ predicted as ``labels[j]``.
10
+
11
+ precision / recall / F1
12
+ -------------------------
13
+ Per class *k*, from the confusion matrix:
14
+
15
+ .. math::
16
+ \mathrm{precision}_k = \frac{TP_k}{TP_k + FP_k}, \quad
17
+ \mathrm{recall}_k = \frac{TP_k}{TP_k + FN_k}, \quad
18
+ F_1 = \frac{2\,\mathrm{precision}\cdot\mathrm{recall}}{\mathrm{precision}+\mathrm{recall}}
19
+
20
+ ``average`` controls how per-class scores are combined: ``'binary'``
21
+ (report the positive class only), ``'macro'`` (unweighted mean),
22
+ ``'micro'`` (global counts pooled across classes), ``'weighted'``
23
+ (mean weighted by class support), or ``None`` (return the raw
24
+ per-class array).
25
+
26
+ roc_curve / roc_auc_score
27
+ ----------------------------
28
+ Binary-only. Sweeps the decision threshold over every distinct score
29
+ value and reports the true/false positive rate at each one; AUC is
30
+ the trapezoidal-rule integral of TPR over FPR.
31
+
32
+ log_loss
33
+ ---------
34
+ .. math::
35
+ -\frac1n \sum_i \log \hat p_i(y_i)
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import numpy as np
41
+ from numpy.typing import ArrayLike, NDArray
42
+
43
+ FloatArray = NDArray[np.float64]
44
+
45
+ _AVERAGE_OPTIONS = {None, "binary", "macro", "micro", "weighted"}
46
+
47
+ # numpy >= 2.0 renamed trapz -> trapezoid; numpy < 2.0 only has trapz.
48
+ _trapezoid = getattr(np, "trapezoid", None) or np.trapz
49
+
50
+
51
+ # ──────────────────────────────────────────────────────────────────────────
52
+ # Validation
53
+ # ──────────────────────────────────────────────────────────────────────────
54
+
55
+
56
+ def _validate_labels(y_true: ArrayLike, y_pred: ArrayLike) -> tuple[NDArray, NDArray]:
57
+ y_true_arr = np.asarray(y_true).flatten()
58
+ y_pred_arr = np.asarray(y_pred).flatten()
59
+ if y_true_arr.shape[0] != y_pred_arr.shape[0]:
60
+ raise ValueError(
61
+ f"y_true has {y_true_arr.shape[0]} samples but y_pred has {y_pred_arr.shape[0]}."
62
+ )
63
+ if y_true_arr.shape[0] == 0:
64
+ raise ValueError("y_true and y_pred must not be empty.")
65
+ return y_true_arr, y_pred_arr
66
+
67
+
68
+ def _safe_divide(numerator: FloatArray, denominator: FloatArray, fill_value: float) -> FloatArray:
69
+ out = np.full_like(numerator, fill_value=float(fill_value), dtype=np.float64)
70
+ mask = denominator > 0
71
+ out[mask] = numerator[mask] / denominator[mask]
72
+ return out
73
+
74
+
75
+ # ──────────────────────────────────────────────────────────────────────────
76
+ # Confusion matrix
77
+ # ──────────────────────────────────────────────────────────────────────────
78
+
79
+
80
+ def confusion_matrix(
81
+ y_true: ArrayLike, y_pred: ArrayLike, labels: ArrayLike | None = None
82
+ ) -> NDArray[np.int64]:
83
+ """Return the confusion matrix ``C`` where ``C[i, j]`` is the count of
84
+ samples with true label ``labels[i]`` predicted as ``labels[j]``."""
85
+ y_true_arr, y_pred_arr = _validate_labels(y_true, y_pred)
86
+ if labels is None:
87
+ labels_arr = np.unique(np.concatenate([y_true_arr, y_pred_arr]))
88
+ else:
89
+ labels_arr = np.asarray(labels)
90
+
91
+ label_to_idx = {label: i for i, label in enumerate(labels_arr)}
92
+ n = labels_arr.size
93
+ cm = np.zeros((n, n), dtype=np.int64)
94
+ for t, p in zip(y_true_arr, y_pred_arr, strict=True):
95
+ ti, pi = label_to_idx.get(t), label_to_idx.get(p)
96
+ if ti is not None and pi is not None:
97
+ cm[ti, pi] += 1
98
+ return cm
99
+
100
+
101
+ def _per_class_counts(
102
+ y_true: NDArray, y_pred: NDArray, labels: NDArray
103
+ ) -> tuple[FloatArray, FloatArray, FloatArray, FloatArray]:
104
+ cm = confusion_matrix(y_true, y_pred, labels=labels).astype(np.float64)
105
+ tp = np.diag(cm)
106
+ fp = cm.sum(axis=0) - tp
107
+ fn = cm.sum(axis=1) - tp
108
+ support = cm.sum(axis=1)
109
+ return tp, fp, fn, support
110
+
111
+
112
+ # ──────────────────────────────────────────────────────────────────────────
113
+ # Simple scalar metrics
114
+ # ──────────────────────────────────────────────────────────────────────────
115
+
116
+
117
+ def accuracy_score(
118
+ y_true: ArrayLike, y_pred: ArrayLike, sample_weight: ArrayLike | None = None
119
+ ) -> float:
120
+ """Fraction (or weighted fraction) of exactly-correct predictions."""
121
+ y_true_arr, y_pred_arr = _validate_labels(y_true, y_pred)
122
+ correct = (y_true_arr == y_pred_arr).astype(np.float64)
123
+ if sample_weight is None:
124
+ return float(np.mean(correct))
125
+ w = np.asarray(sample_weight, dtype=np.float64).flatten()
126
+ return float(np.average(correct, weights=w))
127
+
128
+
129
+ # ──────────────────────────────────────────────────────────────────────────
130
+ # Precision / Recall / F1
131
+ # ──────────────────────────────────────────────────────────────────────────
132
+
133
+
134
+ def precision_recall_fscore_support(
135
+ y_true: ArrayLike,
136
+ y_pred: ArrayLike,
137
+ *,
138
+ average: str | None = "binary",
139
+ labels: ArrayLike | None = None,
140
+ pos_label: object = 1,
141
+ zero_division: float = 0.0,
142
+ ):
143
+ """Compute precision, recall, F1, and support, jointly (one confusion
144
+ matrix pass). Returns 4 scalars if ``average`` is not ``None``, else 4
145
+ arrays of length ``n_classes``."""
146
+ if average not in _AVERAGE_OPTIONS:
147
+ raise ValueError(f"average must be one of {_AVERAGE_OPTIONS}.")
148
+ y_true_arr, y_pred_arr = _validate_labels(y_true, y_pred)
149
+ labels_arr = (
150
+ np.unique(np.concatenate([y_true_arr, y_pred_arr]))
151
+ if labels is None
152
+ else np.asarray(labels)
153
+ )
154
+
155
+ tp, fp, fn, support = _per_class_counts(y_true_arr, y_pred_arr, labels_arr)
156
+ precision = _safe_divide(tp, tp + fp, zero_division)
157
+ recall = _safe_divide(tp, tp + fn, zero_division)
158
+ f1 = _safe_divide(2.0 * precision * recall, precision + recall, zero_division)
159
+
160
+ if average is None:
161
+ return precision, recall, f1, support
162
+
163
+ if average == "binary":
164
+ if labels_arr.size != 2:
165
+ raise ValueError(
166
+ "average='binary' requires exactly 2 classes; use 'macro', "
167
+ "'micro', 'weighted', or None for multiclass problems."
168
+ )
169
+ idx = int(np.flatnonzero(labels_arr == pos_label)[0]) if pos_label in labels_arr else 1
170
+ return float(precision[idx]), float(recall[idx]), float(f1[idx]), int(support[idx])
171
+
172
+ if average == "macro":
173
+ return float(precision.mean()), float(recall.mean()), float(f1.mean()), int(support.sum())
174
+
175
+ if average == "micro":
176
+ tp_s, fp_s, fn_s = tp.sum(), fp.sum(), fn.sum()
177
+ p = tp_s / (tp_s + fp_s) if (tp_s + fp_s) > 0 else zero_division
178
+ r = tp_s / (tp_s + fn_s) if (tp_s + fn_s) > 0 else zero_division
179
+ f = 2 * p * r / (p + r) if (p + r) > 0 else zero_division
180
+ return float(p), float(r), float(f), int(support.sum())
181
+
182
+ # weighted
183
+ total = support.sum()
184
+ weights = support / total if total > 0 else np.zeros_like(support)
185
+ return (
186
+ float(np.sum(precision * weights)),
187
+ float(np.sum(recall * weights)),
188
+ float(np.sum(f1 * weights)),
189
+ int(total),
190
+ )
191
+
192
+
193
+ def precision_score(y_true: ArrayLike, y_pred: ArrayLike, **kwargs) -> float | FloatArray:
194
+ p, _, _, _ = precision_recall_fscore_support(y_true, y_pred, **kwargs)
195
+ return p
196
+
197
+
198
+ def recall_score(y_true: ArrayLike, y_pred: ArrayLike, **kwargs) -> float | FloatArray:
199
+ _, r, _, _ = precision_recall_fscore_support(y_true, y_pred, **kwargs)
200
+ return r
201
+
202
+
203
+ def f1_score(y_true: ArrayLike, y_pred: ArrayLike, **kwargs) -> float | FloatArray:
204
+ _, _, f, _ = precision_recall_fscore_support(y_true, y_pred, **kwargs)
205
+ return f
206
+
207
+
208
+ # ──────────────────────────────────────────────────────────────────────────
209
+ # ROC / AUC
210
+ # ──────────────────────────────────────────────────────────────────────────
211
+
212
+
213
+ def roc_curve(
214
+ y_true: ArrayLike, y_score: ArrayLike, pos_label: object | None = None
215
+ ) -> tuple[FloatArray, FloatArray, FloatArray]:
216
+ """Return (fpr, tpr, thresholds) for a binary classification problem,
217
+ sweeping the threshold over every distinct value of ``y_score``."""
218
+ y_true_arr = np.asarray(y_true).flatten()
219
+ y_score_arr = np.asarray(y_score, dtype=np.float64).flatten()
220
+ if y_true_arr.shape[0] != y_score_arr.shape[0]:
221
+ raise ValueError(
222
+ f"y_true has {y_true_arr.shape[0]} samples but y_score has {y_score_arr.shape[0]}."
223
+ )
224
+ classes = np.unique(y_true_arr)
225
+ if classes.size != 2:
226
+ raise ValueError("roc_curve requires a binary y_true (exactly 2 classes).")
227
+ if pos_label is None:
228
+ pos_label = classes[-1]
229
+ y_bin = (y_true_arr == pos_label).astype(np.float64)
230
+
231
+ order = np.argsort(-y_score_arr, kind="mergesort")
232
+ y_sorted = y_bin[order]
233
+ scores_sorted = y_score_arr[order]
234
+
235
+ distinct_idx = np.flatnonzero(np.diff(scores_sorted))
236
+ threshold_idx = np.r_[distinct_idx, y_sorted.size - 1]
237
+
238
+ tps = np.cumsum(y_sorted)[threshold_idx]
239
+ fps = (threshold_idx + 1) - tps
240
+
241
+ tps = np.r_[0.0, tps]
242
+ fps = np.r_[0.0, fps]
243
+ thresholds = np.r_[np.inf, scores_sorted[threshold_idx]]
244
+
245
+ n_pos, n_neg = y_bin.sum(), y_bin.size - y_bin.sum()
246
+ tpr = tps / n_pos if n_pos > 0 else np.zeros_like(tps)
247
+ fpr = fps / n_neg if n_neg > 0 else np.zeros_like(fps)
248
+ return fpr, tpr, thresholds
249
+
250
+
251
+ def roc_auc_score(y_true: ArrayLike, y_score: ArrayLike, pos_label: object | None = None) -> float:
252
+ """Area under the ROC curve (trapezoidal rule)."""
253
+ fpr, tpr, _ = roc_curve(y_true, y_score, pos_label=pos_label)
254
+ return float(_trapezoid(tpr, fpr))
255
+
256
+
257
+ # ──────────────────────────────────────────────────────────────────────────
258
+ # Log loss
259
+ # ──────────────────────────────────────────────────────────────────────────
260
+
261
+
262
+ def log_loss(
263
+ y_true: ArrayLike, y_pred_proba: ArrayLike, eps: float = 1e-15, labels: ArrayLike | None = None
264
+ ) -> float:
265
+ """Cross-entropy / binomial-or-multinomial deviance.
266
+
267
+ ``y_pred_proba`` may be a 1-D array of positive-class probabilities
268
+ (binary shorthand) or a 2-D array of shape ``(n_samples, n_classes)``.
269
+ """
270
+ y_true_arr = np.asarray(y_true).flatten()
271
+ proba = np.asarray(y_pred_proba, dtype=np.float64)
272
+ if proba.ndim == 1:
273
+ proba = np.column_stack([1.0 - proba, proba])
274
+ if proba.shape[0] != y_true_arr.shape[0]:
275
+ raise ValueError(
276
+ f"y_true has {y_true_arr.shape[0]} samples but y_pred_proba has {proba.shape[0]}."
277
+ )
278
+
279
+ proba = np.clip(proba, eps, 1.0 - eps)
280
+ proba = proba / proba.sum(axis=1, keepdims=True)
281
+
282
+ labels_arr = np.unique(y_true_arr) if labels is None else np.asarray(labels)
283
+ if labels_arr.size != proba.shape[1]:
284
+ raise ValueError(
285
+ f"y_pred_proba has {proba.shape[1]} columns but {labels_arr.size} labels were found."
286
+ )
287
+ label_to_idx = {label: i for i, label in enumerate(labels_arr)}
288
+ col = np.array([label_to_idx[t] for t in y_true_arr])
289
+ n = y_true_arr.shape[0]
290
+ return float(-np.mean(np.log(proba[np.arange(n), col])))
291
+
292
+
293
+ # ──────────────────────────────────────────────────────────────────────────
294
+ # Classification report
295
+ # ──────────────────────────────────────────────────────────────────────────
296
+
297
+
298
+ def classification_report(
299
+ y_true: ArrayLike,
300
+ y_pred: ArrayLike,
301
+ labels: ArrayLike | None = None,
302
+ target_names: list[str] | None = None,
303
+ digits: int = 2,
304
+ zero_division: float = 0.0,
305
+ ) -> str:
306
+ """Return a sklearn-style formatted text report of the main per-class
307
+ classification metrics, plus accuracy and macro/weighted averages."""
308
+ y_true_arr, y_pred_arr = _validate_labels(y_true, y_pred)
309
+ labels_arr = (
310
+ np.unique(np.concatenate([y_true_arr, y_pred_arr]))
311
+ if labels is None
312
+ else np.asarray(labels)
313
+ )
314
+ names = (
315
+ [str(n) for n in target_names]
316
+ if target_names is not None
317
+ else [str(label) for label in labels_arr]
318
+ )
319
+
320
+ precision, recall, f1, support = precision_recall_fscore_support(
321
+ y_true_arr, y_pred_arr, average=None, labels=labels_arr, zero_division=zero_division
322
+ )
323
+ acc = accuracy_score(y_true_arr, y_pred_arr)
324
+ macro = precision_recall_fscore_support(
325
+ y_true_arr, y_pred_arr, average="macro", labels=labels_arr, zero_division=zero_division
326
+ )
327
+ weighted = precision_recall_fscore_support(
328
+ y_true_arr, y_pred_arr, average="weighted", labels=labels_arr, zero_division=zero_division
329
+ )
330
+
331
+ headers = ["precision", "recall", "f1-score", "support"]
332
+ name_width = max(len(n) for n in names + ["weighted avg", "macro avg"])
333
+ col_width = max(len(h) for h in headers) + 2
334
+
335
+ lines = []
336
+ header_line = " " * (name_width + 2) + "".join(h.rjust(col_width) for h in headers)
337
+ lines.append(header_line)
338
+ lines.append("")
339
+ for name, p, r, f, s in zip(names, precision, recall, f1, support, strict=True):
340
+ row = (
341
+ f"{name:<{name_width}} "
342
+ + "".join(f"{v:>{col_width}.{digits}f}" for v in (p, r, f))
343
+ + f"{int(s):>{col_width}d}"
344
+ )
345
+ lines.append(row)
346
+ lines.append("")
347
+
348
+ total_support = int(support.sum())
349
+ acc_row = (
350
+ f"{'accuracy':<{name_width}} "
351
+ + " " * (col_width * 2)
352
+ + f"{acc:>{col_width}.{digits}f}"
353
+ + f"{total_support:>{col_width}d}"
354
+ )
355
+ lines.append(acc_row)
356
+
357
+ for label, (p, r, f, s) in (("macro avg", macro), ("weighted avg", weighted)):
358
+ row = (
359
+ f"{label:<{name_width}} "
360
+ + "".join(f"{v:>{col_width}.{digits}f}" for v in (p, r, f))
361
+ + f"{int(s):>{col_width}d}"
362
+ )
363
+ lines.append(row)
364
+
365
+ return "\n".join(lines)